feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
Build and Push Images / build-and-push (push) Has been cancelled
Build and Push Images / build-and-push (push) Has been cancelled
이전 세션들에서 작업된 아래 범위를 모두 포함: Fleet 서브시스템 (src/fleet/) - fleetDeviceService / fleetCommandService / fleetDeploymentService / fleetReleaseService - fleetMetricsService, fleetScriptService, fleetEdgeConfigService - Edge 디바이스 관리, 커맨드 발행, 배포/릴리스, 스크립트 동기화 Collector 확장 - centralMqttForwarder / centralForwarderConfigService - equipmentStateService, pythonHookRunner, scriptCache - Modbus/OPC-UA/S7/XGT 프로토콜 클라이언트 - targetDbIntrospection (저장 DB 조회) Routes / API - automationDashboardRoutes, centralForwarderRoutes, equipmentStateRoutes DB - importEdgeConfig (Python cached config → Pipeline DB) - seedDataSources (external_db_connections 초기 시드) 엣지 배포 리소스 - docker/edge/Dockerfile.backend.prod, Dockerfile.frontend.prod - docker/edge/docker-compose.edge.yml 프론트엔드 - admin/automaticMng (centralForwarder, dashboard, equipmentState) - admin/fleet (commands, devices, deployments, releases, scripts, alerts) - admin/pipeline-device 개선 (저장 DB 드롭다운, 태그 매핑 등) - ExternalDbConnectionModal, ScriptsManagerDialog 등 신규 컴포넌트 - lib/api: automationDashboard, centralForwarder, equipmentState, fleet docs/ - EDGE_SERVER_STRUCTURE, FLEET_COMPLETE, FLEET_EDGE_INTEGRATION, FLEET_HOOK_INTEGRATION Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1024
-18
File diff suppressed because it is too large
Load Diff
@@ -24,12 +24,15 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/mssql": "^9.1.8",
|
||||
"aedes": "^0.51.3",
|
||||
"aedes-server-factory": "^0.2.1",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dockerode": "^4.0.10",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
@@ -44,6 +47,7 @@
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.5",
|
||||
"mqtt": "^5.15.1",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
@@ -65,6 +69,7 @@
|
||||
"@types/bwip-js": "^3.2.3",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/imap": "^0.8.42",
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 엣지 Config JSON → Pipeline DB 임포트
|
||||
*
|
||||
* 엣지 Python data-collector가 사용 중인 config_cache.json 포맷을 받아
|
||||
* pipeline_device_connections + pipeline_tag_mappings 테이블에 upsert.
|
||||
*
|
||||
* 프로토콜 매핑:
|
||||
* ls_xgt → PLC_ETHERNET
|
||||
* modbus_tcp → MODBUS_TCP
|
||||
* modbus_rtu → MODBUS_RTU
|
||||
* opcua → OPCUA
|
||||
* s7 → S7
|
||||
* mqtt → MQTT
|
||||
* rest_api → REST_API
|
||||
*
|
||||
* 중복 방지: (company_code, connection_name) 기준으로 이미 존재하면 tags만 sync.
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "./db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export interface EdgeImportTag {
|
||||
name: string;
|
||||
address: string | number | null;
|
||||
data_type?: string;
|
||||
byte_order?: string;
|
||||
scale?: number;
|
||||
offset?: number;
|
||||
unit?: string | null;
|
||||
description?: string | null;
|
||||
bit_index?: number | null;
|
||||
deadband?: number | null;
|
||||
column_name?: string | null; // SQL 수집 시 target 컬럼명
|
||||
}
|
||||
|
||||
export interface EdgeImportDevice {
|
||||
id?: string;
|
||||
name: string;
|
||||
protocol: string;
|
||||
connection: { host?: string; port?: number; [k: string]: unknown };
|
||||
interval_ms?: number;
|
||||
enabled?: boolean;
|
||||
tags: EdgeImportTag[];
|
||||
}
|
||||
|
||||
export interface EdgeImportConfig {
|
||||
edge_id?: string;
|
||||
edge_name?: string;
|
||||
devices: EdgeImportDevice[];
|
||||
company_code?: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
edgeName: string;
|
||||
connections: Array<{
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
status: "created" | "updated";
|
||||
tagsInserted: number;
|
||||
tagsSkipped: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const PROTOCOL_MAP: Record<string, string> = {
|
||||
ls_xgt: "LS_XGT",
|
||||
xgt: "LS_XGT",
|
||||
plc_ethernet: "LS_XGT",
|
||||
modbus_tcp: "MODBUS_TCP",
|
||||
modbus_rtu: "MODBUS_RTU",
|
||||
opcua: "OPCUA",
|
||||
s7: "SIEMENS_S7",
|
||||
siemens_s7: "SIEMENS_S7",
|
||||
mqtt: "MQTT",
|
||||
rest_api: "REST_API",
|
||||
};
|
||||
|
||||
function normalizeProtocol(p: string): string {
|
||||
const key = (p || "").toLowerCase();
|
||||
return PROTOCOL_MAP[key] || p.toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeDataType(dt?: string): string {
|
||||
const v = (dt || "").toUpperCase();
|
||||
// pipeline_tag_mappings.tag_data_type CHECK: INT16, INT32, FLOAT32, FLOAT64, BOOLEAN, STRING
|
||||
switch (v) {
|
||||
case "UINT16":
|
||||
case "INT16":
|
||||
case "WORD":
|
||||
return "INT16";
|
||||
case "UINT32":
|
||||
case "INT32":
|
||||
case "DWORD":
|
||||
return "INT32";
|
||||
case "FLOAT":
|
||||
case "FLOAT32":
|
||||
case "REAL":
|
||||
return "FLOAT32";
|
||||
case "DOUBLE":
|
||||
case "FLOAT64":
|
||||
return "FLOAT64";
|
||||
case "BOOL":
|
||||
case "BOOLEAN":
|
||||
case "BIT":
|
||||
return "BOOLEAN";
|
||||
case "STR":
|
||||
case "STRING":
|
||||
return "STRING";
|
||||
default:
|
||||
return "INT16";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeByteOrder(bo?: string): string {
|
||||
if (!bo) return "BIG_ENDIAN";
|
||||
return bo.toUpperCase();
|
||||
}
|
||||
|
||||
export async function importEdgeConfig(
|
||||
cfg: EdgeImportConfig,
|
||||
user = "system"
|
||||
): Promise<ImportResult> {
|
||||
const companyCode = cfg.company_code || "*";
|
||||
const edgeName = cfg.edge_name || cfg.edge_id || "edge";
|
||||
const result: ImportResult = { edgeName, connections: [] };
|
||||
|
||||
for (const device of cfg.devices || []) {
|
||||
const connectionName = device.name;
|
||||
const protocol = normalizeProtocol(device.protocol);
|
||||
const host = device.connection?.host || "";
|
||||
const port = Number(device.connection?.port || 0);
|
||||
|
||||
// protocol_config: host/port 제외한 나머지 연결 속성
|
||||
const { host: _h, port: _p, ...protoCfg } = device.connection || {};
|
||||
|
||||
// 기존 연결 찾기
|
||||
let conn = await queryOne<{ id: number }>(
|
||||
`SELECT id FROM pipeline_device_connections
|
||||
WHERE connection_name = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[connectionName, companyCode]
|
||||
);
|
||||
|
||||
let status: "created" | "updated";
|
||||
|
||||
if (!conn) {
|
||||
const inserted = await queryOne<{ id: number }>(
|
||||
`INSERT INTO pipeline_device_connections
|
||||
(connection_name, description, protocol, host, port, protocol_config,
|
||||
polling_interval_ms, timeout_ms, retry_count, status,
|
||||
company_code, is_active, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
connectionName,
|
||||
`엣지에서 임포트: ${edgeName}`,
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
JSON.stringify(protoCfg || {}),
|
||||
device.interval_ms ?? 1000,
|
||||
5000,
|
||||
3,
|
||||
"inactive",
|
||||
companyCode,
|
||||
device.enabled === false ? "N" : "Y",
|
||||
user,
|
||||
]
|
||||
);
|
||||
conn = inserted!;
|
||||
status = "created";
|
||||
logger.info(`[EdgeImport] 신규 연결: ${connectionName} (id=${conn.id})`);
|
||||
} else {
|
||||
await query(
|
||||
`UPDATE pipeline_device_connections
|
||||
SET protocol = $1, host = $2, port = $3, protocol_config = $4::jsonb,
|
||||
polling_interval_ms = $5, updated_at = NOW()
|
||||
WHERE id = $6`,
|
||||
[
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
JSON.stringify(protoCfg || {}),
|
||||
device.interval_ms ?? 1000,
|
||||
conn.id,
|
||||
]
|
||||
);
|
||||
status = "updated";
|
||||
logger.info(`[EdgeImport] 연결 업데이트: ${connectionName} (id=${conn.id})`);
|
||||
}
|
||||
|
||||
// 태그 UPSERT
|
||||
let tagsInserted = 0;
|
||||
let tagsSkipped = 0;
|
||||
for (const tag of device.tags || []) {
|
||||
const existing = await queryOne<{ id: number }>(
|
||||
`SELECT id FROM pipeline_tag_mappings
|
||||
WHERE connection_id = $1 AND tag_name = $2
|
||||
LIMIT 1`,
|
||||
[conn.id, tag.name]
|
||||
);
|
||||
|
||||
const targetCol = tag.column_name ?? null;
|
||||
|
||||
if (existing) {
|
||||
await query(
|
||||
`UPDATE pipeline_tag_mappings
|
||||
SET address = $1, tag_data_type = $2, byte_order = $3,
|
||||
scale_factor = $4, offset_value = $5,
|
||||
bit_index = $6, deadband = $7,
|
||||
tag_unit = $8, description = $9, target_column_name = $10, updated_at = NOW()
|
||||
WHERE id = $11`,
|
||||
[
|
||||
tag.address != null ? String(tag.address) : "",
|
||||
normalizeDataType(tag.data_type),
|
||||
normalizeByteOrder(tag.byte_order),
|
||||
tag.scale ?? 1.0,
|
||||
tag.offset ?? 0.0,
|
||||
tag.bit_index ?? null,
|
||||
tag.deadband ?? null,
|
||||
tag.unit ?? null,
|
||||
tag.description ?? null,
|
||||
targetCol,
|
||||
existing.id,
|
||||
]
|
||||
);
|
||||
tagsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO pipeline_tag_mappings
|
||||
(connection_id, tag_name, tag_display_name, tag_unit, tag_data_type,
|
||||
address, scale_factor, offset_value, byte_order, bit_index, deadband,
|
||||
description, target_column_name, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())`,
|
||||
[
|
||||
conn.id,
|
||||
tag.name,
|
||||
tag.name,
|
||||
tag.unit ?? null,
|
||||
normalizeDataType(tag.data_type),
|
||||
tag.address != null ? String(tag.address) : "",
|
||||
tag.scale ?? 1.0,
|
||||
tag.offset ?? 0.0,
|
||||
normalizeByteOrder(tag.byte_order),
|
||||
tag.bit_index ?? null,
|
||||
tag.deadband ?? null,
|
||||
tag.description ?? null,
|
||||
targetCol,
|
||||
"Y",
|
||||
]
|
||||
);
|
||||
tagsInserted++;
|
||||
}
|
||||
|
||||
result.connections.push({
|
||||
connectionId: conn.id,
|
||||
connectionName,
|
||||
status,
|
||||
tagsInserted,
|
||||
tagsSkipped,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -320,6 +320,70 @@ export async function runPipelineDeviceMigration() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCentralForwarderMigration() {
|
||||
try {
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/308_central_forwarder_and_equipment_state.sql"
|
||||
);
|
||||
if (!fs.existsSync(sqlFilePath)) return;
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
console.log("✅ 중앙 MQTT 포워더 + 장비 현재값 테이블 생성 완료");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 중앙 포워더/장비 상태 테이블 이미 존재");
|
||||
} else {
|
||||
console.error("❌ 중앙 포워더 마이그레이션 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProtocolConstraintMigration() {
|
||||
try {
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/309_expand_protocol_constraint.sql"
|
||||
);
|
||||
if (!fs.existsSync(sqlFilePath)) return;
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
console.log("✅ 프로토콜 CHECK 제약 확장 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 프로토콜 제약 마이그레이션 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDataTargetMigration() {
|
||||
try {
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/310_add_data_target.sql"
|
||||
);
|
||||
if (!fs.existsSync(sqlFilePath)) return;
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
console.log("✅ 데이터 저장 대상 컬럼(target_db/table/column) 추가 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 데이터 저장 대상 마이그레이션 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runEdgeDeviceIdentifierMigration() {
|
||||
try {
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/311_add_edge_device_identifier.sql"
|
||||
);
|
||||
if (!fs.existsSync(sqlFilePath)) return;
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
console.log("✅ edge_identifier / device_identifier 컬럼 추가 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ edge/device identifier 마이그레이션 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenClawMigration() {
|
||||
try {
|
||||
console.log("🔄 OpenClaw AI 에이전트 마이그레이션 시작...");
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 기본 데이터 소스 연결 시드
|
||||
*
|
||||
* 부팅 시 IDC 엣지 관련 연결 정보를 external_db_connections 테이블에 등록.
|
||||
* 이미 같은 이름의 연결이 있으면 스킵.
|
||||
*
|
||||
* 등록 대상 (2026-04-21 기준):
|
||||
* - IDC Central TimescaleDB (edge_telemetry) — 수집 데이터 시계열
|
||||
* - IDC Digital-Twin PostgreSQL — 메타데이터
|
||||
* - IDC Fleet PostgreSQL — fleet 관리 메타
|
||||
* - IDC Vex Space PostgreSQL — Vex Space 전용
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "./db";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
interface DefaultDataSource {
|
||||
connection_name: string;
|
||||
description: string;
|
||||
db_type: "postgresql" | "mysql" | "mariadb" | "mssql" | "oracle";
|
||||
host: string;
|
||||
port: number;
|
||||
database_name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
company_code: string;
|
||||
is_active: "Y" | "N";
|
||||
connection_options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_SOURCES: DefaultDataSource[] = [
|
||||
{
|
||||
connection_name: "IDC_TimescaleDB_edge_telemetry",
|
||||
description:
|
||||
"IDC 중앙 TimescaleDB — 엣지 수집 데이터 시계열 (edge_telemetry DB). digital-twin-timescale NodePort :30543",
|
||||
db_type: "postgresql",
|
||||
host: "211.115.91.170",
|
||||
port: 30543,
|
||||
database_name: "edge_telemetry",
|
||||
username: "telemetry_user",
|
||||
password: "qlalfqjsgh11",
|
||||
company_code: "*",
|
||||
is_active: "Y",
|
||||
connection_options: { note: "TimescaleDB extension enabled" },
|
||||
},
|
||||
{
|
||||
connection_name: "IDC_DigitalTwin_Postgres",
|
||||
description:
|
||||
"IDC 중앙 Digital-Twin 웹 메타데이터 PostgreSQL (NodePort :30533). digital-twin-web-postgres",
|
||||
db_type: "postgresql",
|
||||
host: "211.115.91.170",
|
||||
port: 30533,
|
||||
database_name: "digital_twin_web_database",
|
||||
username: "digital_twin_web_user_dev",
|
||||
password: "", // 비어 있으면 스킵
|
||||
company_code: "*",
|
||||
is_active: "N", // 비밀번호 모르므로 비활성으로 등록
|
||||
},
|
||||
{
|
||||
connection_name: "IDC_VexSpace_Postgres",
|
||||
description: "IDC VexSpace 전용 PostgreSQL (NodePort :31141). vexspace-postgres",
|
||||
db_type: "postgresql",
|
||||
host: "211.115.91.170",
|
||||
port: 31141,
|
||||
database_name: "vexspace",
|
||||
username: "vexspace_user",
|
||||
password: "", // 비어 있으면 스킵
|
||||
company_code: "*",
|
||||
is_active: "N",
|
||||
},
|
||||
{
|
||||
connection_name: "IDC_Fleet_Postgres",
|
||||
description: "IDC Fleet 관리 PostgreSQL (NodePort :31985). fleet-postgres",
|
||||
db_type: "postgresql",
|
||||
host: "211.115.91.170",
|
||||
port: 31985,
|
||||
database_name: "fleet",
|
||||
username: "fleet_user",
|
||||
password: "", // 비밀번호 모르므로 비활성
|
||||
company_code: "*",
|
||||
is_active: "N",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 기본 데이터 소스 연결을 시드. 이미 존재하면 스킵.
|
||||
* 비밀번호가 비어있는 항목도 등록하지만 is_active='N'으로 두어 사용자가 나중에 채울 수 있게.
|
||||
*/
|
||||
export async function seedDefaultDataSources(): Promise<void> {
|
||||
try {
|
||||
// external_db_connections 테이블이 없으면 스킵
|
||||
const tableCheck = await queryOne<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'external_db_connections'
|
||||
) AS exists`
|
||||
);
|
||||
if (!tableCheck?.exists) {
|
||||
logger.info("[DataSourceSeed] external_db_connections 없음 — 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const src of DEFAULT_SOURCES) {
|
||||
const existing = await queryOne(
|
||||
`SELECT id FROM external_db_connections
|
||||
WHERE connection_name = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[src.connection_name, src.company_code]
|
||||
);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPassword = src.password
|
||||
? PasswordEncryption.encrypt(src.password)
|
||||
: "";
|
||||
|
||||
await query(
|
||||
`INSERT INTO external_db_connections (
|
||||
connection_name, description, db_type, host, port, database_name,
|
||||
username, password, connection_timeout, query_timeout, max_connections,
|
||||
ssl_enabled, connection_options, company_code, is_active,
|
||||
created_by, updated_by, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), NOW())`,
|
||||
[
|
||||
src.connection_name,
|
||||
src.description,
|
||||
src.db_type,
|
||||
src.host,
|
||||
src.port,
|
||||
src.database_name,
|
||||
src.username,
|
||||
encryptedPassword,
|
||||
30,
|
||||
60,
|
||||
10,
|
||||
"N",
|
||||
JSON.stringify(src.connection_options || {}),
|
||||
src.company_code,
|
||||
src.is_active,
|
||||
"system",
|
||||
"system",
|
||||
]
|
||||
);
|
||||
inserted++;
|
||||
logger.info(
|
||||
`[DataSourceSeed] 등록: ${src.connection_name} (${src.host}:${src.port}, is_active=${src.is_active})`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[DataSourceSeed] 완료: 신규 ${inserted}개, 기존 ${skipped}개 스킵`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`[DataSourceSeed] 실패: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Fleet Alert Rule Service
|
||||
* - 알림 규칙 CRUD (웹에서 편집 가능)
|
||||
* - 알림 채널 (email, messenger, webhook)
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface AlertRule {
|
||||
id?: number;
|
||||
rule_name: string;
|
||||
description?: string;
|
||||
company_code?: string;
|
||||
metric: string; // cpu_percent, memory_percent, disk_percent, offline_duration
|
||||
operator: string; // >, <, >=, <=, ==
|
||||
threshold: number;
|
||||
duration_sec?: number;
|
||||
severity?: "info" | "warning" | "critical";
|
||||
enabled?: boolean;
|
||||
notify_channels?: string[];
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export class FleetAlertRuleService {
|
||||
static async list(filter: { company_code?: string; enabled?: boolean } = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.company_code && filter.company_code !== "*") {
|
||||
wheres.push(`(company_code = $${idx} OR company_code = '*' OR company_code IS NULL)`);
|
||||
params.push(filter.company_code);
|
||||
idx++;
|
||||
}
|
||||
if (filter.enabled !== undefined) {
|
||||
wheres.push(`enabled = $${idx++}`);
|
||||
params.push(filter.enabled);
|
||||
}
|
||||
|
||||
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
return await query<any>(
|
||||
`SELECT r.*,
|
||||
(SELECT COUNT(*) FROM fleet_alerts WHERE rule_id = r.id) as alert_count,
|
||||
(SELECT COUNT(*) FROM fleet_alerts WHERE rule_id = r.id AND status = 'open') as open_count
|
||||
FROM fleet_alert_rules r ${where}
|
||||
ORDER BY r.severity DESC, r.id`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static async get(id: number) {
|
||||
return await queryOne<any>(`SELECT * FROM fleet_alert_rules WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
static async create(data: AlertRule) {
|
||||
if (!data.rule_name || !data.metric || !data.operator || data.threshold === undefined) {
|
||||
throw new Error("rule_name, metric, operator, threshold 필수");
|
||||
}
|
||||
const r = await query<any>(
|
||||
`INSERT INTO fleet_alert_rules
|
||||
(rule_name, description, company_code, metric, operator, threshold,
|
||||
duration_sec, severity, enabled, notify_channels, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.rule_name,
|
||||
data.description || null,
|
||||
data.company_code || "*",
|
||||
data.metric,
|
||||
data.operator,
|
||||
data.threshold,
|
||||
data.duration_sec || 60,
|
||||
data.severity || "warning",
|
||||
data.enabled !== false,
|
||||
JSON.stringify(data.notify_channels || []),
|
||||
data.created_by || null,
|
||||
],
|
||||
);
|
||||
logger.info(`[Fleet AlertRule] 생성: ${data.rule_name} (${data.metric} ${data.operator} ${data.threshold})`);
|
||||
return r[0];
|
||||
}
|
||||
|
||||
static async update(id: number, data: Partial<AlertRule>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const fields: (keyof AlertRule)[] = [
|
||||
"rule_name", "description", "company_code", "metric", "operator",
|
||||
"threshold", "duration_sec", "severity", "enabled",
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (data[f] !== undefined) { sets.push(`${f} = $${idx++}`); params.push(data[f]); }
|
||||
}
|
||||
if (data.notify_channels !== undefined) {
|
||||
sets.push(`notify_channels = $${idx++}::jsonb`);
|
||||
params.push(JSON.stringify(data.notify_channels));
|
||||
}
|
||||
sets.push(`updated_at = NOW()`);
|
||||
if (sets.length === 1) return this.get(id);
|
||||
|
||||
params.push(id);
|
||||
const r = await query<any>(
|
||||
`UPDATE fleet_alert_rules SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return r[0];
|
||||
}
|
||||
|
||||
static async delete(id: number) {
|
||||
await query(`DELETE FROM fleet_alert_rules WHERE id = $1`, [id]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
static async toggle(id: number) {
|
||||
const r = await query<any>(
|
||||
`UPDATE fleet_alert_rules SET enabled = NOT enabled, updated_at = NOW() WHERE id = $1 RETURNING *`,
|
||||
[id],
|
||||
);
|
||||
return r[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Fleet Audit Log Service
|
||||
* - 주요 이벤트 기록 (디바이스/커맨드/배포/스크립트/알림 등)
|
||||
* - 검색/필터링/통계
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
export interface AuditLog {
|
||||
event_type: string;
|
||||
actor_id?: string;
|
||||
actor_name?: string;
|
||||
target_type?: string;
|
||||
target_id?: string;
|
||||
action: string;
|
||||
before_data?: any;
|
||||
after_data?: any;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
result?: "success" | "failed";
|
||||
error_message?: string;
|
||||
company_code?: string;
|
||||
}
|
||||
|
||||
export class FleetAuditService {
|
||||
static async log(entry: AuditLog) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO fleet_audit_logs
|
||||
(event_type, actor_id, actor_name, target_type, target_id,
|
||||
action, before_data, after_data, ip_address, user_agent,
|
||||
result, error_message, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13)`,
|
||||
[
|
||||
entry.event_type,
|
||||
entry.actor_id || null,
|
||||
entry.actor_name || null,
|
||||
entry.target_type || null,
|
||||
entry.target_id || null,
|
||||
entry.action,
|
||||
entry.before_data ? JSON.stringify(entry.before_data) : null,
|
||||
entry.after_data ? JSON.stringify(entry.after_data) : null,
|
||||
entry.ip_address || null,
|
||||
entry.user_agent || null,
|
||||
entry.result || "success",
|
||||
entry.error_message || null,
|
||||
entry.company_code || null,
|
||||
],
|
||||
);
|
||||
} catch {
|
||||
// audit 실패가 주 로직을 막으면 안 됨
|
||||
}
|
||||
}
|
||||
|
||||
static async list(filter: {
|
||||
event_type?: string;
|
||||
target_type?: string;
|
||||
target_id?: string;
|
||||
actor_id?: string;
|
||||
result?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
company_code?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.event_type) { wheres.push(`event_type = $${idx++}`); params.push(filter.event_type); }
|
||||
if (filter.target_type) { wheres.push(`target_type = $${idx++}`); params.push(filter.target_type); }
|
||||
if (filter.target_id) { wheres.push(`target_id = $${idx++}`); params.push(filter.target_id); }
|
||||
if (filter.actor_id) { wheres.push(`actor_id = $${idx++}`); params.push(filter.actor_id); }
|
||||
if (filter.result) { wheres.push(`result = $${idx++}`); params.push(filter.result); }
|
||||
if (filter.from) { wheres.push(`created_at >= $${idx++}`); params.push(filter.from); }
|
||||
if (filter.to) { wheres.push(`created_at <= $${idx++}`); params.push(filter.to); }
|
||||
if (filter.company_code && filter.company_code !== "*") {
|
||||
wheres.push(`(company_code = $${idx} OR company_code IS NULL)`);
|
||||
params.push(filter.company_code);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
params.push(filter.limit || 200);
|
||||
|
||||
return await query<any>(
|
||||
`SELECT * FROM fleet_audit_logs ${where}
|
||||
ORDER BY created_at DESC LIMIT $${idx}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static async stats(filter: { from?: Date; to?: Date } = {}) {
|
||||
const params: any[] = [];
|
||||
const timeClause = filter.from && filter.to ? `created_at BETWEEN $1 AND $2` : "1=1";
|
||||
if (filter.from && filter.to) params.push(filter.from, filter.to);
|
||||
|
||||
const byEvent = await query<any>(
|
||||
`SELECT event_type, COUNT(*) as n FROM fleet_audit_logs WHERE ${timeClause}
|
||||
GROUP BY event_type ORDER BY n DESC LIMIT 20`,
|
||||
params,
|
||||
);
|
||||
const byActor = await query<any>(
|
||||
`SELECT actor_id, COUNT(*) as n FROM fleet_audit_logs
|
||||
WHERE ${timeClause} AND actor_id IS NOT NULL
|
||||
GROUP BY actor_id ORDER BY n DESC LIMIT 10`,
|
||||
params,
|
||||
);
|
||||
const failures = await queryOne<any>(
|
||||
`SELECT COUNT(*) as n FROM fleet_audit_logs WHERE ${timeClause} AND result = 'failed'`,
|
||||
params,
|
||||
);
|
||||
return { byEvent, byActor, failures: parseInt(failures?.n || 0) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Fleet Command Service
|
||||
* - 원격 커맨드 실행 (MQTT 기반)
|
||||
* - 9가지 커맨드 타입 지원
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { getFleetMqttBroker } from "./mqttBroker";
|
||||
|
||||
export type CommandType =
|
||||
| "restart_container"
|
||||
| "pull_image"
|
||||
| "update_agent"
|
||||
| "health_check"
|
||||
| "execute_script"
|
||||
| "deploy"
|
||||
| "rollback"
|
||||
| "collect_logs"
|
||||
| "restart_device";
|
||||
|
||||
export type CommandStatus = "pending" | "sent" | "executing" | "success" | "failed" | "timeout";
|
||||
|
||||
export interface FleetCommand {
|
||||
id?: number;
|
||||
device_id: string;
|
||||
command_type: CommandType;
|
||||
payload?: Record<string, any>;
|
||||
status?: CommandStatus;
|
||||
result?: Record<string, any>;
|
||||
error_message?: string;
|
||||
issued_by?: string;
|
||||
issued_at?: Date;
|
||||
sent_at?: Date;
|
||||
responded_at?: Date;
|
||||
timeout_sec?: number;
|
||||
}
|
||||
|
||||
export class FleetCommandService {
|
||||
/**
|
||||
* 커맨드 발행 (DB 저장 + MQTT 전송)
|
||||
*/
|
||||
static async issueCommand(
|
||||
deviceId: string,
|
||||
commandType: CommandType,
|
||||
payload: Record<string, any> = {},
|
||||
issuedBy?: string,
|
||||
timeoutSec = 300,
|
||||
): Promise<FleetCommand> {
|
||||
// 커맨드 타입 검증
|
||||
const typeCheck = await queryOne<any>(
|
||||
`SELECT command_type FROM fleet_command_types WHERE command_type = $1`,
|
||||
[commandType],
|
||||
);
|
||||
if (!typeCheck) {
|
||||
throw new Error(`알 수 없는 커맨드 타입: ${commandType}`);
|
||||
}
|
||||
|
||||
// 디바이스 확인
|
||||
const device = await queryOne<any>(
|
||||
`SELECT device_id, is_online FROM fleet_devices WHERE device_id = $1`,
|
||||
[deviceId],
|
||||
);
|
||||
if (!device) {
|
||||
throw new Error(`존재하지 않는 디바이스: ${deviceId}`);
|
||||
}
|
||||
|
||||
// DB에 커맨드 기록
|
||||
const result = await query<FleetCommand>(
|
||||
`INSERT INTO fleet_commands
|
||||
(device_id, command_type, payload, status, issued_by, timeout_sec)
|
||||
VALUES ($1, $2, $3::jsonb, 'pending', $4, $5)
|
||||
RETURNING *`,
|
||||
[deviceId, commandType, JSON.stringify(payload), issuedBy || null, timeoutSec],
|
||||
);
|
||||
const command = result[0];
|
||||
|
||||
// MQTT로 디바이스에 전송
|
||||
try {
|
||||
const broker = getFleetMqttBroker();
|
||||
await broker.sendCommandToDevice(deviceId, {
|
||||
command_id: command.id,
|
||||
command_type: commandType,
|
||||
payload,
|
||||
timeout_sec: timeoutSec,
|
||||
});
|
||||
|
||||
await query(
|
||||
`UPDATE fleet_commands SET status = 'sent', sent_at = NOW() WHERE id = $1`,
|
||||
[command.id],
|
||||
);
|
||||
command.status = "sent";
|
||||
logger.info(`[Fleet] 커맨드 발송: ${commandType} → ${deviceId} (id=${command.id})`);
|
||||
} catch (e: any) {
|
||||
await query(
|
||||
`UPDATE fleet_commands SET status = 'failed', error_message = $2 WHERE id = $1`,
|
||||
[command.id, `MQTT 발송 실패: ${e.message}`],
|
||||
);
|
||||
command.status = "failed";
|
||||
command.error_message = e.message;
|
||||
logger.error(`[Fleet] 커맨드 MQTT 전송 실패 (id=${command.id}):`, e);
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바이스 응답 수신 처리 (MQTT 구독자에서 호출)
|
||||
*/
|
||||
static async handleResponse(
|
||||
deviceId: string,
|
||||
response: {
|
||||
command_id: number;
|
||||
status: "success" | "failed" | "executing";
|
||||
result?: Record<string, any>;
|
||||
error?: string;
|
||||
},
|
||||
) {
|
||||
const newStatus: CommandStatus =
|
||||
response.status === "success"
|
||||
? "success"
|
||||
: response.status === "failed"
|
||||
? "failed"
|
||||
: "executing";
|
||||
|
||||
await query(
|
||||
`UPDATE fleet_commands SET
|
||||
status = $1,
|
||||
result = $2::jsonb,
|
||||
error_message = $3,
|
||||
responded_at = NOW()
|
||||
WHERE id = $4 AND device_id = $5`,
|
||||
[
|
||||
newStatus,
|
||||
JSON.stringify(response.result || {}),
|
||||
response.error || null,
|
||||
response.command_id,
|
||||
deviceId,
|
||||
],
|
||||
);
|
||||
logger.info(
|
||||
`[Fleet] 커맨드 응답: id=${response.command_id} device=${deviceId} status=${newStatus}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드 목록 조회
|
||||
*/
|
||||
static async listCommands(filter: {
|
||||
device_id?: string;
|
||||
command_type?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.device_id) {
|
||||
wheres.push(`device_id = $${idx++}`);
|
||||
params.push(filter.device_id);
|
||||
}
|
||||
if (filter.command_type) {
|
||||
wheres.push(`command_type = $${idx++}`);
|
||||
params.push(filter.command_type);
|
||||
}
|
||||
if (filter.status) {
|
||||
wheres.push(`status = $${idx++}`);
|
||||
params.push(filter.status);
|
||||
}
|
||||
|
||||
const whereClause = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
params.push(filter.limit || 100);
|
||||
|
||||
return await query<any>(
|
||||
`SELECT * FROM fleet_commands ${whereClause}
|
||||
ORDER BY issued_at DESC LIMIT $${idx}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임아웃된 커맨드 정리 (주기적으로 호출)
|
||||
*/
|
||||
static async markTimedOutCommands() {
|
||||
const result = await query<any>(
|
||||
`UPDATE fleet_commands
|
||||
SET status = 'timeout',
|
||||
error_message = '응답 타임아웃'
|
||||
WHERE status IN ('sent', 'executing')
|
||||
AND sent_at IS NOT NULL
|
||||
AND sent_at < NOW() - (timeout_sec || ' seconds')::INTERVAL
|
||||
RETURNING id, device_id, command_type`,
|
||||
);
|
||||
if (result.length > 0) {
|
||||
logger.warn(`[Fleet] 타임아웃 커맨드: ${result.length}건`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static async getCommandTypes() {
|
||||
return await query<any>(
|
||||
`SELECT * FROM fleet_command_types ORDER BY category, command_type`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Fleet Edge Data Service
|
||||
* - 엣지에서 수집된 실시간 데이터 조회
|
||||
* - 장비별/태그별 시계열 데이터
|
||||
*/
|
||||
|
||||
import { query } from "../database/db";
|
||||
|
||||
export class FleetDataService {
|
||||
/**
|
||||
* 디바이스별 최신 태그 값 (각 태그의 가장 최근 값)
|
||||
*/
|
||||
static async getLatestValuesByDevice(deviceId: string) {
|
||||
return await query<any>(
|
||||
`SELECT DISTINCT ON (tag_name)
|
||||
tag_name,
|
||||
value,
|
||||
value_text,
|
||||
quality,
|
||||
time,
|
||||
equipment_id,
|
||||
connection_id
|
||||
FROM fleet_edge_raw_data
|
||||
WHERE device_id = $1 AND time > NOW() - INTERVAL '1 hour'
|
||||
ORDER BY tag_name, time DESC`,
|
||||
[deviceId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 장비별 최신 태그 값 (pipeline_equipment 기준)
|
||||
*/
|
||||
static async getLatestValuesByEquipment(equipmentId: number) {
|
||||
return await query<any>(
|
||||
`SELECT DISTINCT ON (tag_name)
|
||||
tag_name,
|
||||
value,
|
||||
value_text,
|
||||
quality,
|
||||
time,
|
||||
device_id
|
||||
FROM fleet_edge_raw_data
|
||||
WHERE equipment_id = $1 AND time > NOW() - INTERVAL '1 hour'
|
||||
ORDER BY tag_name, time DESC`,
|
||||
[equipmentId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그별 시계열 데이터 (차트용)
|
||||
*/
|
||||
static async getTagTimeseries(
|
||||
deviceId: string,
|
||||
tagName: string,
|
||||
fromTime?: Date,
|
||||
toTime?: Date,
|
||||
limit = 500,
|
||||
) {
|
||||
const from = fromTime || new Date(Date.now() - 60 * 60 * 1000); // 기본 1시간
|
||||
const to = toTime || new Date();
|
||||
|
||||
return await query<any>(
|
||||
`SELECT time, value, value_text, quality
|
||||
FROM fleet_edge_raw_data
|
||||
WHERE device_id = $1 AND tag_name = $2
|
||||
AND time >= $3 AND time <= $4
|
||||
ORDER BY time DESC
|
||||
LIMIT $5`,
|
||||
[deviceId, tagName, from, to, limit],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 통계
|
||||
*/
|
||||
static async getCollectionStats(deviceId?: string) {
|
||||
const params: any[] = [];
|
||||
let where = "WHERE time > NOW() - INTERVAL '24 hours'";
|
||||
if (deviceId) {
|
||||
params.push(deviceId);
|
||||
where += ` AND device_id = $${params.length}`;
|
||||
}
|
||||
|
||||
const r = await query<any>(
|
||||
`SELECT
|
||||
COUNT(*) as total_records,
|
||||
COUNT(DISTINCT device_id) as device_count,
|
||||
COUNT(DISTINCT tag_name) as tag_count,
|
||||
MIN(time) as first_record,
|
||||
MAX(time) as last_record
|
||||
FROM fleet_edge_raw_data ${where}`,
|
||||
params,
|
||||
);
|
||||
return r[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Fleet Deployment Service - 실제 배포 엔진
|
||||
* - 릴리즈를 선택한 디바이스에 배포
|
||||
* - 롤아웃 전략: immediate, canary, rolling
|
||||
* - 각 디바이스별 deploy 커맨드 발행 (MQTT)
|
||||
* - 응답 받아 성공/실패 집계
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { FleetCommandService } from "./fleetCommandService";
|
||||
|
||||
export type DeploymentStatus =
|
||||
| "pending" // 생성됨, 아직 실행 전
|
||||
| "running" // 진행 중
|
||||
| "paused" // 일시정지 (실패율 초과)
|
||||
| "completed" // 완료
|
||||
| "failed" // 실패
|
||||
| "cancelled" // 취소됨
|
||||
| "rolled_back";
|
||||
|
||||
export interface FleetDeployment {
|
||||
id?: number;
|
||||
release_id: number;
|
||||
target_type: "all" | "company" | "group" | "device_list";
|
||||
target_value?: string; // company_code, group name, or device ids (csv)
|
||||
rollout_strategy: "immediate" | "canary" | "rolling";
|
||||
rollout_percentage?: number;
|
||||
batch_size?: number;
|
||||
max_failures?: number;
|
||||
pause_on_failure?: boolean;
|
||||
description?: string;
|
||||
scheduled_at?: Date;
|
||||
status?: DeploymentStatus;
|
||||
}
|
||||
|
||||
export class FleetDeploymentService {
|
||||
static async list(filter: { status?: string; release_id?: number } = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.status) { wheres.push(`d.status = $${idx++}`); params.push(filter.status); }
|
||||
if (filter.release_id) { wheres.push(`d.release_id = $${idx++}`); params.push(filter.release_id); }
|
||||
|
||||
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
return await query<any>(
|
||||
`SELECT d.*,
|
||||
r.version as release_version, r.backend_image, r.frontend_image, r.agent_image
|
||||
FROM fleet_deployments d
|
||||
LEFT JOIN fleet_releases r ON d.release_id = r.id
|
||||
${where}
|
||||
ORDER BY d.created_at DESC LIMIT 100`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static async get(id: number) {
|
||||
return await queryOne<any>(
|
||||
`SELECT d.*, r.version as release_version, r.backend_image, r.frontend_image, r.agent_image
|
||||
FROM fleet_deployments d
|
||||
LEFT JOIN fleet_releases r ON d.release_id = r.id
|
||||
WHERE d.id = $1`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
static async getStatus(deploymentId: number) {
|
||||
return await query<any>(
|
||||
`SELECT ds.*, d.device_name, d.company_code
|
||||
FROM fleet_deployment_status ds
|
||||
LEFT JOIN fleet_devices d ON ds.device_id = d.device_id
|
||||
WHERE ds.deployment_id = $1
|
||||
ORDER BY ds.device_id`,
|
||||
[deploymentId],
|
||||
);
|
||||
}
|
||||
|
||||
static async create(data: FleetDeployment & { created_by?: string }): Promise<any> {
|
||||
if (!data.release_id || !data.target_type) {
|
||||
throw new Error("release_id, target_type 필수");
|
||||
}
|
||||
|
||||
// 대상 디바이스 선정
|
||||
const deviceIds = await this.resolveTargetDevices(data.target_type, data.target_value);
|
||||
if (deviceIds.length === 0) throw new Error("대상 디바이스가 없습니다.");
|
||||
|
||||
const deploy = await query<any>(
|
||||
`INSERT INTO fleet_deployments
|
||||
(release_id, target_type, target_value, rollout_strategy, rollout_percentage,
|
||||
batch_size, max_failures, pause_on_failure, description, scheduled_at,
|
||||
status, total_devices, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11,$12)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.release_id,
|
||||
data.target_type,
|
||||
data.target_value || null,
|
||||
data.rollout_strategy || "rolling",
|
||||
data.rollout_percentage || 100,
|
||||
data.batch_size || 10,
|
||||
data.max_failures || 3,
|
||||
data.pause_on_failure !== false,
|
||||
data.description || null,
|
||||
data.scheduled_at || null,
|
||||
deviceIds.length,
|
||||
data.created_by || null,
|
||||
],
|
||||
);
|
||||
|
||||
// 각 대상 디바이스별 status 레코드 생성
|
||||
for (const did of deviceIds) {
|
||||
await query(
|
||||
`INSERT INTO fleet_deployment_status (deployment_id, device_id, status)
|
||||
VALUES ($1, $2, 'pending')`,
|
||||
[deploy[0].id, did],
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`[Fleet Deploy] 생성: id=${deploy[0].id}, 대상 ${deviceIds.length}개`);
|
||||
return { ...deploy[0], device_count: deviceIds.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 배포 대상 디바이스 목록 해석
|
||||
*/
|
||||
static async resolveTargetDevices(
|
||||
targetType: string,
|
||||
targetValue?: string | null,
|
||||
): Promise<string[]> {
|
||||
if (targetType === "all") {
|
||||
const r = await query<any>(
|
||||
`SELECT device_id FROM fleet_devices WHERE is_online = TRUE`,
|
||||
);
|
||||
return r.map((x: any) => x.device_id);
|
||||
}
|
||||
if (targetType === "company" && targetValue) {
|
||||
const r = await query<any>(
|
||||
`SELECT device_id FROM fleet_devices WHERE company_code = $1 AND is_online = TRUE`,
|
||||
[targetValue],
|
||||
);
|
||||
return r.map((x: any) => x.device_id);
|
||||
}
|
||||
if (targetType === "group" && targetValue) {
|
||||
const r = await query<any>(
|
||||
`SELECT device_id FROM fleet_devices WHERE device_group = $1 AND is_online = TRUE`,
|
||||
[targetValue],
|
||||
);
|
||||
return r.map((x: any) => x.device_id);
|
||||
}
|
||||
if (targetType === "device_list" && targetValue) {
|
||||
return targetValue.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 배포 시작 (실제 실행)
|
||||
* - 전략별 배치 처리
|
||||
* - 각 디바이스에 deploy 커맨드 발행 (MQTT)
|
||||
* - 실패율 모니터링
|
||||
*/
|
||||
static async start(deploymentId: number, issuedBy?: string): Promise<any> {
|
||||
const deploy = await this.get(deploymentId);
|
||||
if (!deploy) throw new Error("배포 없음");
|
||||
if (deploy.status !== "pending" && deploy.status !== "paused") {
|
||||
throw new Error(`현재 상태(${deploy.status})에서 시작 불가`);
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE fleet_deployments SET status = 'running', started_at = COALESCE(started_at, NOW()) WHERE id = $1`,
|
||||
[deploymentId],
|
||||
);
|
||||
|
||||
// 대기 중 디바이스 조회
|
||||
const pendingDevices = await query<any>(
|
||||
`SELECT device_id FROM fleet_deployment_status
|
||||
WHERE deployment_id = $1 AND status = 'pending'
|
||||
ORDER BY device_id`,
|
||||
[deploymentId],
|
||||
);
|
||||
|
||||
logger.info(`[Fleet Deploy] 시작: id=${deploymentId}, 대기중 ${pendingDevices.length}개`);
|
||||
|
||||
// 전략별 실행
|
||||
const strategy = deploy.rollout_strategy;
|
||||
const batchSize = deploy.batch_size || 10;
|
||||
|
||||
// 비동기로 백그라운드 배포 진행
|
||||
this.runDeployment(deploymentId, pendingDevices.map((d: any) => d.device_id), strategy, batchSize, issuedBy)
|
||||
.catch((e) => {
|
||||
logger.error(`[Fleet Deploy] 실행 에러 (id=${deploymentId}):`, e);
|
||||
});
|
||||
|
||||
return { deploymentId, status: "running", scheduled: pendingDevices.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 배포 루프 (백그라운드)
|
||||
*/
|
||||
private static async runDeployment(
|
||||
deploymentId: number,
|
||||
deviceIds: string[],
|
||||
strategy: string,
|
||||
batchSize: number,
|
||||
issuedBy?: string,
|
||||
): Promise<void> {
|
||||
const deploy = await this.get(deploymentId);
|
||||
const release = deploy ? { image: deploy.backend_image, version: deploy.release_version } : {};
|
||||
|
||||
let failures = 0;
|
||||
const maxFailures = deploy?.max_failures || 3;
|
||||
const pauseOnFail = deploy?.pause_on_failure !== false;
|
||||
|
||||
// 카나리: 첫 1개만 먼저 배포
|
||||
const executeOrder: string[][] = [];
|
||||
if (strategy === "canary" && deviceIds.length > 1) {
|
||||
executeOrder.push([deviceIds[0]]); // canary
|
||||
// 나머지는 배치로
|
||||
for (let i = 1; i < deviceIds.length; i += batchSize) {
|
||||
executeOrder.push(deviceIds.slice(i, i + batchSize));
|
||||
}
|
||||
} else if (strategy === "rolling") {
|
||||
for (let i = 0; i < deviceIds.length; i += batchSize) {
|
||||
executeOrder.push(deviceIds.slice(i, i + batchSize));
|
||||
}
|
||||
} else {
|
||||
// immediate
|
||||
executeOrder.push(deviceIds);
|
||||
}
|
||||
|
||||
for (const batch of executeOrder) {
|
||||
// 취소 체크
|
||||
const cur = await this.get(deploymentId);
|
||||
if (!cur || cur.status === "cancelled") {
|
||||
logger.info(`[Fleet Deploy] 취소 감지: id=${deploymentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
batch.map((did) => this.deployToDevice(deploymentId, did, deploy, issuedBy)),
|
||||
);
|
||||
|
||||
// 배치 완료 후 실패율 체크
|
||||
const failCount = await queryOne<any>(
|
||||
`SELECT COUNT(*) as n FROM fleet_deployment_status
|
||||
WHERE deployment_id = $1 AND status IN ('failed','timeout')`,
|
||||
[deploymentId],
|
||||
);
|
||||
failures = parseInt(failCount?.n || 0);
|
||||
|
||||
if (pauseOnFail && failures >= maxFailures) {
|
||||
await query(
|
||||
`UPDATE fleet_deployments SET status = 'paused', completed_at = NULL WHERE id = $1`,
|
||||
[deploymentId],
|
||||
);
|
||||
logger.warn(`[Fleet Deploy] 실패율 초과로 일시정지: id=${deploymentId} (실패 ${failures}개)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 카나리: 첫 배치 완료 후 안정성 대기
|
||||
if (strategy === "canary" && executeOrder.indexOf(batch) === 0) {
|
||||
await new Promise((r) => setTimeout(r, 3000)); // 3초 관찰
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 집계
|
||||
const stats = await queryOne<any>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'success') as success,
|
||||
COUNT(*) FILTER (WHERE status IN ('failed','timeout')) as failed,
|
||||
COUNT(*) as total
|
||||
FROM fleet_deployment_status WHERE deployment_id = $1`,
|
||||
[deploymentId],
|
||||
);
|
||||
|
||||
const finalStatus =
|
||||
parseInt(stats.failed) > 0
|
||||
? (parseInt(stats.success) > 0 ? "completed" : "failed")
|
||||
: "completed";
|
||||
|
||||
await query(
|
||||
`UPDATE fleet_deployments
|
||||
SET status = $2, completed_at = NOW(),
|
||||
success_count = $3, failed_count = $4
|
||||
WHERE id = $1`,
|
||||
[deploymentId, finalStatus, parseInt(stats.success), parseInt(stats.failed)],
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`[Fleet Deploy] 완료: id=${deploymentId} 성공 ${stats.success} / 실패 ${stats.failed} / 총 ${stats.total}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 디바이스 배포 (MQTT 커맨드)
|
||||
*/
|
||||
private static async deployToDevice(
|
||||
deploymentId: number,
|
||||
deviceId: string,
|
||||
deploy: any,
|
||||
issuedBy?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`UPDATE fleet_deployment_status
|
||||
SET status = 'running', started_at = NOW()
|
||||
WHERE deployment_id = $1 AND device_id = $2`,
|
||||
[deploymentId, deviceId],
|
||||
);
|
||||
|
||||
// MQTT로 deploy 커맨드 발행
|
||||
const cmd = await FleetCommandService.issueCommand(
|
||||
deviceId,
|
||||
"deploy",
|
||||
{
|
||||
deployment_id: deploymentId,
|
||||
release_id: deploy.release_id,
|
||||
backend_image: deploy.backend_image,
|
||||
frontend_image: deploy.frontend_image,
|
||||
agent_image: deploy.agent_image,
|
||||
version: deploy.release_version,
|
||||
},
|
||||
issuedBy,
|
||||
600, // 10분 타임아웃
|
||||
);
|
||||
|
||||
// 응답 대기 (폴링) - 60초
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const cs = await queryOne<any>(
|
||||
`SELECT status FROM fleet_commands WHERE id = $1`,
|
||||
[cmd.id],
|
||||
);
|
||||
if (["success", "failed", "timeout"].includes(cs?.status)) {
|
||||
await query(
|
||||
`UPDATE fleet_deployment_status
|
||||
SET status = $3, completed_at = NOW()
|
||||
WHERE deployment_id = $1 AND device_id = $2`,
|
||||
[deploymentId, deviceId, cs.status],
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 타임아웃
|
||||
await query(
|
||||
`UPDATE fleet_deployment_status
|
||||
SET status = 'timeout', completed_at = NOW(), error_message = '응답 타임아웃'
|
||||
WHERE deployment_id = $1 AND device_id = $2`,
|
||||
[deploymentId, deviceId],
|
||||
);
|
||||
} catch (e: any) {
|
||||
await query(
|
||||
`UPDATE fleet_deployment_status
|
||||
SET status = 'failed', completed_at = NOW(), error_message = $3
|
||||
WHERE deployment_id = $1 AND device_id = $2`,
|
||||
[deploymentId, deviceId, e.message],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static async cancel(deploymentId: number) {
|
||||
await query(
|
||||
`UPDATE fleet_deployments SET status = 'cancelled', completed_at = NOW() WHERE id = $1`,
|
||||
[deploymentId],
|
||||
);
|
||||
await query(
|
||||
`UPDATE fleet_deployment_status
|
||||
SET status = 'cancelled', completed_at = NOW()
|
||||
WHERE deployment_id = $1 AND status IN ('pending','running')`,
|
||||
[deploymentId],
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 롤백: 해당 배포의 이전 버전으로 복원
|
||||
*/
|
||||
static async rollback(deploymentId: number, issuedBy?: string) {
|
||||
const deploy = await this.get(deploymentId);
|
||||
if (!deploy) throw new Error("배포 없음");
|
||||
if (!["completed", "failed", "paused"].includes(deploy.status)) {
|
||||
throw new Error(`현재 상태(${deploy.status})에서 롤백 불가`);
|
||||
}
|
||||
|
||||
// 이전 completed 배포 찾기 (같은 target)
|
||||
const previous = await queryOne<any>(
|
||||
`SELECT d.*, r.backend_image, r.frontend_image, r.agent_image, r.version as release_version
|
||||
FROM fleet_deployments d
|
||||
LEFT JOIN fleet_releases r ON d.release_id = r.id
|
||||
WHERE d.id < $1
|
||||
AND d.target_type = $2
|
||||
AND d.target_value IS NOT DISTINCT FROM $3
|
||||
AND d.status = 'completed'
|
||||
ORDER BY d.id DESC LIMIT 1`,
|
||||
[deploymentId, deploy.target_type, deploy.target_value],
|
||||
);
|
||||
|
||||
if (!previous) throw new Error("롤백할 이전 배포 없음");
|
||||
|
||||
const rollbackDeploy = await this.create({
|
||||
release_id: previous.release_id,
|
||||
target_type: deploy.target_type,
|
||||
target_value: deploy.target_value,
|
||||
rollout_strategy: "immediate",
|
||||
rollout_percentage: 100,
|
||||
description: `롤백: #${deploymentId} → #${previous.id} (${previous.release_version})`,
|
||||
created_by: issuedBy,
|
||||
} as any);
|
||||
|
||||
await this.start(rollbackDeploy.id, issuedBy);
|
||||
|
||||
await query(
|
||||
`UPDATE fleet_deployments SET status = 'rolled_back' WHERE id = $1`,
|
||||
[deploymentId],
|
||||
);
|
||||
|
||||
return { deploymentId: rollbackDeploy.id, originalId: deploymentId, previousReleaseId: previous.release_id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Fleet Device Service
|
||||
* - 디바이스 등록 (하드웨어 핑거프린트 기반 DPS 패턴)
|
||||
* - 상태 조회 / 업데이트
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface FleetDevice {
|
||||
id?: number;
|
||||
device_id: string;
|
||||
company_code?: string;
|
||||
company_id?: string;
|
||||
device_name?: string;
|
||||
device_type?: string;
|
||||
ip_address?: string;
|
||||
mac_address?: string;
|
||||
hardware_fingerprint?: string;
|
||||
last_seen_at?: Date;
|
||||
is_online?: boolean;
|
||||
equipment_id?: number | null;
|
||||
agent_version?: string;
|
||||
app_version?: string;
|
||||
os_info?: Record<string, any>;
|
||||
hardware_info?: Record<string, any>;
|
||||
device_group?: string;
|
||||
tags?: any[];
|
||||
}
|
||||
|
||||
export class FleetDeviceService {
|
||||
/**
|
||||
* 디바이스 등록 (핑거프린트 기반 - DPS 패턴)
|
||||
* - 동일한 핑거프린트가 있으면 기존 레코드 업데이트
|
||||
* - 없으면 신규 등록
|
||||
*/
|
||||
static async registerDevice(data: Partial<FleetDevice>): Promise<FleetDevice> {
|
||||
if (!data.device_id) throw new Error("device_id 필수");
|
||||
|
||||
// 핑거프린트 매칭
|
||||
if (data.hardware_fingerprint) {
|
||||
const existing = await queryOne<any>(
|
||||
`SELECT * FROM fleet_devices WHERE hardware_fingerprint = $1 LIMIT 1`,
|
||||
[data.hardware_fingerprint],
|
||||
);
|
||||
if (existing && existing.device_id !== data.device_id) {
|
||||
logger.info(
|
||||
`[Fleet] 핑거프린트 중복 감지 - 기존 device_id 재사용: ${existing.device_id} (요청: ${data.device_id})`,
|
||||
);
|
||||
data.device_id = existing.device_id;
|
||||
}
|
||||
}
|
||||
|
||||
// UPSERT
|
||||
const result = await query<FleetDevice>(
|
||||
`INSERT INTO fleet_devices
|
||||
(device_id, company_code, company_id, device_name, device_type,
|
||||
ip_address, mac_address, hardware_fingerprint,
|
||||
agent_version, app_version, os_info, hardware_info,
|
||||
device_group, tags, equipment_id, last_seen_at, is_online)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14::jsonb,$15,NOW(),TRUE)
|
||||
ON CONFLICT (device_id) DO UPDATE SET
|
||||
company_code = COALESCE(EXCLUDED.company_code, fleet_devices.company_code),
|
||||
device_name = COALESCE(EXCLUDED.device_name, fleet_devices.device_name),
|
||||
device_type = COALESCE(EXCLUDED.device_type, fleet_devices.device_type),
|
||||
ip_address = COALESCE(EXCLUDED.ip_address, fleet_devices.ip_address),
|
||||
mac_address = COALESCE(EXCLUDED.mac_address, fleet_devices.mac_address),
|
||||
hardware_fingerprint = COALESCE(EXCLUDED.hardware_fingerprint, fleet_devices.hardware_fingerprint),
|
||||
agent_version = COALESCE(EXCLUDED.agent_version, fleet_devices.agent_version),
|
||||
app_version = COALESCE(EXCLUDED.app_version, fleet_devices.app_version),
|
||||
os_info = COALESCE(EXCLUDED.os_info, fleet_devices.os_info),
|
||||
hardware_info = COALESCE(EXCLUDED.hardware_info, fleet_devices.hardware_info),
|
||||
device_group = COALESCE(EXCLUDED.device_group, fleet_devices.device_group),
|
||||
equipment_id = COALESCE(EXCLUDED.equipment_id, fleet_devices.equipment_id),
|
||||
last_seen_at = NOW(),
|
||||
is_online = TRUE,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[
|
||||
data.device_id,
|
||||
data.company_code || null,
|
||||
data.company_id || null,
|
||||
data.device_name || null,
|
||||
data.device_type || "edge",
|
||||
data.ip_address || null,
|
||||
data.mac_address || null,
|
||||
data.hardware_fingerprint || null,
|
||||
data.agent_version || null,
|
||||
data.app_version || null,
|
||||
JSON.stringify(data.os_info || {}),
|
||||
JSON.stringify(data.hardware_info || {}),
|
||||
data.device_group || null,
|
||||
JSON.stringify(data.tags || []),
|
||||
data.equipment_id || null,
|
||||
],
|
||||
);
|
||||
|
||||
logger.info(`[Fleet] 디바이스 등록/업데이트: ${data.device_id}`);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async listDevices(filter: {
|
||||
company_code?: string;
|
||||
is_online?: boolean;
|
||||
device_type?: string;
|
||||
search?: string;
|
||||
} = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.company_code && filter.company_code !== "*") {
|
||||
wheres.push(`(d.company_code = $${idx} OR d.company_code = '*')`);
|
||||
params.push(filter.company_code);
|
||||
idx++;
|
||||
}
|
||||
if (filter.is_online !== undefined) {
|
||||
wheres.push(`d.is_online = $${idx}`);
|
||||
params.push(filter.is_online);
|
||||
idx++;
|
||||
}
|
||||
if (filter.device_type) {
|
||||
wheres.push(`d.device_type = $${idx}`);
|
||||
params.push(filter.device_type);
|
||||
idx++;
|
||||
}
|
||||
if (filter.search?.trim()) {
|
||||
wheres.push(`(d.device_id ILIKE $${idx} OR d.device_name ILIKE $${idx})`);
|
||||
params.push(`%${filter.search.trim()}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
return await query<any>(
|
||||
`SELECT d.*, e.equipment_name, e.equipment_code
|
||||
FROM fleet_devices d
|
||||
LEFT JOIN pipeline_equipment e ON d.equipment_id = e.id
|
||||
${whereClause}
|
||||
ORDER BY d.is_online DESC, d.last_seen_at DESC NULLS LAST`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static async getDeviceById(deviceId: string) {
|
||||
return await queryOne<any>(
|
||||
`SELECT d.*, e.equipment_name, e.equipment_code
|
||||
FROM fleet_devices d
|
||||
LEFT JOIN pipeline_equipment e ON d.equipment_id = e.id
|
||||
WHERE d.device_id = $1`,
|
||||
[deviceId],
|
||||
);
|
||||
}
|
||||
|
||||
static async updateDevice(deviceId: string, data: Partial<FleetDevice>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const fields: (keyof FleetDevice)[] = [
|
||||
"device_name",
|
||||
"device_type",
|
||||
"company_code",
|
||||
"device_group",
|
||||
"equipment_id",
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (data[f] !== undefined) {
|
||||
sets.push(`${f} = $${idx++}`);
|
||||
params.push(data[f]);
|
||||
}
|
||||
}
|
||||
if (data.tags !== undefined) {
|
||||
sets.push(`tags = $${idx++}::jsonb`);
|
||||
params.push(JSON.stringify(data.tags));
|
||||
}
|
||||
|
||||
if (sets.length === 0) return this.getDeviceById(deviceId);
|
||||
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(deviceId);
|
||||
|
||||
const result = await query<any>(
|
||||
`UPDATE fleet_devices SET ${sets.join(", ")} WHERE device_id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async deleteDevice(deviceId: string) {
|
||||
await query(`DELETE FROM fleet_devices WHERE device_id = $1`, [deviceId]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat 수신 처리 - 디바이스 상태 + 메트릭 업데이트
|
||||
*/
|
||||
static async handleHeartbeat(deviceId: string, data: {
|
||||
status?: string;
|
||||
uptime_seconds?: number;
|
||||
cpu_percent?: number;
|
||||
memory_percent?: number;
|
||||
disk_percent?: number;
|
||||
containers?: any[];
|
||||
ip_address?: string;
|
||||
}) {
|
||||
// UPSERT: 없으면 자동 등록 (heartbeat 수신 = 자동 등록)
|
||||
await query(
|
||||
`INSERT INTO fleet_devices (device_id, ip_address, last_seen_at, is_online, device_type, company_code)
|
||||
VALUES ($1, $2, NOW(), TRUE, 'edge', '*')
|
||||
ON CONFLICT (device_id) DO UPDATE SET
|
||||
last_seen_at = NOW(),
|
||||
is_online = TRUE,
|
||||
ip_address = COALESCE(EXCLUDED.ip_address, fleet_devices.ip_address),
|
||||
updated_at = NOW()`,
|
||||
[deviceId, data.ip_address || null],
|
||||
);
|
||||
|
||||
// Heartbeat 로그 삽입
|
||||
await query(
|
||||
`INSERT INTO fleet_heartbeats
|
||||
(device_id, status, uptime_seconds, cpu_percent, memory_percent, disk_percent, containers)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)
|
||||
ON CONFLICT (device_id, received_at) DO NOTHING`,
|
||||
[
|
||||
deviceId,
|
||||
data.status || "online",
|
||||
data.uptime_seconds || null,
|
||||
data.cpu_percent || null,
|
||||
data.memory_percent || null,
|
||||
data.disk_percent || null,
|
||||
JSON.stringify(data.containers || []),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오프라인 감지 - 일정 시간 이상 heartbeat 없으면 offline 표시
|
||||
* 주기적으로 호출해야 함 (예: 1분마다)
|
||||
*/
|
||||
static async markStaleDevicesOffline(thresholdSeconds = 120) {
|
||||
const result = await query(
|
||||
`UPDATE fleet_devices
|
||||
SET is_online = FALSE, updated_at = NOW()
|
||||
WHERE is_online = TRUE
|
||||
AND (last_seen_at IS NULL OR last_seen_at < NOW() - ($1 || ' seconds')::INTERVAL)
|
||||
RETURNING device_id`,
|
||||
[thresholdSeconds.toString()],
|
||||
);
|
||||
if (result.length > 0) {
|
||||
logger.info(`[Fleet] 오프라인 감지: ${result.length}개 (${result.map((r: any) => r.device_id).join(", ")})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 heartbeat 메트릭 조회
|
||||
*/
|
||||
static async getRecentMetrics(deviceId: string, limit = 100) {
|
||||
return await query<any>(
|
||||
`SELECT * FROM fleet_heartbeats
|
||||
WHERE device_id = $1
|
||||
ORDER BY received_at DESC
|
||||
LIMIT $2`,
|
||||
[deviceId, limit],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Fleet Edge Config Service
|
||||
* - Python Data Collector가 부팅 시 호출
|
||||
* - GET /api/v1/edges/{deviceId}/config 응답 생성
|
||||
* - pipeline_device_connections + pipeline_tag_mappings → EdgeConfig 변환
|
||||
*/
|
||||
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface EdgeTagConfig {
|
||||
name: string;
|
||||
address: string | number;
|
||||
data_type: string;
|
||||
byte_order: string;
|
||||
scale: number;
|
||||
offset: number;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
bit_index?: number | null;
|
||||
deadband?: number | null;
|
||||
}
|
||||
|
||||
export interface EdgeDeviceConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: string; // modbus, opcua, xgt, s7, mqtt 등
|
||||
connection: Record<string, any>; // host, port, protocol_config
|
||||
interval_ms: number;
|
||||
tags: EdgeTagConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface EdgeScript {
|
||||
id: number;
|
||||
script_name: string;
|
||||
scope: string;
|
||||
equipment_id?: number | null;
|
||||
connection_id?: number | null;
|
||||
hook_type: string;
|
||||
code: string;
|
||||
priority: number;
|
||||
timeout_ms: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface EdgeFullConfig {
|
||||
version: string;
|
||||
edge_id: string;
|
||||
edge_name?: string;
|
||||
devices: EdgeDeviceConfig[];
|
||||
scripts: EdgeScript[];
|
||||
aggregation_interval_sec: number;
|
||||
local_retention_days: number;
|
||||
}
|
||||
|
||||
export class FleetEdgeConfigService {
|
||||
/**
|
||||
* 엣지 디바이스별 수집 설정 생성
|
||||
* - fleet_devices에서 해당 edge 정보 조회
|
||||
* - equipment_id로 연결된 pipeline_device_connections 조회
|
||||
* - 각 connection의 pipeline_tag_mappings 조회
|
||||
*/
|
||||
static async getEdgeConfig(edgeId: string): Promise<EdgeFullConfig> {
|
||||
// 디바이스 확인
|
||||
const device = await query<any>(
|
||||
`SELECT device_id, device_name, equipment_id, company_code
|
||||
FROM fleet_devices WHERE device_id = $1`,
|
||||
[edgeId],
|
||||
);
|
||||
|
||||
if (device.length === 0) {
|
||||
// 등록되지 않은 디바이스는 빈 설정 반환 (자동 등록은 heartbeat로)
|
||||
logger.warn(`[Fleet Config] 미등록 디바이스 요청: ${edgeId}`);
|
||||
return {
|
||||
version: "1.0",
|
||||
edge_id: edgeId,
|
||||
devices: [],
|
||||
scripts: [],
|
||||
aggregation_interval_sec: 60,
|
||||
local_retention_days: 7,
|
||||
};
|
||||
}
|
||||
|
||||
const edgeInfo = device[0];
|
||||
|
||||
// 이 엣지에 할당된 통신 연결 조회
|
||||
// 방법 1: fleet_devices.equipment_id → pipeline_device_connections.equipment_id
|
||||
// 방법 2: 회사 전체 연결 (company_code 매칭)
|
||||
const connections = await query<any>(
|
||||
`SELECT c.*, e.equipment_name, e.equipment_code
|
||||
FROM pipeline_device_connections c
|
||||
LEFT JOIN pipeline_equipment e ON c.equipment_id = e.id
|
||||
WHERE c.is_active = 'Y'
|
||||
AND (
|
||||
c.equipment_id = $1
|
||||
OR (c.company_code IS NULL OR c.company_code = $2 OR c.company_code = '*')
|
||||
)
|
||||
ORDER BY c.id`,
|
||||
[edgeInfo.equipment_id || 0, edgeInfo.company_code || "*"],
|
||||
);
|
||||
|
||||
// 각 연결의 태그 조회
|
||||
const devices: EdgeDeviceConfig[] = [];
|
||||
for (const conn of connections) {
|
||||
const tags = await query<any>(
|
||||
`SELECT * FROM pipeline_tag_mappings
|
||||
WHERE connection_id = $1 AND is_active = 'Y'
|
||||
ORDER BY id`,
|
||||
[conn.id],
|
||||
);
|
||||
|
||||
const protocolMap: Record<string, string> = {
|
||||
MODBUS_TCP: "modbus_tcp",
|
||||
MODBUS_RTU: "modbus_rtu",
|
||||
OPCUA: "opcua",
|
||||
SIEMENS_S7: "s7",
|
||||
LS_XGT: "xgt",
|
||||
MQTT: "mqtt",
|
||||
REST_API: "rest_api",
|
||||
};
|
||||
|
||||
devices.push({
|
||||
id: conn.id.toString(),
|
||||
name: conn.connection_name,
|
||||
protocol: protocolMap[conn.protocol] || conn.protocol.toLowerCase(),
|
||||
connection: {
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
...conn.protocol_config,
|
||||
},
|
||||
interval_ms: conn.polling_interval_ms || 1000,
|
||||
enabled: conn.is_active === "Y",
|
||||
tags: tags.map((t: any) => ({
|
||||
name: t.tag_name,
|
||||
address: t.address,
|
||||
data_type: t.tag_data_type || "UINT16",
|
||||
byte_order: t.byte_order || "BIG_ENDIAN",
|
||||
scale: parseFloat(t.scale_factor || 1),
|
||||
offset: parseFloat(t.offset_value || 0),
|
||||
unit: t.tag_unit || undefined,
|
||||
description: t.description || undefined,
|
||||
bit_index: t.bit_index ?? undefined,
|
||||
deadband: t.deadband ? parseFloat(t.deadband) : undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 버전: 최신 태그 업데이트 시각을 해시처럼 사용
|
||||
const versionResult = await query<any>(
|
||||
`SELECT
|
||||
MAX(GREATEST(c.updated_at, c.created_at)) as conn_ver,
|
||||
MAX(GREATEST(t.updated_at, t.created_at)) as tag_ver
|
||||
FROM pipeline_device_connections c
|
||||
LEFT JOIN pipeline_tag_mappings t ON t.connection_id = c.id
|
||||
WHERE c.is_active = 'Y'`,
|
||||
);
|
||||
const connVer = versionResult[0]?.conn_ver || new Date();
|
||||
const tagVer = versionResult[0]?.tag_ver || new Date();
|
||||
const maxVer = new Date(Math.max(new Date(connVer).getTime(), new Date(tagVer).getTime()));
|
||||
const version = maxVer.toISOString();
|
||||
|
||||
logger.info(
|
||||
`[Fleet Config] ${edgeId} 설정 제공: ${devices.length}개 장비, ` +
|
||||
`태그 ${devices.reduce((sum, d) => sum + d.tags.length, 0)}개, version=${version}`,
|
||||
);
|
||||
|
||||
// 이 엣지에 적용되는 Python hook 스크립트 조회
|
||||
const connectionIds = connections.map((c: any) => c.id);
|
||||
const { FleetScriptService } = await import("./fleetScriptService");
|
||||
const scripts = await FleetScriptService.getScriptsForEdge(
|
||||
edgeId,
|
||||
edgeInfo.equipment_id,
|
||||
connectionIds,
|
||||
);
|
||||
|
||||
return {
|
||||
version,
|
||||
edge_id: edgeId,
|
||||
edge_name: edgeInfo.device_name,
|
||||
devices,
|
||||
scripts,
|
||||
aggregation_interval_sec: 60,
|
||||
local_retention_days: 7,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 버전만 반환 (ETag 캐싱용 - Python이 If-None-Match로 확인)
|
||||
*/
|
||||
static async getConfigVersion(edgeId: string): Promise<string> {
|
||||
const r = await query<any>(
|
||||
`SELECT
|
||||
MAX(GREATEST(c.updated_at, c.created_at)) as ver
|
||||
FROM pipeline_device_connections c
|
||||
WHERE c.is_active = 'Y'`,
|
||||
);
|
||||
return r[0]?.ver ? new Date(r[0].ver).toISOString() : "1.0";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Fleet Harbor Registry Service
|
||||
* - harbor.wace.me에서 이미지 목록/태그 조회
|
||||
* - 릴리즈 생성 시 이미지 선택용
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const HARBOR_URL = process.env.HARBOR_URL || "https://harbor.wace.me";
|
||||
const HARBOR_USER = process.env.HARBOR_USER || "";
|
||||
const HARBOR_PASSWORD = process.env.HARBOR_PASSWORD || "";
|
||||
|
||||
export class FleetHarborService {
|
||||
private static client = axios.create({
|
||||
baseURL: HARBOR_URL,
|
||||
timeout: 15000,
|
||||
auth: HARBOR_USER && HARBOR_PASSWORD ? {
|
||||
username: HARBOR_USER,
|
||||
password: HARBOR_PASSWORD,
|
||||
} : undefined,
|
||||
validateStatus: (status) => status < 500,
|
||||
});
|
||||
|
||||
/**
|
||||
* 프로젝트 목록
|
||||
*/
|
||||
static async listProjects(): Promise<any[]> {
|
||||
try {
|
||||
const r = await this.client.get("/api/v2.0/projects", {
|
||||
params: { page: 1, page_size: 100 },
|
||||
});
|
||||
return (r.data || []).map((p: any) => ({
|
||||
project_id: p.project_id,
|
||||
name: p.name,
|
||||
public: p.metadata?.public === "true",
|
||||
repo_count: p.repo_count,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
logger.warn(`[Harbor] 프로젝트 조회 실패: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트의 리포지토리 목록
|
||||
*/
|
||||
static async listRepositories(projectName: string): Promise<any[]> {
|
||||
try {
|
||||
const r = await this.client.get(
|
||||
`/api/v2.0/projects/${encodeURIComponent(projectName)}/repositories`,
|
||||
{ params: { page: 1, page_size: 100 } },
|
||||
);
|
||||
return (r.data || []).map((repo: any) => ({
|
||||
name: repo.name,
|
||||
pull_count: repo.pull_count,
|
||||
artifact_count: repo.artifact_count,
|
||||
update_time: repo.update_time,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
logger.warn(`[Harbor] 리포 조회 실패 ${projectName}: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포지토리의 태그 목록
|
||||
*/
|
||||
static async listTags(projectName: string, repoName: string): Promise<any[]> {
|
||||
try {
|
||||
// Harbor에서 repo 이름이 project/repo 형식이면 뒷부분만 사용
|
||||
const repoKey = repoName.includes("/") ? repoName.split("/").slice(1).join("/") : repoName;
|
||||
const r = await this.client.get(
|
||||
`/api/v2.0/projects/${encodeURIComponent(projectName)}/repositories/${encodeURIComponent(repoKey)}/artifacts`,
|
||||
{ params: { page: 1, page_size: 50, with_tag: true } },
|
||||
);
|
||||
const tags: any[] = [];
|
||||
for (const artifact of r.data || []) {
|
||||
for (const tag of artifact.tags || []) {
|
||||
tags.push({
|
||||
tag: tag.name,
|
||||
digest: artifact.digest,
|
||||
size: artifact.size,
|
||||
push_time: tag.push_time,
|
||||
pull_time: tag.pull_time,
|
||||
full_ref: `${HARBOR_URL.replace(/^https?:\/\//, "")}/${projectName}/${repoKey}:${tag.name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return tags.sort((a, b) => new Date(b.push_time).getTime() - new Date(a.push_time).getTime());
|
||||
} catch (e: any) {
|
||||
logger.warn(`[Harbor] 태그 조회 실패 ${projectName}/${repoName}: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 전체 참조 조합 (릴리즈 생성 시 사용)
|
||||
* 예: harbor.wace.me/vexplor_fleet/data-collector:v1.2.3
|
||||
*/
|
||||
static buildImageRef(projectName: string, repoName: string, tag: string): string {
|
||||
const host = HARBOR_URL.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
const repoKey = repoName.includes("/") ? repoName.split("/").slice(1).join("/") : repoName;
|
||||
return `${host}/${projectName}/${repoKey}:${tag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Harbor 연결 상태 체크
|
||||
*/
|
||||
static async ping(): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
const r = await this.client.get("/api/v2.0/health");
|
||||
return { ok: r.status === 200, message: `Harbor ${r.data?.status || "OK"}` };
|
||||
} catch (e: any) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Fleet Prometheus Metrics
|
||||
* - /metrics 엔드포인트에 Prometheus text format으로 노출
|
||||
* - 디바이스/커맨드/배포/알림 통계
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
export class FleetMetricsService {
|
||||
/**
|
||||
* Prometheus text format 메트릭 생성
|
||||
*/
|
||||
static async generate(): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
|
||||
// 디바이스 상태
|
||||
const devices = await query<any>(
|
||||
`SELECT
|
||||
is_online,
|
||||
company_code,
|
||||
COUNT(*) as n
|
||||
FROM fleet_devices
|
||||
GROUP BY is_online, company_code`,
|
||||
);
|
||||
lines.push("# HELP fleet_devices_total 디바이스 총 개수");
|
||||
lines.push("# TYPE fleet_devices_total gauge");
|
||||
for (const d of devices) {
|
||||
const online = d.is_online ? "true" : "false";
|
||||
lines.push(
|
||||
`fleet_devices_total{online="${online}",company_code="${d.company_code || "unknown"}"} ${d.n}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 최근 1시간 heartbeat
|
||||
const hbStats = await queryOne<any>(
|
||||
`SELECT
|
||||
AVG(cpu_percent) as avg_cpu,
|
||||
AVG(memory_percent) as avg_memory,
|
||||
AVG(disk_percent) as avg_disk,
|
||||
COUNT(*) as hb_count
|
||||
FROM fleet_heartbeats
|
||||
WHERE received_at > NOW() - INTERVAL '1 hour'`,
|
||||
);
|
||||
lines.push("# HELP fleet_cpu_percent_avg 최근 1시간 평균 CPU (%)");
|
||||
lines.push("# TYPE fleet_cpu_percent_avg gauge");
|
||||
lines.push(`fleet_cpu_percent_avg ${hbStats?.avg_cpu || 0}`);
|
||||
lines.push("# HELP fleet_memory_percent_avg 최근 1시간 평균 메모리 (%)");
|
||||
lines.push("# TYPE fleet_memory_percent_avg gauge");
|
||||
lines.push(`fleet_memory_percent_avg ${hbStats?.avg_memory || 0}`);
|
||||
lines.push("# HELP fleet_heartbeat_count 최근 1시간 heartbeat 수");
|
||||
lines.push("# TYPE fleet_heartbeat_count counter");
|
||||
lines.push(`fleet_heartbeat_count ${hbStats?.hb_count || 0}`);
|
||||
|
||||
// 커맨드
|
||||
const cmds = await query<any>(
|
||||
`SELECT status, COUNT(*) as n FROM fleet_commands
|
||||
WHERE issued_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY status`,
|
||||
);
|
||||
lines.push("# HELP fleet_commands_total 최근 24시간 커맨드 (상태별)");
|
||||
lines.push("# TYPE fleet_commands_total counter");
|
||||
for (const c of cmds) {
|
||||
lines.push(`fleet_commands_total{status="${c.status}"} ${c.n}`);
|
||||
}
|
||||
|
||||
// 알림
|
||||
const alerts = await query<any>(
|
||||
`SELECT severity, status, COUNT(*) as n FROM fleet_alerts
|
||||
GROUP BY severity, status`,
|
||||
);
|
||||
lines.push("# HELP fleet_alerts_total 알림 (심각도/상태별)");
|
||||
lines.push("# TYPE fleet_alerts_total gauge");
|
||||
for (const a of alerts) {
|
||||
lines.push(
|
||||
`fleet_alerts_total{severity="${a.severity}",status="${a.status}"} ${a.n}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 배포
|
||||
const deploys = await query<any>(
|
||||
`SELECT status, COUNT(*) as n FROM fleet_deployments
|
||||
WHERE created_at > NOW() - INTERVAL '7 days'
|
||||
GROUP BY status`,
|
||||
);
|
||||
lines.push("# HELP fleet_deployments_total 최근 7일 배포 (상태별)");
|
||||
lines.push("# TYPE fleet_deployments_total counter");
|
||||
for (const d of deploys) {
|
||||
lines.push(`fleet_deployments_total{status="${d.status}"} ${d.n}`);
|
||||
}
|
||||
|
||||
// PLC 연결 상태
|
||||
const plcs = await query<any>(
|
||||
`SELECT status, COUNT(*) as n FROM fleet_plc_connections GROUP BY status`,
|
||||
);
|
||||
lines.push("# HELP fleet_plc_connections_total PLC 연결 (상태별)");
|
||||
lines.push("# TYPE fleet_plc_connections_total gauge");
|
||||
for (const p of plcs) {
|
||||
lines.push(`fleet_plc_connections_total{status="${p.status}"} ${p.n}`);
|
||||
}
|
||||
|
||||
// 실시간 데이터 수집
|
||||
const edgeData = await queryOne<any>(
|
||||
`SELECT
|
||||
COUNT(*) as records_1h,
|
||||
COUNT(DISTINCT device_id) as active_devices,
|
||||
COUNT(DISTINCT tag_name) as unique_tags
|
||||
FROM fleet_edge_raw_data
|
||||
WHERE time > NOW() - INTERVAL '1 hour'`,
|
||||
);
|
||||
lines.push("# HELP fleet_edge_records_1h 최근 1시간 수집 레코드");
|
||||
lines.push("# TYPE fleet_edge_records_1h counter");
|
||||
lines.push(`fleet_edge_records_1h ${edgeData?.records_1h || 0}`);
|
||||
lines.push("# HELP fleet_edge_active_devices 최근 1시간 활성 디바이스");
|
||||
lines.push("# TYPE fleet_edge_active_devices gauge");
|
||||
lines.push(`fleet_edge_active_devices ${edgeData?.active_devices || 0}`);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Fleet PLC Connection Status Service
|
||||
* - 각 디바이스의 PLC 연결 실시간 상태 추적
|
||||
* - MQTT로 엣지가 PLC 상태 변경 시 보고 → DB 업데이트
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface PlcConnectionStatus {
|
||||
id?: number;
|
||||
device_id: string;
|
||||
equipment_id?: number;
|
||||
connection_id?: number;
|
||||
protocol?: string;
|
||||
status: "connected" | "disconnected" | "error" | "unknown";
|
||||
last_connected_at?: Date;
|
||||
last_error_at?: Date;
|
||||
last_error_message?: string;
|
||||
tag_count?: number;
|
||||
uptime_sec?: number;
|
||||
reconnect_count?: number;
|
||||
}
|
||||
|
||||
export class FleetPlcStatusService {
|
||||
/**
|
||||
* 디바이스별 PLC 연결 상태 목록
|
||||
*/
|
||||
static async list(filter: { device_id?: string; status?: string } = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.device_id) { wheres.push(`p.device_id = $${idx++}`); params.push(filter.device_id); }
|
||||
if (filter.status) { wheres.push(`p.status = $${idx++}`); params.push(filter.status); }
|
||||
|
||||
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
return await query<any>(
|
||||
`SELECT p.*,
|
||||
e.equipment_name, e.equipment_code,
|
||||
c.connection_name
|
||||
FROM fleet_plc_connections p
|
||||
LEFT JOIN pipeline_equipment e ON p.equipment_id = e.id
|
||||
LEFT JOIN pipeline_device_connections c ON p.connection_id = c.id
|
||||
${where}
|
||||
ORDER BY p.status DESC, p.device_id, p.connection_id`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PLC 상태 보고 (엣지 에이전트가 MQTT로 전송)
|
||||
* topic: vexplor/devices/{deviceId}/plc-status
|
||||
*/
|
||||
static async report(deviceId: string, data: {
|
||||
connection_id: number;
|
||||
equipment_id?: number;
|
||||
protocol?: string;
|
||||
status: string;
|
||||
tag_count?: number;
|
||||
uptime_sec?: number;
|
||||
error_message?: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const isConnected = data.status === "connected";
|
||||
|
||||
await query(
|
||||
`INSERT INTO fleet_plc_connections
|
||||
(device_id, equipment_id, connection_id, protocol, status,
|
||||
last_connected_at, last_error_at, last_error_message,
|
||||
tag_count, uptime_sec, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (device_id, connection_id) DO UPDATE SET
|
||||
equipment_id = EXCLUDED.equipment_id,
|
||||
protocol = EXCLUDED.protocol,
|
||||
status = EXCLUDED.status,
|
||||
last_connected_at = CASE WHEN EXCLUDED.status = 'connected' THEN NOW() ELSE fleet_plc_connections.last_connected_at END,
|
||||
last_error_at = CASE WHEN EXCLUDED.status = 'error' THEN NOW() ELSE fleet_plc_connections.last_error_at END,
|
||||
last_error_message = EXCLUDED.last_error_message,
|
||||
tag_count = EXCLUDED.tag_count,
|
||||
uptime_sec = EXCLUDED.uptime_sec,
|
||||
reconnect_count = CASE
|
||||
WHEN fleet_plc_connections.status != 'connected' AND EXCLUDED.status = 'connected'
|
||||
THEN fleet_plc_connections.reconnect_count + 1
|
||||
ELSE fleet_plc_connections.reconnect_count
|
||||
END,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
deviceId,
|
||||
data.equipment_id || null,
|
||||
data.connection_id,
|
||||
data.protocol || null,
|
||||
data.status,
|
||||
isConnected ? now : null,
|
||||
data.status === "error" ? now : null,
|
||||
data.error_message || null,
|
||||
data.tag_count || 0,
|
||||
data.uptime_sec || 0,
|
||||
],
|
||||
);
|
||||
logger.debug(`[Fleet PLC] 상태 보고: ${deviceId}/conn${data.connection_id} = ${data.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 디바이스의 PLC 연결 모두 삭제
|
||||
*/
|
||||
static async clearDevice(deviceId: string) {
|
||||
await query(`DELETE FROM fleet_plc_connections WHERE device_id = $1`, [deviceId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 요약 통계
|
||||
*/
|
||||
static async summary() {
|
||||
const r = await query<any>(
|
||||
`SELECT
|
||||
status,
|
||||
COUNT(*) as n,
|
||||
COUNT(DISTINCT device_id) as devices
|
||||
FROM fleet_plc_connections
|
||||
GROUP BY status`,
|
||||
);
|
||||
const byStatus: Record<string, any> = {};
|
||||
r.forEach((row: any) => { byStatus[row.status] = { count: parseInt(row.n), devices: parseInt(row.devices) }; });
|
||||
return byStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Fleet Provisioning Service - DPS(Device Provisioning Service) 패턴
|
||||
* - 엣지가 부팅 후 MAC 주소 등록만으로 자동 프로비저닝
|
||||
* - 사전 등록된 디바이스 또는 신규 디바이스 처리
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import crypto from "crypto";
|
||||
|
||||
export interface ProvisionRequest {
|
||||
mac_address: string;
|
||||
hostname?: string;
|
||||
ip_address?: string;
|
||||
serial_number?: string;
|
||||
hardware_info?: Record<string, any>;
|
||||
os_info?: Record<string, any>;
|
||||
company_code?: string;
|
||||
}
|
||||
|
||||
export interface ProvisionResponse {
|
||||
device_id: string;
|
||||
mqtt_broker_url: string;
|
||||
api_url: string;
|
||||
token?: string;
|
||||
assigned_equipment_id?: number;
|
||||
pre_registered: boolean;
|
||||
}
|
||||
|
||||
export class FleetProvisionService {
|
||||
private static readonly MQTT_BROKER_URL =
|
||||
process.env.FLEET_MQTT_BROKER || `mqtt://${process.env.FLEET_HOST || "localhost"}:1883`;
|
||||
private static readonly API_URL =
|
||||
process.env.FLEET_API_URL || `http://${process.env.FLEET_HOST || "localhost"}:8080/api/fleet`;
|
||||
|
||||
/**
|
||||
* 디바이스 프로비저닝 (DPS 패턴)
|
||||
*/
|
||||
static async provision(req: ProvisionRequest): Promise<ProvisionResponse> {
|
||||
if (!req.mac_address) throw new Error("mac_address 필수");
|
||||
|
||||
// 1. 하드웨어 핑거프린트 계산
|
||||
const fingerprint = this.computeFingerprint(req);
|
||||
|
||||
// 2. 기존 디바이스 조회 (핑거프린트 또는 MAC)
|
||||
let device = await queryOne<any>(
|
||||
`SELECT * FROM fleet_devices
|
||||
WHERE hardware_fingerprint = $1 OR mac_address = $2
|
||||
LIMIT 1`,
|
||||
[fingerprint, req.mac_address],
|
||||
);
|
||||
|
||||
let preRegistered = false;
|
||||
let deviceId: string;
|
||||
|
||||
if (device) {
|
||||
// 기존 디바이스 - 정보 업데이트
|
||||
deviceId = device.device_id;
|
||||
preRegistered = true;
|
||||
await query(
|
||||
`UPDATE fleet_devices SET
|
||||
ip_address = COALESCE($2, ip_address),
|
||||
hostname = COALESCE($3, hostname),
|
||||
hardware_info = $4::jsonb,
|
||||
os_info = $5::jsonb,
|
||||
hardware_fingerprint = $6,
|
||||
last_seen_at = NOW(),
|
||||
is_online = TRUE,
|
||||
updated_at = NOW()
|
||||
WHERE device_id = $1`,
|
||||
[
|
||||
deviceId,
|
||||
req.ip_address || null,
|
||||
req.hostname || null,
|
||||
JSON.stringify(req.hardware_info || {}),
|
||||
JSON.stringify(req.os_info || {}),
|
||||
fingerprint,
|
||||
],
|
||||
);
|
||||
logger.info(`[Fleet Provision] 기존 디바이스 재연결: ${deviceId}`);
|
||||
} else {
|
||||
// 신규 디바이스 자동 등록
|
||||
deviceId = this.generateDeviceId(req.mac_address);
|
||||
await query(
|
||||
`INSERT INTO fleet_devices
|
||||
(device_id, company_code, device_name, device_type,
|
||||
ip_address, mac_address, hostname, hardware_fingerprint,
|
||||
hardware_info, os_info, last_seen_at, is_online)
|
||||
VALUES ($1,$2,$3,'edge',$4,$5,$6,$7,$8::jsonb,$9::jsonb,NOW(),TRUE)`,
|
||||
[
|
||||
deviceId,
|
||||
req.company_code || "*",
|
||||
req.hostname || `Edge-${req.mac_address.slice(-5)}`,
|
||||
req.ip_address || null,
|
||||
req.mac_address,
|
||||
req.hostname || null,
|
||||
fingerprint,
|
||||
JSON.stringify(req.hardware_info || {}),
|
||||
JSON.stringify(req.os_info || {}),
|
||||
],
|
||||
);
|
||||
logger.info(`[Fleet Provision] 신규 디바이스 등록: ${deviceId}`);
|
||||
}
|
||||
|
||||
// 3. 프로비저닝 토큰 발급 (단순 랜덤, JWT 아님)
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// 4. 할당된 장비 찾기 (선택)
|
||||
const assigned = await queryOne<any>(
|
||||
`SELECT equipment_id FROM fleet_devices WHERE device_id = $1`,
|
||||
[deviceId],
|
||||
);
|
||||
|
||||
return {
|
||||
device_id: deviceId,
|
||||
mqtt_broker_url: this.MQTT_BROKER_URL,
|
||||
api_url: this.API_URL,
|
||||
token,
|
||||
assigned_equipment_id: assigned?.equipment_id || undefined,
|
||||
pre_registered: preRegistered,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 하드웨어 정보로부터 SHA-256 기반 핑거프린트 생성
|
||||
*/
|
||||
private static computeFingerprint(req: ProvisionRequest): string {
|
||||
const parts = [
|
||||
req.mac_address,
|
||||
req.serial_number || "",
|
||||
req.hardware_info?.cpu_id || "",
|
||||
req.hardware_info?.board_id || "",
|
||||
].filter(Boolean).join("|");
|
||||
return crypto.createHash("sha256").update(parts).digest("hex");
|
||||
}
|
||||
|
||||
private static generateDeviceId(mac: string): string {
|
||||
const prefix = "edge";
|
||||
const macShort = mac.replace(/[:-]/g, "").slice(-8).toLowerCase();
|
||||
return `${prefix}-${macShort}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사전 등록 목록 조회 (아직 연결 안 된 디바이스)
|
||||
*/
|
||||
static async listPreRegistered() {
|
||||
return await query<any>(
|
||||
`SELECT device_id, device_name, mac_address, hardware_fingerprint, company_code,
|
||||
last_seen_at, is_online, created_at
|
||||
FROM fleet_devices
|
||||
WHERE last_seen_at IS NULL OR last_seen_at < NOW() - INTERVAL '1 hour'
|
||||
ORDER BY created_at DESC`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사전 등록 (MAC/핑거프린트만 미리 등록)
|
||||
*/
|
||||
static async preRegister(data: {
|
||||
mac_address: string;
|
||||
device_name?: string;
|
||||
company_code?: string;
|
||||
equipment_id?: number;
|
||||
device_group?: string;
|
||||
}) {
|
||||
const deviceId = this.generateDeviceId(data.mac_address);
|
||||
const r = await query<any>(
|
||||
`INSERT INTO fleet_devices
|
||||
(device_id, mac_address, device_name, company_code, equipment_id, device_group, device_type, is_online)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,'edge',FALSE)
|
||||
ON CONFLICT (device_id) DO UPDATE SET
|
||||
mac_address = EXCLUDED.mac_address,
|
||||
device_name = EXCLUDED.device_name,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[
|
||||
deviceId,
|
||||
data.mac_address,
|
||||
data.device_name || `Pre-${data.mac_address.slice(-5)}`,
|
||||
data.company_code || "*",
|
||||
data.equipment_id || null,
|
||||
data.device_group || null,
|
||||
],
|
||||
);
|
||||
return r[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Fleet Release Service
|
||||
* - 릴리즈 버전 관리 (Harbor 이미지 정보 포함)
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface FleetRelease {
|
||||
id?: number;
|
||||
version: string;
|
||||
release_type?: string;
|
||||
backend_image?: string;
|
||||
frontend_image?: string;
|
||||
agent_image?: string;
|
||||
changelog?: string;
|
||||
harbor_project?: string;
|
||||
is_canary?: boolean;
|
||||
status?: "draft" | "ready" | "released" | "deprecated";
|
||||
released_at?: Date;
|
||||
}
|
||||
|
||||
export class FleetReleaseService {
|
||||
static async list(filter: { status?: string; release_type?: string } = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.status) { wheres.push(`status = $${idx++}`); params.push(filter.status); }
|
||||
if (filter.release_type) { wheres.push(`release_type = $${idx++}`); params.push(filter.release_type); }
|
||||
|
||||
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
return await query<any>(
|
||||
`SELECT r.*,
|
||||
(SELECT COUNT(*) FROM fleet_deployments WHERE release_id = r.id) as deploy_count
|
||||
FROM fleet_releases r ${where}
|
||||
ORDER BY r.id DESC LIMIT 200`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static async get(id: number) {
|
||||
return await queryOne<any>(`SELECT * FROM fleet_releases WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
static async create(data: FleetRelease): Promise<any> {
|
||||
if (!data.version) throw new Error("version 필수");
|
||||
const r = await query<any>(
|
||||
`INSERT INTO fleet_releases
|
||||
(version, release_type, backend_image, frontend_image, agent_image, changelog, harbor_project, is_canary, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.version,
|
||||
data.release_type || "minor",
|
||||
data.backend_image || null,
|
||||
data.frontend_image || null,
|
||||
data.agent_image || null,
|
||||
data.changelog || null,
|
||||
data.harbor_project || null,
|
||||
data.is_canary || false,
|
||||
data.status || "draft",
|
||||
],
|
||||
);
|
||||
logger.info(`[Fleet Release] 생성: ${data.version} (id=${r[0].id})`);
|
||||
return r[0];
|
||||
}
|
||||
|
||||
static async update(id: number, data: Partial<FleetRelease>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const fields: (keyof FleetRelease)[] = [
|
||||
"version", "release_type", "backend_image", "frontend_image", "agent_image",
|
||||
"changelog", "harbor_project", "is_canary", "status",
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (data[f] !== undefined) { sets.push(`${f} = $${idx++}`); params.push(data[f]); }
|
||||
}
|
||||
if (data.status === "released" && !data.released_at) {
|
||||
sets.push(`released_at = NOW()`);
|
||||
}
|
||||
if (sets.length === 0) return this.get(id);
|
||||
|
||||
params.push(id);
|
||||
const r = await query<any>(
|
||||
`UPDATE fleet_releases SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return r[0];
|
||||
}
|
||||
|
||||
static async delete(id: number) {
|
||||
await query(`DELETE FROM fleet_releases WHERE id = $1`, [id]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 릴리즈 상태 전환 (draft → ready → released)
|
||||
*/
|
||||
static async transition(id: number, newStatus: "ready" | "released" | "deprecated") {
|
||||
const r = await this.get(id);
|
||||
if (!r) throw new Error("릴리즈 없음");
|
||||
|
||||
const allowedTransitions: Record<string, string[]> = {
|
||||
draft: ["ready", "deprecated"],
|
||||
ready: ["released", "deprecated"],
|
||||
released: ["deprecated"],
|
||||
deprecated: [],
|
||||
};
|
||||
|
||||
if (!allowedTransitions[r.status]?.includes(newStatus)) {
|
||||
throw new Error(`${r.status} → ${newStatus} 전환 불가`);
|
||||
}
|
||||
|
||||
return await this.update(id, { status: newStatus });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* Fleet Management REST API
|
||||
* GET/POST/PATCH/DELETE /api/fleet/...
|
||||
*/
|
||||
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { FleetDeviceService } from "./fleetDeviceService";
|
||||
import { FleetCommandService, CommandType } from "./fleetCommandService";
|
||||
import { query } from "../database/db";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 엣지 디바이스(Python)가 호출하는 공개 엔드포인트 (인증 전)
|
||||
import { FleetEdgeConfigService } from "./fleetEdgeConfigService";
|
||||
import { FleetProvisionService } from "./fleetProvisionService";
|
||||
|
||||
// DPS Provisioning (엣지 부팅 시 자동 등록)
|
||||
router.post("/provision", async (req, res) => {
|
||||
try {
|
||||
const result = await FleetProvisionService.provision(req.body);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/edge/:edgeId/config", async (req, res) => {
|
||||
try {
|
||||
const edgeId = req.params.edgeId;
|
||||
const ifNoneMatch = req.header("If-None-Match");
|
||||
|
||||
// ETag 체크
|
||||
const currentVersion = await FleetEdgeConfigService.getConfigVersion(edgeId);
|
||||
if (ifNoneMatch && ifNoneMatch === currentVersion) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
const config = await FleetEdgeConfigService.getEdgeConfig(edgeId);
|
||||
res.setHeader("ETag", config.version);
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.json(config);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// v1 호환 엔드포인트 (기존 Python 코드가 /api/v1/edges/{id}/config로 호출)
|
||||
router.get("/v1/edges/:edgeId/config", async (req, res) => {
|
||||
try {
|
||||
const config = await FleetEdgeConfigService.getEdgeConfig(req.params.edgeId);
|
||||
res.setHeader("ETag", config.version);
|
||||
res.json(config);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 엣지 Config JSON을 받아 Pipeline DB로 임포트 (마이그레이션용)
|
||||
router.post("/import-edge-config", async (req, res) => {
|
||||
try {
|
||||
const cfg = req.body;
|
||||
if (!cfg || !Array.isArray(cfg.devices)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "devices 배열이 필요합니다",
|
||||
});
|
||||
}
|
||||
const { importEdgeConfig } = await import("../database/importEdgeConfig");
|
||||
const result = await importEdgeConfig(cfg);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (e: any) {
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ========== 디바이스 ==========
|
||||
|
||||
router.get("/devices", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompany = req.user?.companyCode;
|
||||
const filter: any = {
|
||||
is_online: req.query.is_online === "true" ? true : req.query.is_online === "false" ? false : undefined,
|
||||
device_type: req.query.device_type as string,
|
||||
search: req.query.search as string,
|
||||
};
|
||||
if (userCompany && userCompany !== "*") {
|
||||
filter.company_code = userCompany;
|
||||
} else if (req.query.company_code) {
|
||||
filter.company_code = req.query.company_code as string;
|
||||
}
|
||||
const data = await FleetDeviceService.listDevices(filter);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/devices/register", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = {
|
||||
...req.body,
|
||||
company_code: req.body.company_code || req.user?.companyCode || "*",
|
||||
};
|
||||
const device = await FleetDeviceService.registerDevice(data);
|
||||
res.status(201).json({ success: true, data: device });
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/devices/:deviceId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDeviceService.getDeviceById(req.params.deviceId);
|
||||
if (!data) return res.status(404).json({ success: false, message: "디바이스를 찾을 수 없습니다." });
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/devices/:deviceId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDeviceService.updateDevice(req.params.deviceId, req.body);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/devices/:deviceId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await FleetDeviceService.deleteDevice(req.params.deviceId);
|
||||
res.json({ success: true });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/devices/:deviceId/metrics", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt((req.query.limit as string) || "100", 10);
|
||||
const data = await FleetDeviceService.getRecentMetrics(req.params.deviceId, limit);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 커맨드 ==========
|
||||
|
||||
router.get("/commands", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetCommandService.listCommands({
|
||||
device_id: req.query.device_id as string,
|
||||
command_type: req.query.command_type as string,
|
||||
status: req.query.status as string,
|
||||
limit: parseInt((req.query.limit as string) || "100", 10),
|
||||
});
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/commands/types", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetCommandService.getCommandTypes();
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/commands", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { device_id, command_type, payload, timeout_sec } = req.body;
|
||||
if (!device_id || !command_type) {
|
||||
return res.status(400).json({ success: false, message: "device_id와 command_type 필수" });
|
||||
}
|
||||
const command = await FleetCommandService.issueCommand(
|
||||
device_id,
|
||||
command_type as CommandType,
|
||||
payload || {},
|
||||
req.user?.userId,
|
||||
timeout_sec,
|
||||
);
|
||||
res.status(201).json({ success: true, data: command });
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 알림 ==========
|
||||
|
||||
router.get("/alerts", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const status = (req.query.status as string) || "open";
|
||||
const data = await query<any>(
|
||||
`SELECT a.*, r.rule_name FROM fleet_alerts a
|
||||
LEFT JOIN fleet_alert_rules r ON a.rule_id = r.id
|
||||
WHERE a.status = $1
|
||||
ORDER BY a.created_at DESC LIMIT 100`,
|
||||
[status],
|
||||
);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/alerts/:id/ack", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await query(
|
||||
`UPDATE fleet_alerts SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = NOW() WHERE id = $2`,
|
||||
[req.user?.userId || "system", parseInt(req.params.id)],
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/alerts/:id/resolve", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await query(
|
||||
`UPDATE fleet_alerts SET status = 'resolved', resolved_at = NOW() WHERE id = $1`,
|
||||
[parseInt(_req.params.id)],
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/alert-rules", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await query<any>(`SELECT * FROM fleet_alert_rules ORDER BY id`);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 배포 ==========
|
||||
|
||||
router.get("/deployments", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await query<any>(
|
||||
`SELECT d.*, r.version, r.backend_image, r.frontend_image, r.agent_image
|
||||
FROM fleet_deployments d
|
||||
LEFT JOIN fleet_releases r ON d.release_id = r.id
|
||||
ORDER BY d.created_at DESC LIMIT 100`,
|
||||
);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/releases", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await query<any>(`SELECT * FROM fleet_releases ORDER BY id DESC LIMIT 50`);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 실시간 데이터 ==========
|
||||
import { FleetDataService } from "./fleetDataService";
|
||||
|
||||
// 디바이스별 최신 태그 값
|
||||
router.get("/devices/:deviceId/latest-values", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDataService.getLatestValuesByDevice(req.params.deviceId);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 장비별 최신 태그 값
|
||||
router.get("/equipment/:equipmentId/latest-values", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDataService.getLatestValuesByEquipment(parseInt(req.params.equipmentId));
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 태그 시계열 데이터 (차트용)
|
||||
router.get("/devices/:deviceId/tags/:tagName/timeseries", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt((req.query.limit as string) || "500", 10);
|
||||
const from = req.query.from ? new Date(req.query.from as string) : undefined;
|
||||
const to = req.query.to ? new Date(req.query.to as string) : undefined;
|
||||
const data = await FleetDataService.getTagTimeseries(
|
||||
req.params.deviceId, req.params.tagName, from, to, limit,
|
||||
);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 수집 통계
|
||||
router.get("/data/stats", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDataService.getCollectionStats(req.query.device_id as string);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Python Hook 스크립트 ==========
|
||||
import { FleetScriptService } from "./fleetScriptService";
|
||||
|
||||
router.get("/scripts/hook-types", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetScriptService.getHookTypes();
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/scripts", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const filter: any = {
|
||||
scope: req.query.scope as any,
|
||||
hook_type: req.query.hook_type as any,
|
||||
};
|
||||
if (req.query.equipment_id) filter.equipment_id = parseInt(req.query.equipment_id as string);
|
||||
if (req.query.connection_id) filter.connection_id = parseInt(req.query.connection_id as string);
|
||||
if (req.query.device_id) filter.device_id = req.query.device_id as string;
|
||||
if (req.query.enabled !== undefined) filter.enabled = req.query.enabled === "true";
|
||||
if (req.user?.companyCode && req.user.companyCode !== "*") {
|
||||
filter.company_code = req.user.companyCode;
|
||||
}
|
||||
const data = await FleetScriptService.listScripts(filter);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/scripts/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetScriptService.getScript(parseInt(req.params.id));
|
||||
if (!data) return res.status(404).json({ success: false, message: "스크립트를 찾을 수 없습니다." });
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/scripts", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = {
|
||||
...req.body,
|
||||
company_code: req.body.company_code || req.user?.companyCode || null,
|
||||
created_by: req.user?.userId,
|
||||
};
|
||||
const script = await FleetScriptService.createScript(data);
|
||||
res.status(201).json({ success: true, data: script });
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/scripts/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = { ...req.body, updated_by: req.user?.userId };
|
||||
const script = await FleetScriptService.updateScript(parseInt(req.params.id), data);
|
||||
res.json({ success: true, data: script });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/scripts/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await FleetScriptService.deleteScript(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 버전 이력
|
||||
router.get("/scripts/:id/versions", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetScriptService.getVersions(parseInt(req.params.id));
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/scripts/:id/versions/:version", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetScriptService.getVersion(parseInt(req.params.id), parseInt(req.params.version));
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/scripts/:id/rollback/:version", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetScriptService.rollback(
|
||||
parseInt(req.params.id),
|
||||
parseInt(req.params.version),
|
||||
req.user?.userId,
|
||||
);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Dry-run (테스트 실행)
|
||||
router.post("/scripts/dry-run", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { code, hook_type, test_input, timeout_ms } = req.body;
|
||||
if (!code || !hook_type) {
|
||||
return res.status(400).json({ success: false, message: "code와 hook_type 필수" });
|
||||
}
|
||||
const result = await FleetScriptService.dryRun(
|
||||
code,
|
||||
hook_type,
|
||||
test_input || {},
|
||||
timeout_ms || 3000,
|
||||
);
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 릴리즈 관리 ==========
|
||||
import { FleetReleaseService } from "./fleetReleaseService";
|
||||
|
||||
router.get("/releases", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetReleaseService.list({
|
||||
status: req.query.status as string,
|
||||
release_type: req.query.release_type as string,
|
||||
});
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/releases/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetReleaseService.get(parseInt(req.params.id));
|
||||
if (!data) return res.status(404).json({ success: false, message: "없음" });
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/releases", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetReleaseService.create(req.body);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/releases/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetReleaseService.update(parseInt(req.params.id), req.body);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.delete("/releases/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await FleetReleaseService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/releases/:id/transition", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetReleaseService.transition(parseInt(req.params.id), req.body.status);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== 배포 관리 ==========
|
||||
import { FleetDeploymentService } from "./fleetDeploymentService";
|
||||
|
||||
router.get("/deployments", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const filter: any = { status: req.query.status as string };
|
||||
if (req.query.release_id) filter.release_id = parseInt(req.query.release_id as string);
|
||||
const data = await FleetDeploymentService.list(filter);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/deployments/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDeploymentService.get(parseInt(req.params.id));
|
||||
if (!data) return res.status(404).json({ success: false, message: "없음" });
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/deployments/:id/status", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDeploymentService.getStatus(parseInt(req.params.id));
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/deployments", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDeploymentService.create({ ...req.body, created_by: req.user?.userId });
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/deployments/:id/start", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDeploymentService.start(parseInt(req.params.id), req.user?.userId);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/deployments/:id/cancel", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await FleetDeploymentService.cancel(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/deployments/:id/rollback", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetDeploymentService.rollback(parseInt(req.params.id), req.user?.userId);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== Harbor 이미지 ==========
|
||||
import { FleetHarborService } from "./fleetHarborService";
|
||||
|
||||
router.get("/harbor/projects", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetHarborService.listProjects();
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/harbor/projects/:project/repos", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetHarborService.listRepositories(req.params.project);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/harbor/projects/:project/repos/:repo/tags", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetHarborService.listTags(req.params.project, req.params.repo);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/harbor/ping", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetHarborService.ping();
|
||||
res.json({ success: data.ok, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== 태그 템플릿 ==========
|
||||
import { FleetTagTemplateService } from "./fleetTagTemplateService";
|
||||
|
||||
router.get("/tag-templates", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetTagTemplateService.list({
|
||||
company_code: req.user?.companyCode,
|
||||
equipment_type: req.query.equipment_type as string,
|
||||
protocol: req.query.protocol as string,
|
||||
});
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/tag-templates/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetTagTemplateService.get(parseInt(req.params.id));
|
||||
if (!data) return res.status(404).json({ success: false, message: "없음" });
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/tag-templates", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetTagTemplateService.create({ ...req.body, created_by: req.user?.userId });
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/tag-templates/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetTagTemplateService.update(parseInt(req.params.id), req.body);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.delete("/tag-templates/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await FleetTagTemplateService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/tag-templates/:id/apply/:connectionId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetTagTemplateService.applyToConnection(
|
||||
parseInt(req.params.id),
|
||||
parseInt(req.params.connectionId),
|
||||
{ overwrite: req.body.overwrite === true },
|
||||
);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== 알림 규칙 ==========
|
||||
import { FleetAlertRuleService } from "./fleetAlertRuleService";
|
||||
|
||||
router.post("/alert-rules", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetAlertRuleService.create({ ...req.body, created_by: req.user?.userId });
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/alert-rules/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetAlertRuleService.update(parseInt(req.params.id), req.body);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.delete("/alert-rules/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await FleetAlertRuleService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/alert-rules/:id/toggle", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetAlertRuleService.toggle(parseInt(req.params.id));
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== V1 PLC 매핑 ==========
|
||||
import { FleetV1MappingService } from "./fleetV1MappingService";
|
||||
|
||||
router.get("/v1-mappings", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const filter: any = { v1_system: req.query.v1_system as string };
|
||||
if (req.query.equipment_id) filter.equipment_id = parseInt(req.query.equipment_id as string);
|
||||
const data = await FleetV1MappingService.list(filter);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/v1-mappings", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetV1MappingService.create({ ...req.body, created_by: req.user?.userId });
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.put("/v1-mappings/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetV1MappingService.update(parseInt(req.params.id), req.body);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.delete("/v1-mappings/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await FleetV1MappingService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== PLC 상태 ==========
|
||||
import { FleetPlcStatusService } from "./fleetPlcStatusService";
|
||||
|
||||
router.get("/plc-status", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetPlcStatusService.list({
|
||||
device_id: req.query.device_id as string,
|
||||
status: req.query.status as string,
|
||||
});
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/plc-status/summary", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetPlcStatusService.summary();
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== Audit 로그 ==========
|
||||
import { FleetAuditService } from "./fleetAuditService";
|
||||
|
||||
router.get("/audit-logs", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetAuditService.list({
|
||||
event_type: req.query.event_type as string,
|
||||
target_type: req.query.target_type as string,
|
||||
target_id: req.query.target_id as string,
|
||||
actor_id: req.query.actor_id as string,
|
||||
result: req.query.result as string,
|
||||
company_code: req.user?.companyCode,
|
||||
limit: parseInt((req.query.limit as string) || "200"),
|
||||
});
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.get("/audit-logs/stats", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetAuditService.stats();
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== 사전 등록 ==========
|
||||
router.get("/provision/pre-registered", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetProvisionService.listPreRegistered();
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
router.post("/provision/pre-register", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = await FleetProvisionService.preRegister(req.body);
|
||||
res.json({ success: true, data });
|
||||
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||
});
|
||||
|
||||
// ========== Prometheus Metrics ==========
|
||||
import { FleetMetricsService } from "./fleetMetricsService";
|
||||
|
||||
router.get("/prometheus", async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const text = await FleetMetricsService.generate();
|
||||
res.setHeader("Content-Type", "text/plain; version=0.0.4");
|
||||
res.send(text);
|
||||
} catch (e: any) { res.status(500).send(`# ERROR: ${e.message}`); }
|
||||
});
|
||||
|
||||
// ========== 통계 요약 ==========
|
||||
|
||||
router.get("/stats", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompany = req.user?.companyCode;
|
||||
const companyFilter =
|
||||
userCompany && userCompany !== "*"
|
||||
? `WHERE company_code = '${userCompany}' OR company_code = '*'`
|
||||
: "";
|
||||
|
||||
const [devices, alerts, deployments] = await Promise.all([
|
||||
query<any>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE is_online) as online,
|
||||
COUNT(*) FILTER (WHERE NOT is_online) as offline
|
||||
FROM fleet_devices ${companyFilter}`,
|
||||
),
|
||||
query<any>(
|
||||
`SELECT COUNT(*) as open_count FROM fleet_alerts WHERE status = 'open'`,
|
||||
),
|
||||
query<any>(
|
||||
`SELECT COUNT(*) as active_count FROM fleet_deployments WHERE status IN ('pending', 'running')`,
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
devices: devices[0],
|
||||
alerts: alerts[0],
|
||||
deployments: deployments[0],
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Fleet Edge Scripts Service
|
||||
* - 웹에서 편집한 Python hook 스크립트 관리
|
||||
* - 장비별/연결별/디바이스별/전역 스코프
|
||||
* - 버전 관리 (트리거 기반 자동)
|
||||
* - Dry-run 실행 (Docker 내 Python 서브프로세스)
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export type HookType = "transform" | "derived_tags" | "filter" | "alarm" | "pre_send";
|
||||
export type ScriptScope = "global" | "equipment" | "connection" | "device";
|
||||
|
||||
export interface FleetScript {
|
||||
id?: number;
|
||||
script_name: string;
|
||||
description?: string;
|
||||
scope: ScriptScope;
|
||||
equipment_id?: number | null;
|
||||
connection_id?: number | null;
|
||||
device_id?: string | null;
|
||||
hook_type: HookType;
|
||||
language?: string;
|
||||
code: string;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
timeout_ms?: number;
|
||||
company_code?: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export class FleetScriptService {
|
||||
static async listScripts(filter: {
|
||||
scope?: ScriptScope;
|
||||
equipment_id?: number;
|
||||
connection_id?: number;
|
||||
device_id?: string;
|
||||
hook_type?: HookType;
|
||||
enabled?: boolean;
|
||||
company_code?: string;
|
||||
} = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.scope) { wheres.push(`s.scope = $${idx++}`); params.push(filter.scope); }
|
||||
if (filter.equipment_id != null) { wheres.push(`s.equipment_id = $${idx++}`); params.push(filter.equipment_id); }
|
||||
if (filter.connection_id != null) { wheres.push(`s.connection_id = $${idx++}`); params.push(filter.connection_id); }
|
||||
if (filter.device_id) { wheres.push(`s.device_id = $${idx++}`); params.push(filter.device_id); }
|
||||
if (filter.hook_type) { wheres.push(`s.hook_type = $${idx++}`); params.push(filter.hook_type); }
|
||||
if (filter.enabled !== undefined) { wheres.push(`s.enabled = $${idx++}`); params.push(filter.enabled); }
|
||||
if (filter.company_code && filter.company_code !== "*") {
|
||||
wheres.push(`(s.company_code = $${idx} OR s.company_code IS NULL OR s.company_code = '*')`);
|
||||
params.push(filter.company_code);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
return await query<any>(
|
||||
`SELECT s.*,
|
||||
e.equipment_name,
|
||||
e.equipment_code,
|
||||
c.connection_name
|
||||
FROM fleet_edge_scripts s
|
||||
LEFT JOIN pipeline_equipment e ON s.equipment_id = e.id
|
||||
LEFT JOIN pipeline_device_connections c ON s.connection_id = c.id
|
||||
${whereClause}
|
||||
ORDER BY s.hook_type, s.priority, s.id DESC`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static async getScript(id: number) {
|
||||
return await queryOne<any>(
|
||||
`SELECT s.*, e.equipment_name, c.connection_name
|
||||
FROM fleet_edge_scripts s
|
||||
LEFT JOIN pipeline_equipment e ON s.equipment_id = e.id
|
||||
LEFT JOIN pipeline_device_connections c ON s.connection_id = c.id
|
||||
WHERE s.id = $1`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
static async createScript(data: FleetScript): Promise<any> {
|
||||
if (!data.script_name || !data.hook_type || !data.code) {
|
||||
throw new Error("script_name, hook_type, code는 필수");
|
||||
}
|
||||
|
||||
const result = await query<any>(
|
||||
`INSERT INTO fleet_edge_scripts
|
||||
(script_name, description, scope, equipment_id, connection_id, device_id,
|
||||
hook_type, language, code, enabled, priority, timeout_ms, company_code, created_by, updated_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.script_name,
|
||||
data.description || null,
|
||||
data.scope || "global",
|
||||
data.equipment_id || null,
|
||||
data.connection_id || null,
|
||||
data.device_id || null,
|
||||
data.hook_type,
|
||||
data.language || "python",
|
||||
data.code,
|
||||
data.enabled !== false,
|
||||
data.priority || 100,
|
||||
data.timeout_ms || 1000,
|
||||
data.company_code || null,
|
||||
data.created_by || null,
|
||||
data.created_by || null,
|
||||
],
|
||||
);
|
||||
|
||||
// 버전 이력 추가 (v1)
|
||||
await query(
|
||||
`INSERT INTO fleet_edge_script_versions (script_id, version, code, description, changed_by)
|
||||
VALUES ($1, 1, $2, $3, $4)`,
|
||||
[result[0].id, data.code, data.description || null, data.created_by || null],
|
||||
);
|
||||
|
||||
logger.info(`[Fleet Script] 생성: ${data.hook_type} / ${data.script_name} (id=${result[0].id})`);
|
||||
// 수집기 스크립트 캐시 즉시 무효화
|
||||
try {
|
||||
const { invalidate } = await import("../services/collector/scriptCache");
|
||||
invalidate();
|
||||
} catch { /* 수집기 캐시 미로드 상태면 무시 */ }
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async updateScript(id: number, data: Partial<FleetScript>): Promise<any> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const fields: (keyof FleetScript)[] = [
|
||||
"script_name", "description", "scope", "equipment_id", "connection_id", "device_id",
|
||||
"hook_type", "code", "enabled", "priority", "timeout_ms", "updated_by",
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (data[f] !== undefined) {
|
||||
sets.push(`${f} = $${idx++}`);
|
||||
params.push(data[f]);
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.length === 0) return this.getScript(id);
|
||||
|
||||
params.push(id);
|
||||
const result = await query<any>(
|
||||
`UPDATE fleet_edge_scripts SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
|
||||
try {
|
||||
const { invalidate } = await import("../services/collector/scriptCache");
|
||||
invalidate();
|
||||
} catch { /* noop */ }
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async deleteScript(id: number) {
|
||||
await query(`DELETE FROM fleet_edge_scripts WHERE id = $1`, [id]);
|
||||
try {
|
||||
const { invalidate } = await import("../services/collector/scriptCache");
|
||||
invalidate();
|
||||
} catch { /* noop */ }
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
static async getVersions(scriptId: number) {
|
||||
return await query<any>(
|
||||
`SELECT id, script_id, version, description, changed_by, changed_at,
|
||||
LENGTH(code) as code_size
|
||||
FROM fleet_edge_script_versions
|
||||
WHERE script_id = $1
|
||||
ORDER BY version DESC`,
|
||||
[scriptId],
|
||||
);
|
||||
}
|
||||
|
||||
static async getVersion(scriptId: number, version: number) {
|
||||
return await queryOne<any>(
|
||||
`SELECT * FROM fleet_edge_script_versions WHERE script_id = $1 AND version = $2`,
|
||||
[scriptId, version],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 버전으로 롤백
|
||||
*/
|
||||
static async rollback(scriptId: number, toVersion: number, userId?: string) {
|
||||
const v = await this.getVersion(scriptId, toVersion);
|
||||
if (!v) throw new Error(`버전 ${toVersion}을 찾을 수 없습니다.`);
|
||||
return await this.updateScript(scriptId, { code: v.code, updated_by: userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook 타입 목록
|
||||
*/
|
||||
static async getHookTypes() {
|
||||
return await query<any>(
|
||||
`SELECT * FROM fleet_edge_hook_types ORDER BY execute_order`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run 실행 (Python 서브프로세스 + RestrictedPython-like sandboxing)
|
||||
*
|
||||
* 실행 환경:
|
||||
* - Python3 임시 파일로 실행
|
||||
* - 타임아웃 제한
|
||||
* - import 제한 (화이트리스트)
|
||||
*/
|
||||
static async dryRun(
|
||||
code: string,
|
||||
hookType: HookType,
|
||||
testInput: any,
|
||||
timeoutMs = 3000,
|
||||
): Promise<{ success: boolean; result?: any; error?: string; stdout?: string; duration_ms: number }> {
|
||||
const start = Date.now();
|
||||
|
||||
// Python 래퍼: 사용자 코드와 입력을 stdin으로 전달 (이스케이프 문제 없음)
|
||||
// stdin format: JSON { code, hook_type, test_input }
|
||||
const wrapper = `
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime, date
|
||||
import math
|
||||
|
||||
ALLOWED_BUILTINS = {
|
||||
'abs': abs, 'all': all, 'any': any, 'bool': bool, 'bytes': bytes,
|
||||
'dict': dict, 'enumerate': enumerate, 'filter': filter, 'float': float,
|
||||
'int': int, 'len': len, 'list': list, 'map': map, 'max': max, 'min': min,
|
||||
'print': print, 'range': range, 'round': round, 'set': set, 'sorted': sorted,
|
||||
'str': str, 'sum': sum, 'tuple': tuple, 'type': type, 'zip': zip,
|
||||
'isinstance': isinstance, 'hasattr': hasattr, 'getattr': getattr,
|
||||
'True': True, 'False': False, 'None': None,
|
||||
'__import__': __import__,
|
||||
}
|
||||
|
||||
def default_serializer(o):
|
||||
if isinstance(o, (datetime, date)): return o.isoformat()
|
||||
return str(o)
|
||||
|
||||
try:
|
||||
payload = json.loads(sys.stdin.read())
|
||||
USER_CODE = payload['code']
|
||||
HOOK_TYPE = payload['hook_type']
|
||||
TEST_INPUT = payload.get('test_input', {})
|
||||
|
||||
allowed_globals = {
|
||||
"__builtins__": ALLOWED_BUILTINS,
|
||||
"datetime": datetime,
|
||||
"date": date,
|
||||
"math": math,
|
||||
"json": json,
|
||||
}
|
||||
exec(USER_CODE, allowed_globals)
|
||||
|
||||
func_name_map = {
|
||||
"transform": "transform",
|
||||
"derived_tags": "derived_tags",
|
||||
"filter": "filter_data",
|
||||
"alarm": "alarm",
|
||||
"pre_send": "pre_send",
|
||||
}
|
||||
func_name = func_name_map.get(HOOK_TYPE)
|
||||
if func_name not in allowed_globals:
|
||||
raise NameError(f"{func_name} 함수가 정의되지 않았습니다")
|
||||
|
||||
func = allowed_globals[func_name]
|
||||
|
||||
if HOOK_TYPE == "transform":
|
||||
result = func(TEST_INPUT.get("tag_name"), TEST_INPUT.get("raw_value"), TEST_INPUT.get("context", {}))
|
||||
elif HOOK_TYPE == "derived_tags":
|
||||
result = func(TEST_INPUT.get("tags", {}), TEST_INPUT.get("context", {}))
|
||||
elif HOOK_TYPE == "filter":
|
||||
result = func(TEST_INPUT.get("tags", {}), TEST_INPUT.get("context", {}))
|
||||
elif HOOK_TYPE == "alarm":
|
||||
result = func(TEST_INPUT.get("tag_name"), TEST_INPUT.get("value"), TEST_INPUT.get("context", {}))
|
||||
elif HOOK_TYPE == "pre_send":
|
||||
result = func(TEST_INPUT.get("payload", {}), TEST_INPUT.get("context", {}))
|
||||
else:
|
||||
raise ValueError(f"알 수 없는 hook 타입: {HOOK_TYPE}")
|
||||
|
||||
print(json.dumps({"success": True, "result": result}, default=default_serializer, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e), "traceback": traceback.format_exc()}, default=default_serializer, ensure_ascii=False))
|
||||
sys.exit(0)
|
||||
`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("python3", ["-c", wrapper], {
|
||||
timeout: timeoutMs,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// JSON으로 stdin 전달 (이스케이프 안전)
|
||||
const input = JSON.stringify({ code, hook_type: hookType, test_input: testInput });
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (d) => (stdout += d.toString()));
|
||||
child.stderr.on("data", (d) => (stderr += d.toString()));
|
||||
|
||||
child.on("close", (_code) => {
|
||||
const duration = Date.now() - start;
|
||||
try {
|
||||
const lines = stdout.trim().split("\n");
|
||||
const resultLine = lines[lines.length - 1] || "{}";
|
||||
const parsed = JSON.parse(resultLine);
|
||||
if (parsed.success) {
|
||||
resolve({ success: true, result: parsed.result, stdout: lines.slice(0, -1).join("\n"), duration_ms: duration });
|
||||
} else {
|
||||
resolve({ success: false, error: parsed.error || parsed.traceback, duration_ms: duration });
|
||||
}
|
||||
} catch (e: any) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `실행 결과 파싱 실패: ${e.message}. stderr: ${stderr.slice(0, 500)}. stdout: ${stdout.slice(0, 500)}`,
|
||||
duration_ms: duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err: any) => {
|
||||
if (err.code === "ENOENT") {
|
||||
resolve({ success: false, error: "Python3이 시스템에 설치되지 않음", duration_ms: Date.now() - start });
|
||||
} else {
|
||||
resolve({ success: false, error: err.message, duration_ms: Date.now() - start });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 엣지 디바이스에 적용되는 스크립트 조회 (Python이 사용)
|
||||
*/
|
||||
static async getScriptsForEdge(edgeId: string, equipmentId?: number, connectionIds?: number[]): Promise<any[]> {
|
||||
const wheres: string[] = ["s.enabled = TRUE"];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
// 여러 스코프에서 매칭 (OR 조건)
|
||||
const scopeFilters: string[] = ["s.scope = 'global'"];
|
||||
|
||||
scopeFilters.push(`(s.scope = 'device' AND s.device_id = $${idx++})`);
|
||||
params.push(edgeId);
|
||||
|
||||
if (equipmentId != null) {
|
||||
scopeFilters.push(`(s.scope = 'equipment' AND s.equipment_id = $${idx++})`);
|
||||
params.push(equipmentId);
|
||||
}
|
||||
if (connectionIds && connectionIds.length > 0) {
|
||||
scopeFilters.push(`(s.scope = 'connection' AND s.connection_id = ANY($${idx++}))`);
|
||||
params.push(connectionIds);
|
||||
}
|
||||
|
||||
wheres.push(`(${scopeFilters.join(" OR ")})`);
|
||||
|
||||
return await query<any>(
|
||||
`SELECT s.id, s.script_name, s.scope, s.equipment_id, s.connection_id, s.device_id,
|
||||
s.hook_type, s.code, s.priority, s.timeout_ms, s.version
|
||||
FROM fleet_edge_scripts s
|
||||
WHERE ${wheres.join(" AND ")}
|
||||
ORDER BY s.hook_type, s.priority, s.id`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Fleet Tag Template Service
|
||||
* - 회사/장비별 태그 템플릿 관리
|
||||
* - 템플릿을 선택하면 pipeline_tag_mappings에 일괄 생성
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface TagTemplate {
|
||||
id?: number;
|
||||
template_name: string;
|
||||
description?: string;
|
||||
company_code?: string;
|
||||
equipment_type?: string;
|
||||
protocol?: string;
|
||||
tags: Array<{
|
||||
tag_name: string;
|
||||
tag_display_name?: string;
|
||||
tag_unit?: string;
|
||||
tag_data_type: string;
|
||||
address: string;
|
||||
address_type?: string;
|
||||
byte_order?: string;
|
||||
bit_index?: number | null;
|
||||
scale_factor?: number;
|
||||
offset_value?: number;
|
||||
description?: string;
|
||||
}>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export class FleetTagTemplateService {
|
||||
static async list(filter: { company_code?: string; equipment_type?: string; protocol?: string } = {}) {
|
||||
const wheres: string[] = ["is_active = TRUE"];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.company_code) {
|
||||
wheres.push(`(company_code = $${idx} OR company_code IS NULL OR company_code = '*')`);
|
||||
params.push(filter.company_code);
|
||||
idx++;
|
||||
}
|
||||
if (filter.equipment_type) { wheres.push(`equipment_type = $${idx++}`); params.push(filter.equipment_type); }
|
||||
if (filter.protocol) { wheres.push(`protocol = $${idx++}`); params.push(filter.protocol); }
|
||||
|
||||
return await query<any>(
|
||||
`SELECT t.*, jsonb_array_length(t.tags) as tag_count
|
||||
FROM fleet_tag_templates t
|
||||
WHERE ${wheres.join(" AND ")}
|
||||
ORDER BY t.template_name`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static async get(id: number) {
|
||||
return await queryOne<any>(`SELECT * FROM fleet_tag_templates WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
static async create(data: TagTemplate & { created_by?: string }) {
|
||||
if (!data.template_name) throw new Error("template_name 필수");
|
||||
|
||||
const r = await query<any>(
|
||||
`INSERT INTO fleet_tag_templates
|
||||
(template_name, description, company_code, equipment_type, protocol, tags, is_active, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.template_name,
|
||||
data.description || null,
|
||||
data.company_code || null,
|
||||
data.equipment_type || null,
|
||||
data.protocol || null,
|
||||
JSON.stringify(data.tags || []),
|
||||
data.is_active !== false,
|
||||
data.created_by || null,
|
||||
],
|
||||
);
|
||||
logger.info(`[Fleet TagTemplate] 생성: ${data.template_name} (태그 ${data.tags.length}개)`);
|
||||
return r[0];
|
||||
}
|
||||
|
||||
static async update(id: number, data: Partial<TagTemplate>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const fields: (keyof TagTemplate)[] = [
|
||||
"template_name", "description", "company_code", "equipment_type", "protocol", "is_active",
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (data[f] !== undefined) { sets.push(`${f} = $${idx++}`); params.push(data[f]); }
|
||||
}
|
||||
if (data.tags !== undefined) {
|
||||
sets.push(`tags = $${idx++}::jsonb`);
|
||||
params.push(JSON.stringify(data.tags));
|
||||
}
|
||||
sets.push(`updated_at = NOW()`);
|
||||
if (sets.length === 1) return this.get(id);
|
||||
|
||||
params.push(id);
|
||||
const r = await query<any>(
|
||||
`UPDATE fleet_tag_templates SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return r[0];
|
||||
}
|
||||
|
||||
static async delete(id: number) {
|
||||
await query(`DELETE FROM fleet_tag_templates WHERE id = $1`, [id]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿을 특정 연결(pipeline_device_connections)에 적용
|
||||
* → pipeline_tag_mappings에 태그 일괄 생성
|
||||
*/
|
||||
static async applyToConnection(templateId: number, connectionId: number, opts: { overwrite?: boolean } = {}) {
|
||||
const tpl = await this.get(templateId);
|
||||
if (!tpl) throw new Error("템플릿 없음");
|
||||
|
||||
const conn = await queryOne<any>(
|
||||
`SELECT * FROM pipeline_device_connections WHERE id = $1`,
|
||||
[connectionId],
|
||||
);
|
||||
if (!conn) throw new Error("연결 없음");
|
||||
|
||||
// 기존 태그 삭제 (overwrite 시)
|
||||
if (opts.overwrite) {
|
||||
await query(`DELETE FROM pipeline_tag_mappings WHERE connection_id = $1`, [connectionId]);
|
||||
}
|
||||
|
||||
const tags = tpl.tags || [];
|
||||
let created = 0;
|
||||
for (const t of tags) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO pipeline_tag_mappings
|
||||
(connection_id, tag_name, tag_display_name, tag_unit, tag_data_type,
|
||||
address, address_type, byte_order, bit_index, scale_factor, offset_value, description, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'Y')
|
||||
ON CONFLICT (connection_id, tag_name) DO UPDATE SET
|
||||
tag_display_name = EXCLUDED.tag_display_name,
|
||||
tag_unit = EXCLUDED.tag_unit,
|
||||
tag_data_type = EXCLUDED.tag_data_type,
|
||||
address = EXCLUDED.address,
|
||||
byte_order = EXCLUDED.byte_order,
|
||||
scale_factor = EXCLUDED.scale_factor,
|
||||
offset_value = EXCLUDED.offset_value,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
connectionId,
|
||||
t.tag_name,
|
||||
t.tag_display_name || null,
|
||||
t.tag_unit || null,
|
||||
t.tag_data_type || "UINT16",
|
||||
t.address,
|
||||
t.address_type || null,
|
||||
t.byte_order || "BIG_ENDIAN",
|
||||
t.bit_index ?? null,
|
||||
t.scale_factor ?? 1.0,
|
||||
t.offset_value ?? 0.0,
|
||||
t.description || null,
|
||||
],
|
||||
);
|
||||
created++;
|
||||
} catch (e: any) {
|
||||
logger.warn(`[Fleet TagTemplate] 태그 적용 실패 (${t.tag_name}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
logger.info(`[Fleet TagTemplate] ${tpl.template_name} → connection ${connectionId}: ${created}개 적용`);
|
||||
return { templateId, connectionId, appliedCount: created };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Fleet V1 PLC Mapping Service
|
||||
* - 레거시 v1 시스템(vexplor_v1, AAS, SCADA)의 PLC 태그를 Pipeline에 매핑
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
export interface V1PlcMapping {
|
||||
id?: number;
|
||||
v1_system: string;
|
||||
v1_tag_id: string;
|
||||
v1_tag_name?: string;
|
||||
v1_metadata?: Record<string, any>;
|
||||
equipment_id?: number;
|
||||
connection_id?: number;
|
||||
tag_mapping_id?: number;
|
||||
mapped_tag_name?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export class FleetV1MappingService {
|
||||
static async list(filter: { v1_system?: string; equipment_id?: number } = {}) {
|
||||
const wheres: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filter.v1_system) { wheres.push(`m.v1_system = $${idx++}`); params.push(filter.v1_system); }
|
||||
if (filter.equipment_id) { wheres.push(`m.equipment_id = $${idx++}`); params.push(filter.equipment_id); }
|
||||
|
||||
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||
return await query<any>(
|
||||
`SELECT m.*,
|
||||
e.equipment_name, e.equipment_code,
|
||||
c.connection_name,
|
||||
t.tag_name as pipeline_tag_name
|
||||
FROM fleet_v1_plc_mapping m
|
||||
LEFT JOIN pipeline_equipment e ON m.equipment_id = e.id
|
||||
LEFT JOIN pipeline_device_connections c ON m.connection_id = c.id
|
||||
LEFT JOIN pipeline_tag_mappings t ON m.tag_mapping_id = t.id
|
||||
${where}
|
||||
ORDER BY m.v1_system, m.v1_tag_id`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static async create(data: V1PlcMapping & { created_by?: string }) {
|
||||
if (!data.v1_system || !data.v1_tag_id) throw new Error("v1_system, v1_tag_id 필수");
|
||||
const r = await query<any>(
|
||||
`INSERT INTO fleet_v1_plc_mapping
|
||||
(v1_system, v1_tag_id, v1_tag_name, v1_metadata,
|
||||
equipment_id, connection_id, tag_mapping_id, mapped_tag_name,
|
||||
is_active, created_by)
|
||||
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (v1_system, v1_tag_id) DO UPDATE SET
|
||||
v1_tag_name = EXCLUDED.v1_tag_name,
|
||||
v1_metadata = EXCLUDED.v1_metadata,
|
||||
equipment_id = EXCLUDED.equipment_id,
|
||||
connection_id = EXCLUDED.connection_id,
|
||||
tag_mapping_id = EXCLUDED.tag_mapping_id,
|
||||
mapped_tag_name = EXCLUDED.mapped_tag_name,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[
|
||||
data.v1_system,
|
||||
data.v1_tag_id,
|
||||
data.v1_tag_name || null,
|
||||
JSON.stringify(data.v1_metadata || {}),
|
||||
data.equipment_id || null,
|
||||
data.connection_id || null,
|
||||
data.tag_mapping_id || null,
|
||||
data.mapped_tag_name || null,
|
||||
data.is_active !== false,
|
||||
data.created_by || null,
|
||||
],
|
||||
);
|
||||
return r[0];
|
||||
}
|
||||
|
||||
static async update(id: number, data: Partial<V1PlcMapping>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const fields: (keyof V1PlcMapping)[] = [
|
||||
"v1_tag_name", "equipment_id", "connection_id", "tag_mapping_id",
|
||||
"mapped_tag_name", "is_active",
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (data[f] !== undefined) { sets.push(`${f} = $${idx++}`); params.push(data[f]); }
|
||||
}
|
||||
if (data.v1_metadata !== undefined) {
|
||||
sets.push(`v1_metadata = $${idx++}::jsonb`);
|
||||
params.push(JSON.stringify(data.v1_metadata));
|
||||
}
|
||||
sets.push(`updated_at = NOW()`);
|
||||
if (sets.length === 1) return queryOne(`SELECT * FROM fleet_v1_plc_mapping WHERE id = $1`, [id]);
|
||||
|
||||
params.push(id);
|
||||
const r = await query<any>(
|
||||
`UPDATE fleet_v1_plc_mapping SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return r[0];
|
||||
}
|
||||
|
||||
static async delete(id: number) {
|
||||
await query(`DELETE FROM fleet_v1_plc_mapping WHERE id = $1`, [id]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 시스템에서 받은 태그 값을 Pipeline 태그로 변환 (MQTT bridge 등에서 사용)
|
||||
*/
|
||||
static async resolveV1Tag(v1System: string, v1TagId: string) {
|
||||
return await queryOne<any>(
|
||||
`SELECT m.*, t.tag_name as pipeline_tag, c.connection_name
|
||||
FROM fleet_v1_plc_mapping m
|
||||
LEFT JOIN pipeline_tag_mappings t ON m.tag_mapping_id = t.id
|
||||
LEFT JOIN pipeline_device_connections c ON m.connection_id = c.id
|
||||
WHERE m.v1_system = $1 AND m.v1_tag_id = $2 AND m.is_active = TRUE
|
||||
LIMIT 1`,
|
||||
[v1System, v1TagId],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Fleet 모듈 초기화
|
||||
* - 내장 MQTT 브로커 시작
|
||||
* - 디바이스 heartbeat / 응답 / 데이터 수신 핸들러 등록
|
||||
* - 주기 작업 (오프라인 감지, 커맨드 타임아웃, 알림 평가)
|
||||
*/
|
||||
|
||||
import { logger } from "../utils/logger";
|
||||
import { getFleetMqttBroker, FleetMqttBroker } from "./mqttBroker";
|
||||
import { FleetDeviceService } from "./fleetDeviceService";
|
||||
import { FleetCommandService } from "./fleetCommandService";
|
||||
import { query } from "../database/db";
|
||||
|
||||
export async function initializeFleet(): Promise<void> {
|
||||
try {
|
||||
const broker = getFleetMqttBroker();
|
||||
await broker.start();
|
||||
|
||||
// ========= Heartbeat 구독 =========
|
||||
broker.on("vexplor/devices/+/status", async (topic, payload) => {
|
||||
const deviceId = FleetMqttBroker.extractDeviceId(topic);
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const data = JSON.parse(payload.toString());
|
||||
await FleetDeviceService.handleHeartbeat(deviceId, data);
|
||||
} catch (e) {
|
||||
logger.error(`[Fleet] Heartbeat 처리 실패 (${deviceId}):`, e);
|
||||
}
|
||||
});
|
||||
|
||||
// ========= 메트릭 구독 (heartbeat과 동일) =========
|
||||
broker.on("vexplor/devices/+/metrics", async (topic, payload) => {
|
||||
const deviceId = FleetMqttBroker.extractDeviceId(topic);
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const data = JSON.parse(payload.toString());
|
||||
await FleetDeviceService.handleHeartbeat(deviceId, data);
|
||||
} catch (e) {
|
||||
logger.error(`[Fleet] 메트릭 처리 실패 (${deviceId}):`, e);
|
||||
}
|
||||
});
|
||||
|
||||
// ========= 커맨드 응답 =========
|
||||
broker.on("vexplor/devices/+/responses", async (topic, payload) => {
|
||||
const deviceId = FleetMqttBroker.extractDeviceId(topic);
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const data = JSON.parse(payload.toString());
|
||||
if (data.command_id) {
|
||||
await FleetCommandService.handleResponse(deviceId, data);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`[Fleet] 응답 처리 실패 (${deviceId}):`, e);
|
||||
}
|
||||
});
|
||||
|
||||
// ========= 태그 데이터 수신 (엣지에서 수집한 실시간 데이터) =========
|
||||
broker.on("vexplor/devices/+/data", async (topic, payload) => {
|
||||
const deviceId = FleetMqttBroker.extractDeviceId(topic);
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const data = JSON.parse(payload.toString());
|
||||
await handleEdgeData(deviceId, data);
|
||||
} catch (e) {
|
||||
logger.error(`[Fleet] 데이터 처리 실패 (${deviceId}):`, e);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("[Fleet] 초기화 완료 - MQTT 브로커 + 구독자 시작");
|
||||
|
||||
// ========= 주기 작업 =========
|
||||
// 2분마다 오프라인 감지
|
||||
setInterval(() => {
|
||||
FleetDeviceService.markStaleDevicesOffline(120).catch((e) =>
|
||||
logger.error("[Fleet] 오프라인 감지 에러:", e),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
// 1분마다 커맨드 타임아웃 처리
|
||||
setInterval(() => {
|
||||
FleetCommandService.markTimedOutCommands().catch((e) =>
|
||||
logger.error("[Fleet] 커맨드 타임아웃 에러:", e),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
// 30초마다 알림 규칙 평가
|
||||
setInterval(() => {
|
||||
evaluateAlertRules().catch((e) =>
|
||||
logger.error("[Fleet] 알림 평가 에러:", e),
|
||||
);
|
||||
}, 30 * 1000);
|
||||
} catch (e) {
|
||||
logger.error("[Fleet] 초기화 실패:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엣지에서 수신한 태그 데이터 처리
|
||||
* payload 형식:
|
||||
* {
|
||||
* timestamp: "2024-...",
|
||||
* equipment_id: 123,
|
||||
* connection_id: 45,
|
||||
* tags: {
|
||||
* "tag_name_1": 123.45,
|
||||
* "tag_name_2": true,
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async function handleEdgeData(
|
||||
deviceId: string,
|
||||
data: {
|
||||
timestamp?: string;
|
||||
equipment_id?: number;
|
||||
connection_id?: number;
|
||||
tags?: Record<string, any>;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!data.tags || typeof data.tags !== "object") return;
|
||||
|
||||
const timestamp = data.timestamp ? new Date(data.timestamp) : new Date();
|
||||
const rows: any[] = [];
|
||||
|
||||
for (const [tagName, value] of Object.entries(data.tags)) {
|
||||
let numericValue: number | null = null;
|
||||
let textValue: string | null = null;
|
||||
|
||||
if (typeof value === "number") numericValue = value;
|
||||
else if (typeof value === "boolean") numericValue = value ? 1 : 0;
|
||||
else if (typeof value === "string") textValue = value;
|
||||
else textValue = JSON.stringify(value);
|
||||
|
||||
rows.push([
|
||||
timestamp,
|
||||
deviceId,
|
||||
data.equipment_id || null,
|
||||
data.connection_id || null,
|
||||
tagName,
|
||||
numericValue,
|
||||
textValue,
|
||||
]);
|
||||
}
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// 배치 INSERT
|
||||
const placeholders = rows
|
||||
.map(
|
||||
(_, i) =>
|
||||
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`,
|
||||
)
|
||||
.join(", ");
|
||||
const flatValues = rows.flat();
|
||||
|
||||
await query(
|
||||
`INSERT INTO fleet_edge_raw_data
|
||||
(time, device_id, equipment_id, connection_id, tag_name, value, value_text)
|
||||
VALUES ${placeholders}
|
||||
ON CONFLICT (device_id, tag_name, time) DO NOTHING`,
|
||||
flatValues,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 규칙 평가 (심플 버전)
|
||||
* 각 디바이스의 최근 heartbeat를 규칙과 비교
|
||||
*/
|
||||
async function evaluateAlertRules(): Promise<void> {
|
||||
const rules = await query<any>(
|
||||
`SELECT * FROM fleet_alert_rules WHERE enabled = TRUE`,
|
||||
);
|
||||
if (rules.length === 0) return;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.metric === "offline_duration") {
|
||||
// 오프라인 시간 규칙
|
||||
const offlineDevices = await query<any>(
|
||||
`SELECT device_id, EXTRACT(EPOCH FROM (NOW() - last_seen_at))::int as offline_sec
|
||||
FROM fleet_devices
|
||||
WHERE (last_seen_at IS NULL OR last_seen_at < NOW() - ($1 || ' seconds')::INTERVAL)
|
||||
AND is_online = FALSE`,
|
||||
[rule.threshold.toString()],
|
||||
);
|
||||
for (const d of offlineDevices) {
|
||||
await insertAlertIfNew(rule, d.device_id, d.offline_sec);
|
||||
}
|
||||
} else {
|
||||
// cpu/memory/disk 규칙
|
||||
const column = rule.metric; // cpu_percent, memory_percent, disk_percent
|
||||
if (!["cpu_percent", "memory_percent", "disk_percent"].includes(column)) continue;
|
||||
|
||||
// 최근 heartbeat에서 임계값 초과 디바이스 찾기
|
||||
const op = rule.operator;
|
||||
const overloadedDevices = await query<any>(
|
||||
`SELECT DISTINCT ON (device_id) device_id, ${column} as value
|
||||
FROM fleet_heartbeats
|
||||
WHERE received_at > NOW() - '5 minutes'::INTERVAL
|
||||
AND ${column} ${op === "==" ? "=" : op} $1
|
||||
ORDER BY device_id, received_at DESC`,
|
||||
[rule.threshold],
|
||||
);
|
||||
for (const d of overloadedDevices) {
|
||||
await insertAlertIfNew(rule, d.device_id, d.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function insertAlertIfNew(rule: any, deviceId: string, value: number): Promise<void> {
|
||||
// 같은 규칙 + 디바이스의 open 알림이 이미 있으면 무시
|
||||
const existing = await query<any>(
|
||||
`SELECT id FROM fleet_alerts
|
||||
WHERE rule_id = $1 AND device_id = $2 AND status = 'open'
|
||||
LIMIT 1`,
|
||||
[rule.id, deviceId],
|
||||
);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
await query(
|
||||
`INSERT INTO fleet_alerts
|
||||
(rule_id, device_id, severity, title, message, metric, value, threshold)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
rule.id,
|
||||
deviceId,
|
||||
rule.severity,
|
||||
`${rule.rule_name} (${deviceId})`,
|
||||
`${rule.metric} ${rule.operator} ${rule.threshold} (현재: ${value})`,
|
||||
rule.metric,
|
||||
value,
|
||||
rule.threshold,
|
||||
],
|
||||
);
|
||||
logger.warn(`[Fleet] 알림 발생: ${rule.rule_name} - ${deviceId} (${rule.metric}=${value})`);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 내장 MQTT 브로커 (aedes)
|
||||
* - Pipeline이 MQTT 브로커 역할까지 수행
|
||||
* - aedes 내부 이벤트로 직접 publish 감지 (내부 client 불필요)
|
||||
*
|
||||
* 토픽 규칙 (vexplor_fleet 호환):
|
||||
* vexplor/devices/{deviceId}/status - 디바이스 → 서버
|
||||
* vexplor/devices/{deviceId}/metrics - 디바이스 → 서버
|
||||
* vexplor/devices/{deviceId}/commands - 서버 → 디바이스
|
||||
* vexplor/devices/{deviceId}/responses - 디바이스 → 서버
|
||||
* vexplor/devices/{deviceId}/data - 디바이스 → 서버
|
||||
*/
|
||||
|
||||
import Aedes from "aedes";
|
||||
import { createServer } from "aedes-server-factory";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const MQTT_PORT = parseInt(process.env.MQTT_PORT || "1883", 10);
|
||||
const MQTT_WS_PORT = parseInt(process.env.MQTT_WS_PORT || "8083", 10);
|
||||
|
||||
type MessageHandler = (topic: string, payload: Buffer) => void | Promise<void>;
|
||||
|
||||
export class FleetMqttBroker {
|
||||
private aedes: any;
|
||||
private tcpServer: any;
|
||||
private wsServer: any;
|
||||
private started = false;
|
||||
|
||||
private messageHandlers = new Map<string, MessageHandler[]>();
|
||||
|
||||
constructor() {
|
||||
this.aedes = (Aedes as any)({ id: "pipeline-mqtt-broker" });
|
||||
|
||||
this.aedes.on("client", (client) => {
|
||||
logger.info(`[MQTT] 클라이언트 연결: ${client.id}`);
|
||||
});
|
||||
|
||||
this.aedes.on("clientDisconnect", (client) => {
|
||||
logger.info(`[MQTT] 클라이언트 연결 해제: ${client.id}`);
|
||||
});
|
||||
|
||||
this.aedes.on("subscribe", (subscriptions, client) => {
|
||||
logger.debug(
|
||||
`[MQTT] 구독: ${subscriptions.map((s) => s.topic).join(", ")} (by ${client?.id})`,
|
||||
);
|
||||
});
|
||||
|
||||
// 모든 publish 감지
|
||||
this.aedes.on("publish", (packet, client) => {
|
||||
if (!packet.topic || packet.topic.startsWith("$SYS")) return;
|
||||
// 내부에서 aedes.publish()로 보낸 것도 이 이벤트로 잡힘 - client가 null이면 내부 발행
|
||||
if (client) {
|
||||
this.dispatchMessage(packet.topic, packet.payload as Buffer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 브로커 시작 (TCP + WebSocket)
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.tcpServer = createServer(this.aedes as any);
|
||||
this.tcpServer.once("error", reject);
|
||||
this.tcpServer.listen(MQTT_PORT, "0.0.0.0", () => {
|
||||
logger.info(`[MQTT] TCP 브로커 시작: mqtt://0.0.0.0:${MQTT_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.wsServer = createServer(this.aedes as any, { ws: true });
|
||||
this.wsServer.once("error", reject);
|
||||
this.wsServer.listen(MQTT_WS_PORT, "0.0.0.0", () => {
|
||||
logger.info(`[MQTT] WebSocket 브로커 시작: ws://0.0.0.0:${MQTT_WS_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.started = true;
|
||||
logger.info("[MQTT] 브로커 완전 기동 (내부 publish 가능)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 토픽 패턴 등록 (MQTT 와일드카드 지원: +, #)
|
||||
*/
|
||||
on(topicPattern: string, handler: MessageHandler): void {
|
||||
if (!this.messageHandlers.has(topicPattern)) {
|
||||
this.messageHandlers.set(topicPattern, []);
|
||||
}
|
||||
this.messageHandlers.get(topicPattern)!.push(handler);
|
||||
}
|
||||
|
||||
private dispatchMessage(topic: string, payload: Buffer): void {
|
||||
for (const [pattern, handlers] of this.messageHandlers) {
|
||||
if (this.topicMatches(pattern, topic)) {
|
||||
for (const handler of handlers) {
|
||||
Promise.resolve(handler(topic, payload)).catch((e) =>
|
||||
logger.error(`[MQTT] 핸들러 에러 (${pattern}):`, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private topicMatches(pattern: string, topic: string): boolean {
|
||||
const pParts = pattern.split("/");
|
||||
const tParts = topic.split("/");
|
||||
|
||||
for (let i = 0; i < pParts.length; i++) {
|
||||
if (pParts[i] === "#") return true;
|
||||
if (i >= tParts.length) return false;
|
||||
if (pParts[i] === "+") continue;
|
||||
if (pParts[i] !== tParts[i]) return false;
|
||||
}
|
||||
return pParts.length === tParts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 → 디바이스 메시지 발행 (aedes.publish 직접 사용)
|
||||
*/
|
||||
publish(topic: string, message: string | object, qos: 0 | 1 | 2 = 1): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.started) return reject(new Error("MQTT 브로커가 시작되지 않았습니다."));
|
||||
const payload =
|
||||
typeof message === "string" ? message : JSON.stringify(message);
|
||||
(this.aedes as any).publish(
|
||||
{
|
||||
cmd: "publish",
|
||||
qos,
|
||||
topic,
|
||||
payload: Buffer.from(payload),
|
||||
retain: false,
|
||||
},
|
||||
(err: Error | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바이스에 커맨드 발행
|
||||
*/
|
||||
sendCommandToDevice(deviceId: string, command: object): Promise<void> {
|
||||
const topic = `vexplor/devices/${deviceId}/commands`;
|
||||
return this.publish(topic, command, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토픽에서 deviceId 추출 (vexplor/devices/{deviceId}/...)
|
||||
*/
|
||||
static extractDeviceId(topic: string): string | null {
|
||||
const m = topic.match(/^vexplor\/devices\/([^/]+)\//);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.tcpServer) this.tcpServer.close();
|
||||
if (this.wsServer) this.wsServer.close();
|
||||
this.aedes.close();
|
||||
this.started = false;
|
||||
}
|
||||
}
|
||||
|
||||
let brokerInstance: FleetMqttBroker | null = null;
|
||||
|
||||
export function getFleetMqttBroker(): FleetMqttBroker {
|
||||
if (!brokerInstance) {
|
||||
brokerInstance = new FleetMqttBroker();
|
||||
}
|
||||
return brokerInstance;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 자동화 통합 대시보드 API (조회 전용)
|
||||
*
|
||||
* GET /api/automation-dashboard/overview
|
||||
*
|
||||
* 반환:
|
||||
* - batches: cron 배치 스케줄 목록 + 상태
|
||||
* - pollings: 실시간 장비 폴링 목록
|
||||
* - forwarders: 중앙 MQTT 포워더 상태
|
||||
* - stats: 전체 요약
|
||||
*/
|
||||
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query } from "../database/db";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get(
|
||||
"/overview",
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompany = req.user?.companyCode;
|
||||
const companyFilter =
|
||||
userCompany && userCompany !== "*"
|
||||
? `AND (company_code = '${userCompany.replace(/'/g, "''")}' OR company_code = '*' OR company_code IS NULL)`
|
||||
: "";
|
||||
|
||||
// 1) 크론 배치 (batch_configs)
|
||||
const batches = await query<any>(
|
||||
`SELECT id, batch_name, cron_schedule, is_active, company_code,
|
||||
last_run_date, last_run_result, next_run_date
|
||||
FROM batch_configs
|
||||
WHERE 1=1 ${companyFilter}
|
||||
ORDER BY is_active DESC, batch_name
|
||||
LIMIT 50`
|
||||
).catch(() => []);
|
||||
|
||||
// 2) 장비 폴링 (pipeline_device_connections)
|
||||
const pollings = await query<any>(
|
||||
`SELECT c.id, c.connection_name, c.protocol, c.host, c.port,
|
||||
c.polling_interval_ms, c.is_active, c.status,
|
||||
c.last_test_result, c.last_test_date,
|
||||
c.target_db_connection_id, c.target_table_name,
|
||||
(SELECT COUNT(*) FROM pipeline_tag_mappings t
|
||||
WHERE t.connection_id = c.id AND t.is_active = 'Y') AS tag_count,
|
||||
(SELECT MAX(s.last_collected_at) FROM equipment_current_state s
|
||||
WHERE s.connection_id = c.id) AS last_collected_at
|
||||
FROM pipeline_device_connections c
|
||||
WHERE 1=1 ${companyFilter ? companyFilter.replace(/company_code/g, "c.company_code") : ""}
|
||||
ORDER BY c.is_active DESC, c.connection_name`
|
||||
).catch(() => []);
|
||||
|
||||
// 3) 중앙 MQTT 포워더 설정 + 통계
|
||||
const forwarders = await query<any>(
|
||||
`SELECT f.id, f.config_name, f.company_code, f.company_id, f.edge_id,
|
||||
f.broker_host, f.broker_port, f.topic_pattern,
|
||||
f.batch_size, f.batch_timeout_ms, f.is_enabled,
|
||||
s.messages_forwarded, s.messages_failed, s.messages_dropped,
|
||||
s.batches_sent, s.last_published_at, s.last_error,
|
||||
s.is_connected, s.reconnect_attempts
|
||||
FROM central_mqtt_forwarder_config f
|
||||
LEFT JOIN central_mqtt_forwarder_stats s ON s.config_id = f.id
|
||||
WHERE 1=1 ${companyFilter ? companyFilter.replace(/company_code/g, "f.company_code") : ""}
|
||||
ORDER BY f.is_enabled DESC, f.config_name`
|
||||
).catch(() => []);
|
||||
|
||||
// 4) 요약 통계
|
||||
const activeBatches = batches.filter((b: any) => b.is_active === "Y" || b.is_active === true).length;
|
||||
const activePollings = pollings.filter((p: any) => p.is_active === "Y").length;
|
||||
const activeForwarders = forwarders.filter((f: any) => f.is_enabled === "Y").length;
|
||||
const connectedPolls = pollings.filter((p: any) => p.status === "active" || p.status === "connected").length;
|
||||
const totalTags = pollings.reduce((sum: number, p: any) => sum + Number(p.tag_count || 0), 0);
|
||||
const forwardedTotal = forwarders.reduce((sum: number, f: any) => sum + Number(f.messages_forwarded || 0), 0);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
stats: {
|
||||
batches_total: batches.length,
|
||||
batches_active: activeBatches,
|
||||
pollings_total: pollings.length,
|
||||
pollings_active: activePollings,
|
||||
pollings_connected: connectedPolls,
|
||||
total_tags: totalTags,
|
||||
forwarders_total: forwarders.length,
|
||||
forwarders_enabled: activeForwarders,
|
||||
messages_forwarded_total: forwardedTotal,
|
||||
},
|
||||
batches,
|
||||
pollings,
|
||||
forwarders,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Central MQTT Forwarder 관리 API
|
||||
*
|
||||
* GET /api/central-forwarder 목록
|
||||
* GET /api/central-forwarder/:id 단건
|
||||
* POST /api/central-forwarder 생성
|
||||
* PUT /api/central-forwarder/:id 수정
|
||||
* DELETE /api/central-forwarder/:id 삭제
|
||||
* POST /api/central-forwarder/:id/toggle 활성/비활성
|
||||
* GET /api/central-forwarder/runtime/status 런타임 상태
|
||||
*/
|
||||
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
listConfigs,
|
||||
getConfig,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
deleteConfig,
|
||||
setEnabled,
|
||||
} from "../services/collector/centralForwarderConfigService";
|
||||
import { getRuntimeStatus } from "../services/collector/centralMqttForwarder";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompany = req.user?.companyCode;
|
||||
const rows = await listConfigs(
|
||||
userCompany === "*" ? (req.query.company_code as string | undefined) : userCompany
|
||||
);
|
||||
return res.status(200).json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
logger.error(`forwarder list error: ${(err as Error).message}`);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/runtime/status",
|
||||
authenticateToken,
|
||||
async (_req: AuthenticatedRequest, res: Response) => {
|
||||
return res.status(200).json({ success: true, data: getRuntimeStatus() });
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const row = await getConfig(Number(req.params.id));
|
||||
if (!row) {
|
||||
return res.status(404).json({ success: false, message: "not found" });
|
||||
}
|
||||
// 비밀번호 필드 마스킹
|
||||
const out = { ...(row as Record<string, unknown>) };
|
||||
if (out.password_encrypted) out.password_encrypted = "***ENCRYPTED***";
|
||||
return res.status(200).json({ success: true, data: out });
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const created = await createConfig(req.body, req.user?.userId);
|
||||
return res.status(201).json({ success: true, data: created });
|
||||
} catch (err) {
|
||||
logger.error(`forwarder create error: ${(err as Error).message}`);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await updateConfig(Number(req.params.id), req.body, req.user?.userId);
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await deleteConfig(Number(req.params.id));
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/toggle",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const enabled = req.body?.enabled === true || req.body?.enabled === "Y";
|
||||
await setEnabled(Number(req.params.id), enabled, req.user?.userId);
|
||||
return res.status(200).json({ success: true, data: { enabled } });
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Equipment Current State API
|
||||
*
|
||||
* GET /api/equipment-state/summary 회사별 연결 상태 요약
|
||||
* GET /api/equipment-state/:connectionId 해당 연결의 태그별 최신값
|
||||
*/
|
||||
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
getStateByConnection,
|
||||
getConnectionStatusSummary,
|
||||
} from "../services/collector/equipmentStateService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/summary",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompany = req.user?.companyCode;
|
||||
const companyCode =
|
||||
userCompany === "*"
|
||||
? (req.query.company_code as string | undefined)
|
||||
: userCompany;
|
||||
const rows = await getConnectionStatusSummary(companyCode);
|
||||
return res.status(200).json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:connectionId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const rows = await getStateByConnection(Number(req.params.connectionId));
|
||||
return res.status(200).json({ success: true, data: rows });
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,4 +1,4 @@
|
||||
import cron from "node-cron";
|
||||
import * as cron from "node-cron";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { MultiAgentExecutionEngine } from "./multiAgentExecutionEngine";
|
||||
import { logger } from "../utils/logger";
|
||||
@@ -162,7 +162,7 @@ export class AiSchedulerService {
|
||||
to,
|
||||
subject: `[AI 분석] ${schedule.name} 실행 결과`,
|
||||
html: `<h3>${schedule.name} 분석 결과</h3><pre>${result.finalSummary.substring(0, 3000)}</pre>`,
|
||||
}).catch(() => {});
|
||||
} as any).catch(() => {});
|
||||
}
|
||||
} catch (e) { logger.warn("이메일 발송 실패:", e); }
|
||||
}
|
||||
|
||||
@@ -280,17 +280,61 @@ export class BatchSchedulerService {
|
||||
// 알림 발송 (notification 설정이 있으면)
|
||||
const notification = config.node_flow_context?.notification;
|
||||
if (notification) {
|
||||
// 시스템 공지
|
||||
if (notification.system_notice) {
|
||||
const title = `[AI] ${config.batch_name} 실행 결과`;
|
||||
const summary = result.finalSummary.substring(0, 2000);
|
||||
|
||||
// 메신저 알림 (시스템 내 채팅)
|
||||
if (notification.messenger) {
|
||||
try {
|
||||
const { query: dbQuery } = await import("../database/db");
|
||||
await dbQuery(
|
||||
`INSERT INTO system_notice (title, content, type, is_active, created_by, created_at)
|
||||
VALUES ($1, $2, 'info', true, 'AI_BATCH', NOW())`,
|
||||
[`[AI] ${config.batch_name} 실행 결과`, result.finalSummary.substring(0, 2000)]
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
const recipients = notification.messenger_recipients || [];
|
||||
const sender = config.created_by || "system";
|
||||
const companyCode = config.company_code || "*";
|
||||
for (const recipientId of recipients) {
|
||||
// DM 방 찾기 또는 생성
|
||||
let room = await dbQuery<any>(
|
||||
`SELECT r.id FROM messenger_rooms r
|
||||
JOIN messenger_participants p1 ON p1.room_id = r.id AND p1.user_id = $1
|
||||
JOIN messenger_participants p2 ON p2.room_id = r.id AND p2.user_id = $2
|
||||
WHERE r.company_code = $3 AND r.room_type = 'dm' LIMIT 1`,
|
||||
[sender, recipientId, companyCode]
|
||||
);
|
||||
let roomId = room?.[0]?.id;
|
||||
if (!roomId) {
|
||||
const created = await dbQuery<any>(
|
||||
`INSERT INTO messenger_rooms (company_code, room_type, created_by) VALUES ($1, 'dm', $2) RETURNING id`,
|
||||
[companyCode, sender]
|
||||
);
|
||||
roomId = created[0].id;
|
||||
await dbQuery(
|
||||
`INSERT INTO messenger_participants (room_id, user_id) VALUES ($1, $2), ($1, $3)`,
|
||||
[roomId, sender, recipientId]
|
||||
);
|
||||
}
|
||||
await dbQuery(
|
||||
`INSERT INTO messenger_messages (room_id, sender_id, company_code, content, message_type)
|
||||
VALUES ($1, $2, $3, $4, 'text')`,
|
||||
[roomId, sender, companyCode, `${title}\n\n${summary}`]
|
||||
);
|
||||
await dbQuery(`UPDATE messenger_rooms SET updated_at = NOW() WHERE id = $1`, [roomId]);
|
||||
}
|
||||
} catch (e) { logger.warn("메신저 알림 실패:", e); }
|
||||
}
|
||||
|
||||
// 이메일 알림
|
||||
if (notification.email && Array.isArray(notification.email) && notification.email.length > 0) {
|
||||
try {
|
||||
const { mailSendSimpleService } = await import("./mailSendSimpleService");
|
||||
for (const to of notification.email) {
|
||||
await mailSendSimpleService.sendMail({
|
||||
to,
|
||||
subject: title,
|
||||
html: `<h3>${config.batch_name}</h3><pre>${summary}</pre>`,
|
||||
} as any).catch(() => {});
|
||||
}
|
||||
} catch (e) { logger.warn("이메일 알림 실패:", e); }
|
||||
}
|
||||
|
||||
// 웹훅
|
||||
if (notification.webhook) {
|
||||
try {
|
||||
@@ -439,16 +483,41 @@ export class BatchSchedulerService {
|
||||
|
||||
// FROM 데이터 조회 (DB 또는 REST API)
|
||||
if (firstMapping.from_connection_type === "restapi") {
|
||||
// from_api_url이 없으면 external_rest_api_connections에서 조회
|
||||
let apiUrl = firstMapping.from_api_url;
|
||||
let apiMethod = firstMapping.from_api_method;
|
||||
let apiKey = firstMapping.from_api_key || "";
|
||||
|
||||
if (!apiUrl && firstMapping.from_connection_id) {
|
||||
const connRes = await query<any>(
|
||||
`SELECT base_url, endpoint_path, default_method, auth_type, auth_config
|
||||
FROM external_rest_api_connections WHERE id = $1`,
|
||||
[firstMapping.from_connection_id]
|
||||
);
|
||||
if (connRes.length > 0) {
|
||||
const conn = connRes[0];
|
||||
const base = (conn.base_url || "").replace(/\/$/, "");
|
||||
const path = conn.endpoint_path || "";
|
||||
apiUrl = base + (path.startsWith("/") ? path : `/${path}`);
|
||||
apiMethod = conn.default_method || "GET";
|
||||
if (conn.auth_type === "bearer" && conn.auth_config?.token) {
|
||||
apiKey = conn.auth_config.token;
|
||||
} else if (conn.auth_type === "apikey" && conn.auth_config?.key) {
|
||||
apiKey = conn.auth_config.key;
|
||||
}
|
||||
logger.info(`API 연결 조회 성공: ${apiUrl} (method: ${apiMethod})`);
|
||||
}
|
||||
}
|
||||
|
||||
// REST API에서 데이터 조회
|
||||
logger.info(
|
||||
`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`
|
||||
`REST API에서 데이터 조회: ${apiUrl}`
|
||||
);
|
||||
const { BatchExternalDbService } = await import(
|
||||
"./batchExternalDbService"
|
||||
);
|
||||
|
||||
// auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용)
|
||||
let apiKey = firstMapping.from_api_key || "";
|
||||
if (config.auth_service_name) {
|
||||
let tokenQuery: string;
|
||||
let tokenParams: any[];
|
||||
@@ -485,14 +554,10 @@ export class BatchSchedulerService {
|
||||
|
||||
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
apiUrl!,
|
||||
apiKey,
|
||||
firstMapping.from_table_name,
|
||||
(firstMapping.from_api_method as
|
||||
| "GET"
|
||||
| "POST"
|
||||
| "PUT"
|
||||
| "DELETE") || "GET",
|
||||
(apiMethod as "GET" | "POST" | "PUT" | "DELETE") || "GET",
|
||||
mappings.map((m: any) => m.from_column_name),
|
||||
100, // limit
|
||||
// 파라미터 정보 전달
|
||||
@@ -505,8 +570,14 @@ export class BatchSchedulerService {
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
// 데이터 배열 경로가 설정되어 있으면 해당 경로에서 배열 추출
|
||||
if (config.data_array_path) {
|
||||
// apiResult.data가 이미 배열 형태(BatchExternalDbService가 뽑아낸 레코드)면 그대로 사용
|
||||
// 객체 형태(API 응답 원본)면 data_array_path로 추출
|
||||
if (Array.isArray(apiResult.data) && apiResult.data.length > 0 && apiResult.data[0] && typeof apiResult.data[0] === "object" && !Array.isArray(apiResult.data[0])) {
|
||||
// 이미 레코드 배열 형태
|
||||
fromData = apiResult.data;
|
||||
logger.info(`REST API에서 ${fromData.length}개 레코드 수신 (배열 형태)`);
|
||||
} else if (config.data_array_path) {
|
||||
// 원본 응답 객체에서 경로로 배열 추출
|
||||
const extractArrayByPath = (obj: any, path: string): any[] => {
|
||||
if (!path) return Array.isArray(obj) ? obj : [obj];
|
||||
const keys = path.split(".");
|
||||
@@ -522,7 +593,6 @@ export class BatchSchedulerService {
|
||||
: [];
|
||||
};
|
||||
|
||||
// apiResult.data가 단일 객체인 경우 (API 응답 전체)
|
||||
const rawData =
|
||||
Array.isArray(apiResult.data) && apiResult.data.length === 1
|
||||
? apiResult.data[0]
|
||||
@@ -533,7 +603,7 @@ export class BatchSchedulerService {
|
||||
`데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출`
|
||||
);
|
||||
} else {
|
||||
fromData = apiResult.data;
|
||||
fromData = Array.isArray(apiResult.data) ? apiResult.data : [apiResult.data];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Central MQTT Forwarder Config — CRUD Service
|
||||
*
|
||||
* central_mqtt_forwarder_config 테이블 관리.
|
||||
* 비밀번호는 PasswordEncryption으로 저장.
|
||||
*/
|
||||
|
||||
import { query, queryOne } from "../../database/db";
|
||||
import { PasswordEncryption } from "../../utils/passwordEncryption";
|
||||
import { startForwarder, stopForwarder } from "./centralMqttForwarder";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
export interface CentralForwarderConfigInput {
|
||||
config_name: string;
|
||||
company_code?: string;
|
||||
company_id: string;
|
||||
edge_id: string;
|
||||
broker_host: string;
|
||||
broker_port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
use_tls?: string;
|
||||
client_id_prefix?: string;
|
||||
topic_pattern?: string;
|
||||
status_topic_pattern?: string;
|
||||
batch_size?: number;
|
||||
batch_timeout_ms?: number;
|
||||
heartbeat_interval_sec?: number;
|
||||
qos?: number;
|
||||
is_enabled?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function listConfigs(companyCode?: string) {
|
||||
if (companyCode && companyCode !== "*") {
|
||||
return query(
|
||||
`SELECT id, config_name, company_code, company_id, edge_id,
|
||||
broker_host, broker_port, username, use_tls,
|
||||
client_id_prefix, topic_pattern, status_topic_pattern,
|
||||
batch_size, batch_timeout_ms, heartbeat_interval_sec, qos,
|
||||
is_enabled, description, created_date, updated_date
|
||||
FROM central_mqtt_forwarder_config
|
||||
WHERE company_code = $1 OR company_code = '*'
|
||||
ORDER BY id DESC`,
|
||||
[companyCode]
|
||||
);
|
||||
}
|
||||
return query(
|
||||
`SELECT id, config_name, company_code, company_id, edge_id,
|
||||
broker_host, broker_port, username, use_tls,
|
||||
client_id_prefix, topic_pattern, status_topic_pattern,
|
||||
batch_size, batch_timeout_ms, heartbeat_interval_sec, qos,
|
||||
is_enabled, description, created_date, updated_date
|
||||
FROM central_mqtt_forwarder_config
|
||||
ORDER BY id DESC`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getConfig(id: number) {
|
||||
return queryOne(
|
||||
`SELECT * FROM central_mqtt_forwarder_config WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
export async function createConfig(
|
||||
input: CentralForwarderConfigInput,
|
||||
user?: string
|
||||
) {
|
||||
const encrypted = input.password
|
||||
? PasswordEncryption.encrypt(input.password)
|
||||
: null;
|
||||
|
||||
const row = await queryOne<{ id: number }>(
|
||||
`INSERT INTO central_mqtt_forwarder_config
|
||||
(config_name, company_code, company_id, edge_id,
|
||||
broker_host, broker_port, username, password_encrypted, use_tls,
|
||||
client_id_prefix, topic_pattern, status_topic_pattern,
|
||||
batch_size, batch_timeout_ms, heartbeat_interval_sec, qos,
|
||||
is_enabled, description, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11, $12, $13, $14, $15, $16,
|
||||
$17, $18, $19, $19)
|
||||
RETURNING id`,
|
||||
[
|
||||
input.config_name,
|
||||
input.company_code || "*",
|
||||
input.company_id,
|
||||
input.edge_id,
|
||||
input.broker_host,
|
||||
input.broker_port || 31883,
|
||||
input.username || null,
|
||||
encrypted,
|
||||
input.use_tls || "N",
|
||||
input.client_id_prefix || "pipeline-forwarder",
|
||||
input.topic_pattern || "dt/v1/data/{company_id}/{edge_id}",
|
||||
input.status_topic_pattern || "dt/v1/status/{company_id}/{edge_id}",
|
||||
input.batch_size ?? 50,
|
||||
input.batch_timeout_ms ?? 3000,
|
||||
input.heartbeat_interval_sec ?? 60,
|
||||
input.qos ?? 1,
|
||||
input.is_enabled || "N",
|
||||
input.description || null,
|
||||
user || null,
|
||||
]
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
id: number,
|
||||
input: Partial<CentralForwarderConfigInput>,
|
||||
user?: string
|
||||
) {
|
||||
const existing = await getConfig(id);
|
||||
if (!existing) throw new Error("forwarder config not found");
|
||||
|
||||
// 비밀번호 변경이 있을 때만 암호화
|
||||
const encrypted =
|
||||
input.password && input.password !== "***ENCRYPTED***"
|
||||
? PasswordEncryption.encrypt(input.password)
|
||||
: undefined;
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
const push = (col: string, v: unknown) => {
|
||||
fields.push(`${col} = $${paramIdx++}`);
|
||||
values.push(v);
|
||||
};
|
||||
|
||||
if (input.config_name !== undefined) push("config_name", input.config_name);
|
||||
if (input.company_code !== undefined) push("company_code", input.company_code);
|
||||
if (input.company_id !== undefined) push("company_id", input.company_id);
|
||||
if (input.edge_id !== undefined) push("edge_id", input.edge_id);
|
||||
if (input.broker_host !== undefined) push("broker_host", input.broker_host);
|
||||
if (input.broker_port !== undefined) push("broker_port", input.broker_port);
|
||||
if (input.username !== undefined) push("username", input.username);
|
||||
if (encrypted !== undefined) push("password_encrypted", encrypted);
|
||||
if (input.use_tls !== undefined) push("use_tls", input.use_tls);
|
||||
if (input.client_id_prefix !== undefined)
|
||||
push("client_id_prefix", input.client_id_prefix);
|
||||
if (input.topic_pattern !== undefined) push("topic_pattern", input.topic_pattern);
|
||||
if (input.status_topic_pattern !== undefined)
|
||||
push("status_topic_pattern", input.status_topic_pattern);
|
||||
if (input.batch_size !== undefined) push("batch_size", input.batch_size);
|
||||
if (input.batch_timeout_ms !== undefined)
|
||||
push("batch_timeout_ms", input.batch_timeout_ms);
|
||||
if (input.heartbeat_interval_sec !== undefined)
|
||||
push("heartbeat_interval_sec", input.heartbeat_interval_sec);
|
||||
if (input.qos !== undefined) push("qos", input.qos);
|
||||
if (input.is_enabled !== undefined) push("is_enabled", input.is_enabled);
|
||||
if (input.description !== undefined) push("description", input.description);
|
||||
|
||||
push("updated_by", user || null);
|
||||
fields.push(`updated_date = NOW()`);
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
await query(
|
||||
`UPDATE central_mqtt_forwarder_config SET ${fields.join(", ")} WHERE id = $${paramIdx}`,
|
||||
values
|
||||
);
|
||||
|
||||
// 설정 변경 시 재시작 (활성인 경우)
|
||||
const after = await getConfig(id);
|
||||
if (after && (after as any).is_enabled === "Y") {
|
||||
try {
|
||||
await stopForwarder(id).catch(() => {});
|
||||
await startForwarder(id);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[ForwarderConfig] 재시작 실패 (id=${id}): ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await stopForwarder(id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteConfig(id: number) {
|
||||
await stopForwarder(id).catch(() => {});
|
||||
await query(`DELETE FROM central_mqtt_forwarder_config WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
export async function setEnabled(id: number, enabled: boolean, user?: string) {
|
||||
await query(
|
||||
`UPDATE central_mqtt_forwarder_config
|
||||
SET is_enabled = $1, updated_by = $2, updated_date = NOW()
|
||||
WHERE id = $3`,
|
||||
[enabled ? "Y" : "N", user || null, id]
|
||||
);
|
||||
|
||||
if (enabled) {
|
||||
await startForwarder(id);
|
||||
} else {
|
||||
await stopForwarder(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Central MQTT Forwarder
|
||||
*
|
||||
* Pipeline이 수집한 데이터를 IDC 중앙 EMQX로 전송.
|
||||
* 스피폭스 엣지의 `kafka-to-central-mqtt` 포워더(Python) Node.js 포팅.
|
||||
*
|
||||
* 토픽: dt/v1/data/{company_id}/{edge_id} (QoS 1, MQTTv5)
|
||||
* 하트비트: dt/v1/status/{company_id}/{edge_id}
|
||||
*
|
||||
* 설계:
|
||||
* - 설정은 central_mqtt_forwarder_config 테이블에서 조회 (company_code 단위로 1개)
|
||||
* - 여러 고객사를 한 파이프라인 인스턴스가 처리 가능
|
||||
* - 배치 (batch_size / batch_timeout_ms)
|
||||
* - 실패 시 retry_queue 테이블에 persist
|
||||
* - 통계는 central_mqtt_forwarder_stats 에 주기 업데이트
|
||||
*/
|
||||
|
||||
import mqtt, { MqttClient, IClientOptions } from "mqtt";
|
||||
import { query } from "../../database/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { PasswordEncryption } from "../../utils/passwordEncryption";
|
||||
import type { CollectedData } from "./deviceCollectorService";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────
|
||||
|
||||
interface ForwarderConfig {
|
||||
id: number;
|
||||
config_name: string;
|
||||
company_code: string;
|
||||
company_id: string;
|
||||
edge_id: string;
|
||||
broker_host: string;
|
||||
broker_port: number;
|
||||
username: string | null;
|
||||
password_encrypted: string | null;
|
||||
use_tls: string;
|
||||
client_id_prefix: string | null;
|
||||
topic_pattern: string;
|
||||
status_topic_pattern: string;
|
||||
batch_size: number;
|
||||
batch_timeout_ms: number;
|
||||
heartbeat_interval_sec: number;
|
||||
qos: number;
|
||||
is_enabled: string;
|
||||
}
|
||||
|
||||
interface ForwarderInstance {
|
||||
config: ForwarderConfig;
|
||||
client: MqttClient | null;
|
||||
buffer: CollectedData[];
|
||||
flushTimer: NodeJS.Timeout | null;
|
||||
heartbeatTimer: NodeJS.Timeout | null;
|
||||
stats: {
|
||||
messagesForwarded: number;
|
||||
messagesFailed: number;
|
||||
messagesDropped: number;
|
||||
batchesSent: number;
|
||||
lastPublishedAt: Date | null;
|
||||
startedAt: Date;
|
||||
isConnected: boolean;
|
||||
reconnectAttempts: number;
|
||||
lastError: string | null;
|
||||
lastErrorAt: Date | null;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 전역 인스턴스 맵 (company_code 기준) ───────────
|
||||
|
||||
const instances = new Map<number, ForwarderInstance>();
|
||||
|
||||
// ─── 시작/중지 ──────────────────────────────────────
|
||||
|
||||
export async function startAllEnabled(): Promise<void> {
|
||||
const configs = await query<ForwarderConfig>(
|
||||
`SELECT * FROM central_mqtt_forwarder_config WHERE is_enabled = 'Y'`
|
||||
);
|
||||
logger.info(`[CentralForwarder] 활성 설정 ${configs.length}개 시작`);
|
||||
for (const cfg of configs) {
|
||||
await startForwarder(cfg).catch(err =>
|
||||
logger.error(`[CentralForwarder] 시작 실패 (id=${cfg.id}): ${(err as Error).message}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopAll(): Promise<void> {
|
||||
for (const id of Array.from(instances.keys())) {
|
||||
await stopForwarder(id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startForwarder(cfgOrId: ForwarderConfig | number): Promise<void> {
|
||||
const config: ForwarderConfig =
|
||||
typeof cfgOrId === "number" ? await loadConfig(cfgOrId) : cfgOrId;
|
||||
|
||||
if (instances.has(config.id)) {
|
||||
logger.warn(`[CentralForwarder] 이미 실행 중: id=${config.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedPw = config.password_encrypted
|
||||
? tryDecrypt(config.password_encrypted)
|
||||
: undefined;
|
||||
|
||||
const clientId = `${config.client_id_prefix || "pipeline-forwarder"}-${config.edge_id}-${Date.now()
|
||||
.toString(36)
|
||||
.slice(-6)}`;
|
||||
|
||||
const url = `${config.use_tls === "Y" ? "mqtts" : "mqtt"}://${config.broker_host}:${config.broker_port}`;
|
||||
|
||||
const opts: IClientOptions = {
|
||||
clientId,
|
||||
username: config.username || undefined,
|
||||
password: decryptedPw,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 10000,
|
||||
clean: true,
|
||||
protocolVersion: 5,
|
||||
};
|
||||
|
||||
const client = mqtt.connect(url, opts);
|
||||
|
||||
const instance: ForwarderInstance = {
|
||||
config,
|
||||
client,
|
||||
buffer: [],
|
||||
flushTimer: null,
|
||||
heartbeatTimer: null,
|
||||
stats: {
|
||||
messagesForwarded: 0,
|
||||
messagesFailed: 0,
|
||||
messagesDropped: 0,
|
||||
batchesSent: 0,
|
||||
lastPublishedAt: null,
|
||||
startedAt: new Date(),
|
||||
isConnected: false,
|
||||
reconnectAttempts: 0,
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
},
|
||||
};
|
||||
instances.set(config.id, instance);
|
||||
|
||||
client.on("connect", () => {
|
||||
instance.stats.isConnected = true;
|
||||
logger.info(`[CentralForwarder] 연결됨: ${url} (config=${config.config_name})`);
|
||||
persistStats(instance).catch(() => {});
|
||||
// 접속 즉시 재시도 큐 드레인
|
||||
drainRetryQueue(instance).catch(err =>
|
||||
logger.warn(`[CentralForwarder] 재시도 큐 드레인 실패: ${(err as Error).message}`)
|
||||
);
|
||||
});
|
||||
|
||||
client.on("reconnect", () => {
|
||||
instance.stats.reconnectAttempts++;
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
instance.stats.isConnected = false;
|
||||
});
|
||||
|
||||
client.on("error", err => {
|
||||
instance.stats.lastError = err.message;
|
||||
instance.stats.lastErrorAt = new Date();
|
||||
logger.error(`[CentralForwarder] 연결 오류: ${err.message}`);
|
||||
});
|
||||
|
||||
// 배치 flush 타이머
|
||||
instance.flushTimer = setInterval(() => {
|
||||
flushBuffer(instance).catch(() => {});
|
||||
}, config.batch_timeout_ms);
|
||||
|
||||
// 하트비트 타이머
|
||||
instance.heartbeatTimer = setInterval(() => {
|
||||
sendHeartbeat(instance).catch(() => {});
|
||||
}, config.heartbeat_interval_sec * 1000);
|
||||
|
||||
// 통계 주기 저장
|
||||
setInterval(() => persistStats(instance).catch(() => {}), 30_000);
|
||||
}
|
||||
|
||||
export async function stopForwarder(configId: number): Promise<void> {
|
||||
const inst = instances.get(configId);
|
||||
if (!inst) return;
|
||||
|
||||
if (inst.flushTimer) clearInterval(inst.flushTimer);
|
||||
if (inst.heartbeatTimer) clearInterval(inst.heartbeatTimer);
|
||||
|
||||
// 남은 버퍼 밀어내기
|
||||
await flushBuffer(inst).catch(() => {});
|
||||
|
||||
if (inst.client) {
|
||||
await new Promise<void>(resolve => {
|
||||
inst.client!.end(false, {}, () => resolve());
|
||||
});
|
||||
}
|
||||
instances.delete(configId);
|
||||
await persistStats(inst).catch(() => {});
|
||||
logger.info(`[CentralForwarder] 중지: config_id=${configId}`);
|
||||
}
|
||||
|
||||
// ─── 데이터 인입 ───────────────────────────────────
|
||||
|
||||
/** deviceCollectorService가 이 함수를 호출해 포워딩 파이프라인에 데이터 투입 */
|
||||
export async function ingest(data: CollectedData): Promise<void> {
|
||||
// 회사별 인스턴스 찾기 (company_code 매칭)
|
||||
for (const inst of instances.values()) {
|
||||
const cfg = inst.config;
|
||||
if (cfg.company_code !== "*" && cfg.company_code !== data.companyCode) continue;
|
||||
|
||||
inst.buffer.push(data);
|
||||
|
||||
if (inst.buffer.length >= cfg.batch_size) {
|
||||
await flushBuffer(inst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function flushBuffer(inst: ForwarderInstance): Promise<void> {
|
||||
if (inst.buffer.length === 0) return;
|
||||
const cfg = inst.config;
|
||||
const batch = inst.buffer.splice(0, inst.buffer.length);
|
||||
|
||||
if (!inst.client || !inst.stats.isConnected) {
|
||||
// 연결 안 되어 있으면 retry_queue에 쌓아두기
|
||||
await enqueueRetry(cfg.id, batch, cfg).catch(err =>
|
||||
logger.error(`[CentralForwarder] 재시도 큐 저장 실패: ${(err as Error).message}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const data of batch) {
|
||||
const topic = renderTopic(cfg.topic_pattern, cfg, data);
|
||||
const payload = buildPayload(cfg, data);
|
||||
|
||||
try {
|
||||
await publishAsync(inst.client, topic, payload, cfg.qos as 0 | 1 | 2);
|
||||
inst.stats.messagesForwarded++;
|
||||
inst.stats.lastPublishedAt = new Date();
|
||||
} catch (err) {
|
||||
inst.stats.messagesFailed++;
|
||||
await enqueueRetry(cfg.id, [data], cfg).catch(() => {
|
||||
inst.stats.messagesDropped++;
|
||||
});
|
||||
logger.warn(`[CentralForwarder] publish 실패: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
inst.stats.batchesSent++;
|
||||
}
|
||||
|
||||
async function sendHeartbeat(inst: ForwarderInstance): Promise<void> {
|
||||
if (!inst.client || !inst.stats.isConnected) return;
|
||||
const cfg = inst.config;
|
||||
const topic = cfg.status_topic_pattern
|
||||
.replace("{company_id}", cfg.company_id)
|
||||
.replace("{edge_id}", cfg.edge_id);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
status: "online",
|
||||
timestamp: new Date().toISOString(),
|
||||
company_id: cfg.company_id,
|
||||
edge_id: cfg.edge_id,
|
||||
stats: {
|
||||
forwarded: inst.stats.messagesForwarded,
|
||||
failed: inst.stats.messagesFailed,
|
||||
dropped: inst.stats.messagesDropped,
|
||||
batches_sent: inst.stats.batchesSent,
|
||||
reconnect_attempts: inst.stats.reconnectAttempts,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await publishAsync(inst.client, topic, payload, cfg.qos as 0 | 1 | 2);
|
||||
} catch (err) {
|
||||
logger.debug(`[CentralForwarder] heartbeat 실패: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 재시도 큐 ─────────────────────────────────────
|
||||
|
||||
async function enqueueRetry(
|
||||
configId: number,
|
||||
items: CollectedData[],
|
||||
cfg: ForwarderConfig
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
|
||||
const values: unknown[] = [];
|
||||
const placeholders: string[] = [];
|
||||
items.forEach((data, idx) => {
|
||||
const base = idx * 3;
|
||||
const topic = renderTopic(cfg.topic_pattern, cfg, data);
|
||||
const payload = buildPayload(cfg, data);
|
||||
values.push(configId, topic, payload);
|
||||
placeholders.push(`($${base + 1}, $${base + 2}, $${base + 3}::jsonb)`);
|
||||
});
|
||||
|
||||
await query(
|
||||
`INSERT INTO central_mqtt_forwarder_retry_queue (config_id, topic, payload)
|
||||
VALUES ${placeholders.join(", ")}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
async function drainRetryQueue(inst: ForwarderInstance): Promise<void> {
|
||||
if (!inst.client || !inst.stats.isConnected) return;
|
||||
|
||||
// 한 번에 최대 500건씩 처리
|
||||
const rows = await query<{ id: number; topic: string; payload: string }>(
|
||||
`SELECT id, topic, payload::text AS payload
|
||||
FROM central_mqtt_forwarder_retry_queue
|
||||
WHERE config_id = $1
|
||||
ORDER BY enqueued_at
|
||||
LIMIT 500`,
|
||||
[inst.config.id]
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await publishAsync(inst.client, row.topic, row.payload, inst.config.qos as 0 | 1 | 2);
|
||||
await query(`DELETE FROM central_mqtt_forwarder_retry_queue WHERE id = $1`, [row.id]);
|
||||
inst.stats.messagesForwarded++;
|
||||
} catch (err) {
|
||||
await query(
|
||||
`UPDATE central_mqtt_forwarder_retry_queue
|
||||
SET retry_count = retry_count + 1, last_attempt = NOW(), last_error = $2
|
||||
WHERE id = $1`,
|
||||
[row.id, (err as Error).message]
|
||||
);
|
||||
return; // 하나라도 실패하면 중단 — 재연결 후 다시 시도
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 통계 저장 ─────────────────────────────────────
|
||||
|
||||
async function persistStats(inst: ForwarderInstance): Promise<void> {
|
||||
const s = inst.stats;
|
||||
await query(
|
||||
`INSERT INTO central_mqtt_forwarder_stats
|
||||
(config_id, started_at, last_published_at, messages_forwarded, messages_failed,
|
||||
messages_dropped, batches_sent, last_error, last_error_at, is_connected,
|
||||
reconnect_attempts, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
|
||||
ON CONFLICT (config_id) DO UPDATE SET
|
||||
started_at = EXCLUDED.started_at,
|
||||
last_published_at = EXCLUDED.last_published_at,
|
||||
messages_forwarded = EXCLUDED.messages_forwarded,
|
||||
messages_failed = EXCLUDED.messages_failed,
|
||||
messages_dropped = EXCLUDED.messages_dropped,
|
||||
batches_sent = EXCLUDED.batches_sent,
|
||||
last_error = EXCLUDED.last_error,
|
||||
last_error_at = EXCLUDED.last_error_at,
|
||||
is_connected = EXCLUDED.is_connected,
|
||||
reconnect_attempts = EXCLUDED.reconnect_attempts,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
inst.config.id,
|
||||
s.startedAt,
|
||||
s.lastPublishedAt,
|
||||
s.messagesForwarded,
|
||||
s.messagesFailed,
|
||||
s.messagesDropped,
|
||||
s.batchesSent,
|
||||
s.lastError,
|
||||
s.lastErrorAt,
|
||||
s.isConnected ? "Y" : "N",
|
||||
s.reconnectAttempts,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export function getRuntimeStatus() {
|
||||
return Array.from(instances.values()).map(i => ({
|
||||
config_id: i.config.id,
|
||||
config_name: i.config.config_name,
|
||||
company_code: i.config.company_code,
|
||||
edge_id: i.config.edge_id,
|
||||
broker: `${i.config.broker_host}:${i.config.broker_port}`,
|
||||
connected: i.stats.isConnected,
|
||||
buffered: i.buffer.length,
|
||||
...i.stats,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 유틸 ─────────────────────────────────────────
|
||||
|
||||
async function loadConfig(id: number): Promise<ForwarderConfig> {
|
||||
const rows = await query<ForwarderConfig>(
|
||||
`SELECT * FROM central_mqtt_forwarder_config WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) throw new Error(`forwarder config ${id} 없음`);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function tryDecrypt(encrypted: string): string | undefined {
|
||||
try {
|
||||
return PasswordEncryption.decrypt(encrypted);
|
||||
} catch {
|
||||
logger.warn(`[CentralForwarder] 비밀번호 복호화 실패 — 원본 사용`);
|
||||
return encrypted;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopic(
|
||||
pattern: string,
|
||||
cfg: ForwarderConfig,
|
||||
data: CollectedData
|
||||
): string {
|
||||
return pattern
|
||||
.replace("{company_id}", cfg.company_id)
|
||||
.replace("{edge_id}", cfg.edge_id)
|
||||
.replace("{connection_id}", String(data.connectionId));
|
||||
}
|
||||
|
||||
function buildPayload(cfg: ForwarderConfig, data: CollectedData): string {
|
||||
return JSON.stringify({
|
||||
timestamp: data.timestamp,
|
||||
edge_id: cfg.edge_id,
|
||||
device_id: String(data.connectionId),
|
||||
connection_name: data.connectionName,
|
||||
tags: data.tags,
|
||||
priority: 2,
|
||||
company_id: cfg.company_id,
|
||||
plc_state: data.plcState,
|
||||
error_message: data.errorMessage,
|
||||
forwarded_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function publishAsync(
|
||||
client: MqttClient,
|
||||
topic: string,
|
||||
payload: string,
|
||||
qos: 0 | 1 | 2
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.publish(topic, payload, { qos }, err => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Equipment Current State Service
|
||||
*
|
||||
* 장비 태그별 최신값 스냅샷 관리.
|
||||
* IDC의 equipment-status-sync.service.js와 동일 역할.
|
||||
*
|
||||
* fleet_edge_raw_data는 시계열(append-only)인 반면,
|
||||
* equipment_current_state는 태그별 최신값 1건만 유지 (UPSERT).
|
||||
*/
|
||||
|
||||
import { query } from "../../database/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import type { CollectedData } from "./deviceCollectorService";
|
||||
|
||||
/**
|
||||
* 수집 결과를 equipment_current_state에 UPSERT.
|
||||
* 한 번 호출에 데이터의 모든 태그를 처리.
|
||||
*/
|
||||
export async function upsertEquipmentState(data: CollectedData): Promise<void> {
|
||||
const tagEntries = Object.entries(data.tags);
|
||||
if (tagEntries.length === 0) return;
|
||||
|
||||
// 배치 UPSERT — 한 번에 모든 태그
|
||||
const values: unknown[] = [];
|
||||
const placeholders: string[] = [];
|
||||
|
||||
tagEntries.forEach(([tagName, raw], idx) => {
|
||||
const base = idx * 8;
|
||||
const { numeric, text, bool } = splitValue(raw);
|
||||
const quality = raw === null || raw === undefined ? "bad" : "good";
|
||||
|
||||
values.push(
|
||||
data.connectionId,
|
||||
data.companyCode || "*",
|
||||
tagName,
|
||||
numeric,
|
||||
text,
|
||||
bool,
|
||||
quality,
|
||||
data.timestamp
|
||||
);
|
||||
placeholders.push(
|
||||
`($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8})`
|
||||
);
|
||||
});
|
||||
|
||||
const sql = `
|
||||
INSERT INTO equipment_current_state
|
||||
(connection_id, company_code, tag_name, value_numeric, value_text, value_boolean, quality, last_collected_at)
|
||||
VALUES ${placeholders.join(", ")}
|
||||
ON CONFLICT (connection_id, tag_name) DO UPDATE SET
|
||||
value_numeric = EXCLUDED.value_numeric,
|
||||
value_text = EXCLUDED.value_text,
|
||||
value_boolean = EXCLUDED.value_boolean,
|
||||
quality = EXCLUDED.quality,
|
||||
last_collected_at = EXCLUDED.last_collected_at,
|
||||
updated_at = NOW()
|
||||
`;
|
||||
|
||||
try {
|
||||
await query(sql, values);
|
||||
} catch (err) {
|
||||
logger.error(`[EquipmentState] UPSERT 실패: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function splitValue(raw: unknown): {
|
||||
numeric: number | null;
|
||||
text: string | null;
|
||||
bool: boolean | null;
|
||||
} {
|
||||
if (raw === null || raw === undefined) {
|
||||
return { numeric: null, text: null, bool: null };
|
||||
}
|
||||
if (typeof raw === "boolean") {
|
||||
return { numeric: raw ? 1 : 0, text: null, bool: raw };
|
||||
}
|
||||
if (typeof raw === "number") {
|
||||
return { numeric: Number.isFinite(raw) ? raw : null, text: null, bool: null };
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
const n = Number(raw);
|
||||
return {
|
||||
numeric: Number.isFinite(n) ? n : null,
|
||||
text: raw,
|
||||
bool: null,
|
||||
};
|
||||
}
|
||||
return { numeric: null, text: JSON.stringify(raw), bool: null };
|
||||
}
|
||||
|
||||
/** 연결별 현재 상태 조회 */
|
||||
export async function getStateByConnection(connectionId: number) {
|
||||
return query(
|
||||
`SELECT * FROM equipment_current_state WHERE connection_id = $1 ORDER BY tag_name`,
|
||||
[connectionId]
|
||||
);
|
||||
}
|
||||
|
||||
/** 회사별 전체 장비 상태 요약 */
|
||||
export async function getConnectionStatusSummary(companyCode?: string) {
|
||||
if (companyCode && companyCode !== "*") {
|
||||
return query(
|
||||
`SELECT * FROM v_equipment_connection_status WHERE company_code = $1 OR company_code = '*' ORDER BY connection_name`,
|
||||
[companyCode]
|
||||
);
|
||||
}
|
||||
return query(`SELECT * FROM v_equipment_connection_status ORDER BY connection_name`);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* OPC UA Client
|
||||
*
|
||||
* node-opcua를 lazy-load로 사용합니다.
|
||||
* 사용 전 설치 필요: npm install node-opcua
|
||||
*
|
||||
* 미설치 상태에서도 서버는 기동되며, 이 프로토콜 사용 시에만 에러 발생.
|
||||
*/
|
||||
|
||||
import { logger } from "../../../utils/logger";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────
|
||||
|
||||
export interface OpcuaReadResult {
|
||||
tagName: string;
|
||||
address: string;
|
||||
value: number | boolean | string | null;
|
||||
quality: "good" | "bad" | "uncertain";
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface OpcuaTagConfig {
|
||||
tagName: string;
|
||||
/** NodeId 표기, 예: "ns=2;s=Temperature" 또는 "ns=4;i=1001" */
|
||||
address: string;
|
||||
dataType?: string;
|
||||
scaleFactor?: number;
|
||||
offsetValue?: number;
|
||||
}
|
||||
|
||||
// ─── lazy-load ────────────────────────────────────
|
||||
|
||||
let opcuaLib: any = null;
|
||||
function loadOpcua(): any {
|
||||
if (opcuaLib) return opcuaLib;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
opcuaLib = require("node-opcua");
|
||||
return opcuaLib;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"OPC UA 라이브러리가 설치되지 않았습니다. `npm install node-opcua`를 실행하세요."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 클라이언트 ───────────────────────────────────
|
||||
|
||||
export class OpcuaClient {
|
||||
private client: any = null;
|
||||
private session: any = null;
|
||||
private connected = false;
|
||||
|
||||
constructor(
|
||||
private readonly endpointUrl: string, // 예: opc.tcp://192.168.1.10:4840
|
||||
private readonly securityMode: "None" | "Sign" | "SignAndEncrypt" = "None",
|
||||
private readonly username?: string,
|
||||
private readonly password?: string,
|
||||
private readonly timeoutMs: number = 5000
|
||||
) {}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const opcua = loadOpcua();
|
||||
const { OPCUAClient, MessageSecurityMode, SecurityPolicy, UserTokenType } = opcua;
|
||||
|
||||
this.client = OPCUAClient.create({
|
||||
applicationName: "vexplor-pipeline",
|
||||
connectionStrategy: {
|
||||
initialDelay: 500,
|
||||
maxRetry: 3,
|
||||
},
|
||||
securityMode: MessageSecurityMode[this.securityMode] ?? MessageSecurityMode.None,
|
||||
securityPolicy: SecurityPolicy.None,
|
||||
endpointMustExist: false,
|
||||
requestedSessionTimeout: this.timeoutMs * 2,
|
||||
});
|
||||
|
||||
await this.client.connect(this.endpointUrl);
|
||||
|
||||
const userIdentity = this.username
|
||||
? {
|
||||
type: UserTokenType.UserName,
|
||||
userName: this.username,
|
||||
password: this.password,
|
||||
}
|
||||
: { type: UserTokenType.Anonymous };
|
||||
|
||||
this.session = await this.client.createSession(userIdentity);
|
||||
this.connected = true;
|
||||
logger.info(`[OpcUA] 연결 성공: ${this.endpointUrl}`);
|
||||
}
|
||||
|
||||
async readTags(tags: OpcuaTagConfig[]): Promise<OpcuaReadResult[]> {
|
||||
if (!this.connected || !this.session) {
|
||||
throw new Error("OPC UA 세션이 연결되지 않았습니다.");
|
||||
}
|
||||
const opcua = loadOpcua();
|
||||
const { AttributeIds } = opcua;
|
||||
|
||||
const nodesToRead = tags.map(t => ({
|
||||
nodeId: t.address,
|
||||
attributeId: AttributeIds.Value,
|
||||
}));
|
||||
|
||||
const readings = await this.session.read(nodesToRead);
|
||||
|
||||
const results: OpcuaReadResult[] = [];
|
||||
readings.forEach((r: any, idx: number) => {
|
||||
const tag = tags[idx];
|
||||
const raw = r?.value?.value;
|
||||
const quality: OpcuaReadResult["quality"] =
|
||||
r?.statusCode?.name === "Good" ? "good" : "bad";
|
||||
|
||||
let value: number | boolean | string | null = null;
|
||||
if (raw !== undefined && raw !== null) {
|
||||
if (typeof raw === "number") {
|
||||
value = raw * (tag.scaleFactor ?? 1) + (tag.offsetValue ?? 0);
|
||||
} else if (typeof raw === "boolean") {
|
||||
value = raw;
|
||||
} else {
|
||||
value = String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
tagName: tag.tagName,
|
||||
address: tag.address,
|
||||
value,
|
||||
quality,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
if (this.session) {
|
||||
await this.session.close();
|
||||
this.session = null;
|
||||
}
|
||||
if (this.client) {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[OpcUA] disconnect 오류: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Siemens S7 Client
|
||||
*
|
||||
* nodes7을 lazy-load로 사용. 사용 전 설치: npm install nodes7
|
||||
* 미설치 상태에서도 서버는 기동됩니다.
|
||||
*/
|
||||
|
||||
import { logger } from "../../../utils/logger";
|
||||
|
||||
export interface S7ReadResult {
|
||||
tagName: string;
|
||||
address: string;
|
||||
value: number | boolean | string | null;
|
||||
quality: "good" | "bad";
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface S7TagConfig {
|
||||
tagName: string;
|
||||
/** nodes7 주소 표기. 예: "DB1,INT0", "DB10,REAL4", "M10.0", "Q0.0" */
|
||||
address: string;
|
||||
dataType?: string;
|
||||
scaleFactor?: number;
|
||||
offsetValue?: number;
|
||||
}
|
||||
|
||||
// ─── lazy-load ────────────────────────────────────
|
||||
|
||||
let s7Lib: any = null;
|
||||
function loadS7(): any {
|
||||
if (s7Lib) return s7Lib;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
s7Lib = require("nodes7");
|
||||
return s7Lib;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Siemens S7 라이브러리가 설치되지 않았습니다. `npm install nodes7`를 실행하세요."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 클라이언트 ───────────────────────────────────
|
||||
|
||||
export class S7Client {
|
||||
private conn: any = null;
|
||||
private connected = false;
|
||||
|
||||
constructor(
|
||||
private readonly host: string,
|
||||
private readonly rack: number = 0,
|
||||
private readonly slot: number = 1,
|
||||
private readonly port: number = 102,
|
||||
private readonly timeoutMs: number = 5000
|
||||
) {}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const NodeS7 = loadS7();
|
||||
this.conn = new NodeS7();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const params: Record<string, unknown> = {
|
||||
port: this.port,
|
||||
host: this.host,
|
||||
rack: this.rack,
|
||||
slot: this.slot,
|
||||
timeout: this.timeoutMs,
|
||||
};
|
||||
this.conn.initiateConnection(params, (err: Error | null) => {
|
||||
if (err) return reject(err);
|
||||
this.connected = true;
|
||||
logger.info(`[S7] 연결 성공: ${this.host}:${this.port} rack=${this.rack} slot=${this.slot}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async readTags(tags: S7TagConfig[]): Promise<S7ReadResult[]> {
|
||||
if (!this.connected || !this.conn) {
|
||||
throw new Error("S7 연결이 없습니다.");
|
||||
}
|
||||
|
||||
// nodes7은 변수 이름 등록 → 읽기 방식
|
||||
const varMap: Record<string, string> = {};
|
||||
tags.forEach(t => {
|
||||
varMap[t.tagName] = t.address;
|
||||
});
|
||||
this.conn.setTranslationCB((tagName: string) => varMap[tagName] ?? tagName);
|
||||
this.conn.addItems(Object.keys(varMap));
|
||||
|
||||
return new Promise<S7ReadResult[]>((resolve, reject) => {
|
||||
this.conn.readAllItems((err: Error | null, values: Record<string, unknown>) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
const results: S7ReadResult[] = tags.map(t => {
|
||||
const raw = values[t.tagName];
|
||||
const goodValue = raw !== undefined && raw !== null && raw !== "BAD 255";
|
||||
let value: number | boolean | string | null = null;
|
||||
|
||||
if (goodValue) {
|
||||
if (typeof raw === "number") {
|
||||
value = raw * (t.scaleFactor ?? 1) + (t.offsetValue ?? 0);
|
||||
} else if (typeof raw === "boolean") {
|
||||
value = raw;
|
||||
} else {
|
||||
value = String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tagName: t.tagName,
|
||||
address: t.address,
|
||||
value,
|
||||
quality: goodValue ? "good" : "bad",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
});
|
||||
|
||||
resolve(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.conn) return;
|
||||
try {
|
||||
this.conn.dropConnection(() => {
|
||||
/* noop */
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`[S7] disconnect 오류: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.connected = false;
|
||||
this.conn = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Python Hook Runner
|
||||
*
|
||||
* Pipeline(Node.js)이 사용자 작성 Python 훅을 **자식 프로세스**로 실행.
|
||||
* 엣지 Python data-collector를 대체하기 위한 핵심 컴포넌트.
|
||||
*
|
||||
* 훅 타입별 계약:
|
||||
* transform(tag_name, raw_value, context) → 변환된 값
|
||||
* filter(tag_name, value, context) → True면 통과, False면 버림
|
||||
* derived_tags(device_data, context) → { new_tag_name: value, ... }
|
||||
* alarm(tag_name, value, context) → None/{level, message}
|
||||
*
|
||||
* 보안/안전:
|
||||
* - python3 자식 프로세스로 격리
|
||||
* - timeout 초과 시 SIGKILL
|
||||
* - stdout 용량 제한 (1MB)
|
||||
* - OS-level이므로 Node 이벤트 루프 블록 안 함
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
export type HookType =
|
||||
| "transform"
|
||||
| "filter"
|
||||
| "aggregator"
|
||||
| "alarm"
|
||||
| "derived_tags"
|
||||
| "pre_send";
|
||||
|
||||
export interface HookInput {
|
||||
hook_type: HookType;
|
||||
code: string;
|
||||
tag_name?: string;
|
||||
raw_value?: unknown;
|
||||
value?: unknown;
|
||||
device_data?: Record<string, unknown>;
|
||||
context?: Record<string, unknown>;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface HookResult {
|
||||
success: boolean;
|
||||
value?: unknown;
|
||||
skip?: boolean;
|
||||
alarm?: { level: string; message: string } | null;
|
||||
derived?: Record<string, unknown>;
|
||||
error?: string;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
// Python 쪽에서 실행할 runner 스크립트 (한 번 생성해 재사용)
|
||||
const PYTHON_RUNNER_SCRIPT = `
|
||||
import sys, json, traceback, signal, resource
|
||||
|
||||
# 메모리 제한 (128MB)
|
||||
try:
|
||||
resource.setrlimit(resource.RLIMIT_AS, (128*1024*1024, 128*1024*1024))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def main():
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": f"JSON parse error: {e}"}))
|
||||
return
|
||||
|
||||
hook_type = payload.get("hook_type")
|
||||
code = payload.get("code", "")
|
||||
context = payload.get("context") or {}
|
||||
|
||||
# 사용자 코드 exec — 함수 정의만 추출
|
||||
user_globals = {"__builtins__": __builtins__}
|
||||
try:
|
||||
exec(code, user_globals)
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": f"Compile error: {e}\\n{traceback.format_exc()}"}))
|
||||
return
|
||||
|
||||
fn = user_globals.get(hook_type)
|
||||
if not callable(fn):
|
||||
print(json.dumps({"success": False, "error": f"function '{hook_type}' not defined"}))
|
||||
return
|
||||
|
||||
try:
|
||||
if hook_type == "transform":
|
||||
value = fn(payload.get("tag_name"), payload.get("raw_value"), context)
|
||||
out = {"success": True, "value": value}
|
||||
elif hook_type == "filter":
|
||||
keep = fn(payload.get("tag_name"), payload.get("value"), context)
|
||||
out = {"success": True, "skip": not bool(keep)}
|
||||
elif hook_type == "alarm":
|
||||
alarm = fn(payload.get("tag_name"), payload.get("value"), context)
|
||||
out = {"success": True, "alarm": alarm}
|
||||
elif hook_type == "derived_tags":
|
||||
derived = fn(payload.get("device_data") or {}, context) or {}
|
||||
out = {"success": True, "derived": derived}
|
||||
elif hook_type == "aggregator":
|
||||
value = fn(payload.get("tag_name"), payload.get("value"), context)
|
||||
out = {"success": True, "value": value}
|
||||
elif hook_type == "pre_send":
|
||||
value = fn(payload.get("device_data") or {}, context)
|
||||
out = {"success": True, "value": value}
|
||||
else:
|
||||
out = {"success": False, "error": f"unknown hook_type {hook_type}"}
|
||||
except Exception as e:
|
||||
out = {"success": False, "error": f"Runtime error: {e}\\n{traceback.format_exc()}"}
|
||||
|
||||
try:
|
||||
print(json.dumps(out, default=str))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": f"serialize error: {e}"}))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
`.trim();
|
||||
|
||||
/** 훅 하나 실행. 타임아웃 강제 kill. */
|
||||
export async function executeHook(input: HookInput): Promise<HookResult> {
|
||||
const timeoutMs = input.timeout_ms ?? 1500;
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise<HookResult>((resolve) => {
|
||||
const child = spawn("python3", ["-c", PYTHON_RUNNER_SCRIPT], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutBytes = 0;
|
||||
const MAX_STDOUT = 1024 * 1024; // 1MB
|
||||
|
||||
let killed = false;
|
||||
const killTimer = setTimeout(() => {
|
||||
killed = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes > MAX_STDOUT) {
|
||||
killed = true;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return;
|
||||
}
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(killTimer);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `spawn error: ${err.message}`,
|
||||
duration_ms: Date.now() - start,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(killTimer);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (killed) {
|
||||
return resolve({
|
||||
success: false,
|
||||
error: `timeout ${timeoutMs}ms 초과 또는 stdout 한계 초과`,
|
||||
duration_ms: duration,
|
||||
});
|
||||
}
|
||||
if (code !== 0) {
|
||||
return resolve({
|
||||
success: false,
|
||||
error: `python exit ${code}: ${stderr || stdout}`.slice(0, 2000),
|
||||
duration_ms: duration,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim().split("\n").pop() || "{}");
|
||||
resolve({ ...parsed, duration_ms: duration });
|
||||
} catch (err) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `result parse fail: ${(err as Error).message} — raw=${stdout.slice(0, 500)}`,
|
||||
duration_ms: duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
child.stdin.write(JSON.stringify(input));
|
||||
child.stdin.end();
|
||||
} catch (err) {
|
||||
logger.warn(`[PyHook] stdin 쓰기 실패: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** python3 사용 가능 여부 확인 (부팅 시 1회 체크용) */
|
||||
export async function checkPython3Available(): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn("python3", ["--version"], { stdio: "pipe" });
|
||||
child.on("error", () => resolve(false));
|
||||
child.on("close", (code) => resolve(code === 0));
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
resolve(false);
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Script Cache — 연결별 활성 Python 훅을 메모리에 캐시.
|
||||
*
|
||||
* - 5분마다 자동 갱신 (또는 invalidate() 호출 시)
|
||||
* - 훅 타입별/우선순위별 정렬해 반환
|
||||
*/
|
||||
|
||||
import { query } from "../../database/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import type { HookType } from "./pythonHookRunner";
|
||||
|
||||
export interface CachedScript {
|
||||
id: number;
|
||||
script_name: string;
|
||||
hook_type: HookType;
|
||||
scope: string;
|
||||
equipment_id: number | null;
|
||||
connection_id: number | null;
|
||||
code: string;
|
||||
priority: number;
|
||||
timeout_ms: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
type CacheKey = `${number}:${HookType}`; // connection_id:hook_type
|
||||
|
||||
const cache = new Map<CacheKey, CachedScript[]>();
|
||||
let lastRefresh = 0;
|
||||
const REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5분
|
||||
|
||||
async function refreshCache(): Promise<void> {
|
||||
const rows = await query<CachedScript>(
|
||||
`SELECT id, script_name, hook_type, scope,
|
||||
equipment_id, connection_id,
|
||||
code, priority, COALESCE(timeout_ms, 1500) AS timeout_ms,
|
||||
COALESCE(version, 1) AS version
|
||||
FROM fleet_edge_scripts
|
||||
WHERE enabled = true
|
||||
ORDER BY priority ASC, id ASC`
|
||||
);
|
||||
|
||||
cache.clear();
|
||||
|
||||
for (const s of rows) {
|
||||
// 연결 스코프: 특정 connection_id
|
||||
if (s.scope === "connection" && s.connection_id) {
|
||||
const key: CacheKey = `${s.connection_id}:${s.hook_type}`;
|
||||
const list = cache.get(key) || [];
|
||||
list.push(s);
|
||||
cache.set(key, list);
|
||||
}
|
||||
// 글로벌 스코프: 모든 연결에 적용 (connection_id 0 sentinel)
|
||||
else if (s.scope === "global") {
|
||||
const key: CacheKey = `0:${s.hook_type}`;
|
||||
const list = cache.get(key) || [];
|
||||
list.push(s);
|
||||
cache.set(key, list);
|
||||
}
|
||||
// equipment/device 스코프는 당분간 사용 안 함 (추후 확장)
|
||||
}
|
||||
|
||||
lastRefresh = Date.now();
|
||||
logger.info(`[ScriptCache] 갱신 완료: ${rows.length}개 훅 (엔트리 ${cache.size})`);
|
||||
}
|
||||
|
||||
export async function ensureCache(): Promise<void> {
|
||||
if (Date.now() - lastRefresh > REFRESH_INTERVAL_MS || cache.size === 0) {
|
||||
try {
|
||||
await refreshCache();
|
||||
} catch (err) {
|
||||
logger.warn(`[ScriptCache] 갱신 실패: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidate(): void {
|
||||
lastRefresh = 0;
|
||||
}
|
||||
|
||||
/** 연결에 적용되는 훅 (글로벌 + 연결별) 우선순위 순 */
|
||||
export async function getHooksForConnection(
|
||||
connectionId: number,
|
||||
hookType: HookType
|
||||
): Promise<CachedScript[]> {
|
||||
await ensureCache();
|
||||
const globalKey: CacheKey = `0:${hookType}`;
|
||||
const connKey: CacheKey = `${connectionId}:${hookType}`;
|
||||
const global = cache.get(globalKey) || [];
|
||||
const conn = cache.get(connKey) || [];
|
||||
return [...global, ...conn].sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
export function getCacheStats() {
|
||||
return {
|
||||
entries: cache.size,
|
||||
last_refresh: lastRefresh ? new Date(lastRefresh).toISOString() : null,
|
||||
total_scripts: Array.from(cache.values()).reduce((s, v) => s + v.length, 0),
|
||||
};
|
||||
}
|
||||
@@ -310,9 +310,9 @@ export class FlowExternalDbConnectionService {
|
||||
let query: string;
|
||||
switch (connection.dbType) {
|
||||
case "postgresql":
|
||||
query = `SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
query = `SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
ORDER BY ordinal_position`;
|
||||
break;
|
||||
case "mysql":
|
||||
|
||||
@@ -53,9 +53,12 @@ export class PipelineDeviceConnectionService {
|
||||
const connections = await query<any>(
|
||||
`SELECT d.*,
|
||||
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count,
|
||||
COALESCE(c.company_name, d.company_code) as company_name
|
||||
COALESCE(c.company_name, d.company_code) as company_name,
|
||||
e.equipment_name,
|
||||
e.equipment_code
|
||||
FROM pipeline_device_connections d
|
||||
LEFT JOIN company_mng c ON d.company_code = c.company_code
|
||||
LEFT JOIN pipeline_equipment e ON d.equipment_id = e.id
|
||||
${whereClause}
|
||||
ORDER BY d.is_active DESC, d.connection_name ASC`,
|
||||
params
|
||||
@@ -67,8 +70,11 @@ export class PipelineDeviceConnectionService {
|
||||
static async getConnectionById(id: number) {
|
||||
const conn = await queryOne<any>(
|
||||
`SELECT d.*,
|
||||
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count
|
||||
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count,
|
||||
e.equipment_name,
|
||||
e.equipment_code
|
||||
FROM pipeline_device_connections d
|
||||
LEFT JOIN pipeline_equipment e ON d.equipment_id = e.id
|
||||
WHERE d.id = $1`,
|
||||
[id]
|
||||
);
|
||||
@@ -77,22 +83,24 @@ export class PipelineDeviceConnectionService {
|
||||
}
|
||||
|
||||
static async createConnection(data: Partial<PipelineDeviceConnection>) {
|
||||
if (!data.connection_name || !data.protocol || !data.host || !data.port) {
|
||||
if (!data.connection_name || !data.protocol || !data.host) {
|
||||
return { success: false, message: "필수 필드가 누락되었습니다." };
|
||||
}
|
||||
|
||||
const result = await query<PipelineDeviceConnection>(
|
||||
`INSERT INTO pipeline_device_connections
|
||||
(connection_name, description, protocol, host, port, protocol_config,
|
||||
polling_interval_ms, timeout_ms, retry_count, status, company_code, is_active, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13)
|
||||
(equipment_id, connection_name, description, protocol, host, port, protocol_config,
|
||||
polling_interval_ms, timeout_ms, retry_count, status, company_code, is_active, created_by,
|
||||
target_db_connection_id, target_table_name, target_time_column, target_insert_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.equipment_id || null,
|
||||
data.connection_name,
|
||||
data.description || null,
|
||||
data.protocol,
|
||||
data.host,
|
||||
data.port,
|
||||
data.port || 0,
|
||||
JSON.stringify(data.protocol_config || {}),
|
||||
data.polling_interval_ms || 1000,
|
||||
data.timeout_ms || 5000,
|
||||
@@ -101,6 +109,10 @@ export class PipelineDeviceConnectionService {
|
||||
data.company_code || null,
|
||||
data.is_active || "Y",
|
||||
data.created_by || null,
|
||||
(data as any).target_db_connection_id || null,
|
||||
(data as any).target_table_name || null,
|
||||
(data as any).target_time_column || "timestamp",
|
||||
(data as any).target_insert_mode || "append",
|
||||
]
|
||||
);
|
||||
|
||||
@@ -112,6 +124,7 @@ export class PipelineDeviceConnectionService {
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.equipment_id !== undefined) { sets.push(`equipment_id = $${idx++}`); params.push(data.equipment_id); }
|
||||
if (data.connection_name !== undefined) { sets.push(`connection_name = $${idx++}`); params.push(data.connection_name); }
|
||||
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
|
||||
if (data.protocol !== undefined) { sets.push(`protocol = $${idx++}`); params.push(data.protocol); }
|
||||
@@ -123,6 +136,12 @@ export class PipelineDeviceConnectionService {
|
||||
if (data.retry_count !== undefined) { sets.push(`retry_count = $${idx++}`); params.push(data.retry_count); }
|
||||
if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); }
|
||||
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
|
||||
if ((data as any).target_db_connection_id !== undefined) { sets.push(`target_db_connection_id = $${idx++}`); params.push((data as any).target_db_connection_id); }
|
||||
if ((data as any).target_table_name !== undefined) { sets.push(`target_table_name = $${idx++}`); params.push((data as any).target_table_name); }
|
||||
if ((data as any).target_time_column !== undefined) { sets.push(`target_time_column = $${idx++}`); params.push((data as any).target_time_column); }
|
||||
if ((data as any).target_insert_mode !== undefined) { sets.push(`target_insert_mode = $${idx++}`); params.push((data as any).target_insert_mode); }
|
||||
if ((data as any).edge_identifier !== undefined) { sets.push(`edge_identifier = $${idx++}`); params.push((data as any).edge_identifier); }
|
||||
if ((data as any).device_identifier !== undefined) { sets.push(`device_identifier = $${idx++}`); params.push((data as any).device_identifier); }
|
||||
|
||||
if (sets.length === 0) return this.getConnectionById(id);
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Target DB Introspection Service
|
||||
*
|
||||
* 장비 통신에서 "수집값 저장 대상 DB"로 Pipeline 내장 DB(id=0) 및 external_db_connections
|
||||
* 양쪽을 동일 인터페이스로 조회. 테이블/컬럼 목록 제공.
|
||||
*/
|
||||
|
||||
import { query as internalQuery, queryOne as internalQueryOne } from "../database/db";
|
||||
import { executeExternalQuery } from "./externalDbHelper";
|
||||
import { query as runInternal } from "../database/db";
|
||||
|
||||
export interface TargetDbSummary {
|
||||
id: number; // 0 = Pipeline 내장
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database_name: string;
|
||||
username?: string;
|
||||
is_internal: boolean;
|
||||
}
|
||||
|
||||
const INTERNAL_DB: TargetDbSummary = {
|
||||
id: 0,
|
||||
name: "Pipeline 내장 (PostgreSQL)",
|
||||
db_type: "postgresql",
|
||||
host: "internal",
|
||||
port: 0,
|
||||
database_name: "vexplor_pipeline",
|
||||
is_internal: true,
|
||||
};
|
||||
|
||||
/** 내장 + 외부 합쳐서 모두 반환 */
|
||||
export async function listTargetDatabases(
|
||||
companyCode?: string
|
||||
): Promise<TargetDbSummary[]> {
|
||||
const result: TargetDbSummary[] = [INTERNAL_DB];
|
||||
|
||||
const sql =
|
||||
companyCode && companyCode !== "*"
|
||||
? `SELECT id, connection_name, db_type, host, port, database_name, username
|
||||
FROM external_db_connections
|
||||
WHERE is_active = 'Y' AND (company_code = $1 OR company_code = '*')
|
||||
ORDER BY id`
|
||||
: `SELECT id, connection_name, db_type, host, port, database_name, username
|
||||
FROM external_db_connections
|
||||
WHERE is_active = 'Y'
|
||||
ORDER BY id`;
|
||||
|
||||
const rows = await internalQuery<any>(
|
||||
sql,
|
||||
companyCode && companyCode !== "*" ? [companyCode] : []
|
||||
);
|
||||
|
||||
for (const r of rows) {
|
||||
result.push({
|
||||
id: r.id,
|
||||
name: r.connection_name,
|
||||
db_type: r.db_type,
|
||||
host: r.host,
|
||||
port: r.port,
|
||||
database_name: r.database_name,
|
||||
username: r.username,
|
||||
is_internal: false,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 특정 DB의 테이블 목록 */
|
||||
export async function listTables(dbId: number): Promise<string[]> {
|
||||
if (dbId === 0) {
|
||||
// 내장 DB
|
||||
const rows = await internalQuery<{ tablename: string }>(
|
||||
`SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY tablename`
|
||||
);
|
||||
return rows.map(r => r.tablename);
|
||||
}
|
||||
|
||||
const conn = await internalQueryOne<{ db_type: string; database_name: string }>(
|
||||
`SELECT db_type, database_name FROM external_db_connections WHERE id = $1`,
|
||||
[dbId]
|
||||
);
|
||||
if (!conn) throw new Error(`external DB ${dbId} not found`);
|
||||
|
||||
const dbType = (conn.db_type || "").toLowerCase();
|
||||
|
||||
if (dbType === "postgresql" || dbType === "timescaledb") {
|
||||
const res = await executeExternalQuery(
|
||||
dbId,
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename`,
|
||||
[]
|
||||
);
|
||||
return (res.rows || []).map((r: any) => r.tablename);
|
||||
}
|
||||
if (dbType === "mysql" || dbType === "mariadb") {
|
||||
const res = await executeExternalQuery(
|
||||
dbId,
|
||||
`SELECT TABLE_NAME as tablename FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`,
|
||||
[conn.database_name]
|
||||
);
|
||||
return (res.rows || []).map((r: any) => r.tablename || r.TABLE_NAME);
|
||||
}
|
||||
if (dbType === "mssql") {
|
||||
const res = await executeExternalQuery(
|
||||
dbId,
|
||||
`SELECT TABLE_NAME as tablename FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME`,
|
||||
[]
|
||||
);
|
||||
return (res.rows || []).map((r: any) => r.tablename);
|
||||
}
|
||||
if (dbType === "oracle") {
|
||||
const res = await executeExternalQuery(
|
||||
dbId,
|
||||
`SELECT table_name as tablename FROM user_tables ORDER BY table_name`,
|
||||
[]
|
||||
);
|
||||
return (res.rows || []).map((r: any) => r.tablename || r.TABLENAME);
|
||||
}
|
||||
throw new Error(`지원하지 않는 DB 타입: ${dbType}`);
|
||||
}
|
||||
|
||||
/** 테이블의 컬럼 목록 */
|
||||
export interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: boolean;
|
||||
column_default?: string | null;
|
||||
}
|
||||
|
||||
export async function listColumns(
|
||||
dbId: number,
|
||||
tableName: string
|
||||
): Promise<ColumnInfo[]> {
|
||||
// 테이블명 sanity (identifier만 허용)
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) {
|
||||
throw new Error("잘못된 테이블명");
|
||||
}
|
||||
|
||||
if (dbId === 0) {
|
||||
const rows = await internalQuery<any>(
|
||||
`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
return rows.map(r => ({
|
||||
column_name: r.column_name,
|
||||
data_type: r.data_type,
|
||||
is_nullable: r.is_nullable === "YES",
|
||||
column_default: r.column_default,
|
||||
}));
|
||||
}
|
||||
|
||||
const conn = await internalQueryOne<{ db_type: string; database_name: string }>(
|
||||
`SELECT db_type, database_name FROM external_db_connections WHERE id = $1`,
|
||||
[dbId]
|
||||
);
|
||||
if (!conn) throw new Error(`external DB ${dbId} not found`);
|
||||
|
||||
const dbType = (conn.db_type || "").toLowerCase();
|
||||
|
||||
if (dbType === "postgresql" || dbType === "timescaledb") {
|
||||
const res = await executeExternalQuery(
|
||||
dbId,
|
||||
`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=$1
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
return (res.rows || []).map((r: any) => ({
|
||||
column_name: r.column_name,
|
||||
data_type: r.data_type,
|
||||
is_nullable: r.is_nullable === "YES",
|
||||
column_default: r.column_default,
|
||||
}));
|
||||
}
|
||||
if (dbType === "mysql" || dbType === "mariadb") {
|
||||
const res = await executeExternalQuery(
|
||||
dbId,
|
||||
`SELECT COLUMN_NAME as column_name, DATA_TYPE as data_type,
|
||||
IS_NULLABLE as is_nullable, COLUMN_DEFAULT as column_default
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION`,
|
||||
[conn.database_name, tableName]
|
||||
);
|
||||
return (res.rows || []).map((r: any) => ({
|
||||
column_name: r.column_name || r.COLUMN_NAME,
|
||||
data_type: r.data_type || r.DATA_TYPE,
|
||||
is_nullable: (r.is_nullable || r.IS_NULLABLE) === "YES",
|
||||
column_default: r.column_default || r.COLUMN_DEFAULT,
|
||||
}));
|
||||
}
|
||||
if (dbType === "mssql") {
|
||||
const res = await executeExternalQuery(
|
||||
dbId,
|
||||
`SELECT COLUMN_NAME as column_name, DATA_TYPE as data_type, IS_NULLABLE as is_nullable
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = @p1
|
||||
ORDER BY ORDINAL_POSITION`,
|
||||
[tableName]
|
||||
);
|
||||
return (res.rows || []).map((r: any) => ({
|
||||
column_name: r.column_name,
|
||||
data_type: r.data_type,
|
||||
is_nullable: r.is_nullable === "YES",
|
||||
}));
|
||||
}
|
||||
if (dbType === "oracle") {
|
||||
const res = await executeExternalQuery(
|
||||
dbId,
|
||||
`SELECT column_name, data_type, nullable FROM user_tab_columns
|
||||
WHERE table_name = :1 ORDER BY column_id`,
|
||||
[tableName.toUpperCase()]
|
||||
);
|
||||
return (res.rows || []).map((r: any) => ({
|
||||
column_name: (r.COLUMN_NAME || r.column_name || "").toLowerCase(),
|
||||
data_type: (r.DATA_TYPE || r.data_type || "").toLowerCase(),
|
||||
is_nullable: (r.NULLABLE || r.nullable) === "Y",
|
||||
}));
|
||||
}
|
||||
throw new Error(`지원하지 않는 DB 타입: ${dbType}`);
|
||||
}
|
||||
|
||||
// Re-export internal query for convenience in deviceCollectorService
|
||||
export { runInternal };
|
||||
Reference in New Issue
Block a user