77d35220b1
Build and Push Images / build-and-push (push) Has been cancelled
- edgeDeviceConfigReporter 신규: pipeline_device_connections + pipeline_equipment 조인해 IDC TimescaleDB의 edge_device_config_1 에 5분 주기로 적재 (DISTINCT ON 으로 최신값 조회용) - app.ts: 부팅 시 startEdgeDeviceConfigReporter, SIGTERM/SIGINT 시 graceful stop 추가 - CollectedData 에 host/port 필드 추가, collectDevice 에서 device row 값 채움 - centralMqttForwarder.buildPayload 에 protocol/host/port 포함 (IDC 컨슈머가 활용) - 312 마이그레이션(fleet_edge_raw_data host/port 컬럼) 등록 (sql 파일은 .gitignore 로 미동봉, OPS 절차로 적용) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
import { Router, Response } from "express";
|
|
import { PipelineDeviceConnectionService } from "../services/pipelineDeviceConnectionService";
|
|
import { PROTOCOL_OPTIONS, PROTOCOL_DEFAULTS, TAG_DATA_TYPE_OPTIONS, ADDRESS_TYPE_OPTIONS, BYTE_ORDER_OPTIONS } from "../types/pipelineDeviceTypes";
|
|
import { authenticateToken } from "../middleware/authMiddleware";
|
|
import { AuthenticatedRequest } from "../types/auth";
|
|
import { query } from "../database/db";
|
|
|
|
const router = Router();
|
|
|
|
// 모든 라우트 인증 필요
|
|
router.use(authenticateToken);
|
|
|
|
// ===== 프로토콜 목록 (정적 경로 우선) =====
|
|
router.get("/protocols", async (req: AuthenticatedRequest, res: Response) => {
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
protocols: PROTOCOL_OPTIONS,
|
|
defaults: PROTOCOL_DEFAULTS,
|
|
tagDataTypes: TAG_DATA_TYPE_OPTIONS,
|
|
addressTypes: ADDRESS_TYPE_OPTIONS,
|
|
byteOrders: BYTE_ORDER_OPTIONS,
|
|
},
|
|
});
|
|
});
|
|
|
|
// ===== 장비 목록 (pipeline_equipment) - 드롭다운용 =====
|
|
router.get("/equipment-list", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const userCompanyCode = req.user?.companyCode;
|
|
const search = (req.query.search as string) || "";
|
|
const params: any[] = [];
|
|
let where = "WHERE is_active = 'Y'";
|
|
|
|
if (userCompanyCode && userCompanyCode !== "*") {
|
|
params.push(userCompanyCode);
|
|
where += ` AND (company_code = $${params.length} OR company_code = '공통')`;
|
|
}
|
|
if (search.trim()) {
|
|
params.push(`%${search.trim()}%`);
|
|
where += ` AND (equipment_name ILIKE $${params.length} OR equipment_code ILIKE $${params.length})`;
|
|
}
|
|
|
|
const rows = await query<any>(
|
|
`SELECT id, equipment_code, equipment_name, equipment_type, manufacturer, model, serial_number
|
|
FROM pipeline_equipment ${where}
|
|
ORDER BY equipment_name LIMIT 500`,
|
|
params,
|
|
);
|
|
res.json({ success: true, data: rows });
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// ===== 태그 수정/삭제 (정적 경로 우선) =====
|
|
router.put("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await PipelineDeviceConnectionService.updateTagMapping(parseInt(req.params.tagId), req.body);
|
|
res.status(result.success ? 200 : 400).json(result);
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
router.delete("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await PipelineDeviceConnectionService.deleteTagMapping(parseInt(req.params.tagId));
|
|
res.json(result);
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// ===== 연결 CRUD =====
|
|
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const userCompanyCode = req.user?.companyCode;
|
|
let companyCodeFilter: string | undefined;
|
|
if (userCompanyCode === "*") {
|
|
companyCodeFilter = req.query.company_code as string;
|
|
} else {
|
|
companyCodeFilter = userCompanyCode;
|
|
}
|
|
|
|
const filter = {
|
|
protocol: req.query.protocol as string,
|
|
is_active: req.query.is_active as string,
|
|
company_code: companyCodeFilter,
|
|
search: req.query.search as string,
|
|
status: req.query.status as string,
|
|
};
|
|
|
|
Object.keys(filter).forEach((key) => {
|
|
if (!filter[key as keyof typeof filter]) delete filter[key as keyof typeof filter];
|
|
});
|
|
|
|
const result = await PipelineDeviceConnectionService.getConnections(filter, userCompanyCode);
|
|
res.json(result);
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// ===== Target DB 조회 (정적 경로는 반드시 /:id 위에) =====
|
|
router.get("/target-databases", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const { listTargetDatabases } = await import("../services/targetDbIntrospection");
|
|
const companyCode =
|
|
req.user?.companyCode === "*" ? (req.query.company_code as string | undefined) : req.user?.companyCode;
|
|
const data = await listTargetDatabases(companyCode);
|
|
res.json({ success: true, data });
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
router.get("/target-databases/:dbId/tables", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const { listTables } = await import("../services/targetDbIntrospection");
|
|
const data = await listTables(Number(req.params.dbId));
|
|
res.json({ success: true, data });
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
router.get(
|
|
"/target-databases/:dbId/tables/:tableName/columns",
|
|
async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const { listColumns } = await import("../services/targetDbIntrospection");
|
|
const data = await listColumns(Number(req.params.dbId), req.params.tableName);
|
|
res.json({ success: true, data });
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
router.get("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await PipelineDeviceConnectionService.getConnectionById(parseInt(req.params.id));
|
|
res.status(result.success ? 200 : 404).json(result);
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const data = {
|
|
...req.body,
|
|
company_code: req.body.company_code || req.user?.companyCode,
|
|
created_by: req.user?.userId,
|
|
};
|
|
const result = await PipelineDeviceConnectionService.createConnection(data);
|
|
res.status(result.success ? 201 : 400).json(result);
|
|
} catch (e: any) {
|
|
if (e.message?.includes("duplicate") || e.code === "23505") {
|
|
res.status(409).json({ success: false, message: "동일한 연결명이 이미 존재합니다." });
|
|
} else {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
}
|
|
});
|
|
|
|
router.put("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await PipelineDeviceConnectionService.updateConnection(parseInt(req.params.id), req.body);
|
|
res.status(result.success ? 200 : 404).json(result);
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await PipelineDeviceConnectionService.deleteConnection(parseInt(req.params.id));
|
|
res.json(result);
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// ===== 연결 테스트 =====
|
|
router.post("/:id/test", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await PipelineDeviceConnectionService.testConnection(parseInt(req.params.id));
|
|
res.json({ success: result.success, data: result });
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// ===== 태그 매핑 =====
|
|
router.get("/:id/tags", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await PipelineDeviceConnectionService.getTagMappings(parseInt(req.params.id));
|
|
res.json(result);
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
router.post("/:id/tags", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const result = await PipelineDeviceConnectionService.createTagMapping(parseInt(req.params.id), req.body);
|
|
res.status(result.success ? 201 : 400).json(result);
|
|
} catch (e: any) {
|
|
if (e.message?.includes("duplicate") || e.code === "23505") {
|
|
res.status(409).json({ success: false, message: "동일한 태그명이 이미 존재합니다." });
|
|
} else {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
}
|
|
});
|
|
|
|
// ===== 태그 컬럼 매핑 일괄 업데이트 =====
|
|
router.put("/:id/tag-column-mapping", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const connectionId = parseInt(req.params.id);
|
|
const { mapping } = req.body as { mapping: Array<{ tag_id: number; target_column_name: string | null }> };
|
|
if (!Array.isArray(mapping)) {
|
|
return res.status(400).json({ success: false, message: "mapping 배열 필요" });
|
|
}
|
|
const { query } = await import("../database/db");
|
|
for (const m of mapping) {
|
|
await query(
|
|
`UPDATE pipeline_tag_mappings SET target_column_name = $1, updated_at = NOW()
|
|
WHERE id = $2 AND connection_id = $3`,
|
|
[m.target_column_name, m.tag_id, connectionId]
|
|
);
|
|
}
|
|
res.json({ success: true, updated: mapping.length });
|
|
} catch (e: any) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// ===== 수동 1회 수집 트리거 (변경 사항 즉시 검증용) =====
|
|
router.post("/:id/collect-once", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const connectionId = parseInt(req.params.id);
|
|
const { collectDevice, writeToTargetDb } = await import(
|
|
"../services/collector/deviceCollectorService"
|
|
);
|
|
const { upsertEquipmentState } = await import(
|
|
"../services/collector/equipmentStateService"
|
|
);
|
|
|
|
const data = await collectDevice(connectionId);
|
|
|
|
// 수집 성공 시 DB 저장 (Long/Wide 자동 감지)
|
|
if (data.plcState === "connected" && Object.keys(data.tags).length > 0) {
|
|
await upsertEquipmentState(data).catch(() => {});
|
|
await writeToTargetDb(data).catch(err =>
|
|
res.locals.targetDbError = (err as Error).message
|
|
);
|
|
}
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
plc_state: data.plcState,
|
|
error_message: data.errorMessage,
|
|
tags_count: Object.keys(data.tags).length,
|
|
tags: data.tags,
|
|
target_db_error: res.locals.targetDbError,
|
|
timestamp: data.timestamp,
|
|
},
|
|
});
|
|
} catch (e: any) {
|
|
return res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// ===== 훅 체인 테스트 (수정 화면에서 값 넣고 실행) =====
|
|
router.post("/:id/test-chain", async (req: AuthenticatedRequest, res: Response) => {
|
|
try {
|
|
const connectionId = parseInt(req.params.id);
|
|
const { tag_name, raw_value, save_to_db } = req.body as {
|
|
tag_name: string;
|
|
raw_value: unknown;
|
|
save_to_db?: boolean;
|
|
};
|
|
if (!tag_name) {
|
|
return res.status(400).json({ success: false, message: "tag_name 필수" });
|
|
}
|
|
|
|
const { getHooksForConnection } = await import(
|
|
"../services/collector/scriptCache"
|
|
);
|
|
const { executeHook } = await import("../services/collector/pythonHookRunner");
|
|
|
|
const steps: Array<{
|
|
stage: string;
|
|
hook_id?: number;
|
|
hook_name?: string;
|
|
input: unknown;
|
|
output: unknown;
|
|
error?: string;
|
|
duration_ms?: number;
|
|
}> = [];
|
|
|
|
// 1. transform 체인
|
|
let value = raw_value;
|
|
const transforms = await getHooksForConnection(connectionId, "transform");
|
|
for (const h of transforms) {
|
|
const r = await executeHook({
|
|
hook_type: "transform",
|
|
code: h.code,
|
|
tag_name,
|
|
raw_value: value,
|
|
context: { hook_id: h.id, hook_name: h.script_name },
|
|
timeout_ms: h.timeout_ms,
|
|
});
|
|
steps.push({
|
|
stage: "transform",
|
|
hook_id: h.id,
|
|
hook_name: h.script_name,
|
|
input: value,
|
|
output: r.value,
|
|
error: r.error,
|
|
duration_ms: r.duration_ms,
|
|
});
|
|
if (!r.success) break;
|
|
value = r.value;
|
|
}
|
|
|
|
// 2. filter
|
|
let kept = true;
|
|
const filters = await getHooksForConnection(connectionId, "filter");
|
|
for (const h of filters) {
|
|
const r = await executeHook({
|
|
hook_type: "filter",
|
|
code: h.code,
|
|
tag_name,
|
|
value,
|
|
context: { hook_id: h.id },
|
|
timeout_ms: h.timeout_ms,
|
|
});
|
|
steps.push({
|
|
stage: "filter",
|
|
hook_id: h.id,
|
|
hook_name: h.script_name,
|
|
input: value,
|
|
output: r.skip ? "DROP" : "PASS",
|
|
error: r.error,
|
|
duration_ms: r.duration_ms,
|
|
});
|
|
if (r.success && r.skip) {
|
|
kept = false;
|
|
}
|
|
}
|
|
|
|
// 3. DB 저장 옵션
|
|
let saved = false;
|
|
let saved_where: string | null = null;
|
|
if (save_to_db && kept) {
|
|
const { upsertEquipmentState } = await import(
|
|
"../services/collector/equipmentStateService"
|
|
);
|
|
await upsertEquipmentState({
|
|
connectionId,
|
|
connectionName: "test",
|
|
protocol: "",
|
|
companyCode: "*",
|
|
host: null,
|
|
port: null,
|
|
timestamp: new Date().toISOString(),
|
|
plcState: "connected",
|
|
errorMessage: null,
|
|
tags: { [tag_name]: value as any },
|
|
});
|
|
saved = true;
|
|
saved_where = "equipment_current_state";
|
|
}
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
steps,
|
|
final_value: value,
|
|
filter_kept: kept,
|
|
saved,
|
|
saved_where,
|
|
},
|
|
});
|
|
} catch (e: any) {
|
|
return res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
export default router;
|