feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
Build and Push Images / build-and-push (push) Has been cancelled
Build and Push Images / build-and-push (push) Has been cancelled
이전 세션들에서 작업된 아래 범위를 모두 포함: Fleet 서브시스템 (src/fleet/) - fleetDeviceService / fleetCommandService / fleetDeploymentService / fleetReleaseService - fleetMetricsService, fleetScriptService, fleetEdgeConfigService - Edge 디바이스 관리, 커맨드 발행, 배포/릴리스, 스크립트 동기화 Collector 확장 - centralMqttForwarder / centralForwarderConfigService - equipmentStateService, pythonHookRunner, scriptCache - Modbus/OPC-UA/S7/XGT 프로토콜 클라이언트 - targetDbIntrospection (저장 DB 조회) Routes / API - automationDashboardRoutes, centralForwarderRoutes, equipmentStateRoutes DB - importEdgeConfig (Python cached config → Pipeline DB) - seedDataSources (external_db_connections 초기 시드) 엣지 배포 리소스 - docker/edge/Dockerfile.backend.prod, Dockerfile.frontend.prod - docker/edge/docker-compose.edge.yml 프론트엔드 - admin/automaticMng (centralForwarder, dashboard, equipmentState) - admin/fleet (commands, devices, deployments, releases, scripts, alerts) - admin/pipeline-device 개선 (저장 DB 드롭다운, 태그 매핑 등) - ExternalDbConnectionModal, ScriptsManagerDialog 등 신규 컴포넌트 - lib/api: automationDashboard, centralForwarder, equipmentState, fleet docs/ - EDGE_SERVER_STRUCTURE, FLEET_COMPLETE, FLEET_EDGE_INTEGRATION, FLEET_HOOK_INTEGRATION Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1024
-18
File diff suppressed because it is too large
Load Diff
@@ -24,12 +24,15 @@
|
|||||||
"license": "ISC",
|
"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;
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Audit Log Service
|
||||||
|
* - 주요 이벤트 기록 (디바이스/커맨드/배포/스크립트/알림 등)
|
||||||
|
* - 검색/필터링/통계
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
event_type: string;
|
||||||
|
actor_id?: string;
|
||||||
|
actor_name?: string;
|
||||||
|
target_type?: string;
|
||||||
|
target_id?: string;
|
||||||
|
action: string;
|
||||||
|
before_data?: any;
|
||||||
|
after_data?: any;
|
||||||
|
ip_address?: string;
|
||||||
|
user_agent?: string;
|
||||||
|
result?: "success" | "failed";
|
||||||
|
error_message?: string;
|
||||||
|
company_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetAuditService {
|
||||||
|
static async log(entry: AuditLog) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_audit_logs
|
||||||
|
(event_type, actor_id, actor_name, target_type, target_id,
|
||||||
|
action, before_data, after_data, ip_address, user_agent,
|
||||||
|
result, error_message, company_code)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13)`,
|
||||||
|
[
|
||||||
|
entry.event_type,
|
||||||
|
entry.actor_id || null,
|
||||||
|
entry.actor_name || null,
|
||||||
|
entry.target_type || null,
|
||||||
|
entry.target_id || null,
|
||||||
|
entry.action,
|
||||||
|
entry.before_data ? JSON.stringify(entry.before_data) : null,
|
||||||
|
entry.after_data ? JSON.stringify(entry.after_data) : null,
|
||||||
|
entry.ip_address || null,
|
||||||
|
entry.user_agent || null,
|
||||||
|
entry.result || "success",
|
||||||
|
entry.error_message || null,
|
||||||
|
entry.company_code || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// audit 실패가 주 로직을 막으면 안 됨
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async list(filter: {
|
||||||
|
event_type?: string;
|
||||||
|
target_type?: string;
|
||||||
|
target_id?: string;
|
||||||
|
actor_id?: string;
|
||||||
|
result?: string;
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
company_code?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
const wheres: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.event_type) { wheres.push(`event_type = $${idx++}`); params.push(filter.event_type); }
|
||||||
|
if (filter.target_type) { wheres.push(`target_type = $${idx++}`); params.push(filter.target_type); }
|
||||||
|
if (filter.target_id) { wheres.push(`target_id = $${idx++}`); params.push(filter.target_id); }
|
||||||
|
if (filter.actor_id) { wheres.push(`actor_id = $${idx++}`); params.push(filter.actor_id); }
|
||||||
|
if (filter.result) { wheres.push(`result = $${idx++}`); params.push(filter.result); }
|
||||||
|
if (filter.from) { wheres.push(`created_at >= $${idx++}`); params.push(filter.from); }
|
||||||
|
if (filter.to) { wheres.push(`created_at <= $${idx++}`); params.push(filter.to); }
|
||||||
|
if (filter.company_code && filter.company_code !== "*") {
|
||||||
|
wheres.push(`(company_code = $${idx} OR company_code IS NULL)`);
|
||||||
|
params.push(filter.company_code);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||||
|
params.push(filter.limit || 200);
|
||||||
|
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT * FROM fleet_audit_logs ${where}
|
||||||
|
ORDER BY created_at DESC LIMIT $${idx}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async stats(filter: { from?: Date; to?: Date } = {}) {
|
||||||
|
const params: any[] = [];
|
||||||
|
const timeClause = filter.from && filter.to ? `created_at BETWEEN $1 AND $2` : "1=1";
|
||||||
|
if (filter.from && filter.to) params.push(filter.from, filter.to);
|
||||||
|
|
||||||
|
const byEvent = await query<any>(
|
||||||
|
`SELECT event_type, COUNT(*) as n FROM fleet_audit_logs WHERE ${timeClause}
|
||||||
|
GROUP BY event_type ORDER BY n DESC LIMIT 20`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const byActor = await query<any>(
|
||||||
|
`SELECT actor_id, COUNT(*) as n FROM fleet_audit_logs
|
||||||
|
WHERE ${timeClause} AND actor_id IS NOT NULL
|
||||||
|
GROUP BY actor_id ORDER BY n DESC LIMIT 10`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const failures = await queryOne<any>(
|
||||||
|
`SELECT COUNT(*) as n FROM fleet_audit_logs WHERE ${timeClause} AND result = 'failed'`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return { byEvent, byActor, failures: parseInt(failures?.n || 0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Command Service
|
||||||
|
* - 원격 커맨드 실행 (MQTT 기반)
|
||||||
|
* - 9가지 커맨드 타입 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { getFleetMqttBroker } from "./mqttBroker";
|
||||||
|
|
||||||
|
export type CommandType =
|
||||||
|
| "restart_container"
|
||||||
|
| "pull_image"
|
||||||
|
| "update_agent"
|
||||||
|
| "health_check"
|
||||||
|
| "execute_script"
|
||||||
|
| "deploy"
|
||||||
|
| "rollback"
|
||||||
|
| "collect_logs"
|
||||||
|
| "restart_device";
|
||||||
|
|
||||||
|
export type CommandStatus = "pending" | "sent" | "executing" | "success" | "failed" | "timeout";
|
||||||
|
|
||||||
|
export interface FleetCommand {
|
||||||
|
id?: number;
|
||||||
|
device_id: string;
|
||||||
|
command_type: CommandType;
|
||||||
|
payload?: Record<string, any>;
|
||||||
|
status?: CommandStatus;
|
||||||
|
result?: Record<string, any>;
|
||||||
|
error_message?: string;
|
||||||
|
issued_by?: string;
|
||||||
|
issued_at?: Date;
|
||||||
|
sent_at?: Date;
|
||||||
|
responded_at?: Date;
|
||||||
|
timeout_sec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetCommandService {
|
||||||
|
/**
|
||||||
|
* 커맨드 발행 (DB 저장 + MQTT 전송)
|
||||||
|
*/
|
||||||
|
static async issueCommand(
|
||||||
|
deviceId: string,
|
||||||
|
commandType: CommandType,
|
||||||
|
payload: Record<string, any> = {},
|
||||||
|
issuedBy?: string,
|
||||||
|
timeoutSec = 300,
|
||||||
|
): Promise<FleetCommand> {
|
||||||
|
// 커맨드 타입 검증
|
||||||
|
const typeCheck = await queryOne<any>(
|
||||||
|
`SELECT command_type FROM fleet_command_types WHERE command_type = $1`,
|
||||||
|
[commandType],
|
||||||
|
);
|
||||||
|
if (!typeCheck) {
|
||||||
|
throw new Error(`알 수 없는 커맨드 타입: ${commandType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디바이스 확인
|
||||||
|
const device = await queryOne<any>(
|
||||||
|
`SELECT device_id, is_online FROM fleet_devices WHERE device_id = $1`,
|
||||||
|
[deviceId],
|
||||||
|
);
|
||||||
|
if (!device) {
|
||||||
|
throw new Error(`존재하지 않는 디바이스: ${deviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB에 커맨드 기록
|
||||||
|
const result = await query<FleetCommand>(
|
||||||
|
`INSERT INTO fleet_commands
|
||||||
|
(device_id, command_type, payload, status, issued_by, timeout_sec)
|
||||||
|
VALUES ($1, $2, $3::jsonb, 'pending', $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[deviceId, commandType, JSON.stringify(payload), issuedBy || null, timeoutSec],
|
||||||
|
);
|
||||||
|
const command = result[0];
|
||||||
|
|
||||||
|
// MQTT로 디바이스에 전송
|
||||||
|
try {
|
||||||
|
const broker = getFleetMqttBroker();
|
||||||
|
await broker.sendCommandToDevice(deviceId, {
|
||||||
|
command_id: command.id,
|
||||||
|
command_type: commandType,
|
||||||
|
payload,
|
||||||
|
timeout_sec: timeoutSec,
|
||||||
|
});
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_commands SET status = 'sent', sent_at = NOW() WHERE id = $1`,
|
||||||
|
[command.id],
|
||||||
|
);
|
||||||
|
command.status = "sent";
|
||||||
|
logger.info(`[Fleet] 커맨드 발송: ${commandType} → ${deviceId} (id=${command.id})`);
|
||||||
|
} catch (e: any) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_commands SET status = 'failed', error_message = $2 WHERE id = $1`,
|
||||||
|
[command.id, `MQTT 발송 실패: ${e.message}`],
|
||||||
|
);
|
||||||
|
command.status = "failed";
|
||||||
|
command.error_message = e.message;
|
||||||
|
logger.error(`[Fleet] 커맨드 MQTT 전송 실패 (id=${command.id}):`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디바이스 응답 수신 처리 (MQTT 구독자에서 호출)
|
||||||
|
*/
|
||||||
|
static async handleResponse(
|
||||||
|
deviceId: string,
|
||||||
|
response: {
|
||||||
|
command_id: number;
|
||||||
|
status: "success" | "failed" | "executing";
|
||||||
|
result?: Record<string, any>;
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const newStatus: CommandStatus =
|
||||||
|
response.status === "success"
|
||||||
|
? "success"
|
||||||
|
: response.status === "failed"
|
||||||
|
? "failed"
|
||||||
|
: "executing";
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_commands SET
|
||||||
|
status = $1,
|
||||||
|
result = $2::jsonb,
|
||||||
|
error_message = $3,
|
||||||
|
responded_at = NOW()
|
||||||
|
WHERE id = $4 AND device_id = $5`,
|
||||||
|
[
|
||||||
|
newStatus,
|
||||||
|
JSON.stringify(response.result || {}),
|
||||||
|
response.error || null,
|
||||||
|
response.command_id,
|
||||||
|
deviceId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`[Fleet] 커맨드 응답: id=${response.command_id} device=${deviceId} status=${newStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커맨드 목록 조회
|
||||||
|
*/
|
||||||
|
static async listCommands(filter: {
|
||||||
|
device_id?: string;
|
||||||
|
command_type?: string;
|
||||||
|
status?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
const wheres: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.device_id) {
|
||||||
|
wheres.push(`device_id = $${idx++}`);
|
||||||
|
params.push(filter.device_id);
|
||||||
|
}
|
||||||
|
if (filter.command_type) {
|
||||||
|
wheres.push(`command_type = $${idx++}`);
|
||||||
|
params.push(filter.command_type);
|
||||||
|
}
|
||||||
|
if (filter.status) {
|
||||||
|
wheres.push(`status = $${idx++}`);
|
||||||
|
params.push(filter.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||||
|
params.push(filter.limit || 100);
|
||||||
|
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT * FROM fleet_commands ${whereClause}
|
||||||
|
ORDER BY issued_at DESC LIMIT $${idx}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임아웃된 커맨드 정리 (주기적으로 호출)
|
||||||
|
*/
|
||||||
|
static async markTimedOutCommands() {
|
||||||
|
const result = await query<any>(
|
||||||
|
`UPDATE fleet_commands
|
||||||
|
SET status = 'timeout',
|
||||||
|
error_message = '응답 타임아웃'
|
||||||
|
WHERE status IN ('sent', 'executing')
|
||||||
|
AND sent_at IS NOT NULL
|
||||||
|
AND sent_at < NOW() - (timeout_sec || ' seconds')::INTERVAL
|
||||||
|
RETURNING id, device_id, command_type`,
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
logger.warn(`[Fleet] 타임아웃 커맨드: ${result.length}건`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getCommandTypes() {
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT * FROM fleet_command_types ORDER BY category, command_type`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Edge Data Service
|
||||||
|
* - 엣지에서 수집된 실시간 데이터 조회
|
||||||
|
* - 장비별/태그별 시계열 데이터
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
export class FleetDataService {
|
||||||
|
/**
|
||||||
|
* 디바이스별 최신 태그 값 (각 태그의 가장 최근 값)
|
||||||
|
*/
|
||||||
|
static async getLatestValuesByDevice(deviceId: string) {
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT DISTINCT ON (tag_name)
|
||||||
|
tag_name,
|
||||||
|
value,
|
||||||
|
value_text,
|
||||||
|
quality,
|
||||||
|
time,
|
||||||
|
equipment_id,
|
||||||
|
connection_id
|
||||||
|
FROM fleet_edge_raw_data
|
||||||
|
WHERE device_id = $1 AND time > NOW() - INTERVAL '1 hour'
|
||||||
|
ORDER BY tag_name, time DESC`,
|
||||||
|
[deviceId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장비별 최신 태그 값 (pipeline_equipment 기준)
|
||||||
|
*/
|
||||||
|
static async getLatestValuesByEquipment(equipmentId: number) {
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT DISTINCT ON (tag_name)
|
||||||
|
tag_name,
|
||||||
|
value,
|
||||||
|
value_text,
|
||||||
|
quality,
|
||||||
|
time,
|
||||||
|
device_id
|
||||||
|
FROM fleet_edge_raw_data
|
||||||
|
WHERE equipment_id = $1 AND time > NOW() - INTERVAL '1 hour'
|
||||||
|
ORDER BY tag_name, time DESC`,
|
||||||
|
[equipmentId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그별 시계열 데이터 (차트용)
|
||||||
|
*/
|
||||||
|
static async getTagTimeseries(
|
||||||
|
deviceId: string,
|
||||||
|
tagName: string,
|
||||||
|
fromTime?: Date,
|
||||||
|
toTime?: Date,
|
||||||
|
limit = 500,
|
||||||
|
) {
|
||||||
|
const from = fromTime || new Date(Date.now() - 60 * 60 * 1000); // 기본 1시간
|
||||||
|
const to = toTime || new Date();
|
||||||
|
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT time, value, value_text, quality
|
||||||
|
FROM fleet_edge_raw_data
|
||||||
|
WHERE device_id = $1 AND tag_name = $2
|
||||||
|
AND time >= $3 AND time <= $4
|
||||||
|
ORDER BY time DESC
|
||||||
|
LIMIT $5`,
|
||||||
|
[deviceId, tagName, from, to, limit],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수집 통계
|
||||||
|
*/
|
||||||
|
static async getCollectionStats(deviceId?: string) {
|
||||||
|
const params: any[] = [];
|
||||||
|
let where = "WHERE time > NOW() - INTERVAL '24 hours'";
|
||||||
|
if (deviceId) {
|
||||||
|
params.push(deviceId);
|
||||||
|
where += ` AND device_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total_records,
|
||||||
|
COUNT(DISTINCT device_id) as device_count,
|
||||||
|
COUNT(DISTINCT tag_name) as tag_count,
|
||||||
|
MIN(time) as first_record,
|
||||||
|
MAX(time) as last_record
|
||||||
|
FROM fleet_edge_raw_data ${where}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Deployment Service - 실제 배포 엔진
|
||||||
|
* - 릴리즈를 선택한 디바이스에 배포
|
||||||
|
* - 롤아웃 전략: immediate, canary, rolling
|
||||||
|
* - 각 디바이스별 deploy 커맨드 발행 (MQTT)
|
||||||
|
* - 응답 받아 성공/실패 집계
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { FleetCommandService } from "./fleetCommandService";
|
||||||
|
|
||||||
|
export type DeploymentStatus =
|
||||||
|
| "pending" // 생성됨, 아직 실행 전
|
||||||
|
| "running" // 진행 중
|
||||||
|
| "paused" // 일시정지 (실패율 초과)
|
||||||
|
| "completed" // 완료
|
||||||
|
| "failed" // 실패
|
||||||
|
| "cancelled" // 취소됨
|
||||||
|
| "rolled_back";
|
||||||
|
|
||||||
|
export interface FleetDeployment {
|
||||||
|
id?: number;
|
||||||
|
release_id: number;
|
||||||
|
target_type: "all" | "company" | "group" | "device_list";
|
||||||
|
target_value?: string; // company_code, group name, or device ids (csv)
|
||||||
|
rollout_strategy: "immediate" | "canary" | "rolling";
|
||||||
|
rollout_percentage?: number;
|
||||||
|
batch_size?: number;
|
||||||
|
max_failures?: number;
|
||||||
|
pause_on_failure?: boolean;
|
||||||
|
description?: string;
|
||||||
|
scheduled_at?: Date;
|
||||||
|
status?: DeploymentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetDeploymentService {
|
||||||
|
static async list(filter: { status?: string; release_id?: number } = {}) {
|
||||||
|
const wheres: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.status) { wheres.push(`d.status = $${idx++}`); params.push(filter.status); }
|
||||||
|
if (filter.release_id) { wheres.push(`d.release_id = $${idx++}`); params.push(filter.release_id); }
|
||||||
|
|
||||||
|
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT d.*,
|
||||||
|
r.version as release_version, r.backend_image, r.frontend_image, r.agent_image
|
||||||
|
FROM fleet_deployments d
|
||||||
|
LEFT JOIN fleet_releases r ON d.release_id = r.id
|
||||||
|
${where}
|
||||||
|
ORDER BY d.created_at DESC LIMIT 100`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get(id: number) {
|
||||||
|
return await queryOne<any>(
|
||||||
|
`SELECT d.*, r.version as release_version, r.backend_image, r.frontend_image, r.agent_image
|
||||||
|
FROM fleet_deployments d
|
||||||
|
LEFT JOIN fleet_releases r ON d.release_id = r.id
|
||||||
|
WHERE d.id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getStatus(deploymentId: number) {
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT ds.*, d.device_name, d.company_code
|
||||||
|
FROM fleet_deployment_status ds
|
||||||
|
LEFT JOIN fleet_devices d ON ds.device_id = d.device_id
|
||||||
|
WHERE ds.deployment_id = $1
|
||||||
|
ORDER BY ds.device_id`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(data: FleetDeployment & { created_by?: string }): Promise<any> {
|
||||||
|
if (!data.release_id || !data.target_type) {
|
||||||
|
throw new Error("release_id, target_type 필수");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대상 디바이스 선정
|
||||||
|
const deviceIds = await this.resolveTargetDevices(data.target_type, data.target_value);
|
||||||
|
if (deviceIds.length === 0) throw new Error("대상 디바이스가 없습니다.");
|
||||||
|
|
||||||
|
const deploy = await query<any>(
|
||||||
|
`INSERT INTO fleet_deployments
|
||||||
|
(release_id, target_type, target_value, rollout_strategy, rollout_percentage,
|
||||||
|
batch_size, max_failures, pause_on_failure, description, scheduled_at,
|
||||||
|
status, total_devices, created_by)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11,$12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.release_id,
|
||||||
|
data.target_type,
|
||||||
|
data.target_value || null,
|
||||||
|
data.rollout_strategy || "rolling",
|
||||||
|
data.rollout_percentage || 100,
|
||||||
|
data.batch_size || 10,
|
||||||
|
data.max_failures || 3,
|
||||||
|
data.pause_on_failure !== false,
|
||||||
|
data.description || null,
|
||||||
|
data.scheduled_at || null,
|
||||||
|
deviceIds.length,
|
||||||
|
data.created_by || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 각 대상 디바이스별 status 레코드 생성
|
||||||
|
for (const did of deviceIds) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_deployment_status (deployment_id, device_id, status)
|
||||||
|
VALUES ($1, $2, 'pending')`,
|
||||||
|
[deploy[0].id, did],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[Fleet Deploy] 생성: id=${deploy[0].id}, 대상 ${deviceIds.length}개`);
|
||||||
|
return { ...deploy[0], device_count: deviceIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배포 대상 디바이스 목록 해석
|
||||||
|
*/
|
||||||
|
static async resolveTargetDevices(
|
||||||
|
targetType: string,
|
||||||
|
targetValue?: string | null,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (targetType === "all") {
|
||||||
|
const r = await query<any>(
|
||||||
|
`SELECT device_id FROM fleet_devices WHERE is_online = TRUE`,
|
||||||
|
);
|
||||||
|
return r.map((x: any) => x.device_id);
|
||||||
|
}
|
||||||
|
if (targetType === "company" && targetValue) {
|
||||||
|
const r = await query<any>(
|
||||||
|
`SELECT device_id FROM fleet_devices WHERE company_code = $1 AND is_online = TRUE`,
|
||||||
|
[targetValue],
|
||||||
|
);
|
||||||
|
return r.map((x: any) => x.device_id);
|
||||||
|
}
|
||||||
|
if (targetType === "group" && targetValue) {
|
||||||
|
const r = await query<any>(
|
||||||
|
`SELECT device_id FROM fleet_devices WHERE device_group = $1 AND is_online = TRUE`,
|
||||||
|
[targetValue],
|
||||||
|
);
|
||||||
|
return r.map((x: any) => x.device_id);
|
||||||
|
}
|
||||||
|
if (targetType === "device_list" && targetValue) {
|
||||||
|
return targetValue.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배포 시작 (실제 실행)
|
||||||
|
* - 전략별 배치 처리
|
||||||
|
* - 각 디바이스에 deploy 커맨드 발행 (MQTT)
|
||||||
|
* - 실패율 모니터링
|
||||||
|
*/
|
||||||
|
static async start(deploymentId: number, issuedBy?: string): Promise<any> {
|
||||||
|
const deploy = await this.get(deploymentId);
|
||||||
|
if (!deploy) throw new Error("배포 없음");
|
||||||
|
if (deploy.status !== "pending" && deploy.status !== "paused") {
|
||||||
|
throw new Error(`현재 상태(${deploy.status})에서 시작 불가`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployments SET status = 'running', started_at = COALESCE(started_at, NOW()) WHERE id = $1`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 대기 중 디바이스 조회
|
||||||
|
const pendingDevices = await query<any>(
|
||||||
|
`SELECT device_id FROM fleet_deployment_status
|
||||||
|
WHERE deployment_id = $1 AND status = 'pending'
|
||||||
|
ORDER BY device_id`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[Fleet Deploy] 시작: id=${deploymentId}, 대기중 ${pendingDevices.length}개`);
|
||||||
|
|
||||||
|
// 전략별 실행
|
||||||
|
const strategy = deploy.rollout_strategy;
|
||||||
|
const batchSize = deploy.batch_size || 10;
|
||||||
|
|
||||||
|
// 비동기로 백그라운드 배포 진행
|
||||||
|
this.runDeployment(deploymentId, pendingDevices.map((d: any) => d.device_id), strategy, batchSize, issuedBy)
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error(`[Fleet Deploy] 실행 에러 (id=${deploymentId}):`, e);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deploymentId, status: "running", scheduled: pendingDevices.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 배포 루프 (백그라운드)
|
||||||
|
*/
|
||||||
|
private static async runDeployment(
|
||||||
|
deploymentId: number,
|
||||||
|
deviceIds: string[],
|
||||||
|
strategy: string,
|
||||||
|
batchSize: number,
|
||||||
|
issuedBy?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const deploy = await this.get(deploymentId);
|
||||||
|
const release = deploy ? { image: deploy.backend_image, version: deploy.release_version } : {};
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const maxFailures = deploy?.max_failures || 3;
|
||||||
|
const pauseOnFail = deploy?.pause_on_failure !== false;
|
||||||
|
|
||||||
|
// 카나리: 첫 1개만 먼저 배포
|
||||||
|
const executeOrder: string[][] = [];
|
||||||
|
if (strategy === "canary" && deviceIds.length > 1) {
|
||||||
|
executeOrder.push([deviceIds[0]]); // canary
|
||||||
|
// 나머지는 배치로
|
||||||
|
for (let i = 1; i < deviceIds.length; i += batchSize) {
|
||||||
|
executeOrder.push(deviceIds.slice(i, i + batchSize));
|
||||||
|
}
|
||||||
|
} else if (strategy === "rolling") {
|
||||||
|
for (let i = 0; i < deviceIds.length; i += batchSize) {
|
||||||
|
executeOrder.push(deviceIds.slice(i, i + batchSize));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// immediate
|
||||||
|
executeOrder.push(deviceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const batch of executeOrder) {
|
||||||
|
// 취소 체크
|
||||||
|
const cur = await this.get(deploymentId);
|
||||||
|
if (!cur || cur.status === "cancelled") {
|
||||||
|
logger.info(`[Fleet Deploy] 취소 감지: id=${deploymentId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batch.map((did) => this.deployToDevice(deploymentId, did, deploy, issuedBy)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 배치 완료 후 실패율 체크
|
||||||
|
const failCount = await queryOne<any>(
|
||||||
|
`SELECT COUNT(*) as n FROM fleet_deployment_status
|
||||||
|
WHERE deployment_id = $1 AND status IN ('failed','timeout')`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
failures = parseInt(failCount?.n || 0);
|
||||||
|
|
||||||
|
if (pauseOnFail && failures >= maxFailures) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployments SET status = 'paused', completed_at = NULL WHERE id = $1`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
logger.warn(`[Fleet Deploy] 실패율 초과로 일시정지: id=${deploymentId} (실패 ${failures}개)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카나리: 첫 배치 완료 후 안정성 대기
|
||||||
|
if (strategy === "canary" && executeOrder.indexOf(batch) === 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000)); // 3초 관찰
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최종 집계
|
||||||
|
const stats = await queryOne<any>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'success') as success,
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('failed','timeout')) as failed,
|
||||||
|
COUNT(*) as total
|
||||||
|
FROM fleet_deployment_status WHERE deployment_id = $1`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalStatus =
|
||||||
|
parseInt(stats.failed) > 0
|
||||||
|
? (parseInt(stats.success) > 0 ? "completed" : "failed")
|
||||||
|
: "completed";
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployments
|
||||||
|
SET status = $2, completed_at = NOW(),
|
||||||
|
success_count = $3, failed_count = $4
|
||||||
|
WHERE id = $1`,
|
||||||
|
[deploymentId, finalStatus, parseInt(stats.success), parseInt(stats.failed)],
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[Fleet Deploy] 완료: id=${deploymentId} 성공 ${stats.success} / 실패 ${stats.failed} / 총 ${stats.total}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 디바이스 배포 (MQTT 커맨드)
|
||||||
|
*/
|
||||||
|
private static async deployToDevice(
|
||||||
|
deploymentId: number,
|
||||||
|
deviceId: string,
|
||||||
|
deploy: any,
|
||||||
|
issuedBy?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployment_status
|
||||||
|
SET status = 'running', started_at = NOW()
|
||||||
|
WHERE deployment_id = $1 AND device_id = $2`,
|
||||||
|
[deploymentId, deviceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// MQTT로 deploy 커맨드 발행
|
||||||
|
const cmd = await FleetCommandService.issueCommand(
|
||||||
|
deviceId,
|
||||||
|
"deploy",
|
||||||
|
{
|
||||||
|
deployment_id: deploymentId,
|
||||||
|
release_id: deploy.release_id,
|
||||||
|
backend_image: deploy.backend_image,
|
||||||
|
frontend_image: deploy.frontend_image,
|
||||||
|
agent_image: deploy.agent_image,
|
||||||
|
version: deploy.release_version,
|
||||||
|
},
|
||||||
|
issuedBy,
|
||||||
|
600, // 10분 타임아웃
|
||||||
|
);
|
||||||
|
|
||||||
|
// 응답 대기 (폴링) - 60초
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
const cs = await queryOne<any>(
|
||||||
|
`SELECT status FROM fleet_commands WHERE id = $1`,
|
||||||
|
[cmd.id],
|
||||||
|
);
|
||||||
|
if (["success", "failed", "timeout"].includes(cs?.status)) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployment_status
|
||||||
|
SET status = $3, completed_at = NOW()
|
||||||
|
WHERE deployment_id = $1 AND device_id = $2`,
|
||||||
|
[deploymentId, deviceId, cs.status],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타임아웃
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployment_status
|
||||||
|
SET status = 'timeout', completed_at = NOW(), error_message = '응답 타임아웃'
|
||||||
|
WHERE deployment_id = $1 AND device_id = $2`,
|
||||||
|
[deploymentId, deviceId],
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployment_status
|
||||||
|
SET status = 'failed', completed_at = NOW(), error_message = $3
|
||||||
|
WHERE deployment_id = $1 AND device_id = $2`,
|
||||||
|
[deploymentId, deviceId, e.message],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async cancel(deploymentId: number) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployments SET status = 'cancelled', completed_at = NOW() WHERE id = $1`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployment_status
|
||||||
|
SET status = 'cancelled', completed_at = NOW()
|
||||||
|
WHERE deployment_id = $1 AND status IN ('pending','running')`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 롤백: 해당 배포의 이전 버전으로 복원
|
||||||
|
*/
|
||||||
|
static async rollback(deploymentId: number, issuedBy?: string) {
|
||||||
|
const deploy = await this.get(deploymentId);
|
||||||
|
if (!deploy) throw new Error("배포 없음");
|
||||||
|
if (!["completed", "failed", "paused"].includes(deploy.status)) {
|
||||||
|
throw new Error(`현재 상태(${deploy.status})에서 롤백 불가`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이전 completed 배포 찾기 (같은 target)
|
||||||
|
const previous = await queryOne<any>(
|
||||||
|
`SELECT d.*, r.backend_image, r.frontend_image, r.agent_image, r.version as release_version
|
||||||
|
FROM fleet_deployments d
|
||||||
|
LEFT JOIN fleet_releases r ON d.release_id = r.id
|
||||||
|
WHERE d.id < $1
|
||||||
|
AND d.target_type = $2
|
||||||
|
AND d.target_value IS NOT DISTINCT FROM $3
|
||||||
|
AND d.status = 'completed'
|
||||||
|
ORDER BY d.id DESC LIMIT 1`,
|
||||||
|
[deploymentId, deploy.target_type, deploy.target_value],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!previous) throw new Error("롤백할 이전 배포 없음");
|
||||||
|
|
||||||
|
const rollbackDeploy = await this.create({
|
||||||
|
release_id: previous.release_id,
|
||||||
|
target_type: deploy.target_type,
|
||||||
|
target_value: deploy.target_value,
|
||||||
|
rollout_strategy: "immediate",
|
||||||
|
rollout_percentage: 100,
|
||||||
|
description: `롤백: #${deploymentId} → #${previous.id} (${previous.release_version})`,
|
||||||
|
created_by: issuedBy,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await this.start(rollbackDeploy.id, issuedBy);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_deployments SET status = 'rolled_back' WHERE id = $1`,
|
||||||
|
[deploymentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { deploymentId: rollbackDeploy.id, originalId: deploymentId, previousReleaseId: previous.release_id };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Device Service
|
||||||
|
* - 디바이스 등록 (하드웨어 핑거프린트 기반 DPS 패턴)
|
||||||
|
* - 상태 조회 / 업데이트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export interface FleetDevice {
|
||||||
|
id?: number;
|
||||||
|
device_id: string;
|
||||||
|
company_code?: string;
|
||||||
|
company_id?: string;
|
||||||
|
device_name?: string;
|
||||||
|
device_type?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
mac_address?: string;
|
||||||
|
hardware_fingerprint?: string;
|
||||||
|
last_seen_at?: Date;
|
||||||
|
is_online?: boolean;
|
||||||
|
equipment_id?: number | null;
|
||||||
|
agent_version?: string;
|
||||||
|
app_version?: string;
|
||||||
|
os_info?: Record<string, any>;
|
||||||
|
hardware_info?: Record<string, any>;
|
||||||
|
device_group?: string;
|
||||||
|
tags?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetDeviceService {
|
||||||
|
/**
|
||||||
|
* 디바이스 등록 (핑거프린트 기반 - DPS 패턴)
|
||||||
|
* - 동일한 핑거프린트가 있으면 기존 레코드 업데이트
|
||||||
|
* - 없으면 신규 등록
|
||||||
|
*/
|
||||||
|
static async registerDevice(data: Partial<FleetDevice>): Promise<FleetDevice> {
|
||||||
|
if (!data.device_id) throw new Error("device_id 필수");
|
||||||
|
|
||||||
|
// 핑거프린트 매칭
|
||||||
|
if (data.hardware_fingerprint) {
|
||||||
|
const existing = await queryOne<any>(
|
||||||
|
`SELECT * FROM fleet_devices WHERE hardware_fingerprint = $1 LIMIT 1`,
|
||||||
|
[data.hardware_fingerprint],
|
||||||
|
);
|
||||||
|
if (existing && existing.device_id !== data.device_id) {
|
||||||
|
logger.info(
|
||||||
|
`[Fleet] 핑거프린트 중복 감지 - 기존 device_id 재사용: ${existing.device_id} (요청: ${data.device_id})`,
|
||||||
|
);
|
||||||
|
data.device_id = existing.device_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPSERT
|
||||||
|
const result = await query<FleetDevice>(
|
||||||
|
`INSERT INTO fleet_devices
|
||||||
|
(device_id, company_code, company_id, device_name, device_type,
|
||||||
|
ip_address, mac_address, hardware_fingerprint,
|
||||||
|
agent_version, app_version, os_info, hardware_info,
|
||||||
|
device_group, tags, equipment_id, last_seen_at, is_online)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14::jsonb,$15,NOW(),TRUE)
|
||||||
|
ON CONFLICT (device_id) DO UPDATE SET
|
||||||
|
company_code = COALESCE(EXCLUDED.company_code, fleet_devices.company_code),
|
||||||
|
device_name = COALESCE(EXCLUDED.device_name, fleet_devices.device_name),
|
||||||
|
device_type = COALESCE(EXCLUDED.device_type, fleet_devices.device_type),
|
||||||
|
ip_address = COALESCE(EXCLUDED.ip_address, fleet_devices.ip_address),
|
||||||
|
mac_address = COALESCE(EXCLUDED.mac_address, fleet_devices.mac_address),
|
||||||
|
hardware_fingerprint = COALESCE(EXCLUDED.hardware_fingerprint, fleet_devices.hardware_fingerprint),
|
||||||
|
agent_version = COALESCE(EXCLUDED.agent_version, fleet_devices.agent_version),
|
||||||
|
app_version = COALESCE(EXCLUDED.app_version, fleet_devices.app_version),
|
||||||
|
os_info = COALESCE(EXCLUDED.os_info, fleet_devices.os_info),
|
||||||
|
hardware_info = COALESCE(EXCLUDED.hardware_info, fleet_devices.hardware_info),
|
||||||
|
device_group = COALESCE(EXCLUDED.device_group, fleet_devices.device_group),
|
||||||
|
equipment_id = COALESCE(EXCLUDED.equipment_id, fleet_devices.equipment_id),
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
is_online = TRUE,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.device_id,
|
||||||
|
data.company_code || null,
|
||||||
|
data.company_id || null,
|
||||||
|
data.device_name || null,
|
||||||
|
data.device_type || "edge",
|
||||||
|
data.ip_address || null,
|
||||||
|
data.mac_address || null,
|
||||||
|
data.hardware_fingerprint || null,
|
||||||
|
data.agent_version || null,
|
||||||
|
data.app_version || null,
|
||||||
|
JSON.stringify(data.os_info || {}),
|
||||||
|
JSON.stringify(data.hardware_info || {}),
|
||||||
|
data.device_group || null,
|
||||||
|
JSON.stringify(data.tags || []),
|
||||||
|
data.equipment_id || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[Fleet] 디바이스 등록/업데이트: ${data.device_id}`);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listDevices(filter: {
|
||||||
|
company_code?: string;
|
||||||
|
is_online?: boolean;
|
||||||
|
device_type?: string;
|
||||||
|
search?: string;
|
||||||
|
} = {}) {
|
||||||
|
const wheres: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.company_code && filter.company_code !== "*") {
|
||||||
|
wheres.push(`(d.company_code = $${idx} OR d.company_code = '*')`);
|
||||||
|
params.push(filter.company_code);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (filter.is_online !== undefined) {
|
||||||
|
wheres.push(`d.is_online = $${idx}`);
|
||||||
|
params.push(filter.is_online);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (filter.device_type) {
|
||||||
|
wheres.push(`d.device_type = $${idx}`);
|
||||||
|
params.push(filter.device_type);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (filter.search?.trim()) {
|
||||||
|
wheres.push(`(d.device_id ILIKE $${idx} OR d.device_name ILIKE $${idx})`);
|
||||||
|
params.push(`%${filter.search.trim()}%`);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT d.*, e.equipment_name, e.equipment_code
|
||||||
|
FROM fleet_devices d
|
||||||
|
LEFT JOIN pipeline_equipment e ON d.equipment_id = e.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY d.is_online DESC, d.last_seen_at DESC NULLS LAST`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getDeviceById(deviceId: string) {
|
||||||
|
return await queryOne<any>(
|
||||||
|
`SELECT d.*, e.equipment_name, e.equipment_code
|
||||||
|
FROM fleet_devices d
|
||||||
|
LEFT JOIN pipeline_equipment e ON d.equipment_id = e.id
|
||||||
|
WHERE d.device_id = $1`,
|
||||||
|
[deviceId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateDevice(deviceId: string, data: Partial<FleetDevice>) {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
const fields: (keyof FleetDevice)[] = [
|
||||||
|
"device_name",
|
||||||
|
"device_type",
|
||||||
|
"company_code",
|
||||||
|
"device_group",
|
||||||
|
"equipment_id",
|
||||||
|
];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (data[f] !== undefined) {
|
||||||
|
sets.push(`${f} = $${idx++}`);
|
||||||
|
params.push(data[f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.tags !== undefined) {
|
||||||
|
sets.push(`tags = $${idx++}::jsonb`);
|
||||||
|
params.push(JSON.stringify(data.tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) return this.getDeviceById(deviceId);
|
||||||
|
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
params.push(deviceId);
|
||||||
|
|
||||||
|
const result = await query<any>(
|
||||||
|
`UPDATE fleet_devices SET ${sets.join(", ")} WHERE device_id = $${idx} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteDevice(deviceId: string) {
|
||||||
|
await query(`DELETE FROM fleet_devices WHERE device_id = $1`, [deviceId]);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heartbeat 수신 처리 - 디바이스 상태 + 메트릭 업데이트
|
||||||
|
*/
|
||||||
|
static async handleHeartbeat(deviceId: string, data: {
|
||||||
|
status?: string;
|
||||||
|
uptime_seconds?: number;
|
||||||
|
cpu_percent?: number;
|
||||||
|
memory_percent?: number;
|
||||||
|
disk_percent?: number;
|
||||||
|
containers?: any[];
|
||||||
|
ip_address?: string;
|
||||||
|
}) {
|
||||||
|
// UPSERT: 없으면 자동 등록 (heartbeat 수신 = 자동 등록)
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_devices (device_id, ip_address, last_seen_at, is_online, device_type, company_code)
|
||||||
|
VALUES ($1, $2, NOW(), TRUE, 'edge', '*')
|
||||||
|
ON CONFLICT (device_id) DO UPDATE SET
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
is_online = TRUE,
|
||||||
|
ip_address = COALESCE(EXCLUDED.ip_address, fleet_devices.ip_address),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[deviceId, data.ip_address || null],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Heartbeat 로그 삽입
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_heartbeats
|
||||||
|
(device_id, status, uptime_seconds, cpu_percent, memory_percent, disk_percent, containers)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)
|
||||||
|
ON CONFLICT (device_id, received_at) DO NOTHING`,
|
||||||
|
[
|
||||||
|
deviceId,
|
||||||
|
data.status || "online",
|
||||||
|
data.uptime_seconds || null,
|
||||||
|
data.cpu_percent || null,
|
||||||
|
data.memory_percent || null,
|
||||||
|
data.disk_percent || null,
|
||||||
|
JSON.stringify(data.containers || []),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오프라인 감지 - 일정 시간 이상 heartbeat 없으면 offline 표시
|
||||||
|
* 주기적으로 호출해야 함 (예: 1분마다)
|
||||||
|
*/
|
||||||
|
static async markStaleDevicesOffline(thresholdSeconds = 120) {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE fleet_devices
|
||||||
|
SET is_online = FALSE, updated_at = NOW()
|
||||||
|
WHERE is_online = TRUE
|
||||||
|
AND (last_seen_at IS NULL OR last_seen_at < NOW() - ($1 || ' seconds')::INTERVAL)
|
||||||
|
RETURNING device_id`,
|
||||||
|
[thresholdSeconds.toString()],
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
logger.info(`[Fleet] 오프라인 감지: ${result.length}개 (${result.map((r: any) => r.device_id).join(", ")})`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 heartbeat 메트릭 조회
|
||||||
|
*/
|
||||||
|
static async getRecentMetrics(deviceId: string, limit = 100) {
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT * FROM fleet_heartbeats
|
||||||
|
WHERE device_id = $1
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
[deviceId, limit],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Edge Config Service
|
||||||
|
* - Python Data Collector가 부팅 시 호출
|
||||||
|
* - GET /api/v1/edges/{deviceId}/config 응답 생성
|
||||||
|
* - pipeline_device_connections + pipeline_tag_mappings → EdgeConfig 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export interface EdgeTagConfig {
|
||||||
|
name: string;
|
||||||
|
address: string | number;
|
||||||
|
data_type: string;
|
||||||
|
byte_order: string;
|
||||||
|
scale: number;
|
||||||
|
offset: number;
|
||||||
|
unit?: string;
|
||||||
|
description?: string;
|
||||||
|
bit_index?: number | null;
|
||||||
|
deadband?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeDeviceConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
protocol: string; // modbus, opcua, xgt, s7, mqtt 등
|
||||||
|
connection: Record<string, any>; // host, port, protocol_config
|
||||||
|
interval_ms: number;
|
||||||
|
tags: EdgeTagConfig[];
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeScript {
|
||||||
|
id: number;
|
||||||
|
script_name: string;
|
||||||
|
scope: string;
|
||||||
|
equipment_id?: number | null;
|
||||||
|
connection_id?: number | null;
|
||||||
|
hook_type: string;
|
||||||
|
code: string;
|
||||||
|
priority: number;
|
||||||
|
timeout_ms: number;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeFullConfig {
|
||||||
|
version: string;
|
||||||
|
edge_id: string;
|
||||||
|
edge_name?: string;
|
||||||
|
devices: EdgeDeviceConfig[];
|
||||||
|
scripts: EdgeScript[];
|
||||||
|
aggregation_interval_sec: number;
|
||||||
|
local_retention_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetEdgeConfigService {
|
||||||
|
/**
|
||||||
|
* 엣지 디바이스별 수집 설정 생성
|
||||||
|
* - fleet_devices에서 해당 edge 정보 조회
|
||||||
|
* - equipment_id로 연결된 pipeline_device_connections 조회
|
||||||
|
* - 각 connection의 pipeline_tag_mappings 조회
|
||||||
|
*/
|
||||||
|
static async getEdgeConfig(edgeId: string): Promise<EdgeFullConfig> {
|
||||||
|
// 디바이스 확인
|
||||||
|
const device = await query<any>(
|
||||||
|
`SELECT device_id, device_name, equipment_id, company_code
|
||||||
|
FROM fleet_devices WHERE device_id = $1`,
|
||||||
|
[edgeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (device.length === 0) {
|
||||||
|
// 등록되지 않은 디바이스는 빈 설정 반환 (자동 등록은 heartbeat로)
|
||||||
|
logger.warn(`[Fleet Config] 미등록 디바이스 요청: ${edgeId}`);
|
||||||
|
return {
|
||||||
|
version: "1.0",
|
||||||
|
edge_id: edgeId,
|
||||||
|
devices: [],
|
||||||
|
scripts: [],
|
||||||
|
aggregation_interval_sec: 60,
|
||||||
|
local_retention_days: 7,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeInfo = device[0];
|
||||||
|
|
||||||
|
// 이 엣지에 할당된 통신 연결 조회
|
||||||
|
// 방법 1: fleet_devices.equipment_id → pipeline_device_connections.equipment_id
|
||||||
|
// 방법 2: 회사 전체 연결 (company_code 매칭)
|
||||||
|
const connections = await query<any>(
|
||||||
|
`SELECT c.*, e.equipment_name, e.equipment_code
|
||||||
|
FROM pipeline_device_connections c
|
||||||
|
LEFT JOIN pipeline_equipment e ON c.equipment_id = e.id
|
||||||
|
WHERE c.is_active = 'Y'
|
||||||
|
AND (
|
||||||
|
c.equipment_id = $1
|
||||||
|
OR (c.company_code IS NULL OR c.company_code = $2 OR c.company_code = '*')
|
||||||
|
)
|
||||||
|
ORDER BY c.id`,
|
||||||
|
[edgeInfo.equipment_id || 0, edgeInfo.company_code || "*"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 각 연결의 태그 조회
|
||||||
|
const devices: EdgeDeviceConfig[] = [];
|
||||||
|
for (const conn of connections) {
|
||||||
|
const tags = await query<any>(
|
||||||
|
`SELECT * FROM pipeline_tag_mappings
|
||||||
|
WHERE connection_id = $1 AND is_active = 'Y'
|
||||||
|
ORDER BY id`,
|
||||||
|
[conn.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const protocolMap: Record<string, string> = {
|
||||||
|
MODBUS_TCP: "modbus_tcp",
|
||||||
|
MODBUS_RTU: "modbus_rtu",
|
||||||
|
OPCUA: "opcua",
|
||||||
|
SIEMENS_S7: "s7",
|
||||||
|
LS_XGT: "xgt",
|
||||||
|
MQTT: "mqtt",
|
||||||
|
REST_API: "rest_api",
|
||||||
|
};
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: conn.id.toString(),
|
||||||
|
name: conn.connection_name,
|
||||||
|
protocol: protocolMap[conn.protocol] || conn.protocol.toLowerCase(),
|
||||||
|
connection: {
|
||||||
|
host: conn.host,
|
||||||
|
port: conn.port,
|
||||||
|
...conn.protocol_config,
|
||||||
|
},
|
||||||
|
interval_ms: conn.polling_interval_ms || 1000,
|
||||||
|
enabled: conn.is_active === "Y",
|
||||||
|
tags: tags.map((t: any) => ({
|
||||||
|
name: t.tag_name,
|
||||||
|
address: t.address,
|
||||||
|
data_type: t.tag_data_type || "UINT16",
|
||||||
|
byte_order: t.byte_order || "BIG_ENDIAN",
|
||||||
|
scale: parseFloat(t.scale_factor || 1),
|
||||||
|
offset: parseFloat(t.offset_value || 0),
|
||||||
|
unit: t.tag_unit || undefined,
|
||||||
|
description: t.description || undefined,
|
||||||
|
bit_index: t.bit_index ?? undefined,
|
||||||
|
deadband: t.deadband ? parseFloat(t.deadband) : undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 버전: 최신 태그 업데이트 시각을 해시처럼 사용
|
||||||
|
const versionResult = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
MAX(GREATEST(c.updated_at, c.created_at)) as conn_ver,
|
||||||
|
MAX(GREATEST(t.updated_at, t.created_at)) as tag_ver
|
||||||
|
FROM pipeline_device_connections c
|
||||||
|
LEFT JOIN pipeline_tag_mappings t ON t.connection_id = c.id
|
||||||
|
WHERE c.is_active = 'Y'`,
|
||||||
|
);
|
||||||
|
const connVer = versionResult[0]?.conn_ver || new Date();
|
||||||
|
const tagVer = versionResult[0]?.tag_ver || new Date();
|
||||||
|
const maxVer = new Date(Math.max(new Date(connVer).getTime(), new Date(tagVer).getTime()));
|
||||||
|
const version = maxVer.toISOString();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[Fleet Config] ${edgeId} 설정 제공: ${devices.length}개 장비, ` +
|
||||||
|
`태그 ${devices.reduce((sum, d) => sum + d.tags.length, 0)}개, version=${version}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 이 엣지에 적용되는 Python hook 스크립트 조회
|
||||||
|
const connectionIds = connections.map((c: any) => c.id);
|
||||||
|
const { FleetScriptService } = await import("./fleetScriptService");
|
||||||
|
const scripts = await FleetScriptService.getScriptsForEdge(
|
||||||
|
edgeId,
|
||||||
|
edgeInfo.equipment_id,
|
||||||
|
connectionIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
edge_id: edgeId,
|
||||||
|
edge_name: edgeInfo.device_name,
|
||||||
|
devices,
|
||||||
|
scripts,
|
||||||
|
aggregation_interval_sec: 60,
|
||||||
|
local_retention_days: 7,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 버전만 반환 (ETag 캐싱용 - Python이 If-None-Match로 확인)
|
||||||
|
*/
|
||||||
|
static async getConfigVersion(edgeId: string): Promise<string> {
|
||||||
|
const r = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
MAX(GREATEST(c.updated_at, c.created_at)) as ver
|
||||||
|
FROM pipeline_device_connections c
|
||||||
|
WHERE c.is_active = 'Y'`,
|
||||||
|
);
|
||||||
|
return r[0]?.ver ? new Date(r[0].ver).toISOString() : "1.0";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Harbor Registry Service
|
||||||
|
* - harbor.wace.me에서 이미지 목록/태그 조회
|
||||||
|
* - 릴리즈 생성 시 이미지 선택용
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const HARBOR_URL = process.env.HARBOR_URL || "https://harbor.wace.me";
|
||||||
|
const HARBOR_USER = process.env.HARBOR_USER || "";
|
||||||
|
const HARBOR_PASSWORD = process.env.HARBOR_PASSWORD || "";
|
||||||
|
|
||||||
|
export class FleetHarborService {
|
||||||
|
private static client = axios.create({
|
||||||
|
baseURL: HARBOR_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
auth: HARBOR_USER && HARBOR_PASSWORD ? {
|
||||||
|
username: HARBOR_USER,
|
||||||
|
password: HARBOR_PASSWORD,
|
||||||
|
} : undefined,
|
||||||
|
validateStatus: (status) => status < 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 목록
|
||||||
|
*/
|
||||||
|
static async listProjects(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const r = await this.client.get("/api/v2.0/projects", {
|
||||||
|
params: { page: 1, page_size: 100 },
|
||||||
|
});
|
||||||
|
return (r.data || []).map((p: any) => ({
|
||||||
|
project_id: p.project_id,
|
||||||
|
name: p.name,
|
||||||
|
public: p.metadata?.public === "true",
|
||||||
|
repo_count: p.repo_count,
|
||||||
|
}));
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn(`[Harbor] 프로젝트 조회 실패: ${e.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트의 리포지토리 목록
|
||||||
|
*/
|
||||||
|
static async listRepositories(projectName: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const r = await this.client.get(
|
||||||
|
`/api/v2.0/projects/${encodeURIComponent(projectName)}/repositories`,
|
||||||
|
{ params: { page: 1, page_size: 100 } },
|
||||||
|
);
|
||||||
|
return (r.data || []).map((repo: any) => ({
|
||||||
|
name: repo.name,
|
||||||
|
pull_count: repo.pull_count,
|
||||||
|
artifact_count: repo.artifact_count,
|
||||||
|
update_time: repo.update_time,
|
||||||
|
}));
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn(`[Harbor] 리포 조회 실패 ${projectName}: ${e.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리포지토리의 태그 목록
|
||||||
|
*/
|
||||||
|
static async listTags(projectName: string, repoName: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
// Harbor에서 repo 이름이 project/repo 형식이면 뒷부분만 사용
|
||||||
|
const repoKey = repoName.includes("/") ? repoName.split("/").slice(1).join("/") : repoName;
|
||||||
|
const r = await this.client.get(
|
||||||
|
`/api/v2.0/projects/${encodeURIComponent(projectName)}/repositories/${encodeURIComponent(repoKey)}/artifacts`,
|
||||||
|
{ params: { page: 1, page_size: 50, with_tag: true } },
|
||||||
|
);
|
||||||
|
const tags: any[] = [];
|
||||||
|
for (const artifact of r.data || []) {
|
||||||
|
for (const tag of artifact.tags || []) {
|
||||||
|
tags.push({
|
||||||
|
tag: tag.name,
|
||||||
|
digest: artifact.digest,
|
||||||
|
size: artifact.size,
|
||||||
|
push_time: tag.push_time,
|
||||||
|
pull_time: tag.pull_time,
|
||||||
|
full_ref: `${HARBOR_URL.replace(/^https?:\/\//, "")}/${projectName}/${repoKey}:${tag.name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags.sort((a, b) => new Date(b.push_time).getTime() - new Date(a.push_time).getTime());
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn(`[Harbor] 태그 조회 실패 ${projectName}/${repoName}: ${e.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 전체 참조 조합 (릴리즈 생성 시 사용)
|
||||||
|
* 예: harbor.wace.me/vexplor_fleet/data-collector:v1.2.3
|
||||||
|
*/
|
||||||
|
static buildImageRef(projectName: string, repoName: string, tag: string): string {
|
||||||
|
const host = HARBOR_URL.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||||
|
const repoKey = repoName.includes("/") ? repoName.split("/").slice(1).join("/") : repoName;
|
||||||
|
return `${host}/${projectName}/${repoKey}:${tag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harbor 연결 상태 체크
|
||||||
|
*/
|
||||||
|
static async ping(): Promise<{ ok: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const r = await this.client.get("/api/v2.0/health");
|
||||||
|
return { ok: r.status === 200, message: `Harbor ${r.data?.status || "OK"}` };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, message: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Prometheus Metrics
|
||||||
|
* - /metrics 엔드포인트에 Prometheus text format으로 노출
|
||||||
|
* - 디바이스/커맨드/배포/알림 통계
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
|
||||||
|
export class FleetMetricsService {
|
||||||
|
/**
|
||||||
|
* Prometheus text format 메트릭 생성
|
||||||
|
*/
|
||||||
|
static async generate(): Promise<string> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// 디바이스 상태
|
||||||
|
const devices = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
is_online,
|
||||||
|
company_code,
|
||||||
|
COUNT(*) as n
|
||||||
|
FROM fleet_devices
|
||||||
|
GROUP BY is_online, company_code`,
|
||||||
|
);
|
||||||
|
lines.push("# HELP fleet_devices_total 디바이스 총 개수");
|
||||||
|
lines.push("# TYPE fleet_devices_total gauge");
|
||||||
|
for (const d of devices) {
|
||||||
|
const online = d.is_online ? "true" : "false";
|
||||||
|
lines.push(
|
||||||
|
`fleet_devices_total{online="${online}",company_code="${d.company_code || "unknown"}"} ${d.n}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 1시간 heartbeat
|
||||||
|
const hbStats = await queryOne<any>(
|
||||||
|
`SELECT
|
||||||
|
AVG(cpu_percent) as avg_cpu,
|
||||||
|
AVG(memory_percent) as avg_memory,
|
||||||
|
AVG(disk_percent) as avg_disk,
|
||||||
|
COUNT(*) as hb_count
|
||||||
|
FROM fleet_heartbeats
|
||||||
|
WHERE received_at > NOW() - INTERVAL '1 hour'`,
|
||||||
|
);
|
||||||
|
lines.push("# HELP fleet_cpu_percent_avg 최근 1시간 평균 CPU (%)");
|
||||||
|
lines.push("# TYPE fleet_cpu_percent_avg gauge");
|
||||||
|
lines.push(`fleet_cpu_percent_avg ${hbStats?.avg_cpu || 0}`);
|
||||||
|
lines.push("# HELP fleet_memory_percent_avg 최근 1시간 평균 메모리 (%)");
|
||||||
|
lines.push("# TYPE fleet_memory_percent_avg gauge");
|
||||||
|
lines.push(`fleet_memory_percent_avg ${hbStats?.avg_memory || 0}`);
|
||||||
|
lines.push("# HELP fleet_heartbeat_count 최근 1시간 heartbeat 수");
|
||||||
|
lines.push("# TYPE fleet_heartbeat_count counter");
|
||||||
|
lines.push(`fleet_heartbeat_count ${hbStats?.hb_count || 0}`);
|
||||||
|
|
||||||
|
// 커맨드
|
||||||
|
const cmds = await query<any>(
|
||||||
|
`SELECT status, COUNT(*) as n FROM fleet_commands
|
||||||
|
WHERE issued_at > NOW() - INTERVAL '24 hours'
|
||||||
|
GROUP BY status`,
|
||||||
|
);
|
||||||
|
lines.push("# HELP fleet_commands_total 최근 24시간 커맨드 (상태별)");
|
||||||
|
lines.push("# TYPE fleet_commands_total counter");
|
||||||
|
for (const c of cmds) {
|
||||||
|
lines.push(`fleet_commands_total{status="${c.status}"} ${c.n}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림
|
||||||
|
const alerts = await query<any>(
|
||||||
|
`SELECT severity, status, COUNT(*) as n FROM fleet_alerts
|
||||||
|
GROUP BY severity, status`,
|
||||||
|
);
|
||||||
|
lines.push("# HELP fleet_alerts_total 알림 (심각도/상태별)");
|
||||||
|
lines.push("# TYPE fleet_alerts_total gauge");
|
||||||
|
for (const a of alerts) {
|
||||||
|
lines.push(
|
||||||
|
`fleet_alerts_total{severity="${a.severity}",status="${a.status}"} ${a.n}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배포
|
||||||
|
const deploys = await query<any>(
|
||||||
|
`SELECT status, COUNT(*) as n FROM fleet_deployments
|
||||||
|
WHERE created_at > NOW() - INTERVAL '7 days'
|
||||||
|
GROUP BY status`,
|
||||||
|
);
|
||||||
|
lines.push("# HELP fleet_deployments_total 최근 7일 배포 (상태별)");
|
||||||
|
lines.push("# TYPE fleet_deployments_total counter");
|
||||||
|
for (const d of deploys) {
|
||||||
|
lines.push(`fleet_deployments_total{status="${d.status}"} ${d.n}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLC 연결 상태
|
||||||
|
const plcs = await query<any>(
|
||||||
|
`SELECT status, COUNT(*) as n FROM fleet_plc_connections GROUP BY status`,
|
||||||
|
);
|
||||||
|
lines.push("# HELP fleet_plc_connections_total PLC 연결 (상태별)");
|
||||||
|
lines.push("# TYPE fleet_plc_connections_total gauge");
|
||||||
|
for (const p of plcs) {
|
||||||
|
lines.push(`fleet_plc_connections_total{status="${p.status}"} ${p.n}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실시간 데이터 수집
|
||||||
|
const edgeData = await queryOne<any>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as records_1h,
|
||||||
|
COUNT(DISTINCT device_id) as active_devices,
|
||||||
|
COUNT(DISTINCT tag_name) as unique_tags
|
||||||
|
FROM fleet_edge_raw_data
|
||||||
|
WHERE time > NOW() - INTERVAL '1 hour'`,
|
||||||
|
);
|
||||||
|
lines.push("# HELP fleet_edge_records_1h 최근 1시간 수집 레코드");
|
||||||
|
lines.push("# TYPE fleet_edge_records_1h counter");
|
||||||
|
lines.push(`fleet_edge_records_1h ${edgeData?.records_1h || 0}`);
|
||||||
|
lines.push("# HELP fleet_edge_active_devices 최근 1시간 활성 디바이스");
|
||||||
|
lines.push("# TYPE fleet_edge_active_devices gauge");
|
||||||
|
lines.push(`fleet_edge_active_devices ${edgeData?.active_devices || 0}`);
|
||||||
|
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Fleet PLC Connection Status Service
|
||||||
|
* - 각 디바이스의 PLC 연결 실시간 상태 추적
|
||||||
|
* - MQTT로 엣지가 PLC 상태 변경 시 보고 → DB 업데이트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export interface PlcConnectionStatus {
|
||||||
|
id?: number;
|
||||||
|
device_id: string;
|
||||||
|
equipment_id?: number;
|
||||||
|
connection_id?: number;
|
||||||
|
protocol?: string;
|
||||||
|
status: "connected" | "disconnected" | "error" | "unknown";
|
||||||
|
last_connected_at?: Date;
|
||||||
|
last_error_at?: Date;
|
||||||
|
last_error_message?: string;
|
||||||
|
tag_count?: number;
|
||||||
|
uptime_sec?: number;
|
||||||
|
reconnect_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetPlcStatusService {
|
||||||
|
/**
|
||||||
|
* 디바이스별 PLC 연결 상태 목록
|
||||||
|
*/
|
||||||
|
static async list(filter: { device_id?: string; status?: string } = {}) {
|
||||||
|
const wheres: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.device_id) { wheres.push(`p.device_id = $${idx++}`); params.push(filter.device_id); }
|
||||||
|
if (filter.status) { wheres.push(`p.status = $${idx++}`); params.push(filter.status); }
|
||||||
|
|
||||||
|
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT p.*,
|
||||||
|
e.equipment_name, e.equipment_code,
|
||||||
|
c.connection_name
|
||||||
|
FROM fleet_plc_connections p
|
||||||
|
LEFT JOIN pipeline_equipment e ON p.equipment_id = e.id
|
||||||
|
LEFT JOIN pipeline_device_connections c ON p.connection_id = c.id
|
||||||
|
${where}
|
||||||
|
ORDER BY p.status DESC, p.device_id, p.connection_id`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLC 상태 보고 (엣지 에이전트가 MQTT로 전송)
|
||||||
|
* topic: vexplor/devices/{deviceId}/plc-status
|
||||||
|
*/
|
||||||
|
static async report(deviceId: string, data: {
|
||||||
|
connection_id: number;
|
||||||
|
equipment_id?: number;
|
||||||
|
protocol?: string;
|
||||||
|
status: string;
|
||||||
|
tag_count?: number;
|
||||||
|
uptime_sec?: number;
|
||||||
|
error_message?: string;
|
||||||
|
}) {
|
||||||
|
const now = new Date();
|
||||||
|
const isConnected = data.status === "connected";
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_plc_connections
|
||||||
|
(device_id, equipment_id, connection_id, protocol, status,
|
||||||
|
last_connected_at, last_error_at, last_error_message,
|
||||||
|
tag_count, uptime_sec, updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||||
|
ON CONFLICT (device_id, connection_id) DO UPDATE SET
|
||||||
|
equipment_id = EXCLUDED.equipment_id,
|
||||||
|
protocol = EXCLUDED.protocol,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
last_connected_at = CASE WHEN EXCLUDED.status = 'connected' THEN NOW() ELSE fleet_plc_connections.last_connected_at END,
|
||||||
|
last_error_at = CASE WHEN EXCLUDED.status = 'error' THEN NOW() ELSE fleet_plc_connections.last_error_at END,
|
||||||
|
last_error_message = EXCLUDED.last_error_message,
|
||||||
|
tag_count = EXCLUDED.tag_count,
|
||||||
|
uptime_sec = EXCLUDED.uptime_sec,
|
||||||
|
reconnect_count = CASE
|
||||||
|
WHEN fleet_plc_connections.status != 'connected' AND EXCLUDED.status = 'connected'
|
||||||
|
THEN fleet_plc_connections.reconnect_count + 1
|
||||||
|
ELSE fleet_plc_connections.reconnect_count
|
||||||
|
END,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
deviceId,
|
||||||
|
data.equipment_id || null,
|
||||||
|
data.connection_id,
|
||||||
|
data.protocol || null,
|
||||||
|
data.status,
|
||||||
|
isConnected ? now : null,
|
||||||
|
data.status === "error" ? now : null,
|
||||||
|
data.error_message || null,
|
||||||
|
data.tag_count || 0,
|
||||||
|
data.uptime_sec || 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
logger.debug(`[Fleet PLC] 상태 보고: ${deviceId}/conn${data.connection_id} = ${data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 디바이스의 PLC 연결 모두 삭제
|
||||||
|
*/
|
||||||
|
static async clearDevice(deviceId: string) {
|
||||||
|
await query(`DELETE FROM fleet_plc_connections WHERE device_id = $1`, [deviceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 요약 통계
|
||||||
|
*/
|
||||||
|
static async summary() {
|
||||||
|
const r = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
status,
|
||||||
|
COUNT(*) as n,
|
||||||
|
COUNT(DISTINCT device_id) as devices
|
||||||
|
FROM fleet_plc_connections
|
||||||
|
GROUP BY status`,
|
||||||
|
);
|
||||||
|
const byStatus: Record<string, any> = {};
|
||||||
|
r.forEach((row: any) => { byStatus[row.status] = { count: parseInt(row.n), devices: parseInt(row.devices) }; });
|
||||||
|
return byStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Provisioning Service - DPS(Device Provisioning Service) 패턴
|
||||||
|
* - 엣지가 부팅 후 MAC 주소 등록만으로 자동 프로비저닝
|
||||||
|
* - 사전 등록된 디바이스 또는 신규 디바이스 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
export interface ProvisionRequest {
|
||||||
|
mac_address: string;
|
||||||
|
hostname?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
serial_number?: string;
|
||||||
|
hardware_info?: Record<string, any>;
|
||||||
|
os_info?: Record<string, any>;
|
||||||
|
company_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvisionResponse {
|
||||||
|
device_id: string;
|
||||||
|
mqtt_broker_url: string;
|
||||||
|
api_url: string;
|
||||||
|
token?: string;
|
||||||
|
assigned_equipment_id?: number;
|
||||||
|
pre_registered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetProvisionService {
|
||||||
|
private static readonly MQTT_BROKER_URL =
|
||||||
|
process.env.FLEET_MQTT_BROKER || `mqtt://${process.env.FLEET_HOST || "localhost"}:1883`;
|
||||||
|
private static readonly API_URL =
|
||||||
|
process.env.FLEET_API_URL || `http://${process.env.FLEET_HOST || "localhost"}:8080/api/fleet`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디바이스 프로비저닝 (DPS 패턴)
|
||||||
|
*/
|
||||||
|
static async provision(req: ProvisionRequest): Promise<ProvisionResponse> {
|
||||||
|
if (!req.mac_address) throw new Error("mac_address 필수");
|
||||||
|
|
||||||
|
// 1. 하드웨어 핑거프린트 계산
|
||||||
|
const fingerprint = this.computeFingerprint(req);
|
||||||
|
|
||||||
|
// 2. 기존 디바이스 조회 (핑거프린트 또는 MAC)
|
||||||
|
let device = await queryOne<any>(
|
||||||
|
`SELECT * FROM fleet_devices
|
||||||
|
WHERE hardware_fingerprint = $1 OR mac_address = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[fingerprint, req.mac_address],
|
||||||
|
);
|
||||||
|
|
||||||
|
let preRegistered = false;
|
||||||
|
let deviceId: string;
|
||||||
|
|
||||||
|
if (device) {
|
||||||
|
// 기존 디바이스 - 정보 업데이트
|
||||||
|
deviceId = device.device_id;
|
||||||
|
preRegistered = true;
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_devices SET
|
||||||
|
ip_address = COALESCE($2, ip_address),
|
||||||
|
hostname = COALESCE($3, hostname),
|
||||||
|
hardware_info = $4::jsonb,
|
||||||
|
os_info = $5::jsonb,
|
||||||
|
hardware_fingerprint = $6,
|
||||||
|
last_seen_at = NOW(),
|
||||||
|
is_online = TRUE,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE device_id = $1`,
|
||||||
|
[
|
||||||
|
deviceId,
|
||||||
|
req.ip_address || null,
|
||||||
|
req.hostname || null,
|
||||||
|
JSON.stringify(req.hardware_info || {}),
|
||||||
|
JSON.stringify(req.os_info || {}),
|
||||||
|
fingerprint,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
logger.info(`[Fleet Provision] 기존 디바이스 재연결: ${deviceId}`);
|
||||||
|
} else {
|
||||||
|
// 신규 디바이스 자동 등록
|
||||||
|
deviceId = this.generateDeviceId(req.mac_address);
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_devices
|
||||||
|
(device_id, company_code, device_name, device_type,
|
||||||
|
ip_address, mac_address, hostname, hardware_fingerprint,
|
||||||
|
hardware_info, os_info, last_seen_at, is_online)
|
||||||
|
VALUES ($1,$2,$3,'edge',$4,$5,$6,$7,$8::jsonb,$9::jsonb,NOW(),TRUE)`,
|
||||||
|
[
|
||||||
|
deviceId,
|
||||||
|
req.company_code || "*",
|
||||||
|
req.hostname || `Edge-${req.mac_address.slice(-5)}`,
|
||||||
|
req.ip_address || null,
|
||||||
|
req.mac_address,
|
||||||
|
req.hostname || null,
|
||||||
|
fingerprint,
|
||||||
|
JSON.stringify(req.hardware_info || {}),
|
||||||
|
JSON.stringify(req.os_info || {}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
logger.info(`[Fleet Provision] 신규 디바이스 등록: ${deviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 프로비저닝 토큰 발급 (단순 랜덤, JWT 아님)
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// 4. 할당된 장비 찾기 (선택)
|
||||||
|
const assigned = await queryOne<any>(
|
||||||
|
`SELECT equipment_id FROM fleet_devices WHERE device_id = $1`,
|
||||||
|
[deviceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
device_id: deviceId,
|
||||||
|
mqtt_broker_url: this.MQTT_BROKER_URL,
|
||||||
|
api_url: this.API_URL,
|
||||||
|
token,
|
||||||
|
assigned_equipment_id: assigned?.equipment_id || undefined,
|
||||||
|
pre_registered: preRegistered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하드웨어 정보로부터 SHA-256 기반 핑거프린트 생성
|
||||||
|
*/
|
||||||
|
private static computeFingerprint(req: ProvisionRequest): string {
|
||||||
|
const parts = [
|
||||||
|
req.mac_address,
|
||||||
|
req.serial_number || "",
|
||||||
|
req.hardware_info?.cpu_id || "",
|
||||||
|
req.hardware_info?.board_id || "",
|
||||||
|
].filter(Boolean).join("|");
|
||||||
|
return crypto.createHash("sha256").update(parts).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static generateDeviceId(mac: string): string {
|
||||||
|
const prefix = "edge";
|
||||||
|
const macShort = mac.replace(/[:-]/g, "").slice(-8).toLowerCase();
|
||||||
|
return `${prefix}-${macShort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사전 등록 목록 조회 (아직 연결 안 된 디바이스)
|
||||||
|
*/
|
||||||
|
static async listPreRegistered() {
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT device_id, device_name, mac_address, hardware_fingerprint, company_code,
|
||||||
|
last_seen_at, is_online, created_at
|
||||||
|
FROM fleet_devices
|
||||||
|
WHERE last_seen_at IS NULL OR last_seen_at < NOW() - INTERVAL '1 hour'
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사전 등록 (MAC/핑거프린트만 미리 등록)
|
||||||
|
*/
|
||||||
|
static async preRegister(data: {
|
||||||
|
mac_address: string;
|
||||||
|
device_name?: string;
|
||||||
|
company_code?: string;
|
||||||
|
equipment_id?: number;
|
||||||
|
device_group?: string;
|
||||||
|
}) {
|
||||||
|
const deviceId = this.generateDeviceId(data.mac_address);
|
||||||
|
const r = await query<any>(
|
||||||
|
`INSERT INTO fleet_devices
|
||||||
|
(device_id, mac_address, device_name, company_code, equipment_id, device_group, device_type, is_online)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,'edge',FALSE)
|
||||||
|
ON CONFLICT (device_id) DO UPDATE SET
|
||||||
|
mac_address = EXCLUDED.mac_address,
|
||||||
|
device_name = EXCLUDED.device_name,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
deviceId,
|
||||||
|
data.mac_address,
|
||||||
|
data.device_name || `Pre-${data.mac_address.slice(-5)}`,
|
||||||
|
data.company_code || "*",
|
||||||
|
data.equipment_id || null,
|
||||||
|
data.device_group || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Release Service
|
||||||
|
* - 릴리즈 버전 관리 (Harbor 이미지 정보 포함)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export interface FleetRelease {
|
||||||
|
id?: number;
|
||||||
|
version: string;
|
||||||
|
release_type?: string;
|
||||||
|
backend_image?: string;
|
||||||
|
frontend_image?: string;
|
||||||
|
agent_image?: string;
|
||||||
|
changelog?: string;
|
||||||
|
harbor_project?: string;
|
||||||
|
is_canary?: boolean;
|
||||||
|
status?: "draft" | "ready" | "released" | "deprecated";
|
||||||
|
released_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetReleaseService {
|
||||||
|
static async list(filter: { status?: string; release_type?: string } = {}) {
|
||||||
|
const wheres: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.status) { wheres.push(`status = $${idx++}`); params.push(filter.status); }
|
||||||
|
if (filter.release_type) { wheres.push(`release_type = $${idx++}`); params.push(filter.release_type); }
|
||||||
|
|
||||||
|
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT r.*,
|
||||||
|
(SELECT COUNT(*) FROM fleet_deployments WHERE release_id = r.id) as deploy_count
|
||||||
|
FROM fleet_releases r ${where}
|
||||||
|
ORDER BY r.id DESC LIMIT 200`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get(id: number) {
|
||||||
|
return await queryOne<any>(`SELECT * FROM fleet_releases WHERE id = $1`, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(data: FleetRelease): Promise<any> {
|
||||||
|
if (!data.version) throw new Error("version 필수");
|
||||||
|
const r = await query<any>(
|
||||||
|
`INSERT INTO fleet_releases
|
||||||
|
(version, release_type, backend_image, frontend_image, agent_image, changelog, harbor_project, is_canary, status)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.version,
|
||||||
|
data.release_type || "minor",
|
||||||
|
data.backend_image || null,
|
||||||
|
data.frontend_image || null,
|
||||||
|
data.agent_image || null,
|
||||||
|
data.changelog || null,
|
||||||
|
data.harbor_project || null,
|
||||||
|
data.is_canary || false,
|
||||||
|
data.status || "draft",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
logger.info(`[Fleet Release] 생성: ${data.version} (id=${r[0].id})`);
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id: number, data: Partial<FleetRelease>) {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
const fields: (keyof FleetRelease)[] = [
|
||||||
|
"version", "release_type", "backend_image", "frontend_image", "agent_image",
|
||||||
|
"changelog", "harbor_project", "is_canary", "status",
|
||||||
|
];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (data[f] !== undefined) { sets.push(`${f} = $${idx++}`); params.push(data[f]); }
|
||||||
|
}
|
||||||
|
if (data.status === "released" && !data.released_at) {
|
||||||
|
sets.push(`released_at = NOW()`);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return this.get(id);
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const r = await query<any>(
|
||||||
|
`UPDATE fleet_releases SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async delete(id: number) {
|
||||||
|
await query(`DELETE FROM fleet_releases WHERE id = $1`, [id]);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 릴리즈 상태 전환 (draft → ready → released)
|
||||||
|
*/
|
||||||
|
static async transition(id: number, newStatus: "ready" | "released" | "deprecated") {
|
||||||
|
const r = await this.get(id);
|
||||||
|
if (!r) throw new Error("릴리즈 없음");
|
||||||
|
|
||||||
|
const allowedTransitions: Record<string, string[]> = {
|
||||||
|
draft: ["ready", "deprecated"],
|
||||||
|
ready: ["released", "deprecated"],
|
||||||
|
released: ["deprecated"],
|
||||||
|
deprecated: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!allowedTransitions[r.status]?.includes(newStatus)) {
|
||||||
|
throw new Error(`${r.status} → ${newStatus} 전환 불가`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.update(id, { status: newStatus });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Management REST API
|
||||||
|
* GET/POST/PATCH/DELETE /api/fleet/...
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { FleetDeviceService } from "./fleetDeviceService";
|
||||||
|
import { FleetCommandService, CommandType } from "./fleetCommandService";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 엣지 디바이스(Python)가 호출하는 공개 엔드포인트 (인증 전)
|
||||||
|
import { FleetEdgeConfigService } from "./fleetEdgeConfigService";
|
||||||
|
import { FleetProvisionService } from "./fleetProvisionService";
|
||||||
|
|
||||||
|
// DPS Provisioning (엣지 부팅 시 자동 등록)
|
||||||
|
router.post("/provision", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await FleetProvisionService.provision(req.body);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/edge/:edgeId/config", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const edgeId = req.params.edgeId;
|
||||||
|
const ifNoneMatch = req.header("If-None-Match");
|
||||||
|
|
||||||
|
// ETag 체크
|
||||||
|
const currentVersion = await FleetEdgeConfigService.getConfigVersion(edgeId);
|
||||||
|
if (ifNoneMatch && ifNoneMatch === currentVersion) {
|
||||||
|
return res.status(304).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await FleetEdgeConfigService.getEdgeConfig(edgeId);
|
||||||
|
res.setHeader("ETag", config.version);
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.json(config);
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// v1 호환 엔드포인트 (기존 Python 코드가 /api/v1/edges/{id}/config로 호출)
|
||||||
|
router.get("/v1/edges/:edgeId/config", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = await FleetEdgeConfigService.getEdgeConfig(req.params.edgeId);
|
||||||
|
res.setHeader("ETag", config.version);
|
||||||
|
res.json(config);
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 엣지 Config JSON을 받아 Pipeline DB로 임포트 (마이그레이션용)
|
||||||
|
router.post("/import-edge-config", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cfg = req.body;
|
||||||
|
if (!cfg || !Array.isArray(cfg.devices)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "devices 배열이 필요합니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { importEdgeConfig } = await import("../database/importEdgeConfig");
|
||||||
|
const result = await importEdgeConfig(cfg);
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (e: any) {
|
||||||
|
return res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// ========== 디바이스 ==========
|
||||||
|
|
||||||
|
router.get("/devices", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userCompany = req.user?.companyCode;
|
||||||
|
const filter: any = {
|
||||||
|
is_online: req.query.is_online === "true" ? true : req.query.is_online === "false" ? false : undefined,
|
||||||
|
device_type: req.query.device_type as string,
|
||||||
|
search: req.query.search as string,
|
||||||
|
};
|
||||||
|
if (userCompany && userCompany !== "*") {
|
||||||
|
filter.company_code = userCompany;
|
||||||
|
} else if (req.query.company_code) {
|
||||||
|
filter.company_code = req.query.company_code as string;
|
||||||
|
}
|
||||||
|
const data = await FleetDeviceService.listDevices(filter);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/devices/register", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...req.body,
|
||||||
|
company_code: req.body.company_code || req.user?.companyCode || "*",
|
||||||
|
};
|
||||||
|
const device = await FleetDeviceService.registerDevice(data);
|
||||||
|
res.status(201).json({ success: true, data: device });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/devices/:deviceId", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDeviceService.getDeviceById(req.params.deviceId);
|
||||||
|
if (!data) return res.status(404).json({ success: false, message: "디바이스를 찾을 수 없습니다." });
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/devices/:deviceId", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDeviceService.updateDevice(req.params.deviceId, req.body);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/devices/:deviceId", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await FleetDeviceService.deleteDevice(req.params.deviceId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/devices/:deviceId/metrics", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt((req.query.limit as string) || "100", 10);
|
||||||
|
const data = await FleetDeviceService.getRecentMetrics(req.params.deviceId, limit);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 커맨드 ==========
|
||||||
|
|
||||||
|
router.get("/commands", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetCommandService.listCommands({
|
||||||
|
device_id: req.query.device_id as string,
|
||||||
|
command_type: req.query.command_type as string,
|
||||||
|
status: req.query.status as string,
|
||||||
|
limit: parseInt((req.query.limit as string) || "100", 10),
|
||||||
|
});
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/commands/types", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetCommandService.getCommandTypes();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/commands", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { device_id, command_type, payload, timeout_sec } = req.body;
|
||||||
|
if (!device_id || !command_type) {
|
||||||
|
return res.status(400).json({ success: false, message: "device_id와 command_type 필수" });
|
||||||
|
}
|
||||||
|
const command = await FleetCommandService.issueCommand(
|
||||||
|
device_id,
|
||||||
|
command_type as CommandType,
|
||||||
|
payload || {},
|
||||||
|
req.user?.userId,
|
||||||
|
timeout_sec,
|
||||||
|
);
|
||||||
|
res.status(201).json({ success: true, data: command });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 알림 ==========
|
||||||
|
|
||||||
|
router.get("/alerts", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const status = (req.query.status as string) || "open";
|
||||||
|
const data = await query<any>(
|
||||||
|
`SELECT a.*, r.rule_name FROM fleet_alerts a
|
||||||
|
LEFT JOIN fleet_alert_rules r ON a.rule_id = r.id
|
||||||
|
WHERE a.status = $1
|
||||||
|
ORDER BY a.created_at DESC LIMIT 100`,
|
||||||
|
[status],
|
||||||
|
);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/alerts/:id/ack", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_alerts SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = NOW() WHERE id = $2`,
|
||||||
|
[req.user?.userId || "system", parseInt(req.params.id)],
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/alerts/:id/resolve", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`UPDATE fleet_alerts SET status = 'resolved', resolved_at = NOW() WHERE id = $1`,
|
||||||
|
[parseInt(_req.params.id)],
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/alert-rules", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await query<any>(`SELECT * FROM fleet_alert_rules ORDER BY id`);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 배포 ==========
|
||||||
|
|
||||||
|
router.get("/deployments", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await query<any>(
|
||||||
|
`SELECT d.*, r.version, r.backend_image, r.frontend_image, r.agent_image
|
||||||
|
FROM fleet_deployments d
|
||||||
|
LEFT JOIN fleet_releases r ON d.release_id = r.id
|
||||||
|
ORDER BY d.created_at DESC LIMIT 100`,
|
||||||
|
);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/releases", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await query<any>(`SELECT * FROM fleet_releases ORDER BY id DESC LIMIT 50`);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 실시간 데이터 ==========
|
||||||
|
import { FleetDataService } from "./fleetDataService";
|
||||||
|
|
||||||
|
// 디바이스별 최신 태그 값
|
||||||
|
router.get("/devices/:deviceId/latest-values", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDataService.getLatestValuesByDevice(req.params.deviceId);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 장비별 최신 태그 값
|
||||||
|
router.get("/equipment/:equipmentId/latest-values", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDataService.getLatestValuesByEquipment(parseInt(req.params.equipmentId));
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 태그 시계열 데이터 (차트용)
|
||||||
|
router.get("/devices/:deviceId/tags/:tagName/timeseries", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt((req.query.limit as string) || "500", 10);
|
||||||
|
const from = req.query.from ? new Date(req.query.from as string) : undefined;
|
||||||
|
const to = req.query.to ? new Date(req.query.to as string) : undefined;
|
||||||
|
const data = await FleetDataService.getTagTimeseries(
|
||||||
|
req.params.deviceId, req.params.tagName, from, to, limit,
|
||||||
|
);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 수집 통계
|
||||||
|
router.get("/data/stats", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDataService.getCollectionStats(req.query.device_id as string);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Python Hook 스크립트 ==========
|
||||||
|
import { FleetScriptService } from "./fleetScriptService";
|
||||||
|
|
||||||
|
router.get("/scripts/hook-types", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetScriptService.getHookTypes();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/scripts", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const filter: any = {
|
||||||
|
scope: req.query.scope as any,
|
||||||
|
hook_type: req.query.hook_type as any,
|
||||||
|
};
|
||||||
|
if (req.query.equipment_id) filter.equipment_id = parseInt(req.query.equipment_id as string);
|
||||||
|
if (req.query.connection_id) filter.connection_id = parseInt(req.query.connection_id as string);
|
||||||
|
if (req.query.device_id) filter.device_id = req.query.device_id as string;
|
||||||
|
if (req.query.enabled !== undefined) filter.enabled = req.query.enabled === "true";
|
||||||
|
if (req.user?.companyCode && req.user.companyCode !== "*") {
|
||||||
|
filter.company_code = req.user.companyCode;
|
||||||
|
}
|
||||||
|
const data = await FleetScriptService.listScripts(filter);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/scripts/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetScriptService.getScript(parseInt(req.params.id));
|
||||||
|
if (!data) return res.status(404).json({ success: false, message: "스크립트를 찾을 수 없습니다." });
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/scripts", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...req.body,
|
||||||
|
company_code: req.body.company_code || req.user?.companyCode || null,
|
||||||
|
created_by: req.user?.userId,
|
||||||
|
};
|
||||||
|
const script = await FleetScriptService.createScript(data);
|
||||||
|
res.status(201).json({ success: true, data: script });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/scripts/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = { ...req.body, updated_by: req.user?.userId };
|
||||||
|
const script = await FleetScriptService.updateScript(parseInt(req.params.id), data);
|
||||||
|
res.json({ success: true, data: script });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/scripts/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await FleetScriptService.deleteScript(parseInt(req.params.id));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 버전 이력
|
||||||
|
router.get("/scripts/:id/versions", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetScriptService.getVersions(parseInt(req.params.id));
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/scripts/:id/versions/:version", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetScriptService.getVersion(parseInt(req.params.id), parseInt(req.params.version));
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/scripts/:id/rollback/:version", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetScriptService.rollback(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
parseInt(req.params.version),
|
||||||
|
req.user?.userId,
|
||||||
|
);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dry-run (테스트 실행)
|
||||||
|
router.post("/scripts/dry-run", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { code, hook_type, test_input, timeout_ms } = req.body;
|
||||||
|
if (!code || !hook_type) {
|
||||||
|
return res.status(400).json({ success: false, message: "code와 hook_type 필수" });
|
||||||
|
}
|
||||||
|
const result = await FleetScriptService.dryRun(
|
||||||
|
code,
|
||||||
|
hook_type,
|
||||||
|
test_input || {},
|
||||||
|
timeout_ms || 3000,
|
||||||
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 릴리즈 관리 ==========
|
||||||
|
import { FleetReleaseService } from "./fleetReleaseService";
|
||||||
|
|
||||||
|
router.get("/releases", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetReleaseService.list({
|
||||||
|
status: req.query.status as string,
|
||||||
|
release_type: req.query.release_type as string,
|
||||||
|
});
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/releases/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetReleaseService.get(parseInt(req.params.id));
|
||||||
|
if (!data) return res.status(404).json({ success: false, message: "없음" });
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/releases", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetReleaseService.create(req.body);
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/releases/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetReleaseService.update(parseInt(req.params.id), req.body);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/releases/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await FleetReleaseService.delete(parseInt(req.params.id));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/releases/:id/transition", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetReleaseService.transition(parseInt(req.params.id), req.body.status);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 배포 관리 ==========
|
||||||
|
import { FleetDeploymentService } from "./fleetDeploymentService";
|
||||||
|
|
||||||
|
router.get("/deployments", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const filter: any = { status: req.query.status as string };
|
||||||
|
if (req.query.release_id) filter.release_id = parseInt(req.query.release_id as string);
|
||||||
|
const data = await FleetDeploymentService.list(filter);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/deployments/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDeploymentService.get(parseInt(req.params.id));
|
||||||
|
if (!data) return res.status(404).json({ success: false, message: "없음" });
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/deployments/:id/status", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDeploymentService.getStatus(parseInt(req.params.id));
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/deployments", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDeploymentService.create({ ...req.body, created_by: req.user?.userId });
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/deployments/:id/start", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDeploymentService.start(parseInt(req.params.id), req.user?.userId);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/deployments/:id/cancel", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await FleetDeploymentService.cancel(parseInt(req.params.id));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/deployments/:id/rollback", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetDeploymentService.rollback(parseInt(req.params.id), req.user?.userId);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Harbor 이미지 ==========
|
||||||
|
import { FleetHarborService } from "./fleetHarborService";
|
||||||
|
|
||||||
|
router.get("/harbor/projects", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetHarborService.listProjects();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/harbor/projects/:project/repos", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetHarborService.listRepositories(req.params.project);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/harbor/projects/:project/repos/:repo/tags", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetHarborService.listTags(req.params.project, req.params.repo);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/harbor/ping", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetHarborService.ping();
|
||||||
|
res.json({ success: data.ok, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 태그 템플릿 ==========
|
||||||
|
import { FleetTagTemplateService } from "./fleetTagTemplateService";
|
||||||
|
|
||||||
|
router.get("/tag-templates", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetTagTemplateService.list({
|
||||||
|
company_code: req.user?.companyCode,
|
||||||
|
equipment_type: req.query.equipment_type as string,
|
||||||
|
protocol: req.query.protocol as string,
|
||||||
|
});
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/tag-templates/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetTagTemplateService.get(parseInt(req.params.id));
|
||||||
|
if (!data) return res.status(404).json({ success: false, message: "없음" });
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/tag-templates", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetTagTemplateService.create({ ...req.body, created_by: req.user?.userId });
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/tag-templates/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetTagTemplateService.update(parseInt(req.params.id), req.body);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/tag-templates/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await FleetTagTemplateService.delete(parseInt(req.params.id));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/tag-templates/:id/apply/:connectionId", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetTagTemplateService.applyToConnection(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
parseInt(req.params.connectionId),
|
||||||
|
{ overwrite: req.body.overwrite === true },
|
||||||
|
);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 알림 규칙 ==========
|
||||||
|
import { FleetAlertRuleService } from "./fleetAlertRuleService";
|
||||||
|
|
||||||
|
router.post("/alert-rules", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetAlertRuleService.create({ ...req.body, created_by: req.user?.userId });
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/alert-rules/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetAlertRuleService.update(parseInt(req.params.id), req.body);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/alert-rules/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await FleetAlertRuleService.delete(parseInt(req.params.id));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/alert-rules/:id/toggle", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetAlertRuleService.toggle(parseInt(req.params.id));
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== V1 PLC 매핑 ==========
|
||||||
|
import { FleetV1MappingService } from "./fleetV1MappingService";
|
||||||
|
|
||||||
|
router.get("/v1-mappings", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const filter: any = { v1_system: req.query.v1_system as string };
|
||||||
|
if (req.query.equipment_id) filter.equipment_id = parseInt(req.query.equipment_id as string);
|
||||||
|
const data = await FleetV1MappingService.list(filter);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/v1-mappings", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetV1MappingService.create({ ...req.body, created_by: req.user?.userId });
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/v1-mappings/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetV1MappingService.update(parseInt(req.params.id), req.body);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/v1-mappings/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await FleetV1MappingService.delete(parseInt(req.params.id));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== PLC 상태 ==========
|
||||||
|
import { FleetPlcStatusService } from "./fleetPlcStatusService";
|
||||||
|
|
||||||
|
router.get("/plc-status", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetPlcStatusService.list({
|
||||||
|
device_id: req.query.device_id as string,
|
||||||
|
status: req.query.status as string,
|
||||||
|
});
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/plc-status/summary", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetPlcStatusService.summary();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Audit 로그 ==========
|
||||||
|
import { FleetAuditService } from "./fleetAuditService";
|
||||||
|
|
||||||
|
router.get("/audit-logs", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetAuditService.list({
|
||||||
|
event_type: req.query.event_type as string,
|
||||||
|
target_type: req.query.target_type as string,
|
||||||
|
target_id: req.query.target_id as string,
|
||||||
|
actor_id: req.query.actor_id as string,
|
||||||
|
result: req.query.result as string,
|
||||||
|
company_code: req.user?.companyCode,
|
||||||
|
limit: parseInt((req.query.limit as string) || "200"),
|
||||||
|
});
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/audit-logs/stats", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetAuditService.stats();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 사전 등록 ==========
|
||||||
|
router.get("/provision/pre-registered", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetProvisionService.listPreRegistered();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/provision/pre-register", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await FleetProvisionService.preRegister(req.body);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (e: any) { res.status(400).json({ success: false, message: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Prometheus Metrics ==========
|
||||||
|
import { FleetMetricsService } from "./fleetMetricsService";
|
||||||
|
|
||||||
|
router.get("/prometheus", async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const text = await FleetMetricsService.generate();
|
||||||
|
res.setHeader("Content-Type", "text/plain; version=0.0.4");
|
||||||
|
res.send(text);
|
||||||
|
} catch (e: any) { res.status(500).send(`# ERROR: ${e.message}`); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 통계 요약 ==========
|
||||||
|
|
||||||
|
router.get("/stats", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userCompany = req.user?.companyCode;
|
||||||
|
const companyFilter =
|
||||||
|
userCompany && userCompany !== "*"
|
||||||
|
? `WHERE company_code = '${userCompany}' OR company_code = '*'`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const [devices, alerts, deployments] = await Promise.all([
|
||||||
|
query<any>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE is_online) as online,
|
||||||
|
COUNT(*) FILTER (WHERE NOT is_online) as offline
|
||||||
|
FROM fleet_devices ${companyFilter}`,
|
||||||
|
),
|
||||||
|
query<any>(
|
||||||
|
`SELECT COUNT(*) as open_count FROM fleet_alerts WHERE status = 'open'`,
|
||||||
|
),
|
||||||
|
query<any>(
|
||||||
|
`SELECT COUNT(*) as active_count FROM fleet_deployments WHERE status IN ('pending', 'running')`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
devices: devices[0],
|
||||||
|
alerts: alerts[0],
|
||||||
|
deployments: deployments[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Edge Scripts Service
|
||||||
|
* - 웹에서 편집한 Python hook 스크립트 관리
|
||||||
|
* - 장비별/연결별/디바이스별/전역 스코프
|
||||||
|
* - 버전 관리 (트리거 기반 자동)
|
||||||
|
* - Dry-run 실행 (Docker 내 Python 서브프로세스)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
|
||||||
|
export type HookType = "transform" | "derived_tags" | "filter" | "alarm" | "pre_send";
|
||||||
|
export type ScriptScope = "global" | "equipment" | "connection" | "device";
|
||||||
|
|
||||||
|
export interface FleetScript {
|
||||||
|
id?: number;
|
||||||
|
script_name: string;
|
||||||
|
description?: string;
|
||||||
|
scope: ScriptScope;
|
||||||
|
equipment_id?: number | null;
|
||||||
|
connection_id?: number | null;
|
||||||
|
device_id?: string | null;
|
||||||
|
hook_type: HookType;
|
||||||
|
language?: string;
|
||||||
|
code: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
timeout_ms?: number;
|
||||||
|
company_code?: string;
|
||||||
|
created_by?: string;
|
||||||
|
updated_by?: string;
|
||||||
|
version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetScriptService {
|
||||||
|
static async listScripts(filter: {
|
||||||
|
scope?: ScriptScope;
|
||||||
|
equipment_id?: number;
|
||||||
|
connection_id?: number;
|
||||||
|
device_id?: string;
|
||||||
|
hook_type?: HookType;
|
||||||
|
enabled?: boolean;
|
||||||
|
company_code?: string;
|
||||||
|
} = {}) {
|
||||||
|
const wheres: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.scope) { wheres.push(`s.scope = $${idx++}`); params.push(filter.scope); }
|
||||||
|
if (filter.equipment_id != null) { wheres.push(`s.equipment_id = $${idx++}`); params.push(filter.equipment_id); }
|
||||||
|
if (filter.connection_id != null) { wheres.push(`s.connection_id = $${idx++}`); params.push(filter.connection_id); }
|
||||||
|
if (filter.device_id) { wheres.push(`s.device_id = $${idx++}`); params.push(filter.device_id); }
|
||||||
|
if (filter.hook_type) { wheres.push(`s.hook_type = $${idx++}`); params.push(filter.hook_type); }
|
||||||
|
if (filter.enabled !== undefined) { wheres.push(`s.enabled = $${idx++}`); params.push(filter.enabled); }
|
||||||
|
if (filter.company_code && filter.company_code !== "*") {
|
||||||
|
wheres.push(`(s.company_code = $${idx} OR s.company_code IS NULL OR s.company_code = '*')`);
|
||||||
|
params.push(filter.company_code);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT s.*,
|
||||||
|
e.equipment_name,
|
||||||
|
e.equipment_code,
|
||||||
|
c.connection_name
|
||||||
|
FROM fleet_edge_scripts s
|
||||||
|
LEFT JOIN pipeline_equipment e ON s.equipment_id = e.id
|
||||||
|
LEFT JOIN pipeline_device_connections c ON s.connection_id = c.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY s.hook_type, s.priority, s.id DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getScript(id: number) {
|
||||||
|
return await queryOne<any>(
|
||||||
|
`SELECT s.*, e.equipment_name, c.connection_name
|
||||||
|
FROM fleet_edge_scripts s
|
||||||
|
LEFT JOIN pipeline_equipment e ON s.equipment_id = e.id
|
||||||
|
LEFT JOIN pipeline_device_connections c ON s.connection_id = c.id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createScript(data: FleetScript): Promise<any> {
|
||||||
|
if (!data.script_name || !data.hook_type || !data.code) {
|
||||||
|
throw new Error("script_name, hook_type, code는 필수");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<any>(
|
||||||
|
`INSERT INTO fleet_edge_scripts
|
||||||
|
(script_name, description, scope, equipment_id, connection_id, device_id,
|
||||||
|
hook_type, language, code, enabled, priority, timeout_ms, company_code, created_by, updated_by)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.script_name,
|
||||||
|
data.description || null,
|
||||||
|
data.scope || "global",
|
||||||
|
data.equipment_id || null,
|
||||||
|
data.connection_id || null,
|
||||||
|
data.device_id || null,
|
||||||
|
data.hook_type,
|
||||||
|
data.language || "python",
|
||||||
|
data.code,
|
||||||
|
data.enabled !== false,
|
||||||
|
data.priority || 100,
|
||||||
|
data.timeout_ms || 1000,
|
||||||
|
data.company_code || null,
|
||||||
|
data.created_by || null,
|
||||||
|
data.created_by || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 이력 추가 (v1)
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_edge_script_versions (script_id, version, code, description, changed_by)
|
||||||
|
VALUES ($1, 1, $2, $3, $4)`,
|
||||||
|
[result[0].id, data.code, data.description || null, data.created_by || null],
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[Fleet Script] 생성: ${data.hook_type} / ${data.script_name} (id=${result[0].id})`);
|
||||||
|
// 수집기 스크립트 캐시 즉시 무효화
|
||||||
|
try {
|
||||||
|
const { invalidate } = await import("../services/collector/scriptCache");
|
||||||
|
invalidate();
|
||||||
|
} catch { /* 수집기 캐시 미로드 상태면 무시 */ }
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateScript(id: number, data: Partial<FleetScript>): Promise<any> {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
const fields: (keyof FleetScript)[] = [
|
||||||
|
"script_name", "description", "scope", "equipment_id", "connection_id", "device_id",
|
||||||
|
"hook_type", "code", "enabled", "priority", "timeout_ms", "updated_by",
|
||||||
|
];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (data[f] !== undefined) {
|
||||||
|
sets.push(`${f} = $${idx++}`);
|
||||||
|
params.push(data[f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) return this.getScript(id);
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const result = await query<any>(
|
||||||
|
`UPDATE fleet_edge_scripts SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { invalidate } = await import("../services/collector/scriptCache");
|
||||||
|
invalidate();
|
||||||
|
} catch { /* noop */ }
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteScript(id: number) {
|
||||||
|
await query(`DELETE FROM fleet_edge_scripts WHERE id = $1`, [id]);
|
||||||
|
try {
|
||||||
|
const { invalidate } = await import("../services/collector/scriptCache");
|
||||||
|
invalidate();
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getVersions(scriptId: number) {
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT id, script_id, version, description, changed_by, changed_at,
|
||||||
|
LENGTH(code) as code_size
|
||||||
|
FROM fleet_edge_script_versions
|
||||||
|
WHERE script_id = $1
|
||||||
|
ORDER BY version DESC`,
|
||||||
|
[scriptId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getVersion(scriptId: number, version: number) {
|
||||||
|
return await queryOne<any>(
|
||||||
|
`SELECT * FROM fleet_edge_script_versions WHERE script_id = $1 AND version = $2`,
|
||||||
|
[scriptId, version],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이전 버전으로 롤백
|
||||||
|
*/
|
||||||
|
static async rollback(scriptId: number, toVersion: number, userId?: string) {
|
||||||
|
const v = await this.getVersion(scriptId, toVersion);
|
||||||
|
if (!v) throw new Error(`버전 ${toVersion}을 찾을 수 없습니다.`);
|
||||||
|
return await this.updateScript(scriptId, { code: v.code, updated_by: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook 타입 목록
|
||||||
|
*/
|
||||||
|
static async getHookTypes() {
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT * FROM fleet_edge_hook_types ORDER BY execute_order`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry-run 실행 (Python 서브프로세스 + RestrictedPython-like sandboxing)
|
||||||
|
*
|
||||||
|
* 실행 환경:
|
||||||
|
* - Python3 임시 파일로 실행
|
||||||
|
* - 타임아웃 제한
|
||||||
|
* - import 제한 (화이트리스트)
|
||||||
|
*/
|
||||||
|
static async dryRun(
|
||||||
|
code: string,
|
||||||
|
hookType: HookType,
|
||||||
|
testInput: any,
|
||||||
|
timeoutMs = 3000,
|
||||||
|
): Promise<{ success: boolean; result?: any; error?: string; stdout?: string; duration_ms: number }> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// Python 래퍼: 사용자 코드와 입력을 stdin으로 전달 (이스케이프 문제 없음)
|
||||||
|
// stdin format: JSON { code, hook_type, test_input }
|
||||||
|
const wrapper = `
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, date
|
||||||
|
import math
|
||||||
|
|
||||||
|
ALLOWED_BUILTINS = {
|
||||||
|
'abs': abs, 'all': all, 'any': any, 'bool': bool, 'bytes': bytes,
|
||||||
|
'dict': dict, 'enumerate': enumerate, 'filter': filter, 'float': float,
|
||||||
|
'int': int, 'len': len, 'list': list, 'map': map, 'max': max, 'min': min,
|
||||||
|
'print': print, 'range': range, 'round': round, 'set': set, 'sorted': sorted,
|
||||||
|
'str': str, 'sum': sum, 'tuple': tuple, 'type': type, 'zip': zip,
|
||||||
|
'isinstance': isinstance, 'hasattr': hasattr, 'getattr': getattr,
|
||||||
|
'True': True, 'False': False, 'None': None,
|
||||||
|
'__import__': __import__,
|
||||||
|
}
|
||||||
|
|
||||||
|
def default_serializer(o):
|
||||||
|
if isinstance(o, (datetime, date)): return o.isoformat()
|
||||||
|
return str(o)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(sys.stdin.read())
|
||||||
|
USER_CODE = payload['code']
|
||||||
|
HOOK_TYPE = payload['hook_type']
|
||||||
|
TEST_INPUT = payload.get('test_input', {})
|
||||||
|
|
||||||
|
allowed_globals = {
|
||||||
|
"__builtins__": ALLOWED_BUILTINS,
|
||||||
|
"datetime": datetime,
|
||||||
|
"date": date,
|
||||||
|
"math": math,
|
||||||
|
"json": json,
|
||||||
|
}
|
||||||
|
exec(USER_CODE, allowed_globals)
|
||||||
|
|
||||||
|
func_name_map = {
|
||||||
|
"transform": "transform",
|
||||||
|
"derived_tags": "derived_tags",
|
||||||
|
"filter": "filter_data",
|
||||||
|
"alarm": "alarm",
|
||||||
|
"pre_send": "pre_send",
|
||||||
|
}
|
||||||
|
func_name = func_name_map.get(HOOK_TYPE)
|
||||||
|
if func_name not in allowed_globals:
|
||||||
|
raise NameError(f"{func_name} 함수가 정의되지 않았습니다")
|
||||||
|
|
||||||
|
func = allowed_globals[func_name]
|
||||||
|
|
||||||
|
if HOOK_TYPE == "transform":
|
||||||
|
result = func(TEST_INPUT.get("tag_name"), TEST_INPUT.get("raw_value"), TEST_INPUT.get("context", {}))
|
||||||
|
elif HOOK_TYPE == "derived_tags":
|
||||||
|
result = func(TEST_INPUT.get("tags", {}), TEST_INPUT.get("context", {}))
|
||||||
|
elif HOOK_TYPE == "filter":
|
||||||
|
result = func(TEST_INPUT.get("tags", {}), TEST_INPUT.get("context", {}))
|
||||||
|
elif HOOK_TYPE == "alarm":
|
||||||
|
result = func(TEST_INPUT.get("tag_name"), TEST_INPUT.get("value"), TEST_INPUT.get("context", {}))
|
||||||
|
elif HOOK_TYPE == "pre_send":
|
||||||
|
result = func(TEST_INPUT.get("payload", {}), TEST_INPUT.get("context", {}))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"알 수 없는 hook 타입: {HOOK_TYPE}")
|
||||||
|
|
||||||
|
print(json.dumps({"success": True, "result": result}, default=default_serializer, ensure_ascii=False))
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"success": False, "error": str(e), "traceback": traceback.format_exc()}, default=default_serializer, ensure_ascii=False))
|
||||||
|
sys.exit(0)
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("python3", ["-c", wrapper], {
|
||||||
|
timeout: timeoutMs,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// JSON으로 stdin 전달 (이스케이프 안전)
|
||||||
|
const input = JSON.stringify({ code, hook_type: hookType, test_input: testInput });
|
||||||
|
child.stdin.write(input);
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (d) => (stdout += d.toString()));
|
||||||
|
child.stderr.on("data", (d) => (stderr += d.toString()));
|
||||||
|
|
||||||
|
child.on("close", (_code) => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
try {
|
||||||
|
const lines = stdout.trim().split("\n");
|
||||||
|
const resultLine = lines[lines.length - 1] || "{}";
|
||||||
|
const parsed = JSON.parse(resultLine);
|
||||||
|
if (parsed.success) {
|
||||||
|
resolve({ success: true, result: parsed.result, stdout: lines.slice(0, -1).join("\n"), duration_ms: duration });
|
||||||
|
} else {
|
||||||
|
resolve({ success: false, error: parsed.error || parsed.traceback, duration_ms: duration });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: `실행 결과 파싱 실패: ${e.message}. stderr: ${stderr.slice(0, 500)}. stdout: ${stdout.slice(0, 500)}`,
|
||||||
|
duration_ms: duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (err: any) => {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
resolve({ success: false, error: "Python3이 시스템에 설치되지 않음", duration_ms: Date.now() - start });
|
||||||
|
} else {
|
||||||
|
resolve({ success: false, error: err.message, duration_ms: Date.now() - start });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 엣지 디바이스에 적용되는 스크립트 조회 (Python이 사용)
|
||||||
|
*/
|
||||||
|
static async getScriptsForEdge(edgeId: string, equipmentId?: number, connectionIds?: number[]): Promise<any[]> {
|
||||||
|
const wheres: string[] = ["s.enabled = TRUE"];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
// 여러 스코프에서 매칭 (OR 조건)
|
||||||
|
const scopeFilters: string[] = ["s.scope = 'global'"];
|
||||||
|
|
||||||
|
scopeFilters.push(`(s.scope = 'device' AND s.device_id = $${idx++})`);
|
||||||
|
params.push(edgeId);
|
||||||
|
|
||||||
|
if (equipmentId != null) {
|
||||||
|
scopeFilters.push(`(s.scope = 'equipment' AND s.equipment_id = $${idx++})`);
|
||||||
|
params.push(equipmentId);
|
||||||
|
}
|
||||||
|
if (connectionIds && connectionIds.length > 0) {
|
||||||
|
scopeFilters.push(`(s.scope = 'connection' AND s.connection_id = ANY($${idx++}))`);
|
||||||
|
params.push(connectionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
wheres.push(`(${scopeFilters.join(" OR ")})`);
|
||||||
|
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT s.id, s.script_name, s.scope, s.equipment_id, s.connection_id, s.device_id,
|
||||||
|
s.hook_type, s.code, s.priority, s.timeout_ms, s.version
|
||||||
|
FROM fleet_edge_scripts s
|
||||||
|
WHERE ${wheres.join(" AND ")}
|
||||||
|
ORDER BY s.hook_type, s.priority, s.id`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Fleet Tag Template Service
|
||||||
|
* - 회사/장비별 태그 템플릿 관리
|
||||||
|
* - 템플릿을 선택하면 pipeline_tag_mappings에 일괄 생성
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export interface TagTemplate {
|
||||||
|
id?: number;
|
||||||
|
template_name: string;
|
||||||
|
description?: string;
|
||||||
|
company_code?: string;
|
||||||
|
equipment_type?: string;
|
||||||
|
protocol?: string;
|
||||||
|
tags: Array<{
|
||||||
|
tag_name: string;
|
||||||
|
tag_display_name?: string;
|
||||||
|
tag_unit?: string;
|
||||||
|
tag_data_type: string;
|
||||||
|
address: string;
|
||||||
|
address_type?: string;
|
||||||
|
byte_order?: string;
|
||||||
|
bit_index?: number | null;
|
||||||
|
scale_factor?: number;
|
||||||
|
offset_value?: number;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetTagTemplateService {
|
||||||
|
static async list(filter: { company_code?: string; equipment_type?: string; protocol?: string } = {}) {
|
||||||
|
const wheres: string[] = ["is_active = TRUE"];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.company_code) {
|
||||||
|
wheres.push(`(company_code = $${idx} OR company_code IS NULL OR company_code = '*')`);
|
||||||
|
params.push(filter.company_code);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (filter.equipment_type) { wheres.push(`equipment_type = $${idx++}`); params.push(filter.equipment_type); }
|
||||||
|
if (filter.protocol) { wheres.push(`protocol = $${idx++}`); params.push(filter.protocol); }
|
||||||
|
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT t.*, jsonb_array_length(t.tags) as tag_count
|
||||||
|
FROM fleet_tag_templates t
|
||||||
|
WHERE ${wheres.join(" AND ")}
|
||||||
|
ORDER BY t.template_name`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get(id: number) {
|
||||||
|
return await queryOne<any>(`SELECT * FROM fleet_tag_templates WHERE id = $1`, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(data: TagTemplate & { created_by?: string }) {
|
||||||
|
if (!data.template_name) throw new Error("template_name 필수");
|
||||||
|
|
||||||
|
const r = await query<any>(
|
||||||
|
`INSERT INTO fleet_tag_templates
|
||||||
|
(template_name, description, company_code, equipment_type, protocol, tags, is_active, created_by)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.template_name,
|
||||||
|
data.description || null,
|
||||||
|
data.company_code || null,
|
||||||
|
data.equipment_type || null,
|
||||||
|
data.protocol || null,
|
||||||
|
JSON.stringify(data.tags || []),
|
||||||
|
data.is_active !== false,
|
||||||
|
data.created_by || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
logger.info(`[Fleet TagTemplate] 생성: ${data.template_name} (태그 ${data.tags.length}개)`);
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id: number, data: Partial<TagTemplate>) {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
const fields: (keyof TagTemplate)[] = [
|
||||||
|
"template_name", "description", "company_code", "equipment_type", "protocol", "is_active",
|
||||||
|
];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (data[f] !== undefined) { sets.push(`${f} = $${idx++}`); params.push(data[f]); }
|
||||||
|
}
|
||||||
|
if (data.tags !== undefined) {
|
||||||
|
sets.push(`tags = $${idx++}::jsonb`);
|
||||||
|
params.push(JSON.stringify(data.tags));
|
||||||
|
}
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
if (sets.length === 1) return this.get(id);
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const r = await query<any>(
|
||||||
|
`UPDATE fleet_tag_templates SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async delete(id: number) {
|
||||||
|
await query(`DELETE FROM fleet_tag_templates WHERE id = $1`, [id]);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 템플릿을 특정 연결(pipeline_device_connections)에 적용
|
||||||
|
* → pipeline_tag_mappings에 태그 일괄 생성
|
||||||
|
*/
|
||||||
|
static async applyToConnection(templateId: number, connectionId: number, opts: { overwrite?: boolean } = {}) {
|
||||||
|
const tpl = await this.get(templateId);
|
||||||
|
if (!tpl) throw new Error("템플릿 없음");
|
||||||
|
|
||||||
|
const conn = await queryOne<any>(
|
||||||
|
`SELECT * FROM pipeline_device_connections WHERE id = $1`,
|
||||||
|
[connectionId],
|
||||||
|
);
|
||||||
|
if (!conn) throw new Error("연결 없음");
|
||||||
|
|
||||||
|
// 기존 태그 삭제 (overwrite 시)
|
||||||
|
if (opts.overwrite) {
|
||||||
|
await query(`DELETE FROM pipeline_tag_mappings WHERE connection_id = $1`, [connectionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = tpl.tags || [];
|
||||||
|
let created = 0;
|
||||||
|
for (const t of tags) {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO pipeline_tag_mappings
|
||||||
|
(connection_id, tag_name, tag_display_name, tag_unit, tag_data_type,
|
||||||
|
address, address_type, byte_order, bit_index, scale_factor, offset_value, description, is_active)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'Y')
|
||||||
|
ON CONFLICT (connection_id, tag_name) DO UPDATE SET
|
||||||
|
tag_display_name = EXCLUDED.tag_display_name,
|
||||||
|
tag_unit = EXCLUDED.tag_unit,
|
||||||
|
tag_data_type = EXCLUDED.tag_data_type,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
byte_order = EXCLUDED.byte_order,
|
||||||
|
scale_factor = EXCLUDED.scale_factor,
|
||||||
|
offset_value = EXCLUDED.offset_value,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
connectionId,
|
||||||
|
t.tag_name,
|
||||||
|
t.tag_display_name || null,
|
||||||
|
t.tag_unit || null,
|
||||||
|
t.tag_data_type || "UINT16",
|
||||||
|
t.address,
|
||||||
|
t.address_type || null,
|
||||||
|
t.byte_order || "BIG_ENDIAN",
|
||||||
|
t.bit_index ?? null,
|
||||||
|
t.scale_factor ?? 1.0,
|
||||||
|
t.offset_value ?? 0.0,
|
||||||
|
t.description || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn(`[Fleet TagTemplate] 태그 적용 실패 (${t.tag_name}): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`[Fleet TagTemplate] ${tpl.template_name} → connection ${connectionId}: ${created}개 적용`);
|
||||||
|
return { templateId, connectionId, appliedCount: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Fleet V1 PLC Mapping Service
|
||||||
|
* - 레거시 v1 시스템(vexplor_v1, AAS, SCADA)의 PLC 태그를 Pipeline에 매핑
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
|
||||||
|
export interface V1PlcMapping {
|
||||||
|
id?: number;
|
||||||
|
v1_system: string;
|
||||||
|
v1_tag_id: string;
|
||||||
|
v1_tag_name?: string;
|
||||||
|
v1_metadata?: Record<string, any>;
|
||||||
|
equipment_id?: number;
|
||||||
|
connection_id?: number;
|
||||||
|
tag_mapping_id?: number;
|
||||||
|
mapped_tag_name?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetV1MappingService {
|
||||||
|
static async list(filter: { v1_system?: string; equipment_id?: number } = {}) {
|
||||||
|
const wheres: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filter.v1_system) { wheres.push(`m.v1_system = $${idx++}`); params.push(filter.v1_system); }
|
||||||
|
if (filter.equipment_id) { wheres.push(`m.equipment_id = $${idx++}`); params.push(filter.equipment_id); }
|
||||||
|
|
||||||
|
const where = wheres.length ? `WHERE ${wheres.join(" AND ")}` : "";
|
||||||
|
return await query<any>(
|
||||||
|
`SELECT m.*,
|
||||||
|
e.equipment_name, e.equipment_code,
|
||||||
|
c.connection_name,
|
||||||
|
t.tag_name as pipeline_tag_name
|
||||||
|
FROM fleet_v1_plc_mapping m
|
||||||
|
LEFT JOIN pipeline_equipment e ON m.equipment_id = e.id
|
||||||
|
LEFT JOIN pipeline_device_connections c ON m.connection_id = c.id
|
||||||
|
LEFT JOIN pipeline_tag_mappings t ON m.tag_mapping_id = t.id
|
||||||
|
${where}
|
||||||
|
ORDER BY m.v1_system, m.v1_tag_id`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(data: V1PlcMapping & { created_by?: string }) {
|
||||||
|
if (!data.v1_system || !data.v1_tag_id) throw new Error("v1_system, v1_tag_id 필수");
|
||||||
|
const r = await query<any>(
|
||||||
|
`INSERT INTO fleet_v1_plc_mapping
|
||||||
|
(v1_system, v1_tag_id, v1_tag_name, v1_metadata,
|
||||||
|
equipment_id, connection_id, tag_mapping_id, mapped_tag_name,
|
||||||
|
is_active, created_by)
|
||||||
|
VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10)
|
||||||
|
ON CONFLICT (v1_system, v1_tag_id) DO UPDATE SET
|
||||||
|
v1_tag_name = EXCLUDED.v1_tag_name,
|
||||||
|
v1_metadata = EXCLUDED.v1_metadata,
|
||||||
|
equipment_id = EXCLUDED.equipment_id,
|
||||||
|
connection_id = EXCLUDED.connection_id,
|
||||||
|
tag_mapping_id = EXCLUDED.tag_mapping_id,
|
||||||
|
mapped_tag_name = EXCLUDED.mapped_tag_name,
|
||||||
|
is_active = EXCLUDED.is_active,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.v1_system,
|
||||||
|
data.v1_tag_id,
|
||||||
|
data.v1_tag_name || null,
|
||||||
|
JSON.stringify(data.v1_metadata || {}),
|
||||||
|
data.equipment_id || null,
|
||||||
|
data.connection_id || null,
|
||||||
|
data.tag_mapping_id || null,
|
||||||
|
data.mapped_tag_name || null,
|
||||||
|
data.is_active !== false,
|
||||||
|
data.created_by || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id: number, data: Partial<V1PlcMapping>) {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
const fields: (keyof V1PlcMapping)[] = [
|
||||||
|
"v1_tag_name", "equipment_id", "connection_id", "tag_mapping_id",
|
||||||
|
"mapped_tag_name", "is_active",
|
||||||
|
];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (data[f] !== undefined) { sets.push(`${f} = $${idx++}`); params.push(data[f]); }
|
||||||
|
}
|
||||||
|
if (data.v1_metadata !== undefined) {
|
||||||
|
sets.push(`v1_metadata = $${idx++}::jsonb`);
|
||||||
|
params.push(JSON.stringify(data.v1_metadata));
|
||||||
|
}
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
if (sets.length === 1) return queryOne(`SELECT * FROM fleet_v1_plc_mapping WHERE id = $1`, [id]);
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const r = await query<any>(
|
||||||
|
`UPDATE fleet_v1_plc_mapping SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async delete(id: number) {
|
||||||
|
await query(`DELETE FROM fleet_v1_plc_mapping WHERE id = $1`, [id]);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 시스템에서 받은 태그 값을 Pipeline 태그로 변환 (MQTT bridge 등에서 사용)
|
||||||
|
*/
|
||||||
|
static async resolveV1Tag(v1System: string, v1TagId: string) {
|
||||||
|
return await queryOne<any>(
|
||||||
|
`SELECT m.*, t.tag_name as pipeline_tag, c.connection_name
|
||||||
|
FROM fleet_v1_plc_mapping m
|
||||||
|
LEFT JOIN pipeline_tag_mappings t ON m.tag_mapping_id = t.id
|
||||||
|
LEFT JOIN pipeline_device_connections c ON m.connection_id = c.id
|
||||||
|
WHERE m.v1_system = $1 AND m.v1_tag_id = $2 AND m.is_active = TRUE
|
||||||
|
LIMIT 1`,
|
||||||
|
[v1System, v1TagId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* Fleet 모듈 초기화
|
||||||
|
* - 내장 MQTT 브로커 시작
|
||||||
|
* - 디바이스 heartbeat / 응답 / 데이터 수신 핸들러 등록
|
||||||
|
* - 주기 작업 (오프라인 감지, 커맨드 타임아웃, 알림 평가)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { getFleetMqttBroker, FleetMqttBroker } from "./mqttBroker";
|
||||||
|
import { FleetDeviceService } from "./fleetDeviceService";
|
||||||
|
import { FleetCommandService } from "./fleetCommandService";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
export async function initializeFleet(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const broker = getFleetMqttBroker();
|
||||||
|
await broker.start();
|
||||||
|
|
||||||
|
// ========= Heartbeat 구독 =========
|
||||||
|
broker.on("vexplor/devices/+/status", async (topic, payload) => {
|
||||||
|
const deviceId = FleetMqttBroker.extractDeviceId(topic);
|
||||||
|
if (!deviceId) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(payload.toString());
|
||||||
|
await FleetDeviceService.handleHeartbeat(deviceId, data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[Fleet] Heartbeat 처리 실패 (${deviceId}):`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========= 메트릭 구독 (heartbeat과 동일) =========
|
||||||
|
broker.on("vexplor/devices/+/metrics", async (topic, payload) => {
|
||||||
|
const deviceId = FleetMqttBroker.extractDeviceId(topic);
|
||||||
|
if (!deviceId) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(payload.toString());
|
||||||
|
await FleetDeviceService.handleHeartbeat(deviceId, data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[Fleet] 메트릭 처리 실패 (${deviceId}):`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========= 커맨드 응답 =========
|
||||||
|
broker.on("vexplor/devices/+/responses", async (topic, payload) => {
|
||||||
|
const deviceId = FleetMqttBroker.extractDeviceId(topic);
|
||||||
|
if (!deviceId) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(payload.toString());
|
||||||
|
if (data.command_id) {
|
||||||
|
await FleetCommandService.handleResponse(deviceId, data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[Fleet] 응답 처리 실패 (${deviceId}):`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========= 태그 데이터 수신 (엣지에서 수집한 실시간 데이터) =========
|
||||||
|
broker.on("vexplor/devices/+/data", async (topic, payload) => {
|
||||||
|
const deviceId = FleetMqttBroker.extractDeviceId(topic);
|
||||||
|
if (!deviceId) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(payload.toString());
|
||||||
|
await handleEdgeData(deviceId, data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[Fleet] 데이터 처리 실패 (${deviceId}):`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("[Fleet] 초기화 완료 - MQTT 브로커 + 구독자 시작");
|
||||||
|
|
||||||
|
// ========= 주기 작업 =========
|
||||||
|
// 2분마다 오프라인 감지
|
||||||
|
setInterval(() => {
|
||||||
|
FleetDeviceService.markStaleDevicesOffline(120).catch((e) =>
|
||||||
|
logger.error("[Fleet] 오프라인 감지 에러:", e),
|
||||||
|
);
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
// 1분마다 커맨드 타임아웃 처리
|
||||||
|
setInterval(() => {
|
||||||
|
FleetCommandService.markTimedOutCommands().catch((e) =>
|
||||||
|
logger.error("[Fleet] 커맨드 타임아웃 에러:", e),
|
||||||
|
);
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
// 30초마다 알림 규칙 평가
|
||||||
|
setInterval(() => {
|
||||||
|
evaluateAlertRules().catch((e) =>
|
||||||
|
logger.error("[Fleet] 알림 평가 에러:", e),
|
||||||
|
);
|
||||||
|
}, 30 * 1000);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("[Fleet] 초기화 실패:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엣지에서 수신한 태그 데이터 처리
|
||||||
|
* payload 형식:
|
||||||
|
* {
|
||||||
|
* timestamp: "2024-...",
|
||||||
|
* equipment_id: 123,
|
||||||
|
* connection_id: 45,
|
||||||
|
* tags: {
|
||||||
|
* "tag_name_1": 123.45,
|
||||||
|
* "tag_name_2": true,
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
async function handleEdgeData(
|
||||||
|
deviceId: string,
|
||||||
|
data: {
|
||||||
|
timestamp?: string;
|
||||||
|
equipment_id?: number;
|
||||||
|
connection_id?: number;
|
||||||
|
tags?: Record<string, any>;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
if (!data.tags || typeof data.tags !== "object") return;
|
||||||
|
|
||||||
|
const timestamp = data.timestamp ? new Date(data.timestamp) : new Date();
|
||||||
|
const rows: any[] = [];
|
||||||
|
|
||||||
|
for (const [tagName, value] of Object.entries(data.tags)) {
|
||||||
|
let numericValue: number | null = null;
|
||||||
|
let textValue: string | null = null;
|
||||||
|
|
||||||
|
if (typeof value === "number") numericValue = value;
|
||||||
|
else if (typeof value === "boolean") numericValue = value ? 1 : 0;
|
||||||
|
else if (typeof value === "string") textValue = value;
|
||||||
|
else textValue = JSON.stringify(value);
|
||||||
|
|
||||||
|
rows.push([
|
||||||
|
timestamp,
|
||||||
|
deviceId,
|
||||||
|
data.equipment_id || null,
|
||||||
|
data.connection_id || null,
|
||||||
|
tagName,
|
||||||
|
numericValue,
|
||||||
|
textValue,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
// 배치 INSERT
|
||||||
|
const placeholders = rows
|
||||||
|
.map(
|
||||||
|
(_, i) =>
|
||||||
|
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`,
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
const flatValues = rows.flat();
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_edge_raw_data
|
||||||
|
(time, device_id, equipment_id, connection_id, tag_name, value, value_text)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
ON CONFLICT (device_id, tag_name, time) DO NOTHING`,
|
||||||
|
flatValues,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림 규칙 평가 (심플 버전)
|
||||||
|
* 각 디바이스의 최근 heartbeat를 규칙과 비교
|
||||||
|
*/
|
||||||
|
async function evaluateAlertRules(): Promise<void> {
|
||||||
|
const rules = await query<any>(
|
||||||
|
`SELECT * FROM fleet_alert_rules WHERE enabled = TRUE`,
|
||||||
|
);
|
||||||
|
if (rules.length === 0) return;
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.metric === "offline_duration") {
|
||||||
|
// 오프라인 시간 규칙
|
||||||
|
const offlineDevices = await query<any>(
|
||||||
|
`SELECT device_id, EXTRACT(EPOCH FROM (NOW() - last_seen_at))::int as offline_sec
|
||||||
|
FROM fleet_devices
|
||||||
|
WHERE (last_seen_at IS NULL OR last_seen_at < NOW() - ($1 || ' seconds')::INTERVAL)
|
||||||
|
AND is_online = FALSE`,
|
||||||
|
[rule.threshold.toString()],
|
||||||
|
);
|
||||||
|
for (const d of offlineDevices) {
|
||||||
|
await insertAlertIfNew(rule, d.device_id, d.offline_sec);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// cpu/memory/disk 규칙
|
||||||
|
const column = rule.metric; // cpu_percent, memory_percent, disk_percent
|
||||||
|
if (!["cpu_percent", "memory_percent", "disk_percent"].includes(column)) continue;
|
||||||
|
|
||||||
|
// 최근 heartbeat에서 임계값 초과 디바이스 찾기
|
||||||
|
const op = rule.operator;
|
||||||
|
const overloadedDevices = await query<any>(
|
||||||
|
`SELECT DISTINCT ON (device_id) device_id, ${column} as value
|
||||||
|
FROM fleet_heartbeats
|
||||||
|
WHERE received_at > NOW() - '5 minutes'::INTERVAL
|
||||||
|
AND ${column} ${op === "==" ? "=" : op} $1
|
||||||
|
ORDER BY device_id, received_at DESC`,
|
||||||
|
[rule.threshold],
|
||||||
|
);
|
||||||
|
for (const d of overloadedDevices) {
|
||||||
|
await insertAlertIfNew(rule, d.device_id, d.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertAlertIfNew(rule: any, deviceId: string, value: number): Promise<void> {
|
||||||
|
// 같은 규칙 + 디바이스의 open 알림이 이미 있으면 무시
|
||||||
|
const existing = await query<any>(
|
||||||
|
`SELECT id FROM fleet_alerts
|
||||||
|
WHERE rule_id = $1 AND device_id = $2 AND status = 'open'
|
||||||
|
LIMIT 1`,
|
||||||
|
[rule.id, deviceId],
|
||||||
|
);
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fleet_alerts
|
||||||
|
(rule_id, device_id, severity, title, message, metric, value, threshold)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
rule.id,
|
||||||
|
deviceId,
|
||||||
|
rule.severity,
|
||||||
|
`${rule.rule_name} (${deviceId})`,
|
||||||
|
`${rule.metric} ${rule.operator} ${rule.threshold} (현재: ${value})`,
|
||||||
|
rule.metric,
|
||||||
|
value,
|
||||||
|
rule.threshold,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
logger.warn(`[Fleet] 알림 발생: ${rule.rule_name} - ${deviceId} (${rule.metric}=${value})`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* 내장 MQTT 브로커 (aedes)
|
||||||
|
* - Pipeline이 MQTT 브로커 역할까지 수행
|
||||||
|
* - aedes 내부 이벤트로 직접 publish 감지 (내부 client 불필요)
|
||||||
|
*
|
||||||
|
* 토픽 규칙 (vexplor_fleet 호환):
|
||||||
|
* vexplor/devices/{deviceId}/status - 디바이스 → 서버
|
||||||
|
* vexplor/devices/{deviceId}/metrics - 디바이스 → 서버
|
||||||
|
* vexplor/devices/{deviceId}/commands - 서버 → 디바이스
|
||||||
|
* vexplor/devices/{deviceId}/responses - 디바이스 → 서버
|
||||||
|
* vexplor/devices/{deviceId}/data - 디바이스 → 서버
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Aedes from "aedes";
|
||||||
|
import { createServer } from "aedes-server-factory";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const MQTT_PORT = parseInt(process.env.MQTT_PORT || "1883", 10);
|
||||||
|
const MQTT_WS_PORT = parseInt(process.env.MQTT_WS_PORT || "8083", 10);
|
||||||
|
|
||||||
|
type MessageHandler = (topic: string, payload: Buffer) => void | Promise<void>;
|
||||||
|
|
||||||
|
export class FleetMqttBroker {
|
||||||
|
private aedes: any;
|
||||||
|
private tcpServer: any;
|
||||||
|
private wsServer: any;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
private messageHandlers = new Map<string, MessageHandler[]>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.aedes = (Aedes as any)({ id: "pipeline-mqtt-broker" });
|
||||||
|
|
||||||
|
this.aedes.on("client", (client) => {
|
||||||
|
logger.info(`[MQTT] 클라이언트 연결: ${client.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.aedes.on("clientDisconnect", (client) => {
|
||||||
|
logger.info(`[MQTT] 클라이언트 연결 해제: ${client.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.aedes.on("subscribe", (subscriptions, client) => {
|
||||||
|
logger.debug(
|
||||||
|
`[MQTT] 구독: ${subscriptions.map((s) => s.topic).join(", ")} (by ${client?.id})`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 publish 감지
|
||||||
|
this.aedes.on("publish", (packet, client) => {
|
||||||
|
if (!packet.topic || packet.topic.startsWith("$SYS")) return;
|
||||||
|
// 내부에서 aedes.publish()로 보낸 것도 이 이벤트로 잡힘 - client가 null이면 내부 발행
|
||||||
|
if (client) {
|
||||||
|
this.dispatchMessage(packet.topic, packet.payload as Buffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브로커 시작 (TCP + WebSocket)
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.started) return;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.tcpServer = createServer(this.aedes as any);
|
||||||
|
this.tcpServer.once("error", reject);
|
||||||
|
this.tcpServer.listen(MQTT_PORT, "0.0.0.0", () => {
|
||||||
|
logger.info(`[MQTT] TCP 브로커 시작: mqtt://0.0.0.0:${MQTT_PORT}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.wsServer = createServer(this.aedes as any, { ws: true });
|
||||||
|
this.wsServer.once("error", reject);
|
||||||
|
this.wsServer.listen(MQTT_WS_PORT, "0.0.0.0", () => {
|
||||||
|
logger.info(`[MQTT] WebSocket 브로커 시작: ws://0.0.0.0:${MQTT_WS_PORT}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.started = true;
|
||||||
|
logger.info("[MQTT] 브로커 완전 기동 (내부 publish 가능)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토픽 패턴 등록 (MQTT 와일드카드 지원: +, #)
|
||||||
|
*/
|
||||||
|
on(topicPattern: string, handler: MessageHandler): void {
|
||||||
|
if (!this.messageHandlers.has(topicPattern)) {
|
||||||
|
this.messageHandlers.set(topicPattern, []);
|
||||||
|
}
|
||||||
|
this.messageHandlers.get(topicPattern)!.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchMessage(topic: string, payload: Buffer): void {
|
||||||
|
for (const [pattern, handlers] of this.messageHandlers) {
|
||||||
|
if (this.topicMatches(pattern, topic)) {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
Promise.resolve(handler(topic, payload)).catch((e) =>
|
||||||
|
logger.error(`[MQTT] 핸들러 에러 (${pattern}):`, e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private topicMatches(pattern: string, topic: string): boolean {
|
||||||
|
const pParts = pattern.split("/");
|
||||||
|
const tParts = topic.split("/");
|
||||||
|
|
||||||
|
for (let i = 0; i < pParts.length; i++) {
|
||||||
|
if (pParts[i] === "#") return true;
|
||||||
|
if (i >= tParts.length) return false;
|
||||||
|
if (pParts[i] === "+") continue;
|
||||||
|
if (pParts[i] !== tParts[i]) return false;
|
||||||
|
}
|
||||||
|
return pParts.length === tParts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 → 디바이스 메시지 발행 (aedes.publish 직접 사용)
|
||||||
|
*/
|
||||||
|
publish(topic: string, message: string | object, qos: 0 | 1 | 2 = 1): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.started) return reject(new Error("MQTT 브로커가 시작되지 않았습니다."));
|
||||||
|
const payload =
|
||||||
|
typeof message === "string" ? message : JSON.stringify(message);
|
||||||
|
(this.aedes as any).publish(
|
||||||
|
{
|
||||||
|
cmd: "publish",
|
||||||
|
qos,
|
||||||
|
topic,
|
||||||
|
payload: Buffer.from(payload),
|
||||||
|
retain: false,
|
||||||
|
},
|
||||||
|
(err: Error | null) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디바이스에 커맨드 발행
|
||||||
|
*/
|
||||||
|
sendCommandToDevice(deviceId: string, command: object): Promise<void> {
|
||||||
|
const topic = `vexplor/devices/${deviceId}/commands`;
|
||||||
|
return this.publish(topic, command, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토픽에서 deviceId 추출 (vexplor/devices/{deviceId}/...)
|
||||||
|
*/
|
||||||
|
static extractDeviceId(topic: string): string | null {
|
||||||
|
const m = topic.match(/^vexplor\/devices\/([^/]+)\//);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.tcpServer) this.tcpServer.close();
|
||||||
|
if (this.wsServer) this.wsServer.close();
|
||||||
|
this.aedes.close();
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let brokerInstance: FleetMqttBroker | null = null;
|
||||||
|
|
||||||
|
export function getFleetMqttBroker(): FleetMqttBroker {
|
||||||
|
if (!brokerInstance) {
|
||||||
|
brokerInstance = new FleetMqttBroker();
|
||||||
|
}
|
||||||
|
return brokerInstance;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* 자동화 통합 대시보드 API (조회 전용)
|
||||||
|
*
|
||||||
|
* GET /api/automation-dashboard/overview
|
||||||
|
*
|
||||||
|
* 반환:
|
||||||
|
* - batches: cron 배치 스케줄 목록 + 상태
|
||||||
|
* - pollings: 실시간 장비 폴링 목록
|
||||||
|
* - forwarders: 중앙 MQTT 포워더 상태
|
||||||
|
* - stats: 전체 요약
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/overview",
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userCompany = req.user?.companyCode;
|
||||||
|
const companyFilter =
|
||||||
|
userCompany && userCompany !== "*"
|
||||||
|
? `AND (company_code = '${userCompany.replace(/'/g, "''")}' OR company_code = '*' OR company_code IS NULL)`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 1) 크론 배치 (batch_configs)
|
||||||
|
const batches = await query<any>(
|
||||||
|
`SELECT id, batch_name, cron_schedule, is_active, company_code,
|
||||||
|
last_run_date, last_run_result, next_run_date
|
||||||
|
FROM batch_configs
|
||||||
|
WHERE 1=1 ${companyFilter}
|
||||||
|
ORDER BY is_active DESC, batch_name
|
||||||
|
LIMIT 50`
|
||||||
|
).catch(() => []);
|
||||||
|
|
||||||
|
// 2) 장비 폴링 (pipeline_device_connections)
|
||||||
|
const pollings = await query<any>(
|
||||||
|
`SELECT c.id, c.connection_name, c.protocol, c.host, c.port,
|
||||||
|
c.polling_interval_ms, c.is_active, c.status,
|
||||||
|
c.last_test_result, c.last_test_date,
|
||||||
|
c.target_db_connection_id, c.target_table_name,
|
||||||
|
(SELECT COUNT(*) FROM pipeline_tag_mappings t
|
||||||
|
WHERE t.connection_id = c.id AND t.is_active = 'Y') AS tag_count,
|
||||||
|
(SELECT MAX(s.last_collected_at) FROM equipment_current_state s
|
||||||
|
WHERE s.connection_id = c.id) AS last_collected_at
|
||||||
|
FROM pipeline_device_connections c
|
||||||
|
WHERE 1=1 ${companyFilter ? companyFilter.replace(/company_code/g, "c.company_code") : ""}
|
||||||
|
ORDER BY c.is_active DESC, c.connection_name`
|
||||||
|
).catch(() => []);
|
||||||
|
|
||||||
|
// 3) 중앙 MQTT 포워더 설정 + 통계
|
||||||
|
const forwarders = await query<any>(
|
||||||
|
`SELECT f.id, f.config_name, f.company_code, f.company_id, f.edge_id,
|
||||||
|
f.broker_host, f.broker_port, f.topic_pattern,
|
||||||
|
f.batch_size, f.batch_timeout_ms, f.is_enabled,
|
||||||
|
s.messages_forwarded, s.messages_failed, s.messages_dropped,
|
||||||
|
s.batches_sent, s.last_published_at, s.last_error,
|
||||||
|
s.is_connected, s.reconnect_attempts
|
||||||
|
FROM central_mqtt_forwarder_config f
|
||||||
|
LEFT JOIN central_mqtt_forwarder_stats s ON s.config_id = f.id
|
||||||
|
WHERE 1=1 ${companyFilter ? companyFilter.replace(/company_code/g, "f.company_code") : ""}
|
||||||
|
ORDER BY f.is_enabled DESC, f.config_name`
|
||||||
|
).catch(() => []);
|
||||||
|
|
||||||
|
// 4) 요약 통계
|
||||||
|
const activeBatches = batches.filter((b: any) => b.is_active === "Y" || b.is_active === true).length;
|
||||||
|
const activePollings = pollings.filter((p: any) => p.is_active === "Y").length;
|
||||||
|
const activeForwarders = forwarders.filter((f: any) => f.is_enabled === "Y").length;
|
||||||
|
const connectedPolls = pollings.filter((p: any) => p.status === "active" || p.status === "connected").length;
|
||||||
|
const totalTags = pollings.reduce((sum: number, p: any) => sum + Number(p.tag_count || 0), 0);
|
||||||
|
const forwardedTotal = forwarders.reduce((sum: number, f: any) => sum + Number(f.messages_forwarded || 0), 0);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
stats: {
|
||||||
|
batches_total: batches.length,
|
||||||
|
batches_active: activeBatches,
|
||||||
|
pollings_total: pollings.length,
|
||||||
|
pollings_active: activePollings,
|
||||||
|
pollings_connected: connectedPolls,
|
||||||
|
total_tags: totalTags,
|
||||||
|
forwarders_total: forwarders.length,
|
||||||
|
forwarders_enabled: activeForwarders,
|
||||||
|
messages_forwarded_total: forwardedTotal,
|
||||||
|
},
|
||||||
|
batches,
|
||||||
|
pollings,
|
||||||
|
forwarders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: (err as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Central MQTT Forwarder 관리 API
|
||||||
|
*
|
||||||
|
* GET /api/central-forwarder 목록
|
||||||
|
* GET /api/central-forwarder/:id 단건
|
||||||
|
* POST /api/central-forwarder 생성
|
||||||
|
* PUT /api/central-forwarder/:id 수정
|
||||||
|
* DELETE /api/central-forwarder/:id 삭제
|
||||||
|
* POST /api/central-forwarder/:id/toggle 활성/비활성
|
||||||
|
* GET /api/central-forwarder/runtime/status 런타임 상태
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import {
|
||||||
|
listConfigs,
|
||||||
|
getConfig,
|
||||||
|
createConfig,
|
||||||
|
updateConfig,
|
||||||
|
deleteConfig,
|
||||||
|
setEnabled,
|
||||||
|
} from "../services/collector/centralForwarderConfigService";
|
||||||
|
import { getRuntimeStatus } from "../services/collector/centralMqttForwarder";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userCompany = req.user?.companyCode;
|
||||||
|
const rows = await listConfigs(
|
||||||
|
userCompany === "*" ? (req.query.company_code as string | undefined) : userCompany
|
||||||
|
);
|
||||||
|
return res.status(200).json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`forwarder list error: ${(err as Error).message}`);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/runtime/status",
|
||||||
|
authenticateToken,
|
||||||
|
async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
return res.status(200).json({ success: true, data: getRuntimeStatus() });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:id",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const row = await getConfig(Number(req.params.id));
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ success: false, message: "not found" });
|
||||||
|
}
|
||||||
|
// 비밀번호 필드 마스킹
|
||||||
|
const out = { ...(row as Record<string, unknown>) };
|
||||||
|
if (out.password_encrypted) out.password_encrypted = "***ENCRYPTED***";
|
||||||
|
return res.status(200).json({ success: true, data: out });
|
||||||
|
} catch (err) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const created = await createConfig(req.body, req.user?.userId);
|
||||||
|
return res.status(201).json({ success: true, data: created });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`forwarder create error: ${(err as Error).message}`);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/:id",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await updateConfig(Number(req.params.id), req.body, req.user?.userId);
|
||||||
|
return res.status(200).json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:id",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await deleteConfig(Number(req.params.id));
|
||||||
|
return res.status(200).json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:id/toggle",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const enabled = req.body?.enabled === true || req.body?.enabled === "Y";
|
||||||
|
await setEnabled(Number(req.params.id), enabled, req.user?.userId);
|
||||||
|
return res.status(200).json({ success: true, data: { enabled } });
|
||||||
|
} catch (err) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Equipment Current State API
|
||||||
|
*
|
||||||
|
* GET /api/equipment-state/summary 회사별 연결 상태 요약
|
||||||
|
* GET /api/equipment-state/:connectionId 해당 연결의 태그별 최신값
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import {
|
||||||
|
getStateByConnection,
|
||||||
|
getConnectionStatusSummary,
|
||||||
|
} from "../services/collector/equipmentStateService";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/summary",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userCompany = req.user?.companyCode;
|
||||||
|
const companyCode =
|
||||||
|
userCompany === "*"
|
||||||
|
? (req.query.company_code as string | undefined)
|
||||||
|
: userCompany;
|
||||||
|
const rows = await getConnectionStatusSummary(companyCode);
|
||||||
|
return res.status(200).json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:connectionId",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const rows = await getStateByConnection(Number(req.params.connectionId));
|
||||||
|
return res.status(200).json({ success: true, data: rows });
|
||||||
|
} catch (err) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import cron from "node-cron";
|
import * as cron from "node-cron";
|
||||||
import { query, queryOne } from "../database/db";
|
import { 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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'
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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 적용 (보안 강화)
|
||||||
@@ -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 재시작 불필요** — 설정은 런타임에 동적 반영됩니다.
|
||||||
@@ -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=">">></SelectItem>
|
||||||
|
<SelectItem value=">=">>=</SelectItem>
|
||||||
|
<SelectItem value="<"><</SelectItem>
|
||||||
|
<SelectItem value="<="><=</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<{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 || [];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 || [];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
|||||||
Generated
+64
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user