feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
Build and Push Images / build-and-push (push) Has been cancelled

이전 세션들에서 작업된 아래 범위를 모두 포함:

Fleet 서브시스템 (src/fleet/)
- fleetDeviceService / fleetCommandService / fleetDeploymentService / fleetReleaseService
- fleetMetricsService, fleetScriptService, fleetEdgeConfigService
- Edge 디바이스 관리, 커맨드 발행, 배포/릴리스, 스크립트 동기화

Collector 확장
- centralMqttForwarder / centralForwarderConfigService
- equipmentStateService, pythonHookRunner, scriptCache
- Modbus/OPC-UA/S7/XGT 프로토콜 클라이언트
- targetDbIntrospection (저장 DB 조회)

Routes / API
- automationDashboardRoutes, centralForwarderRoutes, equipmentStateRoutes

DB
- importEdgeConfig (Python cached config → Pipeline DB)
- seedDataSources (external_db_connections 초기 시드)

엣지 배포 리소스
- docker/edge/Dockerfile.backend.prod, Dockerfile.frontend.prod
- docker/edge/docker-compose.edge.yml

프론트엔드
- admin/automaticMng (centralForwarder, dashboard, equipmentState)
- admin/fleet (commands, devices, deployments, releases, scripts, alerts)
- admin/pipeline-device 개선 (저장 DB 드롭다운, 태그 매핑 등)
- ExternalDbConnectionModal, ScriptsManagerDialog 등 신규 컴포넌트
- lib/api: automationDashboard, centralForwarder, equipmentState, fleet

docs/
- EDGE_SERVER_STRUCTURE, FLEET_COMPLETE, FLEET_EDGE_INTEGRATION, FLEET_HOOK_INTEGRATION

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-23 20:00:06 +09:00
parent 01625d9efd
commit 4c1dc4082e
77 changed files with 14639 additions and 205 deletions
@@ -0,0 +1,266 @@
/**
* 엣지 Config JSON → Pipeline DB 임포트
*
* 엣지 Python data-collector가 사용 중인 config_cache.json 포맷을 받아
* pipeline_device_connections + pipeline_tag_mappings 테이블에 upsert.
*
* 프로토콜 매핑:
* ls_xgt → PLC_ETHERNET
* modbus_tcp → MODBUS_TCP
* modbus_rtu → MODBUS_RTU
* opcua → OPCUA
* s7 → S7
* mqtt → MQTT
* rest_api → REST_API
*
* 중복 방지: (company_code, connection_name) 기준으로 이미 존재하면 tags만 sync.
*/
import { query, queryOne } from "./db";
import logger from "../utils/logger";
export interface EdgeImportTag {
name: string;
address: string | number | null;
data_type?: string;
byte_order?: string;
scale?: number;
offset?: number;
unit?: string | null;
description?: string | null;
bit_index?: number | null;
deadband?: number | null;
column_name?: string | null; // SQL 수집 시 target 컬럼명
}
export interface EdgeImportDevice {
id?: string;
name: string;
protocol: string;
connection: { host?: string; port?: number; [k: string]: unknown };
interval_ms?: number;
enabled?: boolean;
tags: EdgeImportTag[];
}
export interface EdgeImportConfig {
edge_id?: string;
edge_name?: string;
devices: EdgeImportDevice[];
company_code?: string;
}
export interface ImportResult {
edgeName: string;
connections: Array<{
connectionId: number;
connectionName: string;
status: "created" | "updated";
tagsInserted: number;
tagsSkipped: number;
}>;
}
const PROTOCOL_MAP: Record<string, string> = {
ls_xgt: "LS_XGT",
xgt: "LS_XGT",
plc_ethernet: "LS_XGT",
modbus_tcp: "MODBUS_TCP",
modbus_rtu: "MODBUS_RTU",
opcua: "OPCUA",
s7: "SIEMENS_S7",
siemens_s7: "SIEMENS_S7",
mqtt: "MQTT",
rest_api: "REST_API",
};
function normalizeProtocol(p: string): string {
const key = (p || "").toLowerCase();
return PROTOCOL_MAP[key] || p.toUpperCase();
}
function normalizeDataType(dt?: string): string {
const v = (dt || "").toUpperCase();
// pipeline_tag_mappings.tag_data_type CHECK: INT16, INT32, FLOAT32, FLOAT64, BOOLEAN, STRING
switch (v) {
case "UINT16":
case "INT16":
case "WORD":
return "INT16";
case "UINT32":
case "INT32":
case "DWORD":
return "INT32";
case "FLOAT":
case "FLOAT32":
case "REAL":
return "FLOAT32";
case "DOUBLE":
case "FLOAT64":
return "FLOAT64";
case "BOOL":
case "BOOLEAN":
case "BIT":
return "BOOLEAN";
case "STR":
case "STRING":
return "STRING";
default:
return "INT16";
}
}
function normalizeByteOrder(bo?: string): string {
if (!bo) return "BIG_ENDIAN";
return bo.toUpperCase();
}
export async function importEdgeConfig(
cfg: EdgeImportConfig,
user = "system"
): Promise<ImportResult> {
const companyCode = cfg.company_code || "*";
const edgeName = cfg.edge_name || cfg.edge_id || "edge";
const result: ImportResult = { edgeName, connections: [] };
for (const device of cfg.devices || []) {
const connectionName = device.name;
const protocol = normalizeProtocol(device.protocol);
const host = device.connection?.host || "";
const port = Number(device.connection?.port || 0);
// protocol_config: host/port 제외한 나머지 연결 속성
const { host: _h, port: _p, ...protoCfg } = device.connection || {};
// 기존 연결 찾기
let conn = await queryOne<{ id: number }>(
`SELECT id FROM pipeline_device_connections
WHERE connection_name = $1 AND company_code = $2
LIMIT 1`,
[connectionName, companyCode]
);
let status: "created" | "updated";
if (!conn) {
const inserted = await queryOne<{ id: number }>(
`INSERT INTO pipeline_device_connections
(connection_name, description, protocol, host, port, protocol_config,
polling_interval_ms, timeout_ms, retry_count, status,
company_code, is_active, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
RETURNING id`,
[
connectionName,
`엣지에서 임포트: ${edgeName}`,
protocol,
host,
port,
JSON.stringify(protoCfg || {}),
device.interval_ms ?? 1000,
5000,
3,
"inactive",
companyCode,
device.enabled === false ? "N" : "Y",
user,
]
);
conn = inserted!;
status = "created";
logger.info(`[EdgeImport] 신규 연결: ${connectionName} (id=${conn.id})`);
} else {
await query(
`UPDATE pipeline_device_connections
SET protocol = $1, host = $2, port = $3, protocol_config = $4::jsonb,
polling_interval_ms = $5, updated_at = NOW()
WHERE id = $6`,
[
protocol,
host,
port,
JSON.stringify(protoCfg || {}),
device.interval_ms ?? 1000,
conn.id,
]
);
status = "updated";
logger.info(`[EdgeImport] 연결 업데이트: ${connectionName} (id=${conn.id})`);
}
// 태그 UPSERT
let tagsInserted = 0;
let tagsSkipped = 0;
for (const tag of device.tags || []) {
const existing = await queryOne<{ id: number }>(
`SELECT id FROM pipeline_tag_mappings
WHERE connection_id = $1 AND tag_name = $2
LIMIT 1`,
[conn.id, tag.name]
);
const targetCol = tag.column_name ?? null;
if (existing) {
await query(
`UPDATE pipeline_tag_mappings
SET address = $1, tag_data_type = $2, byte_order = $3,
scale_factor = $4, offset_value = $5,
bit_index = $6, deadband = $7,
tag_unit = $8, description = $9, target_column_name = $10, updated_at = NOW()
WHERE id = $11`,
[
tag.address != null ? String(tag.address) : "",
normalizeDataType(tag.data_type),
normalizeByteOrder(tag.byte_order),
tag.scale ?? 1.0,
tag.offset ?? 0.0,
tag.bit_index ?? null,
tag.deadband ?? null,
tag.unit ?? null,
tag.description ?? null,
targetCol,
existing.id,
]
);
tagsSkipped++;
continue;
}
await query(
`INSERT INTO pipeline_tag_mappings
(connection_id, tag_name, tag_display_name, tag_unit, tag_data_type,
address, scale_factor, offset_value, byte_order, bit_index, deadband,
description, target_column_name, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())`,
[
conn.id,
tag.name,
tag.name,
tag.unit ?? null,
normalizeDataType(tag.data_type),
tag.address != null ? String(tag.address) : "",
tag.scale ?? 1.0,
tag.offset ?? 0.0,
normalizeByteOrder(tag.byte_order),
tag.bit_index ?? null,
tag.deadband ?? null,
tag.description ?? null,
targetCol,
"Y",
]
);
tagsInserted++;
}
result.connections.push({
connectionId: conn.id,
connectionName,
status,
tagsInserted,
tagsSkipped,
});
}
return result;
}
+64
View File
@@ -320,6 +320,70 @@ export async function runPipelineDeviceMigration() {
}
}
export async function runCentralForwarderMigration() {
try {
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/308_central_forwarder_and_equipment_state.sql"
);
if (!fs.existsSync(sqlFilePath)) return;
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 중앙 MQTT 포워더 + 장비 현재값 테이블 생성 완료");
} catch (error) {
if (error instanceof Error && error.message.includes("already exists")) {
console.log("ℹ️ 중앙 포워더/장비 상태 테이블 이미 존재");
} else {
console.error("❌ 중앙 포워더 마이그레이션 실패:", error);
}
}
}
export async function runProtocolConstraintMigration() {
try {
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/309_expand_protocol_constraint.sql"
);
if (!fs.existsSync(sqlFilePath)) return;
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 프로토콜 CHECK 제약 확장 완료");
} catch (error) {
console.error("❌ 프로토콜 제약 마이그레이션 실패:", error);
}
}
export async function runDataTargetMigration() {
try {
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/310_add_data_target.sql"
);
if (!fs.existsSync(sqlFilePath)) return;
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 데이터 저장 대상 컬럼(target_db/table/column) 추가 완료");
} catch (error) {
console.error("❌ 데이터 저장 대상 마이그레이션 실패:", error);
}
}
export async function runEdgeDeviceIdentifierMigration() {
try {
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/311_add_edge_device_identifier.sql"
);
if (!fs.existsSync(sqlFilePath)) return;
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ edge_identifier / device_identifier 컬럼 추가 완료");
} catch (error) {
console.error("❌ edge/device identifier 마이그레이션 실패:", error);
}
}
export async function runOpenClawMigration() {
try {
console.log("🔄 OpenClaw AI 에이전트 마이그레이션 시작...");
@@ -0,0 +1,162 @@
/**
* 기본 데이터 소스 연결 시드
*
* 부팅 시 IDC 엣지 관련 연결 정보를 external_db_connections 테이블에 등록.
* 이미 같은 이름의 연결이 있으면 스킵.
*
* 등록 대상 (2026-04-21 기준):
* - IDC Central TimescaleDB (edge_telemetry) — 수집 데이터 시계열
* - IDC Digital-Twin PostgreSQL — 메타데이터
* - IDC Fleet PostgreSQL — fleet 관리 메타
* - IDC Vex Space PostgreSQL — Vex Space 전용
*/
import { query, queryOne } from "./db";
import { PasswordEncryption } from "../utils/passwordEncryption";
import logger from "../utils/logger";
interface DefaultDataSource {
connection_name: string;
description: string;
db_type: "postgresql" | "mysql" | "mariadb" | "mssql" | "oracle";
host: string;
port: number;
database_name: string;
username: string;
password: string;
company_code: string;
is_active: "Y" | "N";
connection_options?: Record<string, unknown>;
}
const DEFAULT_SOURCES: DefaultDataSource[] = [
{
connection_name: "IDC_TimescaleDB_edge_telemetry",
description:
"IDC 중앙 TimescaleDB — 엣지 수집 데이터 시계열 (edge_telemetry DB). digital-twin-timescale NodePort :30543",
db_type: "postgresql",
host: "211.115.91.170",
port: 30543,
database_name: "edge_telemetry",
username: "telemetry_user",
password: "qlalfqjsgh11",
company_code: "*",
is_active: "Y",
connection_options: { note: "TimescaleDB extension enabled" },
},
{
connection_name: "IDC_DigitalTwin_Postgres",
description:
"IDC 중앙 Digital-Twin 웹 메타데이터 PostgreSQL (NodePort :30533). digital-twin-web-postgres",
db_type: "postgresql",
host: "211.115.91.170",
port: 30533,
database_name: "digital_twin_web_database",
username: "digital_twin_web_user_dev",
password: "", // 비어 있으면 스킵
company_code: "*",
is_active: "N", // 비밀번호 모르므로 비활성으로 등록
},
{
connection_name: "IDC_VexSpace_Postgres",
description: "IDC VexSpace 전용 PostgreSQL (NodePort :31141). vexspace-postgres",
db_type: "postgresql",
host: "211.115.91.170",
port: 31141,
database_name: "vexspace",
username: "vexspace_user",
password: "", // 비어 있으면 스킵
company_code: "*",
is_active: "N",
},
{
connection_name: "IDC_Fleet_Postgres",
description: "IDC Fleet 관리 PostgreSQL (NodePort :31985). fleet-postgres",
db_type: "postgresql",
host: "211.115.91.170",
port: 31985,
database_name: "fleet",
username: "fleet_user",
password: "", // 비밀번호 모르므로 비활성
company_code: "*",
is_active: "N",
},
];
/**
* 기본 데이터 소스 연결을 시드. 이미 존재하면 스킵.
* 비밀번호가 비어있는 항목도 등록하지만 is_active='N'으로 두어 사용자가 나중에 채울 수 있게.
*/
export async function seedDefaultDataSources(): Promise<void> {
try {
// external_db_connections 테이블이 없으면 스킵
const tableCheck = await queryOne<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'external_db_connections'
) AS exists`
);
if (!tableCheck?.exists) {
logger.info("[DataSourceSeed] external_db_connections 없음 — 스킵");
return;
}
let inserted = 0;
let skipped = 0;
for (const src of DEFAULT_SOURCES) {
const existing = await queryOne(
`SELECT id FROM external_db_connections
WHERE connection_name = $1 AND company_code = $2
LIMIT 1`,
[src.connection_name, src.company_code]
);
if (existing) {
skipped++;
continue;
}
const encryptedPassword = src.password
? PasswordEncryption.encrypt(src.password)
: "";
await query(
`INSERT INTO external_db_connections (
connection_name, description, db_type, host, port, database_name,
username, password, connection_timeout, query_timeout, max_connections,
ssl_enabled, connection_options, company_code, is_active,
created_by, updated_by, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), NOW())`,
[
src.connection_name,
src.description,
src.db_type,
src.host,
src.port,
src.database_name,
src.username,
encryptedPassword,
30,
60,
10,
"N",
JSON.stringify(src.connection_options || {}),
src.company_code,
src.is_active,
"system",
"system",
]
);
inserted++;
logger.info(
`[DataSourceSeed] 등록: ${src.connection_name} (${src.host}:${src.port}, is_active=${src.is_active})`
);
}
logger.info(
`[DataSourceSeed] 완료: 신규 ${inserted}개, 기존 ${skipped}개 스킵`
);
} catch (err) {
logger.error(`[DataSourceSeed] 실패: ${(err as Error).message}`);
}
}