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", "license": "ISC",
"dependencies": { "dependencies": {
"@types/mssql": "^9.1.8", "@types/mssql": "^9.1.8",
"aedes": "^0.51.3",
"aedes-server-factory": "^0.2.1",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0", "bwip-js": "^4.8.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dockerode": "^4.0.10",
"docx": "^9.5.1", "docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
@@ -44,6 +47,7 @@
"joi": "^17.11.0", "joi": "^17.11.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.5", "mailparser": "^3.7.5",
"mqtt": "^5.15.1",
"mssql": "^11.0.1", "mssql": "^11.0.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.15.0", "mysql2": "^3.15.0",
@@ -65,6 +69,7 @@
"@types/bwip-js": "^3.2.3", "@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/dockerode": "^4.0.1",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/imap": "^0.8.42", "@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() { export async function runOpenClawMigration() {
try { try {
console.log("🔄 OpenClaw AI 에이전트 마이그레이션 시작..."); 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 { query, queryOne } from "../database/db";
import { MultiAgentExecutionEngine } from "./multiAgentExecutionEngine"; import { MultiAgentExecutionEngine } from "./multiAgentExecutionEngine";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@@ -162,7 +162,7 @@ export class AiSchedulerService {
to, to,
subject: `[AI 분석] ${schedule.name} 실행 결과`, subject: `[AI 분석] ${schedule.name} 실행 결과`,
html: `<h3>${schedule.name} 분석 결과</h3><pre>${result.finalSummary.substring(0, 3000)}</pre>`, html: `<h3>${schedule.name} 분석 결과</h3><pre>${result.finalSummary.substring(0, 3000)}</pre>`,
}).catch(() => {}); } as any).catch(() => {});
} }
} catch (e) { logger.warn("이메일 발송 실패:", e); } } catch (e) { logger.warn("이메일 발송 실패:", e); }
} }
@@ -280,17 +280,61 @@ export class BatchSchedulerService {
// 알림 발송 (notification 설정이 있으면) // 알림 발송 (notification 설정이 있으면)
const notification = config.node_flow_context?.notification; const notification = config.node_flow_context?.notification;
if (notification) { if (notification) {
// 시스템 공지 const title = `[AI] ${config.batch_name} 실행 결과`;
if (notification.system_notice) { const summary = result.finalSummary.substring(0, 2000);
// 메신저 알림 (시스템 내 채팅)
if (notification.messenger) {
try { try {
const { query: dbQuery } = await import("../database/db"); const { query: dbQuery } = await import("../database/db");
await dbQuery( const recipients = notification.messenger_recipients || [];
`INSERT INTO system_notice (title, content, type, is_active, created_by, created_at) const sender = config.created_by || "system";
VALUES ($1, $2, 'info', true, 'AI_BATCH', NOW())`, const companyCode = config.company_code || "*";
[`[AI] ${config.batch_name} 실행 결과`, result.finalSummary.substring(0, 2000)] for (const recipientId of recipients) {
); // DM 방 찾기 또는 생성
} catch { /* ignore */ } 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) { if (notification.webhook) {
try { try {
@@ -439,16 +483,41 @@ export class BatchSchedulerService {
// FROM 데이터 조회 (DB 또는 REST API) // FROM 데이터 조회 (DB 또는 REST API)
if (firstMapping.from_connection_type === "restapi") { 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에서 데이터 조회 // REST API에서 데이터 조회
logger.info( logger.info(
`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}` `REST API에서 데이터 조회: ${apiUrl}`
); );
const { BatchExternalDbService } = await import( const { BatchExternalDbService } = await import(
"./batchExternalDbService" "./batchExternalDbService"
); );
// auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용) // auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용)
let apiKey = firstMapping.from_api_key || "";
if (config.auth_service_name) { if (config.auth_service_name) {
let tokenQuery: string; let tokenQuery: string;
let tokenParams: any[]; let tokenParams: any[];
@@ -485,14 +554,10 @@ export class BatchSchedulerService {
// 👇 Body 파라미터 추가 (POST 요청 시) // 👇 Body 파라미터 추가 (POST 요청 시)
const apiResult = await BatchExternalDbService.getDataFromRestApi( const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!, apiUrl!,
apiKey, apiKey,
firstMapping.from_table_name, firstMapping.from_table_name,
(firstMapping.from_api_method as (apiMethod as "GET" | "POST" | "PUT" | "DELETE") || "GET",
| "GET"
| "POST"
| "PUT"
| "DELETE") || "GET",
mappings.map((m: any) => m.from_column_name), mappings.map((m: any) => m.from_column_name),
100, // limit 100, // limit
// 파라미터 정보 전달 // 파라미터 정보 전달
@@ -505,8 +570,14 @@ export class BatchSchedulerService {
); );
if (apiResult.success && apiResult.data) { if (apiResult.success && apiResult.data) {
// 데이터 배열 경로가 설정되어 있으면 해당 경로에서 배열 추출 // apiResult.data가 이미 배열 형태(BatchExternalDbService가 뽑아낸 레코드)면 그대로 사용
if (config.data_array_path) { // 객체 형태(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[] => { const extractArrayByPath = (obj: any, path: string): any[] => {
if (!path) return Array.isArray(obj) ? obj : [obj]; if (!path) return Array.isArray(obj) ? obj : [obj];
const keys = path.split("."); const keys = path.split(".");
@@ -522,7 +593,6 @@ export class BatchSchedulerService {
: []; : [];
}; };
// apiResult.data가 단일 객체인 경우 (API 응답 전체)
const rawData = const rawData =
Array.isArray(apiResult.data) && apiResult.data.length === 1 Array.isArray(apiResult.data) && apiResult.data.length === 1
? apiResult.data[0] ? apiResult.data[0]
@@ -533,7 +603,7 @@ export class BatchSchedulerService {
`데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출` `데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출`
); );
} else { } else {
fromData = apiResult.data; fromData = Array.isArray(apiResult.data) ? apiResult.data : [apiResult.data];
} }
} else { } else {
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); 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; let query: string;
switch (connection.dbType) { switch (connection.dbType) {
case "postgresql": case "postgresql":
query = `SELECT column_name, data_type query = `SELECT column_name, data_type
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1 WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position`; ORDER BY ordinal_position`;
break; break;
case "mysql": case "mysql":
@@ -53,9 +53,12 @@ export class PipelineDeviceConnectionService {
const connections = await query<any>( const connections = await query<any>(
`SELECT d.*, `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,
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 FROM pipeline_device_connections d
LEFT JOIN company_mng c ON d.company_code = c.company_code LEFT JOIN company_mng c ON d.company_code = c.company_code
LEFT JOIN pipeline_equipment e ON d.equipment_id = e.id
${whereClause} ${whereClause}
ORDER BY d.is_active DESC, d.connection_name ASC`, ORDER BY d.is_active DESC, d.connection_name ASC`,
params params
@@ -67,8 +70,11 @@ export class PipelineDeviceConnectionService {
static async getConnectionById(id: number) { static async getConnectionById(id: number) {
const conn = await queryOne<any>( const conn = await queryOne<any>(
`SELECT d.*, `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 FROM pipeline_device_connections d
LEFT JOIN pipeline_equipment e ON d.equipment_id = e.id
WHERE d.id = $1`, WHERE d.id = $1`,
[id] [id]
); );
@@ -77,22 +83,24 @@ export class PipelineDeviceConnectionService {
} }
static async createConnection(data: Partial<PipelineDeviceConnection>) { 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: "필수 필드가 누락되었습니다." }; return { success: false, message: "필수 필드가 누락되었습니다." };
} }
const result = await query<PipelineDeviceConnection>( const result = await query<PipelineDeviceConnection>(
`INSERT INTO pipeline_device_connections `INSERT INTO pipeline_device_connections
(connection_name, description, protocol, host, port, protocol_config, (equipment_id, connection_name, description, protocol, host, port, protocol_config,
polling_interval_ms, timeout_ms, retry_count, status, company_code, is_active, created_by) 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) 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 *`, RETURNING *`,
[ [
data.equipment_id || null,
data.connection_name, data.connection_name,
data.description || null, data.description || null,
data.protocol, data.protocol,
data.host, data.host,
data.port, data.port || 0,
JSON.stringify(data.protocol_config || {}), JSON.stringify(data.protocol_config || {}),
data.polling_interval_ms || 1000, data.polling_interval_ms || 1000,
data.timeout_ms || 5000, data.timeout_ms || 5000,
@@ -101,6 +109,10 @@ export class PipelineDeviceConnectionService {
data.company_code || null, data.company_code || null,
data.is_active || "Y", data.is_active || "Y",
data.created_by || null, 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[] = []; const params: any[] = [];
let idx = 1; 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.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.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
if (data.protocol !== undefined) { sets.push(`protocol = $${idx++}`); params.push(data.protocol); } 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.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.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.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); 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 };
+2 -2
View File
@@ -3,9 +3,9 @@ FROM node:20-bookworm-slim
WORKDIR /app WORKDIR /app
# 시스템 패키지 설치 (curl: 헬스 체크용) # 시스템 패키지 설치 (curl: 헬스 체크용, python3: Fleet Hook dry-run 용)
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \ && apt-get install -y --no-install-recommends openssl ca-certificates curl python3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# package.json 복사 및 의존성 설치 (개발 의존성 포함) # package.json 복사 및 의존성 설치 (개발 의존성 포함)
@@ -9,6 +9,8 @@ services:
- ../../backend-node/.env - ../../backend-node/.env
ports: ports:
- "8080:8080" - "8080:8080"
- "1883:1883" # MQTT TCP (내장 브로커)
- "8083:8083" # MQTT WebSocket
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: environment:
+8 -5
View File
@@ -10,14 +10,17 @@ services:
environment: environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api - NEXT_PUBLIC_API_URL=http://localhost:8080/api
- SERVER_API_URL=http://pipeline-backend:8080 - SERVER_API_URL=http://pipeline-backend:8080
- NODE_OPTIONS=--max-old-space-size=8192 - NODE_OPTIONS=--max-old-space-size=6144
- NEXT_TELEMETRY_DISABLED=1 - NEXT_TELEMETRY_DISABLED=1
- WATCHPACK_POLLING=true - WATCHPACK_POLLING=true
- WATCHPACK_POLLING_INTERVAL=3000 - WATCHPACK_POLLING_INTERVAL=3000
# volumes: mem_limit: 8g
# - ../../frontend:/app # 소스 마운트 (Docker for Mac에서 컴파일 느림 → 비활성화) mem_reservation: 3g
# - /app/node_modules mem_swappiness: 0
# - /app/.next volumes:
- ../../frontend:/app:delegated
- /app/node_modules
- /app/.next
networks: networks:
- pipeline-network - pipeline-network
restart: unless-stopped restart: unless-stopped
+25
View File
@@ -0,0 +1,25 @@
# ============================================================
# Pipeline Edge 환경변수 예제 (이 파일을 .env로 복사 후 채우세요)
# ============================================================
# ─── DB 연결 ─────────────────────────────────────────
# 옵션 A: IDC 중앙 PostgreSQL 사용 (간단, 네트워크 의존)
DATABASE_URL=postgresql://vexplor_pipeline_user:pipline0909!!@211.115.91.170:11141/vexplor_pipeline
# 옵션 B: 엣지 로컬 PostgreSQL 쓰려면 같은 compose에 postgres 서비스 추가 후:
# DATABASE_URL=postgresql://pipeline:password@postgres:5432/pipeline
# ─── 보안 (반드시 바꿀 것) ───────────────────────────
JWT_SECRET=change-me-to-strong-random-secret-at-least-32-chars
PASSWORD_ENCRYPTION_KEY=change-me-32-byte-hex-key-for-aes-256
# ─── 엣지 식별 ───────────────────────────────────────
# 고객사 코드
COMPANY_CODE=spifox
# 엣지 UUID (스피폭스 예: aff81fbf-9b4c-43e0-9395-566bf47c3f9c)
EDGE_ID=aff81fbf-9b4c-43e0-9395-566bf47c3f9c
# ─── Pipeline 이미지 (Harbor 경로) ───────────────────
PIPELINE_IMAGE=harbor.wace.me/vexplor_fleet/pipeline-backend:latest
PIPELINE_FRONT_IMAGE=harbor.wace.me/vexplor_fleet/pipeline-front:latest
+58
View File
@@ -0,0 +1,58 @@
# ============================================================
# Pipeline Backend — 엣지 배포용 프로덕션 이미지
#
# Python 훅 실행기용 python3 포함.
# ts-node 대신 dist/app.js 실행 (프로덕션).
# ============================================================
FROM node:20-bookworm-slim AS builder
WORKDIR /app
# 시스템 패키지
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates curl python3 \
&& rm -rf /var/lib/apt/lists/*
# 의존성 설치 (devDependencies 포함 — tsc 빌드 필요)
COPY package*.json ./
RUN npm ci --prefer-offline --no-audit
# 소스 복사 + 빌드
COPY tsconfig.json ./
COPY src ./src
COPY db ./db
RUN npx tsc --outDir dist
# ── Runtime 스테이지 (작은 이미지) ──────────────────
FROM node:20-bookworm-slim
WORKDIR /app
# Python3 + 필수 런타임만
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates curl python3 \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Production 의존성만
COPY package*.json ./
RUN npm ci --omit=dev --prefer-offline --no-audit \
&& npm cache clean --force
# 빌드 결과물 복사
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/db ./db
# 스토리지 폴더
RUN mkdir -p /app/storage /app/uploads \
&& chown -R node:node /app
USER node
EXPOSE 8080 1883 8083
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -fsS http://localhost:8080/health || exit 1
CMD ["node", "dist/app.js"]
+36
View File
@@ -0,0 +1,36 @@
# Pipeline Frontend — 엣지 배포용 프로덕션 이미지 (next build + next start)
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --prefer-offline --no-audit
COPY . .
# 프로덕션 빌드
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ── Runtime 스테이지 ───────────────────────────────
FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev --prefer-offline --no-audit \
&& npm cache clean --force
# 빌드 결과물
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.* ./
EXPOSE 3000
CMD ["npx", "next", "start", "-p", "3000"]
+181
View File
@@ -0,0 +1,181 @@
# Pipeline Edge Deployment
스피폭스 등 고객사 엣지 서버에 Pipeline을 올려 기존 Python data-collector + Kafka + forwarder를 **완전 대체**합니다.
## 기존 vs 신규 구조
```
[기존]
PLC → Python data-collector → 로컬 Kafka → kafka-to-central-mqtt → IDC EMQX → TimescaleDB
[신규 — Pipeline 단일 서비스]
PLC → Pipeline (XGT/Modbus/OPC UA/S7 직접 수집 + Python 훅 실행) → IDC EMQX → TimescaleDB
```
Pipeline이 다음 역할을 모두 수행:
- 장비 폴링 (XGT/Modbus/OPC UA/S7)
- Python 훅 실행 (transform/filter/derived_tags, `python3` 서브프로세스)
- 로컬 현재값 스냅샷 (`equipment_current_state`)
- IDC MQTT 포워딩 (`dt/v1/data/{company_id}/{edge_id}`)
- 재시도 큐 (`central_mqtt_forwarder_retry_queue`)
- 모든 것을 UI에서 관리
## 1. 이미지 빌드 & 푸시 (최초 1회, 로컬에서)
```bash
cd /Users/chpark/workspace/vexplor_Pipeline
# 백엔드 프로덕션 이미지
docker build \
-f docker/edge/Dockerfile.backend.prod \
-t harbor.wace.me/vexplor_fleet/pipeline-backend:latest \
./backend-node
docker push harbor.wace.me/vexplor_fleet/pipeline-backend:latest
# (선택) 프론트엔드 이미지 — 엣지에서 UI 직접 띄우려면
docker build \
-f docker/dev/frontend.Dockerfile \
-t harbor.wace.me/vexplor_fleet/pipeline-front:latest \
./frontend
docker push harbor.wace.me/vexplor_fleet/pipeline-front:latest
```
## 2. 엣지 서버 준비 (스피폭스 `112.168.212.142`)
> ⚠️ **병행 운영 모드**
> 기존 Python data-collector / fleet-agent / kafka-to-central-mqtt는 **절대 중지하지 않고** 그대로 둡니다.
> Pipeline은 옆에서 별도로 기동해 "연결/수집/포워딩이 잘 되는지"만 검증합니다.
> 안정성 확인 후 사용자가 판단해서 기존 컨테이너 중지 여부 결정.
```bash
ssh wace@112.168.212.142
# Harbor 로그인
docker login harbor.wace.me
# Pipeline 전용 디렉토리 (기존 data-collector와 분리)
mkdir -p /home/wace/pipeline-edge
cd /home/wace/pipeline-edge
```
### 포트 충돌 확인 (기존 컨테이너와 겹치지 않는지)
```bash
# 기존 스피폭스 엣지의 포트 사용 현황 확인
docker ps --format 'table {{.Names}}\t{{.Ports}}' | grep -E '8080|1883|8083|9771'
```
만약 겹치면 Pipeline 쪽 포트를 바꿔 기동 (compose에서 `ports:` 좌측 값만 수정).
## 3. compose + env 배치
`docker-compose.edge.yml``.env.example`를 엣지에 업로드 후 `.env.example``.env`로 복사하고 값 설정:
```bash
# 로컬 → 엣지로 scp
scp docker/edge/docker-compose.edge.yml wace@112.168.212.142:/home/wace/pipeline-edge/
scp docker/edge/.env.example wace@112.168.212.142:/home/wace/pipeline-edge/.env
# 엣지에서 .env 편집
ssh wace@112.168.212.142
cd /home/wace/pipeline-edge
vi .env # DATABASE_URL, JWT_SECRET, PASSWORD_ENCRYPTION_KEY, EDGE_ID 등 입력
```
## 4. 기동
```bash
docker compose -f docker-compose.edge.yml up -d
# 프론트 UI도 같이 띄우려면:
docker compose -f docker-compose.edge.yml --profile with-ui up -d
# Watchtower 자동 업데이트까지:
docker compose -f docker-compose.edge.yml --profile watchtower up -d
```
## 5. 검증
```bash
# 헬스체크
curl http://localhost:8080/health
# 부팅 로그 확인
docker logs pipeline-backend --tail 100 | grep -iE 'collector|forwarder|script'
# 기대 출력:
# ✅ 중앙 MQTT 포워더 + 장비 현재값 테이블 생성 완료
# ✅ 프로토콜 CHECK 제약 확장 완료
# 🔌 장비 수집기 자동 시작: N개 연결
# [CentralForwarder] 연결됨: mqtt://211.115.91.170:31883
```
- 이후 웹에서 `http://<엣지IP>:9771`로 UI 접근 (또는 중앙 Pipeline UI에서 같은 DB 공유 시 공통 사용).
- **장비 통신** 페이지에서 PLC 연결 활성화 / 비활성화 가능
- **Python 훅** `/admin/fleet/scripts`에서 편집 → 연결에 체크박스로 붙임 → 다음 폴링부터 자동 반영
## 6. 롤백 / 정리
기존 Python data-collector는 그대로 돌고 있으므로 **Pipeline만 내리면** 원상 복구됩니다.
```bash
# Pipeline만 중지 (기존 data-collector는 영향 없음)
cd /home/wace/pipeline-edge
docker compose -f docker-compose.edge.yml down
```
## 병행 운영 중 주의사항 — **중복 IDC 전송 방지**
기존 `kafka-to-central-mqtt` forwarder가 돌고 있는 상태에서 Pipeline 포워더까지 켜면 **같은 데이터가 IDC에 두 번 들어갑니다** (동일 `edge_id`/`company_id` + 동일 토픽).
### 해결책 (택 1)
**A. Pipeline 포워더는 켜지 말기 (추천 — 연결 검증만 먼저)**
- `/admin/automaticMng/centralForwarder` 에서 포워더 설정 **비활성**(`is_enabled='N'`) 유지
- Pipeline은 수집/UI 테스트만, IDC 전송은 기존 forwarder가 계속 담당
**B. 테스트용 edge_id 사용**
- `.env``EDGE_ID=spifox-pipeline-test` 같은 식별자
- IDC TimescaleDB에서 이 edge_id만 별도로 보면서 수집값 검증
- 검증 끝나면 실 edge_id로 변경 + 기존 forwarder 중지
**C. 기존 포워더 중지 (완전 대체 시점)**
```bash
docker stop kafka-to-central-mqtt
# 이제 Pipeline 포워더 활성화
```
## 주요 환경변수
| 변수 | 설명 | 필수 |
|---|---|---|
| `DATABASE_URL` | PostgreSQL 접속 URL | ✅ |
| `JWT_SECRET` | JWT 서명 키 (32+ 글자) | ✅ |
| `PASSWORD_ENCRYPTION_KEY` | AES-256 키 (32바이트 hex) | ✅ |
| `ENABLE_AUTO_COLLECTOR` | 부팅 시 모든 활성 연결 자동 폴링 (엣지=true) | 엣지용 |
| `COMPANY_CODE` | 고객사 식별 (예: spifox) | ✅ |
| `EDGE_ID` | 엣지 UUID | ✅ |
## 트러블슈팅
### Python 훅 실행 에러
```bash
docker exec pipeline-backend python3 --version # 3.11+이어야 함
```
### IDC MQTT 미연결
```bash
docker exec pipeline-backend node -e '
const mqtt=require("mqtt");
const c=mqtt.connect("mqtt://211.115.91.170:31883",{username:"ingestion",password:"ingestion_secret_prod"});
c.on("connect",()=>{console.log("OK"); c.end();});
c.on("error",e=>console.log("ERR",e.message));
'
```
### PLC 미연결
```bash
docker exec pipeline-backend sh -c 'timeout 3 bash -c "cat < /dev/tcp/192.168.101.50/2004" && echo OK || echo FAIL'
```
+107
View File
@@ -0,0 +1,107 @@
# ============================================================
# Pipeline Edge 배포 Compose
#
# 목적: 스피폭스 등 고객사 엣지 서버에 Pipeline을 올려
# 기존 Python data-collector + Kafka + forwarder를 완전 대체
#
# 실행:
# cd /home/wace/pipeline-edge
# docker compose -f docker-compose.edge.yml up -d
#
# 전제:
# - .env 파일에 DATABASE_URL, PASSWORD_ENCRYPTION_KEY, JWT_SECRET 설정
# - Harbor 레지스트리 로그인 완료 (docker login harbor.wace.me)
# - 엣지에서 PLC(예: 192.168.101.50:2004) 도달 가능
# - 엣지에서 IDC EMQX (211.115.91.170:31883) 도달 가능
# ============================================================
services:
pipeline-backend:
image: ${PIPELINE_IMAGE:-harbor.wace.me/vexplor_fleet/pipeline-backend:latest}
container_name: pipeline-backend
restart: always
ports:
- "8080:8080" # REST API + Admin UI
- "1883:1883" # 내장 MQTT (로컬 용, 선택)
- "8083:8083" # MQTT WebSocket (선택)
environment:
# ─── 핵심 ─────────────────────────────────────────
- NODE_ENV=production
- PORT=8080
# ─── DB 연결 (IDC 원격 또는 로컬 Postgres) ──────────
- DATABASE_URL=${DATABASE_URL}
# ─── 보안 ─────────────────────────────────────────
- JWT_SECRET=${JWT_SECRET}
- PASSWORD_ENCRYPTION_KEY=${PASSWORD_ENCRYPTION_KEY}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
# ─── 장비 수집기 자동 시작 ────────────────────────
# 엣지에선 반드시 true — 부팅 시 DB의 모든 활성 연결 폴링 시작
- ENABLE_AUTO_COLLECTOR=true
# ─── 회사/엣지 식별 ──────────────────────────────
- COMPANY_CODE=${COMPANY_CODE:-spifox}
- EDGE_ID=${EDGE_ID}
volumes:
# 영속 데이터 (업로드, 로그 등)
- pipeline-data:/app/storage
- pipeline-uploads:/app/uploads
networks:
- pipeline-network
labels:
- "com.centurylinklabs.watchtower.enable=true"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
# ─── 프론트엔드 (선택) ──────────────────────────────
# 엣지에서 직접 UI 접근하고 싶으면 켜기. 보통은 중앙 Pipeline UI 사용.
pipeline-front:
image: ${PIPELINE_FRONT_IMAGE:-harbor.wace.me/vexplor_fleet/pipeline-front:latest}
container_name: pipeline-front
restart: always
ports:
- "9771:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
- SERVER_API_URL=http://pipeline-backend:8080
- NODE_OPTIONS=--max-old-space-size=2048
networks:
- pipeline-network
labels:
- "com.centurylinklabs.watchtower.enable=true"
profiles: ["with-ui"] # docker compose --profile with-ui up 로 선택 기동
# ─── Watchtower (자동 업데이트) ──────────────────────
# 기존 스피폭스 엣지와 동일한 패턴: Harbor 폴링 + 라벨 기반
watchtower:
image: nickfedor/watchtower:latest
container_name: watchtower
restart: always
environment:
- WATCHTOWER_POLL_INTERVAL=300
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_LABEL_ENABLE=true
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ~/.docker/config.json:/config.json:ro
command: --interval 300
labels:
- "com.centurylinklabs.watchtower.enable=false"
profiles: ["watchtower"]
networks:
pipeline-network:
driver: bridge
name: pipeline-network
volumes:
pipeline-data:
name: pipeline-data
pipeline-uploads:
name: pipeline-uploads
+404
View File
@@ -0,0 +1,404 @@
# 엣지(스피폭스) ↔ IDC 중앙 수집 파이프라인 — 기존 기능 전수 조사 및 파이프라인 이식 가이드
> 조사 대상
> - **엣지 서버(고객사 수집서버)**: `112.168.212.142` — `waceserver` (Ubuntu, Docker Compose)
> - **IDC 중앙 서버**: `211.115.91.170` — `waceserver01` (Ubuntu, **Kubernetes v1.28 single-node**)
> 조사 일자: 2026-04-20
> 목적: 현재 엣지+IDC가 운용 중인 "수집 → 전송 → 적재 → 조회" 전 기능을 **Pipeline 애플리케이션(vexplor_Pipeline)**에 이식하기 위한 스펙 정리
---
## 0. TL;DR — 파이프라인에 넣어야 할 기능 한 줄 요약
| # | 기능 | 현재 위치 | 파이프라인 이식 방식 |
|---|---|---|---|
| 1 | 다중 프로토콜 수집 (XGT/Modbus/OPC UA/S7/MQTT/SQL/CAS) | 엣지 `data-collector` (Python) | **Pipeline Backend 내부 `collectors/` 모듈**로 이식 |
| 2 | Bootstrap(MAC→UUID) / Config 원격 동기화 | 엣지 `data-collector/bootstrap/` | Pipeline 측 `/api/edge/provision`, `/api/edge/config` 제공 |
| 3 | Store & Forward (로컬 Kafka 버퍼 + RetryQueue) | 엣지 Kafka + `publishers/retry_queue.py` | Pipeline 내부 큐(Kafka or Redis Streams) + 재시도 정책 |
| 4 | Kafka → 중앙 MQTT 배치 포워딩 | 엣지 `kafka-to-central-mqtt` (Python, stateless) | Pipeline `services/forwarder/` 서비스로 이식 |
| 5 | MQTT 공유구독 → TimescaleDB 배치 INSERT | **IDC `digital-twin-web-backend` Node.js** (`mqtt-ingestion.service.js`) | Pipeline Backend의 **데이터 소스(TimescaleDB)** 뒤단에 동일 ingestion 서비스 |
| 6 | Fleet Agent 원격 관리(컨테이너 제어/헬스/오프라인큐) | 엣지 `fleet-agent` (Node.js, `device-supervisor`) | Pipeline이 Fleet API(`fleet-api.vexplor.com`) 소비 측으로 통합 |
| 7 | 이미지 자동 배포 체인 | Harbor → Watchtower 5분 폴링 → 라벨 기반 교체 | Pipeline CI/CD에서 Harbor push + 라벨 규약 유지 |
| 8 | 설비 상태 동기화 (개별 `device_id`별) | IDC 백엔드 `equipment-status-sync.service.js` | Pipeline의 `equipmentStatus` 실시간 갱신 모듈 |
**2026-04-20 파이프라인 작업자 발언 (정책 결정)**:
> "그 엣지 코드 변경되서 커밋하면 harbor에 이미지 올라가는데 플릿 에이전트가 주기적으로 harbor에 있는 이미지가 최신값인지 확인해서 변경사항이 있으면 엣지서버 최신화 될거에요"
>
> ⚠️ **사실 보정**: 실제로 Harbor 폴링을 하는 주체는 **Fleet Agent가 아니라 Watchtower 컨테이너**입니다 (5분 간격, `com.centurylinklabs.watchtower.enable=true` 라벨 기준). Fleet Agent는 **원격 제어/상태 보고**만 담당. 파이프라인에 이식할 때 이 부분을 혼동하지 않도록 구분해야 합니다.
---
## 1. 엣지(스피폭스) 서버 — 현재 구성
### 1.1 전체 구성
- **OS**: Ubuntu, Linux 6.8.0-110-generic
- **오케스트레이션**: Docker Compose 전용 (`kubectl`/`kubeadm` 바이너리는 있지만 클러스터는 `10.10.0.74:6443` 연결 거부로 꺼져 있음)
- **이미지 소스**: `harbor.wace.me/vexplor_fleet/*`
- **자동 업데이트**: Watchtower 컨테이너 (`nickfedor/watchtower:latest`, 5분 폴링, 라벨 기반)
### 1.2 기동 중인 컨테이너 (`docker ps` 시점)
| 컨테이너 | 이미지 | 역할 |
|---|---|---|
| `data-collector` | `harbor.wace.me/vexplor_fleet/data-collector:latest` | 메인 수집기 (XGT/Modbus/OPC UA/S7/MQTT/SQL/CAS) |
| `data-collector-alpet` | 동일 | 알펫 전용 (MSSQL, `network_mode: host`, `EDGE_ID=ALPET-001`) |
| `fleet-agent` | `harbor.wace.me/vexplor_fleet/device-supervisor:latest` | 원격 관리/헬스/컨테이너 제어 |
| `kafka-to-central-mqtt` | `harbor.wace.me/vexplor_fleet/kafka-to-central-mqtt:latest` | 로컬 Kafka → 중앙 MQTT 포워더 |
| `watchtower` | `nickfedor/watchtower:latest` | Harbor 폴링 자동 배포 |
| `kafka` | `confluentinc/cp-kafka:7.5.0` (KRaft) | 로컬 Store & Forward 버퍼 |
> `timescaledb`, `kafka-to-timescale`, `emqx`는 통합 compose에 정의만 존재. **현재 미기동** — TimescaleDB는 IDC로 이전됨.
### 1.3 Data Collector 내부 (이식 대상 핵심)
**컨테이너 내부 경로**: `/app/src/data_collector/`, 엔트리 `python -m data_collector.main`
```
data_collector/
├── main.py # EdgeAgent 메인 루프 (bootstrap → config sync → collect → publish)
├── models.py # DeviceData, TagValue
├── bootstrap/
│ ├── aas_client.py # AAS(Asset Admin Shell) API 클라이언트
│ ├── bootstrapper.py # MAC → UUID 프로비저닝
│ └── config_syncer.py # 서버 Config 주기 pull (기본 5분)
├── collectors/
│ ├── base.py / manager.py
│ ├── cas_collector.py / cas_protocol.py
│ ├── modbus_collector.py
│ ├── mqtt_collector.py
│ ├── opcua_collector.py
│ ├── s7_collector.py # Siemens S7
│ ├── sql_collector.py # MSSQL 등
│ ├── xgt_collector.py + xgt_connection_pool.py # LS XGT
├── processors/
│ ├── aggregator.py / converter.py / filter.py
├── publishers/
│ ├── kafka_publisher.py # 로컬 Kafka publish
│ └── retry_queue.py # Store & Forward (max 100,000건)
├── consumers/
│ └── kafka_to_central_mqtt.py # (임베디드 포워더 변형 — 실행은 별도 컨테이너에서)
└── config/
└── settings.py
```
**`EdgeAgent` 책임 (main.py)**:
1. **Bootstrap** — MAC 주소로 VEX Flow 서버(`https://collectormanager.vexplor.com`)에서 UUID 발급
2. **Config Sync**`EDGE_CONFIG_SOURCE=api | aas` 모드로 주기 pull
3. **Collector Manager** — 태그/프로토콜별 Collector 기동
4. **Kafka Publish** — 수집→`edge-raw-data` 토픽, 실패시 `RetryQueue`
5. **변경 감지**`_last_values`로 중복 송신 억제
**실제 운용 환경변수 (스피폭스)**:
```
EDGE_SERVER_URL=https://collectormanager.vexplor.com
EDGE_CONFIG_SOURCE=api
EDGE_KAFKA_BROKERS=kafka:9092
EDGE_MQTT_BROKER_URL=mqtt://emqx:1883 # 로컬 EMQX (현재 미기동)
EDGE_MQTT_ENABLED=true
DEVICE_ID=edge-0f4d04ed
COMPANY_ID=7f5c058c-ef65-45e3-838e-cebaec2d6170 # spifox
```
### 1.4 Fleet Agent (`device-supervisor`) 내부
**언어/구성**: Node.js + TypeScript 빌드 산출물, 패키지명 `device-supervisor` v1.0.2
```
/app/dist/
├── index.js # 엔트리
├── docker.js # dockerode 기반 컨테이너 제어 (/var/run/docker.sock:ro 마운트)
├── heartbeat.js # 주기 하트비트 (HEARTBEAT_INTERVAL=30)
├── metrics.js # systeminformation 기반 시스템 지표
├── mqtt.js # 중앙 MQTT/Fleet API 통신
├── offline/
│ ├── store.js # better-sqlite3 오프라인 큐
│ └── sync.js # 복구 시 재전송
└── config.js
```
**주요 의존성**: `dockerode`, `mqtt`, `systeminformation`, `node-cron`, `better-sqlite3`, `winston`, `axios`
**엔드포인트**: `FLEET_API_URL=https://fleet-api.vexplor.com`, MQTT `mqtt://211.115.91.170:31883`
**관리 대상**: `MANAGED_CONTAINERS=data-collector,kafka` 등 (env로 주입)
**역할 명확화** (⚠️ 전 담당자 발언 보정): Fleet Agent는 **원격 제어/상태 보고/오프라인 큐** 담당. **Harbor 폴링/이미지 교체는 Watchtower가 수행**하며 Fleet Agent와 무관.
### 1.5 Kafka → 중앙 MQTT 포워더 (Stateless Multi-Tenant)
**엔트리**: `python -u /app/forwarder.py`
**토픽 규칙**:
- 데이터: `dt/v1/data/{company_id}/{edge_id}`
- 하트비트: `dt/v1/status/{company_id}/{edge_id}`
- QoS 1, MQTTv5
- 배치: `BATCH_SIZE=50` 또는 `BATCH_TIMEOUT_MS=3000`
**설계 포인트**:
- **Stateless**: 메시지 페이로드의 `edge_id`로 토픽 동적 라우팅 → 하나의 포워더가 다수 Edge 처리 가능
- **Config API** 지원 (선택): `CONFIG_API_URL`이 있으면 CCM/DT Config API에서 `central_mqtt.{host,port,username,password}` 덮어씀
- `edge_stats`로 edge_id별 forwarded/failed/first_seen/last_seen 통계 추적
**Edge → 중앙 최종 MQTT 페이로드**:
```json
{
"timestamp": "2026-04-11 11:20:14.922601",
"edge_id": "aff81fbf-9b4c-43e0-9395-566bf47c3f9c",
"device_id": "75570e41-821c-4813-a212-1131fc6fb538",
"tags": { "태그명1": value, "태그명2": value },
"priority": 2,
"company_id": "spifox",
"forwarded_at": "..."
}
```
(실 Kafka 메시지엔 `plc_state`, `error_message` 같은 부가 필드 존재)
### 1.6 Watchtower 자동 배포
- 컨테이너가 5분(`--interval 300`)마다 Harbor 폴링
- `WATCHTOWER_LABEL_ENABLE=true` — 라벨 `com.centurylinklabs.watchtower.enable=true`가 붙은 컨테이너만 교체
- `WATCHTOWER_CLEANUP=true` — 구 이미지 자동 삭제
- `~/.docker/config.json` 마운트 → Harbor 인증 사용
**라벨 정책**:
- ON (자동 업데이트): `data-collector`, `data-collector-alpet`, `fleet-agent`, `kafka-to-central-mqtt`, `kafka-to-timescale`
- OFF (보수적): `kafka`, `timescaledb`, `watchtower` 자신
---
## 2. IDC 중앙 서버 — 현재 구성
### 2.1 전체 구성
- **OS**: Ubuntu, Linux 6.8.0-101-generic
- **오케스트레이션**: **Kubernetes v1.28.0 single-node** (control-plane = `waceserver01`, flannel CNI)
- **네임스페이스**: `digital-twin`, `fleet`, `ingress-nginx`, `logic-studio`, `wace-business-management`
- **이미지 레지스트리**: `192.168.1.100:5001/digital-twin/*` (내부 Harbor 프록시)
### 2.2 `digital-twin` 네임스페이스 핵심 파드
| Pod | 역할 |
|---|---|
| `digital-twin-mqtt-*` | **EMQX 브로커** (Edge에서 들어오는 원격 MQTT) |
| `digital-twin-timescale-0` | **TimescaleDB** (`edge_telemetry` DB, 시계열 적재) |
| `digital-twin-web-backend` | **MQTT 구독 + TimescaleDB 적재 + API 서버** (Node.js, Express) |
| `digital-twin-web-frontend` | 웹 UI (2 replicas) |
| `digital-twin-web-postgres-0` | 메타데이터 PostgreSQL |
| `digital-twin-web-redis` | 세션/캐시 |
| `basyx-*` | Eclipse BaSyx AAS 스택 (aas-discovery/env/registry, submodel-registry, cd-repository, web-ui, mongodb) |
| `unity-webgl-server` | Unity 3D 뷰어 |
| `vexspace-postgres-0` | Vex Space 전용 Postgres |
### 2.3 NodePort 외부 노출 (211.115.91.170:*)
| 서비스 | NodePort | 내부 포트 | 용도 |
|---|---|---|---|
| `digital-twin-mqtt-external` | **31883** | 1883 (MQTT) | **Edge → 중앙 MQTT 인입** |
| `digital-twin-mqtt-external` | 31084 | 8083 (WS) | MQTT WebSocket |
| `digital-twin-mqtt-external` | 31183 | 18083 | EMQX Dashboard |
| `digital-twin-timescale-external` | **30543** | 5432 | **TimescaleDB 직접 조회** (파이프라인이 붙는 곳) |
| `digital-twin-web-postgres-external` | 30533 | 5432 | 메타 Postgres |
| `vexspace-postgres-external` | 31141 | 5432 | Vex Space DB |
| `fleet-emqx` | 31884 | 1883 | Fleet 네임스페이스 별도 MQTT |
| `fleet-postgres` | 31985 | 5432 | Fleet 메타 DB |
| `ingress-nginx-controller` | 31878/30361/31591 | 80/443/1884 | 공용 ingress (1884는 MQTT over ingress) |
> 프론트엔드의 **"데이터 소스 - PLC_탑씰"**(`211.115.91.170:30543 / edge_telemetry / telemetry_user`)이 바로 `digital-twin-timescale-external`입니다.
### 2.4 MQTT → TimescaleDB 적재 로직 (핵심, 이식 대상)
**위치**: `digital-twin-web-backend` 컨테이너 내 `src/services/ingestion/mqtt-ingestion.service.js`
**언어/스택**: Node.js, `mqtt` 5.14, `pg` 8.17, `sequelize` 6.35 (단, ingestion은 생 `pg` Pool 사용)
**EMQX 접속**:
```
MQTT_BROKER_URL=mqtt://digital-twin-mqtt:1883
MQTT_INGESTION_USER=ingestion
MQTT_INGESTION_PASSWORD=ingestion_secret # ⚠️ 외부용은 ingestion_secret_prod (엣지 .env 기준)
```
**TimescaleDB 접속** (envVar):
```
TIMESCALE_HOST=digital-twin-timescale
TIMESCALE_PORT=5432
TIMESCALE_DB=edge_telemetry
TIMESCALE_USER=telemetry_user
TIMESCALE_PASSWORD=***MASKED***
```
**구독 패턴 (공유구독 — 수평 확장 가능)**:
```
$share/ingestion-group/dt/v1/data/+/+
$share/ingestion-group/dt/v1/status/+/+
```
- `$share/<group>/...` EMQX 공유구독으로 여러 백엔드 replica 간 메시지 분배
- `+/+` 와일드카드로 `{company_id}/{edge_id}` 모두 수신 (ACL 이슈로 `#` 대신 `+/+` 사용)
**처리 흐름 (`handleTelemetryData`)**:
1. 토픽 파싱 → `[company_id, edge_id]`
2. JSON 파싱
3. `item.tags` 딕셔너리면 각 태그마다 row 1건 생성:
```
time, company_id, edge_id, tag_name, value(DOUBLE), quality, metadata(JSON)
```
4. 단일 태그 형식(`tag_name/value`)도 지원
5. **buffer**에 쌓고 `BATCH_SIZE=1000` 또는 `FLUSH_INTERVAL=5s` 도달 시 `batchInsert('edge_telemetry', rows, cols)`
6. Status(하트비트)는 `edge_status` 테이블에 적재 (`status, ip_address, firmware_version, metadata`)
**신뢰성 기능**:
- **Circuit Breaker**: 연속 실패 5회(`CIRCUIT_BREAKER_MAX_FAILURES=5`) 시 OPEN, 60초 후 HALF_OPEN 회복
- **Exponential backoff 재연결** (1s → 60s)
- **버퍼 오버플로우 방지**: `MAX_BUFFER_SIZE=100,000` 초과 시 오래된 80%부터 drop
- **재시도 큐**: 실패 배치 최대 5,000건 재주입 (`MAX_RETRY_BUFFER_SIZE=10,000`)
- **stats 노출**: `messagesReceived/telemetryInserted/statusInserted/errors/droppedMessages/circuitBreakerTrips`
**설비 상태 동기화 (`handleEquipmentDataReceived`)**:
- 메시지 내 `device_id`별로 원본 값(문자열 포함) 보존
- 별도 서비스 `equipment-status-sync.service.js`가 개별 설비 UUID로 조회해 마지막 수신 시각/값 갱신 (Heartbeat도 포함)
### 2.5 TimescaleDB 스키마 (추정 + 기존 코드 근거)
`timescale.config.js`의 `batchInsert` 호출 컬럼과 과거 `kafka_to_timescale.py` INSERT를 조합하면 다음 형태:
**`edge_telemetry`** (hypertable 가능성, time 기준):
| 컬럼 | 타입 | 설명 |
|---|---|---|
| `time` | TIMESTAMPTZ | 수집 시각 |
| `company_id` | TEXT/UUID | 고객사 ID |
| `edge_id` | TEXT | 엣지 장치 ID |
| `tag_name` | TEXT | 태그명 |
| `value` | DOUBLE PRECISION | 수치값 (비수치는 NULL) |
| `quality` | TEXT | `good` 기본 |
| `metadata` | JSONB | `{device_id, priority, forwarded_at, ...}` |
**`edge_status`**:
| 컬럼 | 타입 |
|---|---|
| `time`, `company_id`, `edge_id` | 공통 |
| `status` | TEXT (`online` 기본) |
| `ip_address`, `firmware_version` | TEXT |
| `metadata` | JSONB |
> 실제 `\d+` 확인은 `digital-twin-timescale-0` 파드의 psql 비밀번호가 로컬 환경에서 필요 (envVar `TIMESCALE_PASSWORD`) — 다음 접속 시 실 스키마/인덱스/리텐션 정책/연속집계(continuous aggregate) 확인 필요.
---
## 3. 전체 데이터 흐름
```
[현장 PLC/장비 — 스피폭스 공장]
│ (XGT / Modbus / OPC UA / S7 / MQTT / MSSQL / CAS)
[엣지 서버: data-collector 컨테이너]
· bootstrap (MAC→UUID)
· config sync (5분마다 collectormanager.vexplor.com)
· 프로토콜별 Collector → processors(filter/aggregate/convert) → publish
[로컬 Kafka — edge-raw-data 토픽] ◀─── RetryQueue (실패 재시도, 최대 10만건)
[kafka-to-central-mqtt 포워더]
· batch 50건 / 3초
· 토픽 동적 라우팅: dt/v1/data/{company_id}/{edge_id}
· QoS 1, MQTTv5
▼ (인터넷 경유)
═══════════════════════════════════════════════════════════════
[IDC 중앙: 211.115.91.170 K8s]
[EMQX (digital-twin-mqtt, NodePort 31883)]
· user=ingestion / pass=ingestion_secret_prod
▼ (공유구독 $share/ingestion-group/dt/v1/+/+/+)
[digital-twin-web-backend: mqtt-ingestion.service.js]
· buffer 1000건 / 5초 flush
· Circuit Breaker, Exponential backoff, 버퍼오버플로 방지
· device_id별 → equipment-status-sync.service
▼ pg.batchInsert (ON CONFLICT DO NOTHING)
[TimescaleDB: edge_telemetry DB]
· edge_telemetry (시계열)
· edge_status (하트비트)
▲ NodePort 30543
[Pipeline Frontend — 데이터 소스 "PLC_탑씰"] ← 현재 조회용 read 연결
[Fleet 관리 루프]
fleet-agent(엣지) ──MQTT/HTTPS── fleet-api.vexplor.com ── fleet-emqx(IDC)
└─ dockerode → 엣지 컨테이너 start/stop/restart
[자동 배포 루프]
Harbor(harbor.wace.me) ◀──push── 엣지 코드 CI
│ 5분 폴링 (Watchtower, label=enable)
Watchtower(엣지) ── docker pull & recreate ──▶ 대상 컨테이너 교체
```
---
## 4. Pipeline 애플리케이션에 이식해야 할 기능 (작업 체크리스트)
### 4.1 백엔드 (`backend-node`)
- [ ] **`/api/datasource/timescale`** — TimescaleDB 커넥션 풀 (`pg`) 추가
- envVar: `TIMESCALE_HOST/PORT/DB/USER/PASSWORD` (기본 `211.115.91.170:30543 / edge_telemetry / telemetry_user`)
- `timescale.config.js`의 `batchInsert(table, rows, columns)` 패턴 그대로 포팅 (ON CONFLICT DO NOTHING)
- [ ] **`services/ingestion/mqtt-ingestion.service`** — EMQX 공유구독 + 버퍼 + Circuit Breaker
- 토픽: `$share/<groupId>/dt/v1/data/+/+`, `dt/v1/status/+/+`
- envVar: `MQTT_BROKER_URL`, `MQTT_INGESTION_USER/PASSWORD`, `INGESTION_BATCH_SIZE=1000`, `INGESTION_FLUSH_INTERVAL=5000`, `INGESTION_MAX_BUFFER_SIZE=100000`, `CIRCUIT_BREAKER_MAX_FAILURES=5`, `CIRCUIT_BREAKER_RESET_MS=60000`
- `edge_telemetry` / `edge_status` 2개 테이블 적재 분기
- [ ] **`services/forwarder/kafka-to-mqtt.service`** — (엣지 수집을 파이프라인이 직접 도맡을 경우) 기존 Python `kafka_to_central_mqtt.py`를 Node로 포팅
- [ ] **`services/collectors/*`** — 프로토콜별 수집기 (XGT/Modbus/OPC UA/S7/MQTT/SQL/CAS) Node 이식
- 라이브러리 후보: `modbus-serial`, `node-opcua`, `nodes7`, `mqtt`, `mssql/mysql2/pg`, `ls-electric-xgt`(자체 구현 필요)
- [ ] **`services/bootstrap/provisioning`** — 엣지의 `bootstrap/aas_client.py` + `bootstrapper.py` 역할
- `POST /api/edge/provision`으로 `{mac_address, company_id}` 받아 UUID/access_token 발급
- `GET /api/edge/config?edge_id=...`로 수집 태그/주기 Config 반환 (기존 `config_syncer.py` 호환)
- [ ] **`services/equipment-status-sync`** — `device_id`별 마지막 수신시각/값 갱신
- 기존 프로젝트의 [backend-node/src/services/batchSchedulerService.ts](../backend-node/src/services/batchSchedulerService.ts)와 통합 고려
- [ ] **`services/fleet-agent-bridge`** — Fleet API 소비자
- 엣지에서 올라오는 heartbeat/metrics를 UI에 노출
- 파이프라인 자체를 Fleet 피관리 대상으로도 등록 가능하게 (원격 재시작 허용)
### 4.2 프론트엔드 (`frontend`)
- [ ] 데이터 소스 관리 화면([frontend/app/(main)/admin/automaticMng/batchmngList/](../frontend/app/(main)/admin/automaticMng/batchmngList/))에 **TimescaleDB 타입** 추가 (현재는 MariaDB/PostgreSQL만)
- [ ] 엣지 디바이스 목록(Fleet 연동) 화면 — DEVICE_ID/COMPANY_ID/last_seen/image_version 노출
- [ ] Ingestion 실시간 통계 대시보드 — `messagesReceived/telemetryInserted/droppedMessages/circuitBreakerTrips`
- [ ] 태그별 시계열 조회 — `edge_telemetry` 쿼리 (time_bucket, continuous aggregate 활용)
### 4.3 CI/CD / 배포
- [ ] **Harbor 푸시 파이프라인** — 엣지 컴포넌트(`data-collector`, `fleet-agent`, `kafka-to-central-mqtt`) 이미지 빌드/푸시 단계를 Jenkinsfile에 통합
- [ ] **Watchtower 라벨 정책 유지** — 새 컨테이너는 반드시 `com.centurylinklabs.watchtower.enable=true` 라벨을 명시적으로 붙이거나 떼기 (불투명한 자동 롤아웃 방지)
- [ ] **릴리스 게이트** — `:latest` 즉시 롤아웃을 피할 필요가 있으면 `:stable`/`:canary` 태그 도입 검토
### 4.4 보안/비밀 관리
- [ ] TimescaleDB 비밀번호, MQTT `ingestion` 계정, Harbor 자격, Fleet API 토큰은 **K8s Secret / `.env` 중 한 곳에서만 관리**하고 소스 커밋 금지
- [ ] 현재 IDC `digital-twin-web-backend` Deployment에 **평문으로 `TIMESCALE_PASSWORD` 노출** 중 → 파이프라인 이식 시 `secretKeyRef`로 전환 권장
---
## 5. 외부 엔드포인트 레퍼런스
| 대상 | 주소 | 용도 |
|---|---|---|
| VEX Flow (프로비저닝/Config) | `https://collectormanager.vexplor.com` | data-collector `EDGE_SERVER_URL` |
| Fleet Manager API | `https://fleet-api.vexplor.com` | fleet-agent 원격관리 |
| 중앙 MQTT (EMQX) | `211.115.91.170:31883` → svc `digital-twin-mqtt` | 엣지 → 중앙 데이터 인입 |
| 중앙 TimescaleDB | `211.115.91.170:30543` → svc `digital-twin-timescale` | 시계열 조회/적재 |
| Harbor 레지스트리 | `harbor.wace.me` | 모든 엣지 이미지 소스 |
| 내부 Harbor 프록시(IDC) | `192.168.1.100:5001` | K8s 이미지 풀 경로 |
---
## 6. 추후 확인 필요 사항 (다음 접속 시)
1. **TimescaleDB 실제 스키마** — `\d+ edge_telemetry`, `\d+ edge_status`, hypertable 여부, continuous aggregate, retention policy
2. **`equipment-status-sync.service.js` 전체 소스** — 개별 설비 매칭 로직(equipmentId vs edgeDeviceId fallback)
3. **Fleet Manager API 엔드포인트 계약** — `device-supervisor` 측 `mqtt.js`/`heartbeat.js`의 호출 패턴
4. **EMQX ACL 설정** — `ingestion` 계정이 어떤 토픽에 write/read 권한 갖는지 (로그에서 `#` 구독은 거부 확인됨)
5. **Harbor repository 목록** — `vexplor_fleet/*`, `digital-twin/*` 태깅 규약
6. **Watchtower 라벨 전수 목록** — 각 엣지별로 어떤 컨테이너가 자동배포 대상인지 확정
7. **백엔드 `run-migration` init container** — TimescaleDB 마이그레이션 스크립트(`/app/migrations` 또는 `/app/scripts`) 확인하면 정확한 스키마 확보 가능
---
## 7. 관련 기존 문서
- [FLEET_EDGE_INTEGRATION.md](FLEET_EDGE_INTEGRATION.md)
- [FLEET_HOOK_INTEGRATION.md](FLEET_HOOK_INTEGRATION.md)
- [../customer-snapshot.md](../customer-snapshot.md)
+209
View File
@@ -0,0 +1,209 @@
# Fleet Management - 전체 통합 문서
vexplor_fleet의 모든 기능이 Pipeline으로 통합되었습니다.
## 구조
```
Pipeline (단일 배포)
├─ 백엔드 (Node.js/Express)
│ ├─ Fleet API (/api/fleet/*)
│ ├─ 내장 MQTT 브로커 (aedes, port 1883)
│ ├─ 서비스 레이어
│ │ ├─ fleetDeviceService - 디바이스 등록/관리
│ │ ├─ fleetCommandService - 커맨드 실행 (9종)
│ │ ├─ fleetReleaseService - 릴리즈 관리
│ │ ├─ fleetDeploymentService - 배포 오케스트레이션 (카나리/롤링)
│ │ ├─ fleetHarborService - Harbor Registry 조회
│ │ ├─ fleetTagTemplateService - 태그 템플릿 + 일괄 적용
│ │ ├─ fleetAlertRuleService - 알림 규칙 CRUD
│ │ ├─ fleetProvisionService - DPS 프로비저닝
│ │ ├─ fleetV1MappingService - 레거시 PLC 매핑
│ │ ├─ fleetPlcStatusService - PLC 연결 실시간 상태
│ │ ├─ fleetAuditService - 감사 로그
│ │ ├─ fleetMetricsService - Prometheus 메트릭
│ │ ├─ fleetScriptService - Python Hook 스크립트
│ │ ├─ fleetEdgeConfigService - 엣지 설정 제공
│ │ └─ fleetDataService - 실시간 수집 데이터
│ └─ MQTT 핸들러
│ ├─ vexplor/devices/+/status → 디바이스 heartbeat
│ ├─ vexplor/devices/+/metrics → 메트릭
│ ├─ vexplor/devices/+/responses → 커맨드 응답
│ ├─ vexplor/devices/+/data → 태그 데이터
│ └─ vexplor/devices/+/plc-status → PLC 연결 상태
└─ 프론트엔드 (Next.js, 시스템 관리 메뉴)
├─ 엣지 디바이스 (/admin/fleet/devices)
├─ Fleet 커맨드 (/admin/fleet/commands)
├─ Fleet 알림 (/admin/fleet/alerts)
├─ 실시간 수집 (/admin/fleet/data)
├─ Python Hook (/admin/fleet/scripts)
├─ 배포 관리 (/admin/fleet/deployments)
├─ 릴리즈 관리 (/admin/fleet/releases)
├─ 알림 규칙 (/admin/fleet/rules)
└─ 감사 로그 (/admin/fleet/audit)
```
## DB 스키마 (총 18개 Fleet 테이블)
| 테이블 | 용도 |
|---|---|
| fleet_devices | 엣지 디바이스 레지스트리 |
| fleet_heartbeats | 디바이스 상태 시계열 (30초마다) |
| fleet_commands / command_types | 9종 원격 커맨드 |
| fleet_releases | 릴리즈 버전 관리 |
| fleet_deployments / deployment_status | 배포 작업 + 디바이스별 상태 |
| fleet_alert_rules / alerts | 알림 규칙 + 발생 기록 |
| fleet_edge_raw_data | 실시간 수집 데이터 (시계열) |
| fleet_edge_scripts / script_versions / hook_types | Python Hook (5종, 버전 관리) |
| fleet_plc_connections | PLC 연결 실시간 상태 |
| fleet_tag_templates | 회사/장비별 태그 템플릿 |
| fleet_audit_logs | 전체 이벤트 감사 |
| fleet_users | Fleet 운영자 (SSO) |
| fleet_v1_plc_mapping | 레거시 v1 PLC 태그 매핑 |
## API 엔드포인트 (60+)
### 공개 (인증 없음)
```
POST /api/fleet/provision - DPS 자동 등록
GET /api/fleet/edge/:id/config - 엣지 설정 (ETag 캐싱)
GET /api/fleet/v1/edges/:id/config - 호환 alias
```
### 디바이스 (12개)
```
GET /api/fleet/devices
POST /api/fleet/devices/register
GET /api/fleet/devices/:id
PATCH /api/fleet/devices/:id
DELETE /api/fleet/devices/:id
GET /api/fleet/devices/:id/metrics
GET /api/fleet/devices/:id/latest-values
GET /api/fleet/devices/:id/tags/:tag/timeseries
GET /api/fleet/data/stats
GET /api/fleet/provision/pre-registered
POST /api/fleet/provision/pre-register
GET /api/fleet/stats
```
### 커맨드 (4개)
```
GET /api/fleet/commands
GET /api/fleet/commands/types
POST /api/fleet/commands
```
### Python Hook (10개)
```
GET /api/fleet/scripts/hook-types
GET /api/fleet/scripts
GET /api/fleet/scripts/:id
POST /api/fleet/scripts
PUT /api/fleet/scripts/:id
DELETE /api/fleet/scripts/:id
POST /api/fleet/scripts/dry-run
GET /api/fleet/scripts/:id/versions
GET /api/fleet/scripts/:id/versions/:v
POST /api/fleet/scripts/:id/rollback/:v
```
### 릴리즈 (6개)
```
GET /api/fleet/releases
GET /api/fleet/releases/:id
POST /api/fleet/releases
PUT /api/fleet/releases/:id
DELETE /api/fleet/releases/:id
POST /api/fleet/releases/:id/transition
```
### 배포 (8개)
```
GET /api/fleet/deployments
GET /api/fleet/deployments/:id
GET /api/fleet/deployments/:id/status
POST /api/fleet/deployments
POST /api/fleet/deployments/:id/start
POST /api/fleet/deployments/:id/cancel
POST /api/fleet/deployments/:id/rollback
```
### Harbor (4개)
```
GET /api/fleet/harbor/projects
GET /api/fleet/harbor/projects/:project/repos
GET /api/fleet/harbor/projects/:project/repos/:repo/tags
GET /api/fleet/harbor/ping
```
### 태그 템플릿 (6개)
```
GET /api/fleet/tag-templates
GET /api/fleet/tag-templates/:id
POST /api/fleet/tag-templates
PUT /api/fleet/tag-templates/:id
DELETE /api/fleet/tag-templates/:id
POST /api/fleet/tag-templates/:id/apply/:connectionId
```
### 알림 (7개)
```
GET /api/fleet/alerts
POST /api/fleet/alerts/:id/ack
POST /api/fleet/alerts/:id/resolve
GET /api/fleet/alert-rules
POST /api/fleet/alert-rules
PUT /api/fleet/alert-rules/:id
DELETE /api/fleet/alert-rules/:id
POST /api/fleet/alert-rules/:id/toggle
```
### V1 PLC 매핑 (4개)
```
GET /api/fleet/v1-mappings
POST /api/fleet/v1-mappings
PUT /api/fleet/v1-mappings/:id
DELETE /api/fleet/v1-mappings/:id
```
### PLC 상태, Audit, Metrics
```
GET /api/fleet/plc-status
GET /api/fleet/plc-status/summary
GET /api/fleet/audit-logs
GET /api/fleet/audit-logs/stats
GET /api/fleet/prometheus - Prometheus text format
```
## Device Supervisor 포팅 (엣지 에이전트)
Python Data Collector는 **그대로 유지**하고, 추가로 Node.js Device Supervisor를 엣지에서 돌릴 때는 기존 `vexplor_fleet/device-supervisor/src/` 코드를 그대로 사용합니다. Pipeline 중앙이 MQTT 브로커 역할을 하므로 변경할 건 환경변수만:
```bash
# device-supervisor .env
FLEET_API_URL=http://pipeline.wace.me:8080/api/fleet
MQTT_BROKER_URL=mqtt://pipeline.wace.me:1883
DEVICE_ID=edge-001
COMPANY_CODE=spifox
HEARTBEAT_INTERVAL=30
```
## 환경변수
| 이름 | 기본값 | 설명 |
|---|---|---|
| MQTT_PORT | 1883 | 내장 MQTT TCP |
| MQTT_WS_PORT | 8083 | MQTT WebSocket |
| HARBOR_URL | https://harbor.wace.me | Harbor Registry |
| HARBOR_USER | - | Harbor 사용자 |
| HARBOR_PASSWORD | - | Harbor 비밀번호 |
| FLEET_API_URL | http://localhost:8080/api/fleet | Provisioning 응답용 |
| FLEET_MQTT_BROKER | mqtt://localhost:1883 | Provisioning 응답용 |
## 다음 단계 (선택)
- Grafana 임베드 (Metrics 탭)
- 프로비저닝 토큰 JWT 전환
- 배포 롤아웃 진행률 실시간 WebSocket
- Python 실행 RestrictedPython 적용 (보안 강화)
+181
View File
@@ -0,0 +1,181 @@
# Fleet × Edge Data Collector 연동 가이드
로컬 Pipeline과 엣지(공장) Python Data Collector를 연동하는 방법입니다.
## 연동 방식
```
[Python Data Collector] [Pipeline (로컬)]
▲ ▲
│ 1. GET /api/fleet/v1/edges/ │
│ {edgeId}/config │
│ │
│ │
│ 2. PLC 수집 수행 │
│ │
│ 3. vexplor/devices/{edgeId}/ │
│ data 로 MQTT publish │
│ vexplor/devices/{edgeId}/ │
└──── status (heartbeat) ────────▶│
fleet_edge_raw_data
fleet_heartbeats
```
## 엣지 설정 (.env)
기존 엣지 `/home/wace/data-collector/.env` 수정:
```bash
# Pipeline 서버 URL (Fleet API + MQTT)
EDGE_SERVER_URL=http://<pipeline-host>:8080
MQTT_BROKER_URL=mqtt://<pipeline-host>:1883
# 기존 유지
DEVICE_ID=spifox-001
COMPANY_CODE=spifox
EDGE_CONFIG_SOURCE=api # 'aas' 대신 'api' 선택 시 Pipeline Fleet API 호출
LOG_LEVEL=INFO
# Kafka는 로컬에서 불필요 (Pipeline 내장 MQTT 사용)
# KAFKA_BROKERS= (비워두기)
```
## Pipeline API 엔드포인트
Python Data Collector가 호출하는 엔드포인트:
| 메서드 | 경로 | 용도 |
|---|---|---|
| `GET` | `/api/fleet/v1/edges/{edgeId}/config` | 수집 설정 조회 (ETag 캐싱) |
| `GET` | `/api/fleet/edge/{edgeId}/config` | 위와 동일 (alias) |
응답 형식 (Python `EdgeConfig` 모델과 호환):
```json
{
"version": "2026-04-17T07:25:26.766Z",
"edge_id": "edge-spifox-001",
"edge_name": "스피폭스 엣지 #1",
"devices": [
{
"id": "1",
"name": "CASE프레스_PLC_01",
"protocol": "plc_ethernet",
"connection": {
"host": "192.168.1.10",
"port": 2004,
"unit_id": 1
},
"interval_ms": 1000,
"enabled": true,
"tags": [
{
"name": "temperature",
"address": "40001",
"data_type": "UINT16",
"byte_order": "BIG_ENDIAN",
"scale": 0.1,
"offset": 0,
"unit": "°C"
}
]
}
],
"aggregation_interval_sec": 60,
"local_retention_days": 7
}
```
## MQTT 토픽 규칙
Python이 발행하는 토픽:
| 토픽 | 페이로드 | 주기 |
|---|---|---|
| `vexplor/devices/{edgeId}/status` | heartbeat (CPU/메모리/디스크) | 30초 |
| `vexplor/devices/{edgeId}/data` | 태그 값 (아래 참조) | interval_ms |
| `vexplor/devices/{edgeId}/responses` | 커맨드 응답 | 요청 시 |
### 데이터 페이로드 예시
```json
{
"timestamp": "2026-04-17T08:00:00.123Z",
"equipment_id": 4,
"connection_id": 1,
"tags": {
"temperature": 25.4,
"pressure": 11.2,
"status": true,
"mode": "AUTO"
}
}
```
Pipeline은 이 데이터를 `fleet_edge_raw_data` 테이블에 자동 적재합니다.
## 로컬 테스트
Pipeline이 로컬에 떠있는 상태에서 테스트 엣지 시뮬레이터:
```bash
# MQTT heartbeat 발송 (자동 등록)
docker exec pipeline-backend node -e "
const mqtt = require('mqtt');
const c = mqtt.connect('mqtt://127.0.0.1:1883');
c.on('connect', () => {
c.publish('vexplor/devices/edge-test-001/status', JSON.stringify({
cpu_percent: 25, memory_percent: 45, disk_percent: 60,
ip_address: '192.168.1.100', status: 'online'
}), { qos: 1 }, () => c.end(true));
});
"
# 설정 조회
curl http://localhost:8080/api/fleet/edge/edge-test-001/config
# 태그 데이터 발송
docker exec pipeline-backend node -e "
const mqtt = require('mqtt');
const c = mqtt.connect('mqtt://127.0.0.1:1883');
c.on('connect', () => {
c.publish('vexplor/devices/edge-test-001/data', JSON.stringify({
timestamp: new Date().toISOString(),
equipment_id: 4,
tags: { temperature: 25.5, pressure: 11.2 }
}), { qos: 1 }, () => c.end(true));
});
"
```
## 포트 정리
로컬 Pipeline이 노출하는 포트:
| 포트 | 용도 |
|---|---|
| `8080` | REST API (Fleet + Pipeline) |
| `1883` | MQTT TCP 브로커 (내장 aedes) |
| `8083` | MQTT WebSocket (브라우저 클라이언트) |
| `9771` | 프론트엔드 |
## 흐름 요약
1. **엣지 부팅**: Python이 Pipeline에 heartbeat 발행 → `fleet_devices`에 자동 등록
2. **설정 조회**: Python이 `/api/fleet/v1/edges/{id}/config` 호출 → 현재 장비/태그 설정 받음
3. **PLC 수집**: 설정된 대로 Modbus/OPC UA/S7 등으로 주기 수집
4. **MQTT 발행**: `vexplor/devices/{id}/data` 로 실시간 값 발행
5. **Pipeline 저장**: MQTT 구독 → `fleet_edge_raw_data` 적재
6. **대시보드 표시**: `/admin/fleet/data` 에서 실시간 차트 + 최신값 조회
## 설정 변경 시 반영
사용자가 **웹에서 태그 설정을 변경**하면:
- `pipeline_tag_mappings` UPDATE
- Python이 다음 config sync 주기(기본 30초) 시 변경 감지
- `version` (ETag) 기반이라 변경 없으면 304 응답 (트래픽 절약)
- Python이 자동으로 새 설정으로 수집 재시작
**Python 재시작 불필요** — 설정은 런타임에 동적 반영됩니다.
+327
View File
@@ -0,0 +1,327 @@
# Fleet Hook - 웹에서 Python 로직 편집 가이드
엣지 Data Collector의 동작을 웹에서 Python 스크립트로 커스터마이징하는 기능입니다.
## 개념
```
┌─ Pipeline 웹 UI ─────────────┐
│ 사용자가 Python 함수 편집 │
│ (Monaco 에디터) │
│ ↓ │
│ [테스트] 버튼으로 미리 검증 │
│ ↓ │
│ [저장] → fleet_edge_scripts │
└──────────────┬───────────────┘
│ /api/fleet/v1/edges/{id}/config
│ (ETag 캐싱)
┌─ 엣지 Data Collector (Python) ┐
│ scripts = config["scripts"] │
│ for script in scripts: │
│ load_hook(script) │
│ │
│ 수집 사이클마다: │
│ ├ raw_value = read_plc() │
│ ├ value = transform(...) │ ← Hook 1
│ ├ tags.update(derived(...)) │ ← Hook 2
│ ├ if not filter_data(...): │ ← Hook 3
│ │ skip │
│ ├ alarm_info = alarm(...) │ ← Hook 4
│ ├ payload = pre_send(...) │ ← Hook 5
│ └ publish_mqtt(payload) │
└───────────────────────────────┘
```
## 5가지 Hook 종류
| Hook | 시점 | 입력 | 출력 | 용도 |
|---|---|---|---|---|
| **transform** | 원시값 변환 | tag_name, raw_value, context | 변환된 값 | 센서 스케일링, 단위 변환 |
| **derived_tags** | 파생 태그 계산 | tags 딕셔너리, context | 새 태그 딕셔너리 | 여러 태그 조합 (전력 = V×I) |
| **filter** | 발행 여부 판단 | tags, context | bool | 조건부 수집 (가동 중만) |
| **alarm** | 알람 판정 | tag_name, value, context | dict 또는 None | 임계값 초과 알람 |
| **pre_send** | MQTT 발행 전 | payload, context | 가공된 payload | 최종 메타데이터 추가 |
## 적용 범위 (scope)
- **global**: 모든 엣지에 적용
- **equipment**: 특정 장비만 (pipeline_equipment)
- **connection**: 특정 통신 연결만 (pipeline_device_connections)
- **device**: 특정 엣지 디바이스만 (fleet_devices)
## Python 엣지 쪽 hook loader 샘플
기존 Data Collector 프로젝트(`/Users/chpark/workspace/data-collector/src/data_collector/`)에 추가할 파일:
### `hooks/hook_loader.py`
```python
"""
Hook Loader - Fleet API에서 받은 Python 스크립트를 로드/실행
"""
import logging
from typing import Any, Callable, Dict, List, Optional
import structlog
logger = structlog.get_logger(__name__)
# 허용된 내장 함수/모듈 (보안)
ALLOWED_BUILTINS = {
'abs', 'all', 'any', 'bool', 'bytes', 'dict', 'enumerate', 'filter',
'float', 'int', 'len', 'list', 'map', 'max', 'min', 'print', 'range',
'round', 'set', 'sorted', 'str', 'sum', 'tuple', 'type', 'zip',
'isinstance', 'hasattr', 'getattr', 'True', 'False', 'None',
}
class HookRegistry:
"""Hook 스크립트 등록 및 실행"""
# hook_type → [(script_id, priority, scope, callable, meta)]
hooks: Dict[str, List[Dict[str, Any]]] = {}
# 스크립트 ID → 컴파일된 함수 캐시
compiled: Dict[int, Dict[str, Callable]] = {}
@classmethod
def load_from_config(cls, scripts: List[Dict[str, Any]]) -> None:
"""
Fleet API에서 받은 스크립트 목록을 로드
각 hook별로 priority 순으로 정렬
"""
cls.hooks = {}
cls.compiled = {}
func_name_map = {
"transform": "transform",
"derived_tags": "derived_tags",
"filter": "filter_data",
"alarm": "alarm",
"pre_send": "pre_send",
}
for script in scripts:
try:
hook_type = script["hook_type"]
func_name = func_name_map.get(hook_type)
if not func_name:
continue
# 제한된 네임스페이스에서 컴파일
import math
from datetime import datetime, date
allowed_globals = {
"__builtins__": {k: __builtins__[k] for k in ALLOWED_BUILTINS if k in dir(__builtins__)},
"math": math,
"datetime": datetime,
"date": date,
}
exec(script["code"], allowed_globals)
func = allowed_globals.get(func_name)
if not callable(func):
logger.warning(f"함수 {func_name}가 정의되지 않음: script id={script['id']}")
continue
cls.hooks.setdefault(hook_type, []).append({
"script_id": script["id"],
"script_name": script.get("script_name", ""),
"scope": script.get("scope", "global"),
"equipment_id": script.get("equipment_id"),
"connection_id": script.get("connection_id"),
"priority": script.get("priority", 100),
"timeout_ms": script.get("timeout_ms", 1000),
"func": func,
})
logger.info(f"Hook 로드: {hook_type} / script_id={script['id']} v{script.get('version', 1)}")
except Exception as e:
logger.error(f"Hook 컴파일 실패 (id={script.get('id')}): {e}")
# 우선순위 정렬
for hooks in cls.hooks.values():
hooks.sort(key=lambda h: h["priority"])
@classmethod
def _match_scope(cls, hook: Dict[str, Any], equipment_id: Optional[int], connection_id: Optional[int]) -> bool:
"""스코프 매칭"""
scope = hook.get("scope", "global")
if scope == "global":
return True
if scope == "equipment" and hook.get("equipment_id") == equipment_id:
return True
if scope == "connection" and hook.get("connection_id") == connection_id:
return True
return False
@classmethod
def run_transform(cls, tag_name: str, raw_value: Any, context: dict) -> Any:
"""transform hook 실행 (파이프라인 - 순차 적용)"""
value = raw_value
for hook in cls.hooks.get("transform", []):
if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
continue
try:
value = hook["func"](tag_name, value, context)
except Exception as e:
logger.warning(f"transform 실패 (script_id={hook['script_id']}): {e}")
return value
@classmethod
def run_derived_tags(cls, tags: dict, context: dict) -> dict:
"""derived_tags hook 실행 (모든 hook 결과 병합)"""
result = {}
for hook in cls.hooks.get("derived_tags", []):
if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
continue
try:
new_tags = hook["func"](tags, context) or {}
if isinstance(new_tags, dict):
result.update(new_tags)
except Exception as e:
logger.warning(f"derived_tags 실패: {e}")
return result
@classmethod
def run_filter(cls, tags: dict, context: dict) -> bool:
"""filter hook 실행 (AND - 모두 True여야 발행)"""
for hook in cls.hooks.get("filter", []):
if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
continue
try:
if not hook["func"](tags, context):
return False
except Exception as e:
logger.warning(f"filter 실패: {e}")
return True
@classmethod
def run_alarm(cls, tag_name: str, value: Any, context: dict) -> List[dict]:
"""alarm hook 실행 (모든 알람 수집)"""
alarms = []
for hook in cls.hooks.get("alarm", []):
if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
continue
try:
alarm_info = hook["func"](tag_name, value, context)
if alarm_info:
alarm_info["script_id"] = hook["script_id"]
alarms.append(alarm_info)
except Exception as e:
logger.warning(f"alarm 실패: {e}")
return alarms
@classmethod
def run_pre_send(cls, payload: dict, context: dict) -> dict:
"""pre_send hook 실행 (순차 적용)"""
result = payload
for hook in cls.hooks.get("pre_send", []):
if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
continue
try:
result = hook["func"](result, context) or result
except Exception as e:
logger.warning(f"pre_send 실패: {e}")
return result
```
### 수집 파이프라인 통합 (`collectors/manager.py`)
```python
# 수집 루프 안에서...
from data_collector.hooks.hook_loader import HookRegistry
async def collect_and_publish(self, device):
raw_data = await self.collector.collect()
context = {
"device_id": self.device_id,
"equipment_id": device.equipment_id,
"connection_id": device.id,
"company_code": self.company_code,
}
# 1. transform 각 태그에 적용
tags = {}
for tag_name, raw_value in raw_data.items():
tags[tag_name] = HookRegistry.run_transform(tag_name, raw_value, context)
# 2. derived_tags 병합
tags.update(HookRegistry.run_derived_tags(tags, context))
# 3. filter 체크
if not HookRegistry.run_filter(tags, context):
logger.debug("filter로 스킵")
return
# 4. alarm 판정
alarms = []
for tag_name, value in tags.items():
alarms.extend(HookRegistry.run_alarm(tag_name, value, context))
if alarms:
# 알람 발행 (MQTT vexplor/devices/{id}/alarms 등)
self.publish_alarms(alarms)
# 5. 최종 payload 가공
payload = {
"timestamp": datetime.now().isoformat(),
"equipment_id": device.equipment_id,
"connection_id": device.id,
"tags": tags,
}
payload = HookRegistry.run_pre_send(payload, context)
# 6. MQTT 발행
self.mqtt.publish(f"vexplor/devices/{self.device_id}/data", payload)
```
### config_syncer에 hook 로드 추가
```python
async def fetch_config(self):
# ... 기존 설정 조회 ...
# Hook 스크립트 로드
if config.get("scripts"):
from data_collector.hooks.hook_loader import HookRegistry
HookRegistry.load_from_config(config["scripts"])
logger.info(f"Hook 스크립트 로드: {len(config['scripts'])}")
```
## 로컬 테스트
Pipeline 웹에서:
1. **시스템 관리 > Python Hook** 메뉴 접근
2. **새 스크립트** → Hook 타입 선택 → 예제 코드 자동 로드
3. 우측 Monaco 에디터에서 편집
4. 좌측 하단 **테스트 입력 JSON** 작성 → **실행** 버튼
5. 결과 확인 후 **저장**
## API 엔드포인트
| 메서드 | 경로 | 용도 |
|---|---|---|
| GET | `/api/fleet/scripts/hook-types` | Hook 타입 5종 + 예제 코드 |
| GET | `/api/fleet/scripts` | 스크립트 목록 |
| POST | `/api/fleet/scripts` | 생성 |
| PUT | `/api/fleet/scripts/:id` | 수정 (자동 버전 증가) |
| DELETE | `/api/fleet/scripts/:id` | 삭제 |
| POST | `/api/fleet/scripts/dry-run` | 저장 전 테스트 실행 |
| GET | `/api/fleet/scripts/:id/versions` | 버전 이력 |
| POST | `/api/fleet/scripts/:id/rollback/:version` | 롤백 |
| GET | `/api/fleet/v1/edges/:id/config` | 엣지용 전체 설정 (scripts 포함) |
## 보안 사항
- Python `exec()` 실행 시 제한된 네임스페이스 (ALLOWED_BUILTINS만)
- `import` 제한 (math, datetime, json만 허용)
- 파일 시스템 / 네트워크 접근 차단
- 각 hook 실행 타임아웃 (기본 1초)
- Dry-run 시 Python 서브프로세스 격리
## 실시간 반영
1. 웹에서 수정 → PUT API 호출
2. DB UPDATE 트리거 → version 증가 + 이력 저장
3. Python이 다음 config sync 주기(기본 30초) 시 새 버전 감지
4. `HookRegistry.load_from_config()` 재실행 → 즉시 적용
5. **Python 재시작 불필요**
@@ -299,8 +299,12 @@ export default function BatchCreatePage() {
null, null,
); );
const [aiInputMessage, setAiInputMessage] = useState(""); const [aiInputMessage, setAiInputMessage] = useState("");
const [aiNotifySystem, setAiNotifySystem] = useState(false); const [aiNotifyMessenger, setAiNotifyMessenger] = useState(false);
const [aiMessengerRecipients, setAiMessengerRecipients] = useState<string[]>([]);
const [aiNotifyEmail, setAiNotifyEmail] = useState(false);
const [aiEmailAddresses, setAiEmailAddresses] = useState("");
const [aiWebhookUrl, setAiWebhookUrl] = useState(""); const [aiWebhookUrl, setAiWebhookUrl] = useState("");
const [companyUsers, setCompanyUsers] = useState<any[]>([]);
// Step 3: Crawling state // Step 3: Crawling state
const [crawlConfigs, setCrawlConfigs] = useState<any[]>([]); const [crawlConfigs, setCrawlConfigs] = useState<any[]>([]);
@@ -670,8 +674,12 @@ export default function BatchCreatePage() {
ai_group_id: selectedAiGroupId, ai_group_id: selectedAiGroupId,
ai_input_message: aiInputMessage || undefined, ai_input_message: aiInputMessage || undefined,
notification: { notification: {
system_notice: aiNotifySystem, messenger: aiNotifyMessenger,
webhook_url: aiWebhookUrl || undefined, messenger_recipients: aiNotifyMessenger ? aiMessengerRecipients : undefined,
email: aiNotifyEmail && aiEmailAddresses
? aiEmailAddresses.split(",").map((e) => e.trim()).filter(Boolean)
: undefined,
webhook: aiWebhookUrl || undefined,
}, },
}, },
}); });
@@ -1532,18 +1540,87 @@ export default function BatchCreatePage() {
{/* Notification settings */} {/* Notification settings */}
<div className="space-y-4 rounded-xl border p-4"> <div className="space-y-4 rounded-xl border p-4">
<h3 className="text-xs font-bold"> </h3> <h3 className="text-xs font-bold"> </h3>
<div className="flex items-center justify-between">
<div> {/* 메신저 */}
<p className="text-xs font-medium"> </p> <div className="space-y-2">
<p className="text-[10px] text-muted-foreground"> <div className="flex items-center justify-between">
<div>
</p> <p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
<Switch
checked={aiNotifyMessenger}
onCheckedChange={async (v) => {
setAiNotifyMessenger(v);
if (v && companyUsers.length === 0) {
try {
const res = await fetch("/api/messenger/users", { credentials: "include" });
const data = await res.json();
setCompanyUsers(data.data || data || []);
} catch {}
}
}}
/>
</div> </div>
<Switch {aiNotifyMessenger && (
checked={aiNotifySystem} <div className="space-y-1.5 pl-1">
onCheckedChange={setAiNotifySystem} <Label className="text-xs"> </Label>
/> <div className="max-h-32 overflow-y-auto rounded-lg border p-2 space-y-1">
{companyUsers.length === 0 ? (
<p className="text-[11px] text-muted-foreground"> ...</p>
) : (
companyUsers.map((u: any) => (
<label key={u.user_id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-2 py-1">
<input
type="checkbox"
checked={aiMessengerRecipients.includes(u.user_id)}
onChange={(e) => {
if (e.target.checked) {
setAiMessengerRecipients([...aiMessengerRecipients, u.user_id]);
} else {
setAiMessengerRecipients(aiMessengerRecipients.filter((id) => id !== u.user_id));
}
}}
/>
<span>{u.user_name} <span className="text-muted-foreground">({u.user_id})</span></span>
</label>
))
)}
</div>
</div>
)}
</div> </div>
{/* 이메일 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
<Switch
checked={aiNotifyEmail}
onCheckedChange={setAiNotifyEmail}
/>
</div>
{aiNotifyEmail && (
<div className="space-y-1.5 pl-1">
<Label className="text-xs"> ( )</Label>
<Input
value={aiEmailAddresses}
onChange={(e) => setAiEmailAddresses(e.target.value)}
placeholder="user1@example.com, user2@example.com"
className="h-9 text-sm"
/>
</div>
)}
</div>
{/* 웹훅 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs"> <Label className="text-xs">
URL{" "} URL{" "}
@@ -739,9 +739,9 @@ export default function BatchEditPage() {
} }
return ( return (
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6"> <div className="mx-auto h-full w-full max-w-[1400px] overflow-y-auto p-4 sm:p-6">
{/* 헤더 */} {/* 헤더 */}
<div> <div className="mb-5">
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"> <button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<ArrowLeft className="h-3.5 w-3.5" /> <ArrowLeft className="h-3.5 w-3.5" />
@@ -767,6 +767,9 @@ export default function BatchEditPage() {
</div> </div>
</div> </div>
{/* 2컬럼 레이아웃: 좌측 = 기본정보/스케줄, 우측 = 실행방식/상세설정 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-7">
{/* 기본 정보 */} {/* 기본 정보 */}
<div> <div>
<h2 className="mb-3 text-sm font-bold"> </h2> <h2 className="mb-3 text-sm font-bold"> </h2>
@@ -884,11 +887,15 @@ export default function BatchEditPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
{/* 우측 컬럼: 실행 방식 + 상세 설정 */}
<div className="space-y-7">
{/* 실행 타입 선택 */} {/* 실행 타입 선택 */}
<div> <div>
<h2 className="mb-3 text-sm font-bold"> </h2> <h2 className="mb-3 text-sm font-bold"> </h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3"> <div className="grid grid-cols-2 gap-3">
{([ {([
{ key: "mapping" as BatchExecutionType, label: "DB to DB", desc: "테이블 간 데이터를 옮겨요", icon: <Database className="h-5 w-5" /> }, { key: "mapping" as BatchExecutionType, label: "DB to DB", desc: "테이블 간 데이터를 옮겨요", icon: <Database className="h-5 w-5" /> },
{ key: "rest_api_sync" as BatchExecutionType, label: "REST API", desc: "외부 API 데이터를 DB에 저장해요", icon: <Globe className="h-5 w-5" /> }, { key: "rest_api_sync" as BatchExecutionType, label: "REST API", desc: "외부 API 데이터를 DB에 저장해요", icon: <Globe className="h-5 w-5" /> },
@@ -979,10 +986,12 @@ export default function BatchEditPage() {
)} )}
</div> </div>
)} )}
</div>
</div>
{/* FROM/TO 섹션 가로 배치 (매핑 타입일 때만) */} {/* FROM/TO 섹션 + 매핑 (전체 너비) */}
{executionType === "mapping" && ( {executionType === "mapping" && (
<> <div className="mt-6 space-y-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* FROM 설정 */} {/* FROM 설정 */}
<div className="space-y-3 rounded-lg border border-emerald-500/20 p-4 sm:p-5"> <div className="space-y-3 rounded-lg border border-emerald-500/20 p-4 sm:p-5">
@@ -1561,8 +1570,8 @@ export default function BatchEditPage() {
)} )}
</div> </div>
{/* restapi-to-db 새로운 매핑 UI */} {/* restapi-to-db 새로운 매핑 UI (batchType이 restapi-to-db이거나, 아직 감지 안됐는데 mappingList 있으면 표시) */}
{batchType === "restapi-to-db" && ( {(batchType === "restapi-to-db" || (!batchType && mappingList.length > 0)) && (
<> <>
{mappingList.length === 0 ? ( {mappingList.length === 0 ? (
<div className="flex h-[360px] flex-col items-center justify-center rounded-lg border border-dashed text-center"> <div className="flex h-[360px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
@@ -1825,11 +1834,11 @@ export default function BatchEditPage() {
</div> </div>
</div> </div>
</div> </div>
</> </div>
)} )}
{/* 하단 버튼 */} {/* 하단 버튼 */}
<div className="flex justify-end gap-2 border-t pt-5"> <div className="mt-6 flex justify-end gap-2 border-t pt-5">
<Button variant="outline" size="sm" onClick={goBack} className="h-9 text-xs"></Button> <Button variant="outline" size="sm" onClick={goBack} className="h-9 text-xs"></Button>
<Button <Button
size="sm" size="sm"
@@ -315,7 +315,6 @@ export default function BatchManagementPage() {
const [stats, setStats] = useState<BatchStats | null>(null); const [stats, setStats] = useState<BatchStats | null>(null);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({}); const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({}); const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null); const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
const loadBatchConfigs = useCallback(async () => { const loadBatchConfigs = useCallback(async () => {
@@ -408,11 +407,6 @@ export default function BatchManagementPage() {
} }
}; };
const handleBatchTypeSelect = (type: string) => {
setIsBatchTypeModalOpen(false);
openTab({ type: "admin", title: "새 배치 생성", adminUrl: "/admin/automaticMng/batchmngList/create" });
};
const filteredBatches = batchConfigs.filter((batch) => { const filteredBatches = batchConfigs.filter((batch) => {
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false; if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false;
if (statusFilter === "active" && batch.is_active !== "Y") return false; if (statusFilter === "active" && batch.is_active !== "Y") return false;
@@ -439,7 +433,7 @@ export default function BatchManagementPage() {
<button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"> <button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button> </button>
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs"> <Button size="sm" onClick={() => openTab({ type: "admin", title: "새 배치 생성", adminUrl: "/admin/automaticMng/batchmngList/create" })} className="h-8 gap-1 text-xs">
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
@@ -613,42 +607,6 @@ export default function BatchManagementPage() {
</div> </div>
)} )}
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<h2 className="mb-1 text-base font-bold"> ?</h2>
<p className="mb-5 text-xs text-muted-foreground"> </p>
<div className="space-y-2">
{[
{ type: "db-to-db", icon: Database, iconColor: "text-cyan-500", title: "DB to DB", desc: "테이블 간 데이터를 옮겨요" },
{ type: "restapi-to-db", icon: Globe, iconColor: "text-violet-500", title: "REST API", desc: "외부 API 데이터를 DB에 저장해요" },
{ type: "device", icon: Cpu, iconColor: "text-purple-500", title: "장비 수집", desc: "PLC/Modbus 장비에서 수집해요" },
{ type: "crawling", icon: Globe, iconColor: "text-rose-500", title: "크롤링", desc: "웹 페이지 데이터를 수집해요" },
{ type: "node-flow", icon: Workflow, iconColor: "text-indigo-500", title: "노드 플로우", desc: "만들어 둔 플로우를 실행해요" },
{ type: "ai-agent", icon: Bot, iconColor: "text-amber-500", title: "AI 에이전트", desc: "멀티 에이전트 그룹을 실행해요" },
].map((opt) => (
<button
key={opt.type}
className="flex w-full items-center gap-3.5 rounded-lg border p-4 text-left transition-all hover:border-primary/30 hover:bg-primary/5"
onClick={() => handleBatchTypeSelect(opt.type)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<opt.icon className={`h-[18px] w-[18px] ${opt.iconColor}`} />
</div>
<div>
<p className="text-sm font-semibold">{opt.title}</p>
<p className="text-[11px] text-muted-foreground">{opt.desc}</p>
</div>
</button>
))}
</div>
<button onClick={() => setIsBatchTypeModalOpen(false)} className="mt-4 w-full rounded-md border py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
</button>
</div>
</div>
)}
</div> </div>
<ScrollToTop /> <ScrollToTop />
</div> </div>
@@ -0,0 +1,415 @@
"use client";
import React, { useEffect, useState } from "react";
import { Plus, Pencil, Trash2, Power, RefreshCw, Radio } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import {
CentralForwarderAPI,
CentralForwarderConfig,
ForwarderRuntimeStatus,
} from "@/lib/api/centralForwarder";
const emptyForm: CentralForwarderConfig = {
config_name: "",
company_code: "*",
company_id: "",
edge_id: "",
broker_host: "211.115.91.170",
broker_port: 31883,
username: "ingestion",
password: "",
use_tls: "N",
client_id_prefix: "pipeline-forwarder",
topic_pattern: "dt/v1/data/{company_id}/{edge_id}",
status_topic_pattern: "dt/v1/status/{company_id}/{edge_id}",
batch_size: 50,
batch_timeout_ms: 3000,
heartbeat_interval_sec: 60,
qos: 1,
is_enabled: "N",
description: "",
};
export default function CentralForwarderPage() {
const { toast } = useToast();
const [configs, setConfigs] = useState<CentralForwarderConfig[]>([]);
const [runtime, setRuntime] = useState<ForwarderRuntimeStatus[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<CentralForwarderConfig>({ ...emptyForm });
const [editingId, setEditingId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const load = async () => {
setLoading(true);
try {
const [list, rt] = await Promise.all([
CentralForwarderAPI.list(),
CentralForwarderAPI.runtimeStatus(),
]);
setConfigs(list);
setRuntime(rt);
} catch (err) {
toast({
title: "조회 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const t = setInterval(load, 10000);
return () => clearInterval(t);
}, []);
const rtMap = new Map(runtime.map(r => [r.config_id, r]));
const openCreate = () => {
setEditingId(null);
setForm({ ...emptyForm });
setModalOpen(true);
};
const openEdit = async (id: number) => {
try {
const cfg = await CentralForwarderAPI.get(id);
setEditingId(id);
setForm({ ...cfg, password: "" }); // 비밀번호는 비움 (필요 시 새로 입력)
setModalOpen(true);
} catch (err) {
toast({
title: "조회 실패",
description: (err as Error).message,
variant: "destructive",
});
}
};
const save = async () => {
setSaving(true);
try {
if (editingId) {
const payload = { ...form };
if (!payload.password) delete (payload as { password?: string }).password;
await CentralForwarderAPI.update(editingId, payload);
} else {
await CentralForwarderAPI.create(form);
}
toast({ title: "저장 완료" });
setModalOpen(false);
await load();
} catch (err) {
toast({
title: "저장 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setSaving(false);
}
};
const toggle = async (id: number, enabled: boolean) => {
try {
await CentralForwarderAPI.toggle(id, enabled);
toast({ title: enabled ? "포워더 시작" : "포워더 중지" });
await load();
} catch (err) {
toast({
title: "상태 변경 실패",
description: (err as Error).message,
variant: "destructive",
});
}
};
const remove = async (id: number) => {
if (!confirm("이 포워더 설정을 삭제하시겠습니까?")) return;
try {
await CentralForwarderAPI.delete(id);
toast({ title: "삭제 완료" });
await load();
} catch (err) {
toast({
title: "삭제 실패",
description: (err as Error).message,
variant: "destructive",
});
}
};
return (
<div className="h-full overflow-y-auto bg-background">
<div className="space-y-4 p-4 sm:p-5">
<div className="flex items-center justify-between border-b pb-3">
<div>
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<Radio className="h-4 w-4" />
MQTT
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
IDC EMQX로 (Pipeline = Edge )
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={load}>
<RefreshCw className="h-3 w-3" />
</Button>
<Button size="sm" className="h-8 gap-1 text-xs" onClick={openCreate}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{loading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-44 animate-pulse rounded-lg border bg-muted/30" />
))}
</div>
) : configs.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-lg border border-dashed">
<Radio className="mb-2 h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{configs.map(cfg => {
const rt = rtMap.get(cfg.id!);
const enabled = cfg.is_enabled === "Y";
return (
<div key={cfg.id} className="flex flex-col rounded-lg border bg-card p-3.5">
<div className="mb-2 flex items-center justify-between">
<Badge variant={enabled ? "default" : "secondary"} className="h-5 text-[10px]">
{enabled ? "활성" : "비활성"}
</Badge>
{rt && (
<Badge
variant={rt.connected ? "default" : "destructive"}
className="h-5 text-[10px]"
>
{rt.connected ? "연결됨" : "연결끊김"}
</Badge>
)}
</div>
<h3 className="mb-0.5 truncate text-xs font-semibold">{cfg.config_name}</h3>
<p className="mb-2 truncate text-[11px] text-muted-foreground">
{cfg.company_code === "*" ? "공통" : cfg.company_code}
<span className="mx-1">·</span>
{cfg.edge_id}
</p>
<div className="mb-3 space-y-0.5 rounded-md bg-muted/50 px-2 py-1.5">
<p className="truncate font-mono text-[10px] text-muted-foreground">
{cfg.broker_host}:{cfg.broker_port}
</p>
<p className="truncate font-mono text-[10px]">{cfg.topic_pattern}</p>
{rt && (
<p className="text-[10px] text-muted-foreground">
{rt.messagesForwarded} · {rt.messagesFailed} · {rt.buffered}
</p>
)}
</div>
<div className="mt-auto flex items-center gap-1">
<Button
variant={enabled ? "secondary" : "default"}
size="sm"
onClick={() => toggle(cfg.id!, !enabled)}
className="h-6 flex-1 gap-1 text-[10px]"
>
<Power className="h-3 w-3" />
{enabled ? "중지" : "시작"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openEdit(cfg.id!)}
className="h-6 px-2"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => remove(cfg.id!)}
className="h-6 px-2 text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[640px]">
<DialogHeader>
<DialogTitle className="text-sm">
{editingId ? "포워더 설정 수정" : "새 포워더 설정"}
</DialogTitle>
<DialogDescription className="text-xs">
IDC MQTT(EMQX) .
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field
label="설정명"
value={form.config_name}
onChange={v => setForm({ ...form, config_name: v })}
/>
<Field
label="회사 코드"
value={form.company_code || "*"}
onChange={v => setForm({ ...form, company_code: v })}
/>
<Field
label="Company ID (MQTT 토픽)"
value={form.company_id}
onChange={v => setForm({ ...form, company_id: v })}
placeholder="예: 7f5c058c-ef65-45e3-..."
/>
<Field
label="Edge ID"
value={form.edge_id}
onChange={v => setForm({ ...form, edge_id: v })}
placeholder="예: edge-0f4d04ed"
/>
<Field
label="Broker Host"
value={form.broker_host}
onChange={v => setForm({ ...form, broker_host: v })}
/>
<Field
type="number"
label="Broker Port"
value={String(form.broker_port)}
onChange={v => setForm({ ...form, broker_port: Number(v) })}
/>
<Field
label="Username"
value={form.username || ""}
onChange={v => setForm({ ...form, username: v })}
/>
<Field
type="password"
label={editingId ? "Password (변경 시 입력)" : "Password"}
value={form.password || ""}
onChange={v => setForm({ ...form, password: v })}
/>
<Field
label="토픽 패턴"
value={form.topic_pattern || ""}
onChange={v => setForm({ ...form, topic_pattern: v })}
className="sm:col-span-2"
/>
<Field
type="number"
label="배치 크기"
value={String(form.batch_size || 50)}
onChange={v => setForm({ ...form, batch_size: Number(v) })}
/>
<Field
type="number"
label="배치 타임아웃 (ms)"
value={String(form.batch_timeout_ms || 3000)}
onChange={v => setForm({ ...form, batch_timeout_ms: Number(v) })}
/>
<Field
type="number"
label="하트비트 (초)"
value={String(form.heartbeat_interval_sec || 60)}
onChange={v => setForm({ ...form, heartbeat_interval_sec: Number(v) })}
/>
<Field
type="number"
label="QoS (0/1/2)"
value={String(form.qos ?? 1)}
onChange={v => setForm({ ...form, qos: Number(v) })}
/>
<div className="flex items-center gap-2 sm:col-span-2">
<Switch
checked={form.is_enabled === "Y"}
onCheckedChange={c => setForm({ ...form, is_enabled: c ? "Y" : "N" })}
/>
<Label className="text-xs"> ( )</Label>
</div>
<div className="sm:col-span-2">
<Label className="text-xs"></Label>
<Input
className="mt-1 h-8 text-xs"
value={form.description || ""}
onChange={e => setForm({ ...form, description: e.target.value })}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setModalOpen(false)}
className="h-8 text-xs"
>
</Button>
<Button size="sm" onClick={save} disabled={saving} className="h-8 text-xs">
{saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function Field({
label,
value,
onChange,
type = "text",
placeholder,
className,
}: {
label: string;
value: string;
onChange: (v: string) => void;
type?: string;
placeholder?: string;
className?: string;
}) {
return (
<div className={className}>
<Label className="text-xs">{label}</Label>
<Input
className="mt-1 h-8 text-xs"
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
/>
</div>
);
}
@@ -0,0 +1,402 @@
"use client";
import { useEffect, useState } from "react";
import {
Clock,
Cpu,
Radio,
Activity,
CheckCircle2,
XCircle,
RefreshCw,
Zap,
Database as DatabaseIcon,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import {
AutomationDashboardAPI,
DashboardOverview,
} from "@/lib/api/automationDashboard";
function timeAgo(iso: string | null | undefined): string {
if (!iso) return "—";
const diff = Date.now() - new Date(iso).getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}초 전`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}시간 전`;
return `${Math.floor(hr / 24)}일 전`;
}
function cronToKo(c: string): string {
if (!c) return "—";
const p = c.split(" ");
if (p.length < 5) return c;
const [m, h] = p;
if (m.startsWith("*/")) return `${m.slice(2)}분마다`;
if (h.startsWith("*/")) return `${h.slice(2)}시간마다`;
if (h !== "*" && m !== "*") return `매일 ${h.padStart(2, "0")}:${m.padStart(2, "0")}`;
return c;
}
export default function AutomationDashboardPage() {
const { toast } = useToast();
const [data, setData] = useState<DashboardOverview | null>(null);
const [loading, setLoading] = useState(true);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const load = async () => {
setLoading(true);
try {
const d = await AutomationDashboardAPI.overview();
setData(d);
setLastRefresh(new Date());
} catch (err) {
toast({
title: "조회 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, []);
if (loading && !data) {
return (
<div className="flex h-full items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (!data) return null;
const { stats, batches, pollings, forwarders } = data;
return (
<div className="h-full overflow-y-auto bg-background">
<div className="space-y-5 p-4 sm:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between border-b pb-3">
<div>
<h1 className="flex items-center gap-2 text-lg font-bold">
<Activity className="h-4 w-4" />
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
+ + IDC (15 )
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">
{lastRefresh
? `마지막: ${lastRefresh.toLocaleTimeString("ko-KR")}`
: ""}
</span>
<Button
size="sm"
variant="outline"
onClick={load}
className="h-8 gap-1 text-xs"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard
icon={<Clock className="h-4 w-4 text-blue-600" />}
label="크론 배치"
value={`${stats.batches_active} / ${stats.batches_total}`}
sublabel="활성 / 전체"
/>
<StatCard
icon={<Cpu className="h-4 w-4 text-emerald-600" />}
label="장비 폴링"
value={`${stats.pollings_active} / ${stats.pollings_total}`}
sublabel={`연결됨 ${stats.pollings_connected}`}
/>
<StatCard
icon={<Zap className="h-4 w-4 text-amber-600" />}
label="수집 태그"
value={String(stats.total_tags)}
sublabel="전체 등록"
/>
<StatCard
icon={<Radio className="h-4 w-4 text-purple-600" />}
label="IDC 전송 누적"
value={stats.messages_forwarded_total.toLocaleString()}
sublabel={`포워더 ${stats.forwarders_enabled}/${stats.forwarders_total}`}
/>
</div>
{/* 3열 (데스크탑) / 세로 (모바일) */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* 크론 배치 */}
<Section title="크론 배치 (배치 관리)" icon={<Clock className="h-4 w-4" />} href="/admin/automaticMng/batchmngList">
{batches.length === 0 ? (
<Empty msg="등록된 배치 없음" />
) : (
<div className="space-y-1 overflow-hidden rounded-md border">
{batches.slice(0, 8).map((b) => (
<div
key={b.id}
className="flex items-center gap-2 border-b px-2 py-1.5 text-[11px] last:border-b-0 hover:bg-muted/40"
>
<Badge
variant={b.is_active === "Y" || b.is_active === true ? "default" : "secondary"}
className="h-4 text-[9px]"
>
{b.is_active === "Y" || b.is_active === true ? "ON" : "OFF"}
</Badge>
<span className="flex-1 truncate font-medium" title={b.batch_name}>
{b.batch_name}
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{cronToKo(b.cron_schedule)}
</span>
<span className="text-[10px] text-muted-foreground">
{timeAgo(b.last_run_date)}
</span>
{b.last_run_result === "success" ? (
<CheckCircle2 className="h-3 w-3 text-emerald-600" />
) : b.last_run_result === "failure" ? (
<XCircle className="h-3 w-3 text-destructive" />
) : null}
</div>
))}
{batches.length > 8 && (
<div className="px-2 py-1 text-center text-[10px] text-muted-foreground">
+ {batches.length - 8}
</div>
)}
</div>
)}
</Section>
{/* 장비 폴링 */}
<Section title="장비 실시간 폴링 (장비 통신)" icon={<Cpu className="h-4 w-4" />} href="/admin/pipeline-device">
{pollings.length === 0 ? (
<Empty msg="등록된 장비 통신 없음" />
) : (
<div className="space-y-1 overflow-hidden rounded-md border">
{pollings.slice(0, 10).map((p) => {
const active = p.is_active === "Y";
const connected = p.status === "active";
const hasTarget = p.target_db_connection_id !== null && p.target_table_name;
return (
<div
key={p.id}
className="flex items-center gap-2 border-b px-2 py-1.5 text-[11px] last:border-b-0 hover:bg-muted/40"
>
<Badge
variant={active ? (connected ? "default" : "secondary") : "outline"}
className="h-4 text-[9px]"
>
{active ? (connected ? "정상" : "대기") : "OFF"}
</Badge>
<Badge variant="outline" className="h-4 text-[9px]">
{p.protocol}
</Badge>
<span className="flex-1 truncate font-medium" title={p.connection_name}>
{p.connection_name}
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{p.polling_interval_ms}ms · {p.tag_count}
</span>
{hasTarget && (
<DatabaseIcon className="h-3 w-3 text-blue-500" aria-label="DB 저장 설정됨" />
)}
<span className="text-[10px] text-muted-foreground">
{timeAgo(p.last_collected_at)}
</span>
</div>
);
})}
{pollings.length > 10 && (
<div className="px-2 py-1 text-center text-[10px] text-muted-foreground">
+ {pollings.length - 10}
</div>
)}
</div>
)}
</Section>
{/* IDC 포워더 */}
<Section title="IDC MQTT 포워더" icon={<Radio className="h-4 w-4" />} href="/admin/automaticMng/centralForwarder" className="lg:col-span-2">
{forwarders.length === 0 ? (
<Empty msg="등록된 포워더 없음" />
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{forwarders.map((f) => {
const enabled = f.is_enabled === "Y";
const connected = f.is_connected === "Y";
return (
<div
key={f.id}
className="rounded-md border p-2.5 text-[11px]"
>
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Badge
variant={enabled ? "default" : "secondary"}
className="h-4 text-[9px]"
>
{enabled ? "활성" : "비활성"}
</Badge>
{enabled && (
<Badge
variant={connected ? "default" : "destructive"}
className="h-4 text-[9px]"
>
{connected ? "연결됨" : "끊김"}
</Badge>
)}
<span className="ml-1 truncate font-semibold">
{f.config_name}
</span>
</div>
</div>
<div className="truncate font-mono text-[10px] text-muted-foreground">
{f.broker_host}:{f.broker_port} · edge={f.edge_id}
</div>
<div className="mt-1 truncate font-mono text-[10px] text-muted-foreground">
{f.topic_pattern}
</div>
<div className="mt-2 grid grid-cols-4 gap-1 text-[10px]">
<Stat
label="전송"
value={(f.messages_forwarded || 0).toLocaleString()}
color="text-emerald-600"
/>
<Stat
label="실패"
value={(f.messages_failed || 0).toLocaleString()}
color="text-amber-600"
/>
<Stat
label="유실"
value={(f.messages_dropped || 0).toLocaleString()}
color="text-destructive"
/>
<Stat
label="배치"
value={(f.batches_sent || 0).toLocaleString()}
/>
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
: {timeAgo(f.last_published_at)}
{f.last_error && (
<span className="ml-1 text-destructive">
· <AlertTriangle className="inline h-3 w-3" /> {f.last_error.slice(0, 40)}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</Section>
</div>
</div>
</div>
);
}
function StatCard({
icon,
label,
value,
sublabel,
}: {
icon: React.ReactNode;
label: string;
value: string;
sublabel?: string;
}) {
return (
<div className="rounded-lg border bg-card p-3">
<div className="mb-1 flex items-center gap-1.5">
{icon}
<span className="text-[11px] font-medium text-muted-foreground">{label}</span>
</div>
<div className="text-xl font-bold">{value}</div>
{sublabel && <div className="text-[10px] text-muted-foreground">{sublabel}</div>}
</div>
);
}
function Section({
title,
icon,
children,
href,
className = "",
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
href?: string;
className?: string;
}) {
return (
<div className={`rounded-lg border bg-card p-3 ${className}`}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1.5">
{icon}
<h2 className="text-xs font-semibold">{title}</h2>
</div>
{href && (
<a
href={href}
className="text-[10px] text-blue-600 hover:underline"
>
</a>
)}
</div>
{children}
</div>
);
}
function Empty({ msg }: { msg: string }) {
return (
<div className="flex h-20 items-center justify-center rounded-md border border-dashed text-[11px] text-muted-foreground">
{msg}
</div>
);
}
function Stat({
label,
value,
color,
}: {
label: string;
value: string;
color?: string;
}) {
return (
<div className="rounded bg-muted/40 px-1.5 py-0.5 text-center">
<div className="text-[9px] text-muted-foreground">{label}</div>
<div className={`font-mono font-semibold ${color || ""}`}>{value}</div>
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import React, { useEffect, useState } from "react";
import { RefreshCw, Cpu, Activity, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import {
EquipmentStateAPI,
ConnectionStatusSummary,
EquipmentTagState,
} from "@/lib/api/equipmentState";
export default function EquipmentStatePage() {
const { toast } = useToast();
const [summary, setSummary] = useState<ConnectionStatusSummary[]>([]);
const [expanded, setExpanded] = useState<Record<number, EquipmentTagState[]>>({});
const [loadingId, setLoadingId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const load = async () => {
setLoading(true);
try {
const data = await EquipmentStateAPI.summary();
setSummary(data);
} catch (err) {
toast({
title: "조회 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, []);
const toggleExpand = async (connectionId: number) => {
if (expanded[connectionId]) {
const next = { ...expanded };
delete next[connectionId];
setExpanded(next);
return;
}
setLoadingId(connectionId);
try {
const tags = await EquipmentStateAPI.tagsByConnection(connectionId);
setExpanded(prev => ({ ...prev, [connectionId]: tags }));
} catch (err) {
toast({
title: "태그 조회 실패",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setLoadingId(null);
}
};
const filtered = summary.filter(
s =>
!search ||
s.connection_name?.toLowerCase().includes(search.toLowerCase()) ||
s.host?.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="h-full overflow-y-auto bg-background">
<div className="space-y-4 p-4 sm:p-5">
<div className="flex items-center justify-between border-b pb-3">
<div>
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<Activity className="h-4 w-4" />
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
(15 )
</p>
</div>
<div className="flex items-center gap-2">
<Input
className="h-8 w-48 text-xs"
placeholder="장비명/호스트 검색..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={load}>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-lg border bg-muted/30" />
))}
</div>
) : filtered.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-lg border border-dashed">
<Cpu className="mb-2 h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{filtered.map(s => {
const isOpen = !!expanded[s.connection_id];
const isHealthy = s.connection_status === "active" || s.connection_status === "connected";
return (
<div key={s.connection_id} className="rounded-lg border bg-card">
<button
onClick={() => toggleExpand(s.connection_id)}
className="flex w-full items-center gap-3 p-3 text-left hover:bg-muted/30"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Badge
variant={isHealthy ? "default" : "destructive"}
className="h-5 text-[10px]"
>
{s.connection_status || "unknown"}
</Badge>
<span className="text-xs font-semibold">{s.connection_name}</span>
<Badge variant="secondary" className="h-5 text-[10px]">
{s.protocol}
</Badge>
<span className="text-[11px] text-muted-foreground">
{s.host}:{s.port}
</span>
<div className="ml-auto flex items-center gap-3 text-[11px]">
<span>
<span className="font-medium">{s.tag_count}</span>
</span>
<span className="text-emerald-600">
<span className="font-medium">{s.good_tag_count}</span>
</span>
<span className="text-muted-foreground">
:{" "}
{s.last_collected_at
? new Date(s.last_collected_at).toLocaleString()
: "—"}
</span>
</div>
</button>
{isOpen && (
<div className="border-t bg-muted/10 p-3">
{loadingId === s.connection_id ? (
<p className="text-[11px] text-muted-foreground"> ...</p>
) : (expanded[s.connection_id] || []).length === 0 ? (
<p className="text-[11px] text-muted-foreground"> </p>
) : (
<table className="w-full text-[11px]">
<thead className="text-muted-foreground">
<tr className="border-b">
<th className="p-1 text-left font-normal">Tag</th>
<th className="p-1 text-right font-normal">Value</th>
<th className="p-1 text-left font-normal">Unit</th>
<th className="p-1 text-left font-normal">Quality</th>
<th className="p-1 text-left font-normal">Last Collected</th>
</tr>
</thead>
<tbody>
{(expanded[s.connection_id] || []).map(t => {
const v =
t.value_numeric ??
(t.value_boolean !== null
? String(t.value_boolean)
: t.value_text) ??
"—";
return (
<tr key={t.id} className="border-b last:border-b-0">
<td className="p-1 font-mono">{t.tag_name}</td>
<td className="p-1 text-right font-mono">{String(v)}</td>
<td className="p-1">{t.tag_unit || ""}</td>
<td className="p-1">
<Badge
variant={t.quality === "good" ? "default" : "destructive"}
className="h-4 text-[9px]"
>
{t.quality}
</Badge>
</td>
<td className="p-1 text-muted-foreground">
{new Date(t.last_collected_at).toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe, Cpu, FileText, Bug } from "lucide-react"; import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe, Cpu, FileText } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -31,7 +31,7 @@ import dynamic from "next/dynamic";
const PipelineDevicePage = dynamic(() => import("@/app/(main)/admin/pipeline-device/page"), { ssr: false }); const PipelineDevicePage = dynamic(() => import("@/app/(main)/admin/pipeline-device/page"), { ssr: false });
type ConnectionTabType = "database" | "rest-api" | "device" | "file-reader" | "crawling"; type ConnectionTabType = "database" | "rest-api" | "device" | "file-reader";
// DB 타입 매핑 // DB 타입 매핑
const DB_TYPE_LABELS: Record<string, string> = { const DB_TYPE_LABELS: Record<string, string> = {
@@ -200,16 +200,12 @@ export default function DataSourcePage() {
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="device" className="gap-1.5 px-3 text-xs"> <TabsTrigger value="device" className="gap-1.5 px-3 text-xs">
<Cpu className="h-3 w-3" /> <Cpu className="h-3 w-3" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="file-reader" className="gap-1.5 px-3 text-xs"> <TabsTrigger value="file-reader" className="gap-1.5 px-3 text-xs">
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="crawling" className="gap-1.5 px-3 text-xs">
<Bug className="h-3 w-3" />
</TabsTrigger>
</TabsList> </TabsList>
{/* 데이터베이스 탭 */} {/* 데이터베이스 탭 */}
@@ -396,11 +392,6 @@ export default function DataSourcePage() {
<TabsContent value="file-reader" className="mt-4"> <TabsContent value="file-reader" className="mt-4">
<ComingSoon icon={FileText} title="파일 리더" desc="CSV, TXT, Excel 등 파일 데이터 연결 기능이 준비 중입니다" /> <ComingSoon icon={FileText} title="파일 리더" desc="CSV, TXT, Excel 등 파일 데이터 연결 기능이 준비 중입니다" />
</TabsContent> </TabsContent>
{/* 크롤링 탭 */}
<TabsContent value="crawling" className="mt-4">
<ComingSoon icon={Bug} title="크롤링" desc="웹 크롤링 데이터 수집 기능이 준비 중입니다" />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
<ScrollToTop /> <ScrollToTop />
@@ -0,0 +1,158 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi, FleetAlert } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RefreshCw, AlertTriangle, CheckCircle2, Circle, Loader2 } from "lucide-react";
import { toast } from "sonner";
const SEVERITY_COLORS: Record<string, string> = {
info: "bg-blue-500/10 text-blue-600 border-blue-500/20",
warning: "bg-amber-500/10 text-amber-600 border-amber-500/20",
critical: "bg-red-500/10 text-red-600 border-red-500/20",
};
export default function FleetAlertsPage() {
const [alerts, setAlerts] = useState<FleetAlert[]>([]);
const [rules, setRules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<"open" | "acknowledged" | "resolved">("open");
const load = useCallback(async () => {
setLoading(true);
try {
const [a, r] = await Promise.all([
fleetApi.getAlerts(statusFilter),
fleetApi.getAlertRules(),
]);
setAlerts(a.data || []);
setRules(r.data || []);
} catch { toast.error("알림 조회 실패"); }
setLoading(false);
}, [statusFilter]);
useEffect(() => {
load();
const t = setInterval(load, 30000);
return () => clearInterval(t);
}, [load]);
const ackAlert = async (id: number) => {
try { await fleetApi.ackAlert(id); toast.success("확인 처리"); load(); }
catch { toast.error("실패"); }
};
const resolveAlert = async (id: number) => {
try { await fleetApi.resolveAlert(id); toast.success("해결 처리"); load(); }
catch { toast.error("실패"); }
};
return (
<div className="flex h-full flex-col bg-background">
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Fleet </h1>
<p className="text-sm text-muted-foreground">
(CPU///)
</p>
</div>
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 필터 */}
<div className="mt-4 flex items-center gap-2">
<Select value={statusFilter} onValueChange={(v: any) => setStatusFilter(v)}>
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="open">🔴 </SelectItem>
<SelectItem value="acknowledged">🟡 </SelectItem>
<SelectItem value="resolved"> </SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground"> {alerts.length}</div>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : alerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<CheckCircle2 className="h-10 w-10 mb-3 text-green-500" />
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-2">
{alerts.map((a) => (
<div
key={a.id}
className={`rounded-lg border p-4 ${SEVERITY_COLORS[a.severity] || ""}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<AlertTriangle className="h-5 w-5 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold">{a.title}</p>
<Badge variant="outline" className="text-[10px]">
{a.severity.toUpperCase()}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">{a.message}</p>
<div className="flex items-center gap-3 mt-2 text-[10px] text-muted-foreground">
<span>📱 {a.device_id}</span>
<span>📏 {a.metric} = {a.value} ( {a.threshold})</span>
<span>🕒 {new Date(a.created_at).toLocaleString("ko-KR")}</span>
</div>
</div>
</div>
{a.status === "open" && (
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => ackAlert(a.id)}>
</Button>
<Button size="sm" onClick={() => resolveAlert(a.id)}>
</Button>
</div>
)}
{a.status === "acknowledged" && (
<Button size="sm" onClick={() => resolveAlert(a.id)}>
</Button>
)}
</div>
</div>
))}
</div>
)}
{/* 규칙 */}
<div className="mt-8">
<h2 className="text-sm font-bold mb-3"> ({rules.length})</h2>
<div className="space-y-1">
{rules.map((r) => (
<div key={r.id} className="flex items-center justify-between rounded-md border p-3 text-xs">
<div>
<span className="font-medium">{r.rule_name}</span>
<span className="text-muted-foreground ml-2">
{r.metric} {r.operator} {r.threshold}
</span>
</div>
<Badge variant="outline">{r.severity}</Badge>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ShieldCheck, RefreshCw, Loader2, Search } from "lucide-react";
import { toast } from "sonner";
export default function FleetAuditPage() {
const [logs, setLogs] = useState<any[]>([]);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<any>({ event_type: "", result: "", search: "" });
const load = useCallback(async () => {
setLoading(true);
try {
const f: any = { limit: 200 };
if (filter.event_type) f.event_type = filter.event_type;
if (filter.result) f.result = filter.result;
const [r, s] = await Promise.all([fleetApi.getAuditLogs(f), fleetApi.getAuditStats()]);
let data = r.data || [];
if (filter.search) {
const q = filter.search.toLowerCase();
data = data.filter((l: any) =>
(l.target_id || "").toLowerCase().includes(q) ||
(l.actor_id || "").toLowerCase().includes(q) ||
(l.action || "").toLowerCase().includes(q),
);
}
setLogs(data);
setStats(s.data);
} catch { toast.error("조회 실패"); }
setLoading(false);
}, [filter]);
useEffect(() => { load(); }, [load]);
return (
<div className="flex h-full flex-col bg-background">
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2"><ShieldCheck className="h-5 w-5" /> </h1>
<p className="text-sm text-muted-foreground"> Fleet ( · )</p>
</div>
<Button variant="outline" size="sm" onClick={load}><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
</div>
{/* 통계 */}
{stats && (
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"> </div>
<div className="text-lg font-bold">{(stats.byEvent || []).length}</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"></div>
<div className="text-lg font-bold">{(stats.byActor || []).length}</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"></div>
<div className="text-lg font-bold text-red-500">{stats.failures || 0}</div>
</div>
</div>
)}
{/* 필터 */}
<div className="mt-4 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="검색" value={filter.search} onChange={(e) => setFilter({...filter, search: e.target.value})} className="pl-9" />
</div>
<Select value={filter.event_type || "all"} onValueChange={(v) => setFilter({...filter, event_type: v === "all" ? "" : v})}>
<SelectTrigger className="w-40"><SelectValue placeholder="이벤트 타입" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="device_register"> </SelectItem>
<SelectItem value="command_issue"> </SelectItem>
<SelectItem value="deploy"></SelectItem>
<SelectItem value="script_edit"> </SelectItem>
<SelectItem value="alert_ack"> </SelectItem>
</SelectContent>
</Select>
<Select value={filter.result || "all"} onValueChange={(v) => setFilter({...filter, result: v === "all" ? "" : v})}>
<SelectTrigger className="w-32"><SelectValue placeholder="결과" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="success"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? <Loader2 className="mx-auto my-20 animate-spin" /> :
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={6} className="text-center py-10 text-muted-foreground"> </td></tr>
) : logs.map((l) => (
<tr key={l.id} className="border-t hover:bg-muted/30">
<td className="p-2 whitespace-nowrap">{new Date(l.created_at).toLocaleString("ko-KR")}</td>
<td className="p-2"><Badge variant="outline" className="text-[10px]">{l.event_type}</Badge></td>
<td className="p-2">{l.actor_name || l.actor_id || "-"}</td>
<td className="p-2 font-mono">{l.target_type ? `${l.target_type}#${l.target_id}` : "-"}</td>
<td className="p-2">{l.action}</td>
<td className="p-2">
{l.result === "success"
? <Badge className="bg-green-500/10 text-green-600 text-[10px]"></Badge>
: <Badge className="bg-red-500/10 text-red-600 text-[10px]" title={l.error_message}></Badge>
}
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi, FleetCommand } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RefreshCw, Terminal, Loader2 } from "lucide-react";
import { toast } from "sonner";
const STATUS_COLORS: Record<string, string> = {
pending: "bg-gray-500/10 text-gray-600",
sent: "bg-blue-500/10 text-blue-600",
executing: "bg-amber-500/10 text-amber-600",
success: "bg-green-500/10 text-green-600",
failed: "bg-red-500/10 text-red-600",
timeout: "bg-orange-500/10 text-orange-600",
};
export default function FleetCommandsPage() {
const [commands, setCommands] = useState<FleetCommand[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const r = await fleetApi.getCommands({ limit: 100 });
setCommands(r.data || []);
} catch { toast.error("커맨드 이력 조회 실패"); }
setLoading(false);
}, []);
useEffect(() => {
load();
const t = setInterval(load, 10000);
return () => clearInterval(t);
}, [load]);
return (
<div className="flex h-full flex-col bg-background">
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Fleet </h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : commands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<Terminal className="h-10 w-10 mb-3" />
<p className="text-sm"> </p>
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">ID</th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"> </th>
<th className="text-left p-3 font-medium"></th>
</tr>
</thead>
<tbody>
{commands.map((c) => (
<tr key={c.id} className="border-t hover:bg-muted/30">
<td className="p-3 font-mono text-xs">#{c.id}</td>
<td className="p-3 font-mono text-xs">{c.device_id}</td>
<td className="p-3">{c.command_type}</td>
<td className="p-3">
<Badge
variant="outline"
className={`${STATUS_COLORS[c.status || ""]} text-[10px]`}
>
{c.status}
</Badge>
</td>
<td className="p-3 text-xs text-muted-foreground">{c.issued_by || "-"}</td>
<td className="p-3 text-xs text-muted-foreground">
{c.issued_at ? new Date(c.issued_at).toLocaleString("ko-KR") : "-"}
</td>
<td className="p-3 text-xs text-muted-foreground">
{c.error_message ? (
<span className="text-red-600">{c.error_message}</span>
) : c.responded_at ? (
new Date(c.responded_at).toLocaleString("ko-KR")
) : (
"-"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,263 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi, FleetDevice } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { RefreshCw, Activity, TrendingUp, Loader2, Database, Wifi } from "lucide-react";
import { toast } from "sonner";
export default function FleetDataPage() {
const [devices, setDevices] = useState<FleetDevice[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string>("");
const [latestValues, setLatestValues] = useState<any[]>([]);
const [selectedTag, setSelectedTag] = useState<string>("");
const [timeseries, setTimeseries] = useState<any[]>([]);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [chartLoading, setChartLoading] = useState(false);
// 디바이스 목록 + 수집 통계
const loadDevices = useCallback(async () => {
try {
const [dev, st] = await Promise.all([
fleetApi.getDevices({ is_online: true } as any),
fleetApi.getDataStats(),
]);
const online = (dev.data || []).filter((d: any) => d.is_online);
setDevices(online);
setStats(st.data);
if (online.length > 0 && !selectedDevice) {
setSelectedDevice(online[0].device_id);
}
setLoading(false);
} catch { toast.error("디바이스 조회 실패"); setLoading(false); }
}, [selectedDevice]);
const loadLatestValues = useCallback(async () => {
if (!selectedDevice) return;
try {
const r = await fleetApi.getLatestValues(selectedDevice);
setLatestValues(r.data || []);
if (r.data?.length > 0 && !selectedTag) {
const firstNumeric = r.data.find((v: any) => v.value !== null);
if (firstNumeric) setSelectedTag(firstNumeric.tag_name);
}
} catch { /* ignore */ }
}, [selectedDevice, selectedTag]);
const loadTimeseries = useCallback(async () => {
if (!selectedDevice || !selectedTag) return;
setChartLoading(true);
try {
const r = await fleetApi.getTagTimeseries(selectedDevice, selectedTag, 200);
setTimeseries((r.data || []).reverse()); // 시간 오름차순
} catch { /* ignore */ }
setChartLoading(false);
}, [selectedDevice, selectedTag]);
useEffect(() => { loadDevices(); }, [loadDevices]);
useEffect(() => { loadLatestValues(); }, [loadLatestValues]);
useEffect(() => { loadTimeseries(); }, [loadTimeseries]);
// 3초마다 실시간 갱신
useEffect(() => {
const t = setInterval(() => {
loadLatestValues();
loadTimeseries();
}, 3000);
return () => clearInterval(t);
}, [loadLatestValues, loadTimeseries]);
// 간단한 SVG 차트 (라이브러리 없이)
const renderChart = () => {
if (timeseries.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
</div>
);
}
const values = timeseries.map((p) => p.value).filter((v) => v !== null);
if (values.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
(text: {timeseries[timeseries.length - 1]?.value_text})
</div>
);
}
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const w = 800;
const h = 280;
const pad = 30;
const points = timeseries
.map((p, i) => {
if (p.value === null) return null;
const x = pad + (i * (w - pad * 2)) / Math.max(timeseries.length - 1, 1);
const y = h - pad - ((p.value - min) / range) * (h - pad * 2);
return `${x},${y}`;
})
.filter(Boolean)
.join(" ");
return (
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} className="rounded">
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="#e5e7eb" />
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="#e5e7eb" />
<text x={pad - 5} y={pad + 5} fontSize="10" textAnchor="end" fill="#6b7280">
{max.toFixed(2)}
</text>
<text x={pad - 5} y={h - pad} fontSize="10" textAnchor="end" fill="#6b7280">
{min.toFixed(2)}
</text>
<polyline points={points} fill="none" stroke="#3b82f6" strokeWidth="2" />
{timeseries.map((p, i) => {
if (p.value === null) return null;
const x = pad + (i * (w - pad * 2)) / Math.max(timeseries.length - 1, 1);
const y = h - pad - ((p.value - min) / range) * (h - pad * 2);
return <circle key={i} cx={x} cy={y} r="2" fill="#3b82f6" />;
})}
</svg>
);
};
return (
<div className="flex h-full flex-col bg-background">
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"> </h1>
<p className="text-sm text-muted-foreground">
Data Collector에서 PLC/ (3 )
</p>
</div>
<Button size="sm" variant="outline" onClick={loadDevices} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 통계 */}
{stats && (
<div className="mt-4 grid grid-cols-3 gap-3">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-4 w-4" /> 24
</div>
<p className="mt-1 text-2xl font-bold">
{parseInt(stats.total_records || 0).toLocaleString()}
</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Wifi className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold">{stats.device_count || 0}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Activity className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold">{stats.tag_count || 0}</p>
</div>
</div>
)}
{/* 디바이스 선택 */}
<div className="mt-4">
<Select value={selectedDevice} onValueChange={setSelectedDevice}>
<SelectTrigger className="w-full md:w-96">
<SelectValue placeholder="온라인 디바이스를 선택하세요" />
</SelectTrigger>
<SelectContent>
{devices.map((d) => (
<SelectItem key={d.device_id} value={d.device_id}>
{d.device_name || d.device_id} ({d.equipment_name || "장비 미연결"})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6 grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* 좌측: 태그 목록 */}
<div className="lg:col-span-1 space-y-2">
<h2 className="text-sm font-bold"> ({latestValues.length})</h2>
{latestValues.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-1 max-h-[calc(100vh-400px)] overflow-y-auto">
{latestValues.map((v) => (
<button
key={v.tag_name}
onClick={() => setSelectedTag(v.tag_name)}
className={`w-full text-left rounded-md border p-3 transition-colors ${
selectedTag === v.tag_name
? "bg-primary/10 border-primary"
: "hover:bg-muted/50"
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{v.tag_name}</span>
<Badge variant="outline" className="text-[10px]">
{v.quality || "good"}
</Badge>
</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-lg font-bold font-mono">
{v.value !== null
? typeof v.value === "number"
? v.value.toFixed(2)
: v.value
: v.value_text}
</span>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
{new Date(v.time).toLocaleString("ko-KR")}
</p>
</button>
))}
</div>
)}
</div>
{/* 우측: 차트 */}
<div className="lg:col-span-2 rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-bold flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
{selectedTag && `- ${selectedTag}`}
</h2>
<p className="text-[10px] text-muted-foreground">
200 · 3
</p>
</div>
{chartLoading && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
<div className="h-[280px]">{renderChart()}</div>
{timeseries.length > 0 && (
<div className="mt-3 text-[11px] text-muted-foreground grid grid-cols-3 gap-2">
<div>: {timeseries[timeseries.length - 1]?.value?.toFixed(2) || "-"}</div>
<div>
: {Math.min(...timeseries.filter((p) => p.value !== null).map((p) => p.value)).toFixed(2)}
</div>
<div>
: {Math.max(...timeseries.filter((p) => p.value !== null).map((p) => p.value)).toFixed(2)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,262 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Rocket, RefreshCw, Play, Square, Undo2, Loader2, Package } from "lucide-react";
import { toast } from "sonner";
const STATUS_COLOR: Record<string, string> = {
pending: "bg-gray-500/10 text-gray-600",
running: "bg-blue-500/10 text-blue-600",
paused: "bg-amber-500/10 text-amber-600",
completed: "bg-green-500/10 text-green-600",
failed: "bg-red-500/10 text-red-600",
cancelled: "bg-gray-500/10 text-gray-400",
rolled_back: "bg-orange-500/10 text-orange-600",
};
export default function FleetDeploymentsPage() {
const [list, setList] = useState<any[]>([]);
const [releases, setReleases] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<any>({
release_id: "",
target_type: "all",
target_value: "",
rollout_strategy: "rolling",
batch_size: 10,
max_failures: 3,
description: "",
});
const [statusOpen, setStatusOpen] = useState<{id?: number; open: boolean}>({open: false});
const [statusList, setStatusList] = useState<any[]>([]);
const load = useCallback(async () => {
setLoading(true);
try {
const [d, r] = await Promise.all([fleetApi.getDeployments(), fleetApi.getReleases()]);
setList(d.data || []);
setReleases((r.data || []).filter((x: any) => x.status === "ready" || x.status === "released"));
} catch { toast.error("조회 실패"); }
setLoading(false);
}, []);
useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t); }, [load]);
const handleCreate = async () => {
if (!form.release_id) { toast.error("릴리즈 선택 필요"); return; }
try {
await fleetApi.createDeployment({ ...form, release_id: parseInt(form.release_id) });
toast.success("생성 완료");
setCreateOpen(false);
load();
} catch (e: any) { toast.error(e.response?.data?.message || "실패"); }
};
const start = async (id: number) => { try { await fleetApi.startDeployment(id); toast.success("시작"); load(); } catch (e: any) { toast.error(e.response?.data?.message || "실패"); } };
const cancel = async (id: number) => { if (!confirm("취소하시겠습니까?")) return; try { await fleetApi.cancelDeployment(id); toast.success("취소"); load(); } catch (e: any) { toast.error(e.response?.data?.message || "실패"); } };
const rollback = async (id: number) => { if (!confirm("롤백하시겠습니까?")) return; try { await fleetApi.rollbackDeployment(id); toast.success("롤백 시작"); load(); } catch (e: any) { toast.error(e.response?.data?.message || "실패"); } };
const showStatus = async (id: number) => {
try {
const r = await fleetApi.getDeploymentStatus(id);
setStatusList(r.data || []);
setStatusOpen({id, open: true});
} catch { toast.error("조회 실패"); }
};
return (
<div className="flex h-full flex-col bg-background">
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Rocket className="h-5 w-5" />
</h1>
<p className="text-sm text-muted-foreground">
· / ·
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)} className="gap-1">
<Rocket className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : list.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<Rocket className="h-10 w-10 mb-3" />
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-2">
{list.map((d) => (
<div key={d.id} className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs">#{d.id}</span>
<span className="font-semibold">{d.release_version || "릴리즈 미지정"}</span>
<Badge className={STATUS_COLOR[d.status]}>{d.status}</Badge>
<Badge variant="outline" className="text-[10px]">{d.rollout_strategy}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
: {d.target_type}{d.target_value ? ` (${d.target_value})` : ""} · {d.total_devices}
</p>
<p className="text-[11px] text-muted-foreground">{d.description}</p>
<div className="flex gap-4 mt-2 text-[11px]">
<span className="text-green-600"> {d.success_count || 0}</span>
<span className="text-red-500"> {d.failed_count || 0}</span>
<span className="text-muted-foreground">
{d.created_at && new Date(d.created_at).toLocaleString("ko-KR")}
</span>
</div>
</div>
<div className="flex gap-1 ml-2">
<Button size="sm" variant="outline" onClick={() => showStatus(d.id)}></Button>
{["pending", "paused"].includes(d.status) && (
<Button size="sm" onClick={() => start(d.id)} className="gap-1">
<Play className="h-3 w-3" />
</Button>
)}
{["running", "pending"].includes(d.status) && (
<Button size="sm" variant="outline" onClick={() => cancel(d.id)} className="gap-1">
<Square className="h-3 w-3" />
</Button>
)}
{["completed", "failed", "paused"].includes(d.status) && (
<Button size="sm" variant="outline" onClick={() => rollback(d.id)} className="gap-1">
<Undo2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 생성 모달 */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle></DialogHeader>
<div className="space-y-3 pt-2">
<div>
<Label className="text-xs"> *</Label>
<Select value={form.release_id?.toString()} onValueChange={(v) => setForm({...form, release_id: v})}>
<SelectTrigger className="mt-1"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{releases.map((r) => (
<SelectItem key={r.id} value={r.id.toString()}>
v{r.version} ({r.release_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Select value={form.target_type} onValueChange={(v) => setForm({...form, target_type: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="company"></SelectItem>
<SelectItem value="group"></SelectItem>
<SelectItem value="device_list"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={form.rollout_strategy} onValueChange={(v) => setForm({...form, rollout_strategy: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate"></SelectItem>
<SelectItem value="rolling"></SelectItem>
<SelectItem value="canary"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{form.target_type !== "all" && (
<div>
<Label className="text-xs"> </Label>
<Input
value={form.target_value}
onChange={(e) => setForm({...form, target_value: e.target.value})}
placeholder={
form.target_type === "company" ? "예: spifox" :
form.target_type === "group" ? "예: production" :
"device_id1,device_id2,..."
}
className="mt-1"
/>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input type="number" value={form.batch_size} onChange={(e) => setForm({...form, batch_size: parseInt(e.target.value) || 10})} className="mt-1" />
</div>
<div>
<Label className="text-xs"> </Label>
<Input type="number" value={form.max_failures} onChange={(e) => setForm({...form, max_failures: parseInt(e.target.value) || 3})} className="mt-1" />
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Textarea value={form.description} onChange={(e) => setForm({...form, description: e.target.value})} rows={2} className="mt-1" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setCreateOpen(false)}></Button>
<Button onClick={handleCreate}></Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 상태 모달 */}
<Dialog open={statusOpen.open} onOpenChange={(open) => setStatusOpen({open})}>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle> #{statusOpen.id} - </DialogTitle></DialogHeader>
<div className="max-h-[60vh] overflow-y-auto space-y-1 pt-2">
{statusList.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4"> </p>
) : (
statusList.map((s) => (
<div key={s.id} className="rounded-md border p-2 text-xs flex items-center justify-between">
<div>
<span className="font-mono">{s.device_id}</span>
{s.device_name && <span className="text-muted-foreground ml-2">{s.device_name}</span>}
</div>
<div className="flex items-center gap-2">
<Badge className={STATUS_COLOR[s.status]}>{s.status}</Badge>
{s.error_message && <span className="text-red-500 text-[10px]">{s.error_message}</span>}
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,300 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { fleetApi, FleetDevice } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
RefreshCw, Search, Wifi, WifiOff, Cpu, HardDrive, MemoryStick,
Terminal, Trash2, Send, Loader2, Circle, Activity,
} from "lucide-react";
import { toast } from "sonner";
export default function FleetDevicesPage() {
const [devices, setDevices] = useState<FleetDevice[]>([]);
const [commandTypes, setCommandTypes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [filterOnline, setFilterOnline] = useState<"all" | "online" | "offline">("all");
const [commandModalOpen, setCommandModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<FleetDevice | null>(null);
const [commandForm, setCommandForm] = useState({
command_type: "health_check",
payload_text: "{}",
});
const [sending, setSending] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [dev, types] = await Promise.all([
fleetApi.getDevices(),
fleetApi.getCommandTypes(),
]);
setDevices(dev.data || []);
setCommandTypes(types.data || []);
} catch { toast.error("디바이스 목록 조회 실패"); }
setLoading(false);
}, []);
useEffect(() => {
load();
// 30초마다 자동 갱신
const t = setInterval(load, 30000);
return () => clearInterval(t);
}, [load]);
const filteredDevices = useMemo(() => {
return devices.filter((d) => {
if (filterOnline === "online" && !d.is_online) return false;
if (filterOnline === "offline" && d.is_online) return false;
if (searchTerm) {
const q = searchTerm.toLowerCase();
return (
(d.device_id || "").toLowerCase().includes(q) ||
(d.device_name || "").toLowerCase().includes(q) ||
(d.ip_address || "").toLowerCase().includes(q) ||
(d.equipment_name || "").toLowerCase().includes(q)
);
}
return true;
});
}, [devices, filterOnline, searchTerm]);
const openCommandModal = (device: FleetDevice) => {
setSelectedDevice(device);
setCommandForm({ command_type: "health_check", payload_text: "{}" });
setCommandModalOpen(true);
};
const sendCommand = async () => {
if (!selectedDevice) return;
let payload: any = {};
try { payload = JSON.parse(commandForm.payload_text || "{}"); }
catch { toast.error("Payload JSON 형식이 올바르지 않습니다."); return; }
setSending(true);
try {
await fleetApi.issueCommand({
device_id: selectedDevice.device_id,
command_type: commandForm.command_type,
payload,
});
toast.success(`커맨드 발행 완료: ${commandForm.command_type}`);
setCommandModalOpen(false);
} catch (e: any) {
toast.error(e.response?.data?.message || "커맨드 발행 실패");
}
setSending(false);
};
const deleteDevice = async (deviceId: string) => {
if (!confirm(`'${deviceId}' 디바이스를 삭제하시겠습니까?`)) return;
try {
await fleetApi.deleteDevice(deviceId);
toast.success("삭제 완료");
load();
} catch { toast.error("삭제 실패"); }
};
const onlineCount = devices.filter((d) => d.is_online).length;
const offlineCount = devices.length - onlineCount;
return (
<div className="flex h-full flex-col bg-background">
{/* 헤더 */}
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"> </h1>
<p className="text-sm text-muted-foreground">
Fleet
</p>
</div>
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 통계 */}
<div className="mt-4 grid grid-cols-3 gap-3">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Activity className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold">{devices.length}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-green-600">
<Wifi className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold text-green-600">{onlineCount}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-red-500">
<WifiOff className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold text-red-500">{offlineCount}</p>
</div>
</div>
{/* 검색 + 필터 */}
<div className="mt-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="디바이스 ID, 이름, IP, 장비로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterOnline} onValueChange={(v: any) => setFilterOnline(v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="online"></SelectItem>
<SelectItem value="offline"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 카드 그리드 */}
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : filteredDevices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<WifiOff className="h-10 w-10 mb-3" />
<p className="text-sm"> </p>
<p className="text-xs mt-1"> MQTT로 </p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredDevices.map((d) => (
<div
key={d.device_id}
className="rounded-xl border bg-card p-4 hover:shadow-md transition-shadow"
>
{/* 상단 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Circle
className={`h-3 w-3 ${d.is_online ? "fill-green-500 text-green-500" : "fill-gray-400 text-gray-400"}`}
/>
<Badge variant="outline" className="text-[10px]">
{d.device_type || "edge"}
</Badge>
</div>
<span className={`text-[10px] ${d.is_online ? "text-green-600" : "text-gray-400"}`}>
{d.is_online ? "온라인" : "오프라인"}
</span>
</div>
{/* 디바이스 정보 */}
<div className="mb-3">
<p className="text-sm font-semibold truncate" title={d.device_id}>
{d.device_name || d.device_id}
</p>
<p className="text-[11px] text-muted-foreground font-mono truncate">{d.device_id}</p>
{d.equipment_name && (
<p className="text-[11px] text-muted-foreground mt-1">
🔗 {d.equipment_name}
</p>
)}
</div>
{/* 상세 정보 */}
<div className="space-y-1 text-[11px] text-muted-foreground">
{d.ip_address && <div>📍 {d.ip_address}</div>}
{d.last_seen_at && (
<div> {new Date(d.last_seen_at).toLocaleString("ko-KR")}</div>
)}
{d.agent_version && <div> v{d.agent_version}</div>}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1 mt-3 pt-3 border-t">
<Button
variant="ghost"
size="sm"
className="h-8 flex-1 gap-1"
onClick={() => openCommandModal(d)}
disabled={!d.is_online}
>
<Terminal className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => deleteDevice(d.device_id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 커맨드 모달 */}
<Dialog open={commandModalOpen} onOpenChange={setCommandModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> - {selectedDevice?.device_id}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={commandForm.command_type}
onValueChange={(v) => setCommandForm({ ...commandForm, command_type: v })}
>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{commandTypes.map((t) => (
<SelectItem key={t.command_type} value={t.command_type}>
{t.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
{commandTypes.find((t) => t.command_type === commandForm.command_type)?.description}
</p>
</div>
<div>
<Label className="text-xs">Payload (JSON)</Label>
<Textarea
value={commandForm.payload_text}
onChange={(e) => setCommandForm({ ...commandForm, payload_text: e.target.value })}
placeholder='{"container_name": "data-collector"}'
rows={5}
className="mt-1 font-mono text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCommandModalOpen(false)}></Button>
<Button onClick={sendCommand} disabled={sending} className="gap-1">
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,199 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Package, Plus, Pencil, Trash2, RefreshCw, Loader2, Container } from "lucide-react";
import { toast } from "sonner";
export default function FleetReleasesPage() {
const [list, setList] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const [repos, setRepos] = useState<any[]>([]);
const [tags, setTags] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState<any>({
version: "",
release_type: "minor",
backend_image: "",
frontend_image: "",
agent_image: "",
changelog: "",
is_canary: false,
status: "draft",
});
const load = useCallback(async () => {
setLoading(true);
try {
const r = await fleetApi.getReleases();
setList(r.data || []);
} catch { toast.error("조회 실패"); }
setLoading(false);
}, []);
useEffect(() => {
load();
fleetApi.getHarborProjects().then(r => setProjects(r.data || [])).catch(() => {});
}, [load]);
const open = (r?: any) => {
setEditing(r || null);
setForm(r ? {
version: r.version,
release_type: r.release_type,
backend_image: r.backend_image || "",
frontend_image: r.frontend_image || "",
agent_image: r.agent_image || "",
changelog: r.changelog || "",
is_canary: r.is_canary,
status: r.status,
} : {
version: "", release_type: "minor",
backend_image: "", frontend_image: "", agent_image: "",
changelog: "", is_canary: false, status: "draft",
});
setModalOpen(true);
};
const save = async () => {
try {
if (editing) await fleetApi.updateRelease(editing.id, form);
else await fleetApi.createRelease(form);
toast.success("저장 완료");
setModalOpen(false);
load();
} catch (e: any) { toast.error(e.response?.data?.message || "저장 실패"); }
};
const del = async (id: number) => {
if (!confirm("삭제?")) return;
try { await fleetApi.deleteRelease(id); toast.success("삭제"); load(); } catch { toast.error("실패"); }
};
const transition = async (id: number, status: string) => {
try { await fleetApi.transitionRelease(id, status); toast.success(`${status} 전환`); load(); }
catch (e: any) { toast.error(e.response?.data?.message || "실패"); }
};
return (
<div className="flex h-full flex-col bg-background">
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2"><Package className="h-5 w-5" /> </h1>
<p className="text-sm text-muted-foreground">Harbor </p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={load}><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
<Button size="sm" onClick={() => open()} className="gap-1"><Plus className="h-4 w-4" /> </Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : list.length === 0 ? (
<div className="py-20 text-center text-muted-foreground text-sm rounded-xl border border-dashed">
<Package className="mx-auto h-10 w-10 mb-2" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{list.map((r) => (
<div key={r.id} className="rounded-xl border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<div>
<span className="text-lg font-bold">v{r.version}</span>
<Badge variant="outline" className="ml-2 text-[10px]">{r.release_type}</Badge>
{r.is_canary && <Badge className="ml-1 text-[10px] bg-amber-500/10 text-amber-600">Canary</Badge>}
</div>
<Badge className={
r.status === "released" ? "bg-green-500/10 text-green-600" :
r.status === "ready" ? "bg-blue-500/10 text-blue-600" :
r.status === "deprecated" ? "bg-gray-500/10 text-gray-500" :
"bg-amber-500/10 text-amber-600"
}>{r.status}</Badge>
</div>
{r.backend_image && <p className="text-[10px] text-muted-foreground font-mono truncate" title={r.backend_image}>BE: {r.backend_image.split("/").pop()}</p>}
{r.frontend_image && <p className="text-[10px] text-muted-foreground font-mono truncate" title={r.frontend_image}>FE: {r.frontend_image.split("/").pop()}</p>}
{r.agent_image && <p className="text-[10px] text-muted-foreground font-mono truncate" title={r.agent_image}>AGT: {r.agent_image.split("/").pop()}</p>}
<p className="text-[11px] text-muted-foreground mt-1"> {r.deploy_count || 0}</p>
{r.changelog && <p className="text-[11px] mt-2 line-clamp-2">{r.changelog}</p>}
<div className="flex gap-1 mt-3 pt-2 border-t">
{r.status === "draft" && (
<Button size="sm" variant="outline" className="flex-1" onClick={() => transition(r.id, "ready")}>Ready</Button>
)}
{r.status === "ready" && (
<Button size="sm" className="flex-1" onClick={() => transition(r.id, "released")}>Release</Button>
)}
<Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={() => open(r)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 text-destructive" onClick={() => del(r.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
))}
</div>
)}
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>{editing ? "릴리즈 수정" : "새 릴리즈"}</DialogTitle></DialogHeader>
<div className="space-y-3 pt-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> *</Label>
<Input value={form.version} onChange={(e) => setForm({...form, version: e.target.value})} placeholder="1.2.3" className="mt-1" />
</div>
<div>
<Label className="text-xs"></Label>
<Select value={form.release_type} onValueChange={(v) => setForm({...form, release_type: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="major">major</SelectItem>
<SelectItem value="minor">minor</SelectItem>
<SelectItem value="patch">patch</SelectItem>
<SelectItem value="hotfix">hotfix</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs">Backend </Label>
<Input value={form.backend_image} onChange={(e) => setForm({...form, backend_image: e.target.value})} placeholder="harbor.wace.me/vexplor_fleet/data-collector:v1.2.3" className="mt-1 font-mono text-xs" />
</div>
<div>
<Label className="text-xs">Frontend </Label>
<Input value={form.frontend_image} onChange={(e) => setForm({...form, frontend_image: e.target.value})} className="mt-1 font-mono text-xs" />
</div>
<div>
<Label className="text-xs">Agent </Label>
<Input value={form.agent_image} onChange={(e) => setForm({...form, agent_image: e.target.value})} className="mt-1 font-mono text-xs" />
</div>
<div>
<Label className="text-xs">Changelog</Label>
<Textarea value={form.changelog} onChange={(e) => setForm({...form, changelog: e.target.value})} rows={3} className="mt-1" />
</div>
<div className="flex items-center justify-between rounded-lg border p-2">
<Label className="text-xs">Canary </Label>
<Switch checked={form.is_canary} onCheckedChange={(v) => setForm({...form, is_canary: v})} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={save}>{editing ? "수정" : "생성"}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Bell, Plus, Pencil, Trash2, RefreshCw, Loader2 } from "lucide-react";
import { toast } from "sonner";
export default function FleetRulesPage() {
const [rules, setRules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState<any>({
rule_name: "", description: "",
metric: "cpu_percent", operator: ">", threshold: 80,
duration_sec: 60, severity: "warning",
enabled: true, notify_channels: [],
});
const load = useCallback(async () => {
setLoading(true);
try { const r = await fleetApi.getAlertRules(); setRules(r.data || []); }
catch { toast.error("조회 실패"); }
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const open = (r?: any) => {
setEditing(r || null);
setForm(r ? {
rule_name: r.rule_name, description: r.description || "",
metric: r.metric, operator: r.operator, threshold: r.threshold,
duration_sec: r.duration_sec, severity: r.severity,
enabled: r.enabled,
notify_channels: r.notify_channels || [],
} : {
rule_name: "", description: "",
metric: "cpu_percent", operator: ">", threshold: 80,
duration_sec: 60, severity: "warning",
enabled: true, notify_channels: [],
});
setModalOpen(true);
};
const save = async () => {
try {
if (editing) await fleetApi.updateAlertRule(editing.id, form);
else await fleetApi.createAlertRule(form);
toast.success("저장");
setModalOpen(false);
load();
} catch (e: any) { toast.error(e.response?.data?.message || "실패"); }
};
const del = async (id: number) => { if (!confirm("삭제?")) return; try { await fleetApi.deleteAlertRule(id); load(); } catch { toast.error("실패"); } };
const toggle = async (id: number) => { try { await fleetApi.toggleAlertRule(id); load(); } catch { toast.error("실패"); } };
const toggleChannel = (ch: string) => {
const chs = form.notify_channels.includes(ch) ? form.notify_channels.filter((c: string) => c !== ch) : [...form.notify_channels, ch];
setForm({ ...form, notify_channels: chs });
};
return (
<div className="flex h-full flex-col bg-background">
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2"><Bell className="h-5 w-5" /> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={load}><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
<Button size="sm" onClick={() => open()}><Plus className="h-4 w-4 mr-1" /> </Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6 space-y-2">
{loading ? <Loader2 className="mx-auto my-20 animate-spin" /> :
rules.map((r) => (
<div key={r.id} className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{r.rule_name}</span>
<Badge className={
r.severity === "critical" ? "bg-red-500/10 text-red-600" :
r.severity === "warning" ? "bg-amber-500/10 text-amber-600" :
"bg-blue-500/10 text-blue-600"
}>{r.severity}</Badge>
<span className="text-[10px] text-muted-foreground">#{r.id}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
<code>{r.metric} {r.operator} {r.threshold}</code> · {r.duration_sec}
</p>
{r.description && <p className="text-[11px] mt-1">{r.description}</p>}
<div className="flex gap-2 mt-2">
{(r.notify_channels || []).map((ch: string) => (
<Badge key={ch} variant="outline" className="text-[10px]">{ch}</Badge>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1">
{r.alert_count || 0} · {r.open_count || 0}
</p>
</div>
<div className="flex gap-1 items-center">
<Switch checked={r.enabled} onCheckedChange={() => toggle(r.id)} />
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => open(r)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => del(r.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>
))
}
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>{editing ? "규칙 수정" : "새 규칙"}</DialogTitle></DialogHeader>
<div className="space-y-3 pt-2">
<div>
<Label className="text-xs"> *</Label>
<Input value={form.rule_name} onChange={(e) => setForm({...form, rule_name: e.target.value})} className="mt-1" />
</div>
<div>
<Label className="text-xs"></Label>
<Textarea value={form.description} onChange={(e) => setForm({...form, description: e.target.value})} rows={2} className="mt-1" />
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"></Label>
<Select value={form.metric} onValueChange={(v) => setForm({...form, metric: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="cpu_percent">CPU %</SelectItem>
<SelectItem value="memory_percent"> %</SelectItem>
<SelectItem value="disk_percent"> %</SelectItem>
<SelectItem value="offline_duration"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={form.operator} onValueChange={(v) => setForm({...form, operator: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="==">==</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Input type="number" value={form.threshold} onChange={(e) => setForm({...form, threshold: parseFloat(e.target.value)})} className="mt-1" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> ()</Label>
<Input type="number" value={form.duration_sec} onChange={(e) => setForm({...form, duration_sec: parseInt(e.target.value)})} className="mt-1" />
</div>
<div>
<Label className="text-xs"></Label>
<Select value={form.severity} onValueChange={(v) => setForm({...form, severity: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="info">info</SelectItem>
<SelectItem value="warning">warning</SelectItem>
<SelectItem value="critical">critical</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<div className="flex gap-2 mt-1">
{["email", "messenger", "webhook", "sms"].map((ch) => (
<label key={ch} className="flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" checked={form.notify_channels.includes(ch)} onChange={() => toggleChannel(ch)} />
{ch}
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={save}>{editing ? "수정" : "생성"}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,475 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import dynamic from "next/dynamic";
import { fleetApi } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Plus, Save, Play, Trash2, Pencil, RefreshCw, Code2, Loader2,
History, RotateCcw, CheckCircle2, XCircle,
} from "lucide-react";
import { toast } from "sonner";
// Monaco Editor는 SSR 안됨
const MonacoEditor = dynamic(() => import("@monaco-editor/react").then((m) => m.default), {
ssr: false,
loading: () => <div className="flex items-center justify-center h-full"><Loader2 className="h-5 w-5 animate-spin" /></div>,
});
const SCOPE_LABELS: Record<string, string> = {
global: "전체 엣지",
equipment: "특정 장비",
connection: "특정 연결",
device: "특정 디바이스",
};
const HOOK_COLORS: Record<string, string> = {
transform: "bg-blue-500/10 text-blue-600 border-blue-500/30",
derived_tags: "bg-purple-500/10 text-purple-600 border-purple-500/30",
filter: "bg-amber-500/10 text-amber-600 border-amber-500/30",
alarm: "bg-red-500/10 text-red-600 border-red-500/30",
pre_send: "bg-green-500/10 text-green-600 border-green-500/30",
};
export default function FleetScriptsPage() {
const [scripts, setScripts] = useState<any[]>([]);
const [hookTypes, setHookTypes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [versionsModal, setVersionsModal] = useState<{open: boolean; scriptId?: number}>({open: false});
const [versions, setVersions] = useState<any[]>([]);
const [form, setForm] = useState<any>({
script_name: "",
description: "",
scope: "global",
hook_type: "transform",
code: "",
enabled: true,
priority: 100,
timeout_ms: 1000,
});
const [testInput, setTestInput] = useState<string>("{}");
const [testResult, setTestResult] = useState<any>(null);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [s, h] = await Promise.all([fleetApi.listScripts(), fleetApi.getHookTypes()]);
setScripts(s.data || []);
setHookTypes(h.data || []);
} catch { toast.error("스크립트 조회 실패"); }
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
const defaultHook = hookTypes[0];
setEditing(null);
setForm({
script_name: "",
description: "",
scope: "global",
hook_type: defaultHook?.hook_type || "transform",
code: defaultHook?.example_code || "",
enabled: true,
priority: 100,
timeout_ms: 1000,
});
setTestInput(getDefaultTestInput(defaultHook?.hook_type || "transform"));
setTestResult(null);
setModalOpen(true);
};
const openEdit = (script: any) => {
setEditing(script);
setForm({
script_name: script.script_name,
description: script.description || "",
scope: script.scope,
hook_type: script.hook_type,
code: script.code,
enabled: script.enabled,
priority: script.priority,
timeout_ms: script.timeout_ms,
equipment_id: script.equipment_id,
connection_id: script.connection_id,
device_id: script.device_id,
});
setTestInput(getDefaultTestInput(script.hook_type));
setTestResult(null);
setModalOpen(true);
};
const getDefaultTestInput = (hookType: string): string => {
switch (hookType) {
case "transform": return JSON.stringify({ tag_name: "temperature", raw_value: 800, context: {} }, null, 2);
case "derived_tags": return JSON.stringify({ tags: { voltage: 220, current: 5 }, context: {} }, null, 2);
case "filter": return JSON.stringify({ tags: { running: true, temp: 25 }, context: {} }, null, 2);
case "alarm": return JSON.stringify({ tag_name: "temperature", value: 95, context: {} }, null, 2);
case "pre_send": return JSON.stringify({ payload: { tags: { temp: 25 } }, context: {} }, null, 2);
default: return "{}";
}
};
const handleHookChange = (hookType: string) => {
const hook = hookTypes.find((h) => h.hook_type === hookType);
setForm({ ...form, hook_type: hookType, code: form.code || hook?.example_code || "" });
setTestInput(getDefaultTestInput(hookType));
setTestResult(null);
};
const handleTestRun = async () => {
setTesting(true);
setTestResult(null);
try {
let parsed = {};
try { parsed = JSON.parse(testInput); } catch (e: any) {
toast.error("테스트 입력 JSON 파싱 실패: " + e.message);
setTesting(false);
return;
}
const r = await fleetApi.dryRunScript(form.code, form.hook_type, parsed, form.timeout_ms);
setTestResult(r);
if (r.success) toast.success(`실행 성공 (${r.duration_ms}ms)`);
else toast.error("실행 실패 - 결과 패널 참조");
} catch (e: any) {
toast.error(e.response?.data?.message || e.message);
}
setTesting(false);
};
const handleSave = async () => {
if (!form.script_name || !form.code) {
toast.error("이름과 코드를 입력하세요."); return;
}
setSaving(true);
try {
if (editing) {
await fleetApi.updateScript(editing.id, form);
toast.success("수정 완료");
} else {
await fleetApi.createScript(form);
toast.success("생성 완료");
}
setModalOpen(false);
load();
} catch (e: any) {
toast.error(e.response?.data?.message || "저장 실패");
}
setSaving(false);
};
const handleDelete = async (id: number) => {
if (!confirm("스크립트를 삭제하시겠습니까? 버전 이력도 함께 삭제됩니다.")) return;
try { await fleetApi.deleteScript(id); toast.success("삭제 완료"); load(); }
catch { toast.error("삭제 실패"); }
};
const toggleEnabled = async (script: any) => {
try {
await fleetApi.updateScript(script.id, { enabled: !script.enabled });
load();
} catch { toast.error("상태 변경 실패"); }
};
const openVersions = async (scriptId: number) => {
try {
const r = await fleetApi.getScriptVersions(scriptId);
setVersions(r.data || []);
setVersionsModal({open: true, scriptId});
} catch { toast.error("버전 조회 실패"); }
};
const rollback = async (scriptId: number, version: number) => {
if (!confirm(`v${version}으로 롤백하시겠습니까?`)) return;
try {
await fleetApi.rollbackScript(scriptId, version);
toast.success(`v${version}으로 롤백 완료`);
setVersionsModal({open: false});
load();
} catch (e: any) { toast.error(e.response?.data?.message || "롤백 실패"); }
};
return (
<div className="flex h-full flex-col bg-background">
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Code2 className="h-5 w-5" /> Python Hook
</h1>
<p className="text-sm text-muted-foreground">
Data Collector가 Python ( /)
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" onClick={openCreate} className="gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Hook 타입 설명 */}
<div className="shrink-0 px-6 pb-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{hookTypes.map((h) => (
<div
key={h.hook_type}
className={`rounded-md border p-2 text-[11px] ${HOOK_COLORS[h.hook_type] || ""}`}
>
<div className="font-semibold">{h.display_name}</div>
<div className="text-[10px] opacity-80 mt-0.5 line-clamp-2">{h.description}</div>
</div>
))}
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : scripts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<Code2 className="h-10 w-10 mb-3" />
<p className="text-sm"> Python Hook이 </p>
<Button variant="link" onClick={openCreate} className="mt-2"> </Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{scripts.map((s) => (
<div key={s.id} className="rounded-xl border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<Badge className={HOOK_COLORS[s.hook_type]}>{s.hook_type}</Badge>
<Switch checked={s.enabled} onCheckedChange={() => toggleEnabled(s)} />
</div>
<p className="text-sm font-semibold truncate">{s.script_name}</p>
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
{s.description || "설명 없음"}
</p>
<div className="mt-2 text-[10px] text-muted-foreground space-y-0.5">
<div>📍 {SCOPE_LABELS[s.scope]}</div>
{s.equipment_name && <div>🔧 {s.equipment_name}</div>}
{s.connection_name && <div>🔌 {s.connection_name}</div>}
{s.device_id && <div>📱 {s.device_id}</div>}
<div>🏷 v{s.version} {s.timeout_ms}ms</div>
</div>
<div className="flex gap-1 mt-3 pt-3 border-t">
<Button variant="ghost" size="sm" className="flex-1 h-8 gap-1" onClick={() => openEdit(s)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openVersions(s.id)}>
<History className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDelete(s.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 편집 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `스크립트 수정: ${editing.script_name} (v${editing.version})` : "새 스크립트"}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-2">
{/* 좌측: 메타 + 테스트 */}
<div className="space-y-3 lg:col-span-1">
<div>
<Label className="text-xs"> *</Label>
<Input
value={form.script_name}
onChange={(e) => setForm({ ...form, script_name: e.target.value })}
placeholder="예: 온도 센서 보정"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">Hook *</Label>
<Select value={form.hook_type} onValueChange={handleHookChange}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{hookTypes.map((h) => (
<SelectItem key={h.hook_type} value={h.hook_type}>
{h.display_name} ({h.hook_type})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
{hookTypes.find((h) => h.hook_type === form.hook_type)?.signature}
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={form.scope} onValueChange={(v) => setForm({ ...form, scope: v })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(SCOPE_LABELS).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
className="mt-1 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) || 100 })}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">(ms)</Label>
<Input
type="number"
value={form.timeout_ms}
onChange={(e) => setForm({ ...form, timeout_ms: parseInt(e.target.value) || 1000 })}
className="mt-1"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<span className="text-xs font-medium"></span>
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
/>
</div>
{/* 테스트 */}
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
<Label className="text-xs font-semibold">🧪 </Label>
<div>
<Label className="text-[10px]"> JSON</Label>
<Textarea
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
rows={5}
className="mt-1 font-mono text-xs"
/>
</div>
<Button size="sm" variant="outline" className="w-full gap-1" onClick={handleTestRun} disabled={testing}>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</Button>
{testResult && (
<div className={`rounded-md border p-2 text-xs ${testResult.success ? "bg-green-500/5 border-green-500/30" : "bg-red-500/5 border-red-500/30"}`}>
<div className="flex items-center gap-1 mb-1">
{testResult.success ? <CheckCircle2 className="h-3 w-3 text-green-600"/> : <XCircle className="h-3 w-3 text-red-600"/>}
<span className="font-semibold">{testResult.success ? "성공" : "실패"}</span>
<span className="text-muted-foreground text-[10px] ml-auto">{testResult.duration_ms}ms</span>
</div>
<pre className="whitespace-pre-wrap font-mono text-[10px] max-h-32 overflow-y-auto">
{testResult.success
? JSON.stringify(testResult.result, null, 2)
: testResult.error}
</pre>
</div>
)}
</div>
</div>
{/* 우측: Monaco Editor */}
<div className="lg:col-span-2 border rounded-lg overflow-hidden" style={{ height: "60vh" }}>
<MonacoEditor
height="100%"
language="python"
theme="vs-dark"
value={form.code}
onChange={(v) => setForm({ ...form, code: v || "" })}
options={{
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
tabSize: 4,
wordWrap: "on",
}}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving} className="gap-1">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{editing ? "수정" : "생성"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 버전 이력 모달 */}
<Dialog open={versionsModal.open} onOpenChange={(open) => setVersionsModal({open})}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-2 pt-2 max-h-[60vh] overflow-y-auto">
{versions.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-6"> .</p>
) : (
versions.map((v) => (
<div key={v.id} className="rounded-md border p-3 text-xs">
<div className="flex items-center justify-between">
<div>
<span className="font-semibold">v{v.version}</span>
<span className="text-muted-foreground ml-2">{v.code_size}B</span>
</div>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-[10px]"
onClick={() => rollback(versionsModal.scriptId!, v.version)}
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
<div className="text-[10px] text-muted-foreground mt-1">
{new Date(v.changed_at).toLocaleString("ko-KR")} · {v.changed_by || "system"}
</div>
{v.description && (
<div className="text-[11px] mt-1">{v.description}</div>
)}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -35,6 +35,7 @@ interface ExternalDbConnectionModalProps {
const DEFAULT_PORTS: Record<string, number> = { const DEFAULT_PORTS: Record<string, number> = {
mysql: 3306, mysql: 3306,
postgresql: 5432, postgresql: 5432,
mariadb: 3306,
oracle: 1521, oracle: 1521,
mssql: 1433, mssql: 1433,
sqlite: 0, // SQLite는 파일 기반이므로 포트 없음 sqlite: 0, // SQLite는 파일 기반이므로 포트 없음
@@ -0,0 +1,426 @@
"use client";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Pencil, Trash2, Save, RefreshCw, Play, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { fleetApi } from "@/lib/api/fleet";
const HOOK_TYPES = [
{ value: "transform", label: "transform (값 변환)" },
{ value: "filter", label: "filter (값 필터)" },
{ value: "derived_tags", label: "derived_tags (파생 태그)" },
{ value: "alarm", label: "alarm (알람)" },
{ value: "pre_send", label: "pre_send (전송 전)" },
{ value: "aggregator", label: "aggregator (집계)" },
];
const SCOPES = [
{ value: "global", label: "전체 엣지 (global)" },
{ value: "connection", label: "특정 연결 (connection)" },
{ value: "equipment", label: "특정 장비 (equipment)" },
];
const DEFAULT_CODE: Record<string, string> = {
transform:
"def transform(tag_name, raw_value, context):\n # 입력: raw_value, context={scale,offset,...}\n # 반환: 변환된 값\n return raw_value",
filter:
"def filter(tag_name, value, context):\n # True 반환 시 통과, False면 버림\n return True",
derived_tags:
"def derived_tags(device_data, context):\n # device_data['tags']에서 원본 태그 참조\n # 반환: {'new_tag': value, ...}\n tags = device_data.get('tags', {})\n return {}",
alarm:
"def alarm(tag_name, value, context):\n # 알람 발생 시 {'level': 'warn|error', 'message': '...'}\n # 아니면 None\n return None",
pre_send:
"def pre_send(device_data, context):\n # 전송 직전 최종 가공\n return device_data",
aggregator:
"def aggregator(tag_name, value, context):\n return value",
};
interface Script {
id?: number;
script_name: string;
description?: string;
hook_type: string;
scope: string;
code: string;
priority: number;
timeout_ms: number;
enabled: boolean;
connection_id?: number | null;
equipment_id?: number | null;
}
interface ScriptsManagerDialogProps {
open: boolean;
onClose: () => void;
onAfterChange?: () => void;
/** 이 연결에서 열렸을 때 신규 스크립트에 자동 바인딩 */
defaultConnectionId?: number;
}
export function ScriptsManagerDialog({
open,
onClose,
onAfterChange,
defaultConnectionId,
}: ScriptsManagerDialogProps) {
const [scripts, setScripts] = useState<Script[]>([]);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState<Script | null>(null);
const [testInput, setTestInput] = useState(
'{"tag_name":"test","raw_value":10,"context":{}}'
);
const [testResult, setTestResult] = useState<string>("");
const [running, setRunning] = useState(false);
const load = async () => {
setLoading(true);
try {
const res = await fleetApi.listScripts();
setScripts((res?.data || res || []) as Script[]);
} catch (e: any) {
toast.error(e?.response?.data?.message || "스크립트 조회 실패");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) load();
}, [open]);
const openNew = () => {
setEditing({
script_name: "",
description: "",
hook_type: "transform",
scope: defaultConnectionId ? "connection" : "global",
code: DEFAULT_CODE.transform,
priority: 10,
timeout_ms: 1500,
enabled: true,
connection_id: defaultConnectionId ?? null,
});
setTestResult("");
};
const openEdit = async (s: Script) => {
try {
const res = await fleetApi.getScript(s.id!);
setEditing(res?.data || res);
setTestResult("");
} catch (e: any) {
toast.error(e?.response?.data?.message || "조회 실패");
}
};
const save = async () => {
if (!editing) return;
if (!editing.script_name || !editing.code) {
toast.error("이름과 코드는 필수");
return;
}
try {
if (editing.id) {
await fleetApi.updateScript(editing.id, editing as any);
} else {
await fleetApi.createScript(editing as any);
}
toast.success("저장됨");
setEditing(null);
load();
onAfterChange?.();
} catch (e: any) {
toast.error(e?.response?.data?.message || "저장 실패");
}
};
const remove = async (id: number) => {
if (!confirm("삭제하시겠습니까?")) return;
try {
await fleetApi.deleteScript(id);
toast.success("삭제됨");
load();
onAfterChange?.();
} catch (e: any) {
toast.error(e?.response?.data?.message || "삭제 실패");
}
};
const runDry = async () => {
if (!editing) return;
setRunning(true);
try {
let parsed: any = {};
try {
parsed = JSON.parse(testInput);
} catch {
toast.error("테스트 입력이 JSON이 아닙니다");
setRunning(false);
return;
}
const res = await fleetApi.dryRun(editing.code, editing.hook_type, parsed, editing.timeout_ms);
setTestResult(JSON.stringify(res?.data ?? res, null, 2));
} catch (e: any) {
setTestResult("ERROR: " + (e?.response?.data?.message || e.message));
} finally {
setRunning(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Python </DialogTitle>
<DialogDescription className="text-xs">
Pipeline에서 // .
</DialogDescription>
</DialogHeader>
{editing ? (
// ── 편집 모드 ────────────────────────────────
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs"> *</Label>
<Input
className="mt-1 h-8 text-xs"
value={editing.script_name}
onChange={(e) => setEditing({ ...editing, script_name: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Hook *</Label>
<Select
value={editing.hook_type}
onValueChange={(v) =>
setEditing({
...editing,
hook_type: v,
code: editing.code || DEFAULT_CODE[v] || "",
})
}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOOK_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Scope</Label>
<Select
value={editing.scope}
onValueChange={(v) => setEditing({ ...editing, scope: v })}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPES.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
className="mt-1 h-8 text-xs"
type="number"
value={editing.priority}
onChange={(e) =>
setEditing({ ...editing, priority: Number(e.target.value) })
}
/>
</div>
<div>
<Label className="text-xs"> (ms)</Label>
<Input
className="mt-1 h-8 text-xs"
type="number"
value={editing.timeout_ms}
onChange={(e) =>
setEditing({ ...editing, timeout_ms: Number(e.target.value) })
}
/>
</div>
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Input
className="mt-1 h-8 text-xs"
value={editing.description || ""}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
/>
</div>
<div>
<div className="mb-1 flex items-center justify-between">
<Label className="text-xs">Python </Label>
<div className="flex items-center gap-2">
<Switch
checked={editing.enabled}
onCheckedChange={(c) => setEditing({ ...editing, enabled: c })}
/>
<span className="text-[10px]">{editing.enabled ? "활성" : "비활성"}</span>
</div>
</div>
<Textarea
className="min-h-[240px] font-mono text-[11px]"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
spellCheck={false}
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(: transform, filter).
</p>
</div>
{/* Dry-run */}
<div className="rounded-md border p-2">
<div className="mb-1 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button size="sm" onClick={runDry} disabled={running} className="h-7 gap-1 text-xs">
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Textarea
className="min-h-[80px] font-mono text-[10px]"
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
placeholder='{"tag_name":"t1","raw_value":10,"context":{}}'
/>
<Textarea
className="min-h-[80px] font-mono text-[10px]"
value={testResult}
readOnly
placeholder="결과가 여기에 표시됩니다"
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setEditing(null)}>
</Button>
<Button size="sm" onClick={save} className="gap-1">
<Save className="h-3 w-3" />
</Button>
</div>
</div>
) : (
// ── 목록 모드 ────────────────────────────────
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
<strong>{scripts.length}</strong>
</p>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={load} className="h-7 gap-1 text-xs">
<RefreshCw className="h-3 w-3" />
</Button>
<Button size="sm" onClick={openNew} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : scripts.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-10 text-muted-foreground">
<p className="text-xs"> </p>
<Button variant="link" size="sm" onClick={openNew}>
</Button>
</div>
) : (
<div className="max-h-[50vh] space-y-1 overflow-y-auto rounded-md border p-1">
{scripts.map((s) => (
<div
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-muted/50"
>
<Badge variant="outline" className="h-5 text-[9px]">
{s.hook_type}
</Badge>
<Badge variant="secondary" className="h-5 text-[9px]">
{s.scope}
</Badge>
<span className="flex-1 truncate text-xs font-medium">
{s.script_name}
</span>
<span className="text-[10px] text-muted-foreground">
p={s.priority} / {s.timeout_ms}ms
</span>
{!s.enabled && (
<Badge variant="destructive" className="h-4 text-[9px]">
OFF
</Badge>
)}
<Button variant="ghost" size="sm" className="h-6 px-2" onClick={() => openEdit(s)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-destructive"
onClick={() => remove(s.id!)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -123,6 +123,16 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
"/admin/flow-external-db": () => import("@/app/(main)/admin/flow-external-db/page"), "/admin/flow-external-db": () => import("@/app/(main)/admin/flow-external-db/page"),
// 장비 연결 관리 // 장비 연결 관리
"/admin/pipeline-device": () => import("@/app/(main)/admin/pipeline-device/page"), "/admin/pipeline-device": () => import("@/app/(main)/admin/pipeline-device/page"),
// Fleet 관리
"/admin/fleet/devices": () => import("@/app/(main)/admin/fleet/devices/page"),
"/admin/fleet/commands": () => import("@/app/(main)/admin/fleet/commands/page"),
"/admin/fleet/alerts": () => import("@/app/(main)/admin/fleet/alerts/page"),
"/admin/fleet/data": () => import("@/app/(main)/admin/fleet/data/page"),
"/admin/fleet/scripts": () => import("@/app/(main)/admin/fleet/scripts/page"),
"/admin/fleet/deployments": () => import("@/app/(main)/admin/fleet/deployments/page"),
"/admin/fleet/releases": () => import("@/app/(main)/admin/fleet/releases/page"),
"/admin/fleet/rules": () => import("@/app/(main)/admin/fleet/rules/page"),
"/admin/fleet/audit": () => import("@/app/(main)/admin/fleet/audit/page"),
}; };
const DYNAMIC_ADMIN_PATTERNS: Array<{ const DYNAMIC_ADMIN_PATTERNS: Array<{
+81
View File
@@ -0,0 +1,81 @@
// 자동화 통합 대시보드 API (조회 전용)
import { apiClient } from "./client";
export interface AutomationStats {
batches_total: number;
batches_active: number;
pollings_total: number;
pollings_active: number;
pollings_connected: number;
total_tags: number;
forwarders_total: number;
forwarders_enabled: number;
messages_forwarded_total: number;
}
export interface BatchRow {
id: number;
batch_name: string;
cron_schedule: string;
is_active: string | boolean;
company_code?: string;
last_run_date?: string;
last_run_result?: string;
next_run_date?: string;
}
export interface PollingRow {
id: number;
connection_name: string;
protocol: string;
host: string;
port: number;
polling_interval_ms: number;
is_active: string;
status: string;
last_test_result?: string;
last_test_date?: string;
target_db_connection_id?: number | null;
target_table_name?: string | null;
tag_count: number;
last_collected_at?: string | null;
}
export interface ForwarderRow {
id: number;
config_name: string;
company_code: string;
company_id: string;
edge_id: string;
broker_host: string;
broker_port: number;
topic_pattern: string;
batch_size: number;
batch_timeout_ms: number;
is_enabled: string;
messages_forwarded: number | null;
messages_failed: number | null;
messages_dropped: number | null;
batches_sent: number | null;
last_published_at?: string | null;
last_error?: string | null;
is_connected: string | null;
reconnect_attempts: number | null;
}
export interface DashboardOverview {
stats: AutomationStats;
batches: BatchRow[];
pollings: PollingRow[];
forwarders: ForwarderRow[];
}
export const AutomationDashboardAPI = {
async overview(): Promise<DashboardOverview> {
const r = await apiClient.get<{ success: boolean; data: DashboardOverview }>(
"/api/automation-dashboard/overview"
);
return r.data.data;
},
};
+88
View File
@@ -0,0 +1,88 @@
// Central MQTT Forwarder 관리 API 클라이언트
import { apiClient } from "./client";
export interface CentralForwarderConfig {
id?: number;
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;
created_date?: string;
updated_date?: string;
}
export interface ForwarderRuntimeStatus {
config_id: number;
config_name: string;
company_code: string;
edge_id: string;
broker: string;
connected: boolean;
buffered: number;
messagesForwarded: number;
messagesFailed: number;
messagesDropped: number;
batchesSent: number;
lastPublishedAt: string | null;
startedAt: string;
isConnected: boolean;
reconnectAttempts: number;
lastError: string | null;
lastErrorAt: string | null;
}
const BASE = "/api/central-forwarder";
export const CentralForwarderAPI = {
async list(companyCode?: string): Promise<CentralForwarderConfig[]> {
const url = companyCode ? `${BASE}?company_code=${companyCode}` : BASE;
const r = await apiClient.get<{ success: boolean; data: CentralForwarderConfig[] }>(url);
return r.data.data || [];
},
async get(id: number): Promise<CentralForwarderConfig> {
const r = await apiClient.get<{ success: boolean; data: CentralForwarderConfig }>(
`${BASE}/${id}`
);
return r.data.data;
},
async create(input: CentralForwarderConfig): Promise<{ id: number }> {
const r = await apiClient.post<{ success: boolean; data: { id: number } }>(BASE, input);
return r.data.data;
},
async update(id: number, input: Partial<CentralForwarderConfig>): Promise<void> {
await apiClient.put(`${BASE}/${id}`, input);
},
async delete(id: number): Promise<void> {
await apiClient.delete(`${BASE}/${id}`);
},
async toggle(id: number, enabled: boolean): Promise<void> {
await apiClient.post(`${BASE}/${id}/toggle`, { enabled });
},
async runtimeStatus(): Promise<ForwarderRuntimeStatus[]> {
const r = await apiClient.get<{ success: boolean; data: ForwarderRuntimeStatus[] }>(
`${BASE}/runtime/status`
);
return r.data.data || [];
},
};
+51
View File
@@ -0,0 +1,51 @@
// Equipment Current State API 클라이언트
import { apiClient } from "./client";
export interface EquipmentTagState {
id: number;
connection_id: number;
company_code: string;
tag_name: string;
tag_display_name: string | null;
tag_unit: string | null;
value_numeric: number | null;
value_text: string | null;
value_boolean: boolean | null;
quality: string;
last_collected_at: string;
updated_at: string;
}
export interface ConnectionStatusSummary {
connection_id: number;
connection_name: string;
protocol: string;
host: string;
port: number;
connection_status: string;
last_test_result: string | null;
last_test_message: string | null;
last_test_date: string | null;
company_code: string;
tag_count: number;
last_collected_at: string | null;
good_tag_count: number;
}
const BASE = "/api/equipment-state";
export const EquipmentStateAPI = {
async summary(companyCode?: string): Promise<ConnectionStatusSummary[]> {
const url = companyCode ? `${BASE}/summary?company_code=${companyCode}` : `${BASE}/summary`;
const r = await apiClient.get<{ success: boolean; data: ConnectionStatusSummary[] }>(url);
return r.data.data || [];
},
async tagsByConnection(connectionId: number): Promise<EquipmentTagState[]> {
const r = await apiClient.get<{ success: boolean; data: EquipmentTagState[] }>(
`${BASE}/${connectionId}`
);
return r.data.data || [];
},
};
+213
View File
@@ -0,0 +1,213 @@
import { apiClient } from "./client";
const BASE = "/fleet";
export interface FleetDevice {
id?: number;
device_id: string;
company_code?: string;
device_name?: string;
device_type?: string;
ip_address?: string;
mac_address?: string;
hardware_fingerprint?: string;
last_seen_at?: string;
is_online?: boolean;
equipment_id?: number | null;
equipment_name?: string;
equipment_code?: string;
agent_version?: string;
os_info?: Record<string, any>;
hardware_info?: Record<string, any>;
device_group?: string;
tags?: any[];
}
export interface FleetCommand {
id?: number;
device_id: string;
command_type: string;
payload?: Record<string, any>;
status?: string;
result?: Record<string, any>;
error_message?: string;
issued_by?: string;
issued_at?: string;
sent_at?: string;
responded_at?: string;
}
export interface FleetAlert {
id: number;
rule_id: number;
rule_name?: string;
device_id: string;
severity: string;
title: string;
message: string;
metric: string;
value: number;
threshold: number;
status: "open" | "acknowledged" | "resolved";
created_at: string;
}
export const fleetApi = {
// 디바이스
getDevices: (filter?: { is_online?: boolean; search?: string }) =>
apiClient.get(`${BASE}/devices`, { params: filter }).then((r) => r.data),
getDevice: (deviceId: string) =>
apiClient.get(`${BASE}/devices/${deviceId}`).then((r) => r.data),
registerDevice: (data: Partial<FleetDevice>) =>
apiClient.post(`${BASE}/devices/register`, data).then((r) => r.data),
updateDevice: (deviceId: string, data: Partial<FleetDevice>) =>
apiClient.patch(`${BASE}/devices/${deviceId}`, data).then((r) => r.data),
deleteDevice: (deviceId: string) =>
apiClient.delete(`${BASE}/devices/${deviceId}`).then((r) => r.data),
getMetrics: (deviceId: string, limit = 100) =>
apiClient.get(`${BASE}/devices/${deviceId}/metrics`, { params: { limit } }).then((r) => r.data),
// 커맨드
getCommands: (filter?: { device_id?: string; status?: string; limit?: number }) =>
apiClient.get(`${BASE}/commands`, { params: filter }).then((r) => r.data),
getCommandTypes: () =>
apiClient.get(`${BASE}/commands/types`).then((r) => r.data),
issueCommand: (data: { device_id: string; command_type: string; payload?: any; timeout_sec?: number }) =>
apiClient.post(`${BASE}/commands`, data).then((r) => r.data),
// 알림
getAlerts: (status: string = "open") =>
apiClient.get(`${BASE}/alerts`, { params: { status } }).then((r) => r.data),
ackAlert: (id: number) =>
apiClient.post(`${BASE}/alerts/${id}/ack`).then((r) => r.data),
resolveAlert: (id: number) =>
apiClient.post(`${BASE}/alerts/${id}/resolve`).then((r) => r.data),
getAlertRules: () =>
apiClient.get(`${BASE}/alert-rules`).then((r) => r.data),
// 배포
getDeployments: () =>
apiClient.get(`${BASE}/deployments`).then((r) => r.data),
getReleases: () =>
apiClient.get(`${BASE}/releases`).then((r) => r.data),
// 통계
getStats: () =>
apiClient.get(`${BASE}/stats`).then((r) => r.data),
// 실시간 데이터
getLatestValues: (deviceId: string) =>
apiClient.get(`${BASE}/devices/${deviceId}/latest-values`).then((r) => r.data),
getLatestValuesByEquipment: (equipmentId: number) =>
apiClient.get(`${BASE}/equipment/${equipmentId}/latest-values`).then((r) => r.data),
getTagTimeseries: (deviceId: string, tagName: string, limit = 500) =>
apiClient
.get(`${BASE}/devices/${deviceId}/tags/${encodeURIComponent(tagName)}/timeseries`, {
params: { limit },
})
.then((r) => r.data),
getDataStats: (deviceId?: string) =>
apiClient.get(`${BASE}/data/stats`, { params: { device_id: deviceId } }).then((r) => r.data),
// ===== Python Hook 스크립트 =====
getHookTypes: () =>
apiClient.get(`${BASE}/scripts/hook-types`).then((r) => r.data),
listScripts: (filter?: any) =>
apiClient.get(`${BASE}/scripts`, { params: filter }).then((r) => r.data),
getScript: (id: number) =>
apiClient.get(`${BASE}/scripts/${id}`).then((r) => r.data),
createScript: (data: any) =>
apiClient.post(`${BASE}/scripts`, data).then((r) => r.data),
updateScript: (id: number, data: any) =>
apiClient.put(`${BASE}/scripts/${id}`, data).then((r) => r.data),
deleteScript: (id: number) =>
apiClient.delete(`${BASE}/scripts/${id}`).then((r) => r.data),
dryRunScript: (code: string, hook_type: string, test_input: any, timeout_ms?: number) =>
apiClient.post(`${BASE}/scripts/dry-run`, { code, hook_type, test_input, timeout_ms }).then((r) => r.data),
getScriptVersions: (id: number) =>
apiClient.get(`${BASE}/scripts/${id}/versions`).then((r) => r.data),
getScriptVersion: (id: number, version: number) =>
apiClient.get(`${BASE}/scripts/${id}/versions/${version}`).then((r) => r.data),
rollbackScript: (id: number, version: number) =>
apiClient.post(`${BASE}/scripts/${id}/rollback/${version}`).then((r) => r.data),
// ===== 릴리즈 =====
getReleases: (filter?: any) => apiClient.get(`${BASE}/releases`, { params: filter }).then(r => r.data),
getRelease: (id: number) => apiClient.get(`${BASE}/releases/${id}`).then(r => r.data),
createRelease: (data: any) => apiClient.post(`${BASE}/releases`, data).then(r => r.data),
updateRelease: (id: number, data: any) => apiClient.put(`${BASE}/releases/${id}`, data).then(r => r.data),
deleteRelease: (id: number) => apiClient.delete(`${BASE}/releases/${id}`).then(r => r.data),
transitionRelease: (id: number, status: string) =>
apiClient.post(`${BASE}/releases/${id}/transition`, { status }).then(r => r.data),
// ===== 배포 =====
createDeployment: (data: any) => apiClient.post(`${BASE}/deployments`, data).then(r => r.data),
getDeploymentDetail: (id: number) => apiClient.get(`${BASE}/deployments/${id}`).then(r => r.data),
getDeploymentStatus: (id: number) => apiClient.get(`${BASE}/deployments/${id}/status`).then(r => r.data),
startDeployment: (id: number) => apiClient.post(`${BASE}/deployments/${id}/start`).then(r => r.data),
cancelDeployment: (id: number) => apiClient.post(`${BASE}/deployments/${id}/cancel`).then(r => r.data),
rollbackDeployment: (id: number) => apiClient.post(`${BASE}/deployments/${id}/rollback`).then(r => r.data),
// ===== Harbor =====
getHarborProjects: () => apiClient.get(`${BASE}/harbor/projects`).then(r => r.data),
getHarborRepos: (project: string) => apiClient.get(`${BASE}/harbor/projects/${project}/repos`).then(r => r.data),
getHarborTags: (project: string, repo: string) =>
apiClient.get(`${BASE}/harbor/projects/${project}/repos/${repo}/tags`).then(r => r.data),
pingHarbor: () => apiClient.get(`${BASE}/harbor/ping`).then(r => r.data),
// ===== 태그 템플릿 =====
getTagTemplates: (filter?: any) => apiClient.get(`${BASE}/tag-templates`, { params: filter }).then(r => r.data),
getTagTemplate: (id: number) => apiClient.get(`${BASE}/tag-templates/${id}`).then(r => r.data),
createTagTemplate: (data: any) => apiClient.post(`${BASE}/tag-templates`, data).then(r => r.data),
updateTagTemplate: (id: number, data: any) => apiClient.put(`${BASE}/tag-templates/${id}`, data).then(r => r.data),
deleteTagTemplate: (id: number) => apiClient.delete(`${BASE}/tag-templates/${id}`).then(r => r.data),
applyTagTemplate: (templateId: number, connectionId: number, overwrite = false) =>
apiClient.post(`${BASE}/tag-templates/${templateId}/apply/${connectionId}`, { overwrite }).then(r => r.data),
// ===== 알림 규칙 =====
createAlertRule: (data: any) => apiClient.post(`${BASE}/alert-rules`, data).then(r => r.data),
updateAlertRule: (id: number, data: any) => apiClient.put(`${BASE}/alert-rules/${id}`, data).then(r => r.data),
deleteAlertRule: (id: number) => apiClient.delete(`${BASE}/alert-rules/${id}`).then(r => r.data),
toggleAlertRule: (id: number) => apiClient.post(`${BASE}/alert-rules/${id}/toggle`).then(r => r.data),
// ===== V1 매핑 =====
getV1Mappings: (filter?: any) => apiClient.get(`${BASE}/v1-mappings`, { params: filter }).then(r => r.data),
createV1Mapping: (data: any) => apiClient.post(`${BASE}/v1-mappings`, data).then(r => r.data),
updateV1Mapping: (id: number, data: any) => apiClient.put(`${BASE}/v1-mappings/${id}`, data).then(r => r.data),
deleteV1Mapping: (id: number) => apiClient.delete(`${BASE}/v1-mappings/${id}`).then(r => r.data),
// ===== PLC 상태 =====
getPlcStatus: (filter?: any) => apiClient.get(`${BASE}/plc-status`, { params: filter }).then(r => r.data),
getPlcSummary: () => apiClient.get(`${BASE}/plc-status/summary`).then(r => r.data),
// ===== Audit =====
getAuditLogs: (filter?: any) => apiClient.get(`${BASE}/audit-logs`, { params: filter }).then(r => r.data),
getAuditStats: () => apiClient.get(`${BASE}/audit-logs/stats`).then(r => r.data),
// ===== Provisioning =====
getPreRegistered: () => apiClient.get(`${BASE}/provision/pre-registered`).then(r => r.data),
preRegister: (data: any) => apiClient.post(`${BASE}/provision/pre-register`, data).then(r => r.data),
};
+34
View File
@@ -7,6 +7,10 @@ export const pipelineDeviceApi = {
getProtocols: () => getProtocols: () =>
apiClient.get(`${BASE}/protocols`).then((r) => r.data), apiClient.get(`${BASE}/protocols`).then((r) => r.data),
// 장비 목록 (pipeline_equipment)
getEquipmentList: (search?: string) =>
apiClient.get(`${BASE}/equipment-list`, { params: search ? { search } : {} }).then((r) => r.data),
// 연결 CRUD // 연결 CRUD
getConnections: (params?: { protocol?: string; is_active?: string; search?: string; status?: string }) => getConnections: (params?: { protocol?: string; is_active?: string; search?: string; status?: string }) =>
apiClient.get(BASE, { params }).then((r) => r.data), apiClient.get(BASE, { params }).then((r) => r.data),
@@ -21,6 +25,36 @@ export const pipelineDeviceApi = {
testConnection: (id: number) => testConnection: (id: number) =>
apiClient.post(`${BASE}/${id}/test`).then((r) => r.data), apiClient.post(`${BASE}/${id}/test`).then((r) => r.data),
// 훅 체인 테스트 (원본값 → 체인 실행 → 결과 + DB 저장 옵션)
testChain: (
id: number,
payload: { tag_name: string; raw_value: unknown; save_to_db?: boolean }
) =>
apiClient.post(`${BASE}/${id}/test-chain`, payload).then((r) => r.data),
// 수동 1회 수집 (실제 PLC에서 읽기 + 훅 적용 + DB 저장)
collectOnce: (id: number) =>
apiClient.post(`${BASE}/${id}/collect-once`).then((r) => r.data),
// Target DB introspection
listTargetDatabases: () =>
apiClient.get(`${BASE}/target-databases`).then((r) => r.data),
listTargetTables: (dbId: number) =>
apiClient.get(`${BASE}/target-databases/${dbId}/tables`).then((r) => r.data),
listTargetColumns: (dbId: number, tableName: string) =>
apiClient
.get(`${BASE}/target-databases/${dbId}/tables/${tableName}/columns`)
.then((r) => r.data),
// 태그 컬럼 매핑 일괄 업데이트
updateTagColumnMapping: (
connectionId: number,
mapping: Array<{ tag_id: number; target_column_name: string | null }>
) =>
apiClient
.put(`${BASE}/${connectionId}/tag-column-mapping`, { mapping })
.then((r) => r.data),
// 태그 매핑 // 태그 매핑
getTagMappings: (connectionId: number) => getTagMappings: (connectionId: number) =>
apiClient.get(`${BASE}/${connectionId}/tags`).then((r) => r.data), apiClient.get(`${BASE}/${connectionId}/tags`).then((r) => r.data),
+64
View File
@@ -12,6 +12,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-avatar": "^1.1.0",
@@ -1185,6 +1186,29 @@
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@monogrid/gainmap-js": { "node_modules/@monogrid/gainmap-js": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz",
@@ -12338,6 +12362,19 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -12464,6 +12501,27 @@
"integrity": "sha512-GJkv/yWPOJTlxj1LZDU2k474cDyOWL+LVaqTdDWQwQ5d8zIuTz1892+1cV9V0ZpK6HYZFo/+BNLBbierO9d2TA==", "integrity": "sha512-GJkv/yWPOJTlxj1LZDU2k474cDyOWL+LVaqTdDWQwQ5d8zIuTz1892+1cV9V0ZpK6HYZFo/+BNLBbierO9d2TA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14859,6 +14917,12 @@
"node": ">=0.1.14" "node": ">=0.1.14"
} }
}, },
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/stats-gl": { "node_modules/stats-gl": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-avatar": "^1.1.0",