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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user