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