feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
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:
chpark
2026-04-23 20:00:06 +09:00
parent 01625d9efd
commit 4c1dc4082e
77 changed files with 14639 additions and 205 deletions
+1024 -18
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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;
}
+64
View File
@@ -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];
}
}
+115
View File
@@ -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 });
}
}
+810
View File
@@ -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],
);
}
}
+237
View File
@@ -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})`);
}
+176
View File
@@ -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 };