From 4c1dc4082ea6ce9ce932f086ac40dc22c876e5ba Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 23 Apr 2026 20:00:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Fleet/Collector/=EC=97=A3=EC=A7=80=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EA=B4=80=EB=A0=A8=20=EB=88=84=EC=A0=81=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=9D=BC=EA=B4=84=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 세션들에서 작업된 아래 범위를 모두 포함: 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) --- backend-node/package-lock.json | 1042 +++++++++++- backend-node/package.json | 5 + backend-node/src/database/importEdgeConfig.ts | 266 ++++ backend-node/src/database/runMigration.ts | 64 + backend-node/src/database/seedDataSources.ts | 162 ++ .../src/fleet/fleetAlertRuleService.ts | 123 ++ backend-node/src/fleet/fleetAuditService.ts | 115 ++ backend-node/src/fleet/fleetCommandService.ts | 206 +++ backend-node/src/fleet/fleetDataService.ts | 96 ++ .../src/fleet/fleetDeploymentService.ts | 421 +++++ backend-node/src/fleet/fleetDeviceService.ts | 267 ++++ .../src/fleet/fleetEdgeConfigService.ts | 200 +++ backend-node/src/fleet/fleetHarborService.ts | 118 ++ backend-node/src/fleet/fleetMetricsService.ts | 119 ++ .../src/fleet/fleetPlcStatusService.ts | 127 ++ .../src/fleet/fleetProvisionService.ts | 187 +++ backend-node/src/fleet/fleetReleaseService.ts | 119 ++ backend-node/src/fleet/fleetRoutes.ts | 810 ++++++++++ backend-node/src/fleet/fleetScriptService.ts | 378 +++++ .../src/fleet/fleetTagTemplateService.ts | 174 +++ .../src/fleet/fleetV1MappingService.ts | 126 ++ backend-node/src/fleet/index.ts | 237 +++ backend-node/src/fleet/mqttBroker.ts | 176 +++ .../src/routes/automationDashboardRoutes.ts | 106 ++ .../src/routes/centralForwarderRoutes.ts | 139 ++ .../src/routes/equipmentStateRoutes.ts | 53 + .../src/services/aiSchedulerService.ts | 4 +- .../src/services/batchSchedulerService.ts | 110 +- .../centralForwarderConfigService.ts | 201 +++ .../collector/centralMqttForwarder.ts | 444 ++++++ .../collector/equipmentStateService.ts | 109 ++ .../collector/protocols/opcuaClient.ts | 157 ++ .../services/collector/protocols/s7Client.ts | 141 ++ .../services/collector/pythonHookRunner.ts | 227 +++ .../src/services/collector/scriptCache.ts | 99 ++ .../flowExternalDbConnectionService.ts | 6 +- .../pipelineDeviceConnectionService.ts | 33 +- .../src/services/targetDbIntrospection.ts | 234 +++ docker/dev/backend.Dockerfile | 4 +- docker/dev/docker-compose.backend.mac.yml | 2 + docker/dev/docker-compose.frontend.mac.yml | 13 +- docker/edge/.env.example | 25 + docker/edge/Dockerfile.backend.prod | 58 + docker/edge/Dockerfile.frontend.prod | 36 + docker/edge/README.md | 181 +++ docker/edge/docker-compose.edge.yml | 107 ++ docs/EDGE_SERVER_STRUCTURE.md | 404 +++++ docs/FLEET_COMPLETE.md | 209 +++ docs/FLEET_EDGE_INTEGRATION.md | 181 +++ docs/FLEET_HOOK_INTEGRATION.md | 327 ++++ .../automaticMng/batchmngList/create/page.tsx | 103 +- .../batchmngList/edit/[id]/page.tsx | 27 +- .../admin/automaticMng/batchmngList/page.tsx | 44 +- .../automaticMng/centralForwarder/page.tsx | 415 +++++ .../admin/automaticMng/dashboard/page.tsx | 402 +++++ .../automaticMng/equipmentState/page.tsx | 214 +++ .../admin/automaticMng/exconList/page.tsx | 15 +- .../app/(main)/admin/fleet/alerts/page.tsx | 158 ++ .../app/(main)/admin/fleet/audit/page.tsx | 137 ++ .../app/(main)/admin/fleet/commands/page.tsx | 115 ++ frontend/app/(main)/admin/fleet/data/page.tsx | 263 ++++ .../(main)/admin/fleet/deployments/page.tsx | 262 ++++ .../app/(main)/admin/fleet/devices/page.tsx | 300 ++++ .../app/(main)/admin/fleet/releases/page.tsx | 199 +++ .../app/(main)/admin/fleet/rules/page.tsx | 206 +++ .../app/(main)/admin/fleet/scripts/page.tsx | 475 ++++++ .../app/(main)/admin/pipeline-device/page.tsx | 1392 ++++++++++++++++- .../admin/ExternalDbConnectionModal.tsx | 1 + .../components/admin/ScriptsManagerDialog.tsx | 426 +++++ .../components/layout/AdminPageRenderer.tsx | 10 + frontend/lib/api/automationDashboard.ts | 81 + frontend/lib/api/centralForwarder.ts | 88 ++ frontend/lib/api/equipmentState.ts | 51 + frontend/lib/api/fleet.ts | 213 +++ frontend/lib/api/pipelineDevice.ts | 34 + frontend/package-lock.json | 64 + frontend/package.json | 1 + 77 files changed, 14639 insertions(+), 205 deletions(-) create mode 100644 backend-node/src/database/importEdgeConfig.ts create mode 100644 backend-node/src/database/seedDataSources.ts create mode 100644 backend-node/src/fleet/fleetAlertRuleService.ts create mode 100644 backend-node/src/fleet/fleetAuditService.ts create mode 100644 backend-node/src/fleet/fleetCommandService.ts create mode 100644 backend-node/src/fleet/fleetDataService.ts create mode 100644 backend-node/src/fleet/fleetDeploymentService.ts create mode 100644 backend-node/src/fleet/fleetDeviceService.ts create mode 100644 backend-node/src/fleet/fleetEdgeConfigService.ts create mode 100644 backend-node/src/fleet/fleetHarborService.ts create mode 100644 backend-node/src/fleet/fleetMetricsService.ts create mode 100644 backend-node/src/fleet/fleetPlcStatusService.ts create mode 100644 backend-node/src/fleet/fleetProvisionService.ts create mode 100644 backend-node/src/fleet/fleetReleaseService.ts create mode 100644 backend-node/src/fleet/fleetRoutes.ts create mode 100644 backend-node/src/fleet/fleetScriptService.ts create mode 100644 backend-node/src/fleet/fleetTagTemplateService.ts create mode 100644 backend-node/src/fleet/fleetV1MappingService.ts create mode 100644 backend-node/src/fleet/index.ts create mode 100644 backend-node/src/fleet/mqttBroker.ts create mode 100644 backend-node/src/routes/automationDashboardRoutes.ts create mode 100644 backend-node/src/routes/centralForwarderRoutes.ts create mode 100644 backend-node/src/routes/equipmentStateRoutes.ts create mode 100644 backend-node/src/services/collector/centralForwarderConfigService.ts create mode 100644 backend-node/src/services/collector/centralMqttForwarder.ts create mode 100644 backend-node/src/services/collector/equipmentStateService.ts create mode 100644 backend-node/src/services/collector/protocols/opcuaClient.ts create mode 100644 backend-node/src/services/collector/protocols/s7Client.ts create mode 100644 backend-node/src/services/collector/pythonHookRunner.ts create mode 100644 backend-node/src/services/collector/scriptCache.ts create mode 100644 backend-node/src/services/targetDbIntrospection.ts create mode 100644 docker/edge/.env.example create mode 100644 docker/edge/Dockerfile.backend.prod create mode 100644 docker/edge/Dockerfile.frontend.prod create mode 100644 docker/edge/README.md create mode 100644 docker/edge/docker-compose.edge.yml create mode 100644 docs/EDGE_SERVER_STRUCTURE.md create mode 100644 docs/FLEET_COMPLETE.md create mode 100644 docs/FLEET_EDGE_INTEGRATION.md create mode 100644 docs/FLEET_HOOK_INTEGRATION.md create mode 100644 frontend/app/(main)/admin/automaticMng/centralForwarder/page.tsx create mode 100644 frontend/app/(main)/admin/automaticMng/dashboard/page.tsx create mode 100644 frontend/app/(main)/admin/automaticMng/equipmentState/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/alerts/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/audit/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/commands/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/data/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/deployments/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/devices/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/releases/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/rules/page.tsx create mode 100644 frontend/app/(main)/admin/fleet/scripts/page.tsx create mode 100644 frontend/components/admin/ScriptsManagerDialog.tsx create mode 100644 frontend/lib/api/automationDashboard.ts create mode 100644 frontend/lib/api/centralForwarder.ts create mode 100644 frontend/lib/api/equipmentState.ts create mode 100644 frontend/lib/api/fleet.ts diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index c68b1172..096d549f 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -10,12 +10,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", @@ -30,6 +33,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", @@ -51,6 +55,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", @@ -1480,6 +1485,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -1528,6 +1542,12 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "license": "Apache-2.0" + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -1666,6 +1686,55 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -2215,6 +2284,16 @@ "integrity": "sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==", "license": "BSD-3-Clause" }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2369,6 +2448,70 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -3280,6 +3423,29 @@ "@types/node": "*" } }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/express": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", @@ -3633,6 +3799,33 @@ "@types/send": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4003,6 +4196,178 @@ "node": ">=0.4.0" } }, + "node_modules/aedes": { + "version": "0.51.3", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-0.51.3.tgz", + "integrity": "sha512-aQfiI9w3RbqnowNCdcGMmCtxBFXN9bhJFcuZm24U5/NU06V3MCl42jWK2GUnu8rOypR2Ahi/aEcgq3w7CMcycg==", + "license": "MIT", + "dependencies": { + "aedes-packet": "^3.0.0", + "aedes-persistence": "^9.1.2", + "end-of-stream": "^1.4.4", + "fastfall": "^1.5.1", + "fastparallel": "^2.4.1", + "fastseries": "^2.0.0", + "hyperid": "^3.2.0", + "mqemitter": "^6.0.0", + "mqtt-packet": "^9.0.0", + "retimer": "^4.0.0", + "reusify": "^1.0.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/aedes" + } + }, + "node_modules/aedes-packet": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-3.0.0.tgz", + "integrity": "sha512-swASey0BxGs4/npZGWoiVDmnEyPvVFIRY6l2LVKL4rbiW8IhcIGDLfnb20Qo8U20itXlitAKPQ3MVTEbOGG5ZA==", + "license": "MIT", + "dependencies": { + "mqtt-packet": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes-packet/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/aedes-packet/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/aedes-packet/node_modules/mqtt-packet": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-7.1.2.tgz", + "integrity": "sha512-FFZbcZ2omsf4c5TxEQfcX9hI+JzDpDKPT46OmeIBpVA7+t32ey25UNqlqNXTmeZOr5BLsSIERpQQLsFWJS94SQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.2", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/aedes-packet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/aedes-persistence": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-9.1.2.tgz", + "integrity": "sha512-2Wlr5pwIK0eQOkiTwb8ZF6C20s8UPUlnsJ4kXYePZ3JlQl0NbBA176mzM8wY294BJ5wybpNc9P5XEQxqadRNcQ==", + "license": "MIT", + "dependencies": { + "aedes-packet": "^3.0.0", + "qlobber": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes-protocol-decoder": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/aedes-protocol-decoder/-/aedes-protocol-decoder-2.2.0.tgz", + "integrity": "sha512-VeYR3Gx8KQ+VP/yX1hArwxoBeZJ3wPEg0LoLWfv6qfL/7RkClcxfOumV3nW2gCYs1a/0EMxpooZjn2nhfyFlxA==", + "license": "MIT", + "dependencies": { + "forwarded": "^0.2.0", + "proxy-protocol-js": "^4.0.6" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aedes-server-factory": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/aedes-server-factory/-/aedes-server-factory-0.2.1.tgz", + "integrity": "sha512-pVN5pKyi1UDZRU5DFbL/OnrMmd9xmOfAJ+NqG60mUrTt5nL2ERMJ9j/Of5MrKINz/YH7vctJ8UIhxlVj4zJyKQ==", + "license": "MIT", + "dependencies": { + "aedes-protocol-decoder": "^2.0.1", + "ws": "^7.5.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aedes-server-factory/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/aedes/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4062,7 +4427,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4072,7 +4436,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4141,6 +4504,15 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4354,6 +4726,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -4496,6 +4877,18 @@ "node": ">=8" } }, + "node_modules/broker-factory": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz", + "integrity": "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, "node_modules/browser-split": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz", @@ -4595,6 +4988,15 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -4874,6 +5276,12 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -4901,7 +5309,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -4962,7 +5369,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5033,6 +5439,12 @@ "node": ">=16" } }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -5179,6 +5591,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -5484,6 +5910,66 @@ "node": ">=8" } }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/docker-modem/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/dockerode": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.10.tgz", + "integrity": "sha512-8L/P9JynLBiG7/coiA4FlQXegHltRqS0a+KqI44P1zgQh8QLHTg7FKOwhkBgSJwZTeHsq30WRoVFLuwkfK0YFg==", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -5672,7 +6158,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/enabled": { @@ -5724,6 +6209,15 @@ "node": ">=0.10.0" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.6", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", @@ -5865,7 +6359,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6325,6 +6818,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/fast-xml-parser": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", @@ -6344,6 +6850,28 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -6354,6 +6882,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fastseries": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-2.0.0.tgz", + "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==", + "license": "ISC" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6543,6 +7077,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6615,7 +7155,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6924,6 +7463,12 @@ "node": ">=16.0.0" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -7192,6 +7737,50 @@ "node": ">=10.17.0" } }, + "node_modules/hyperid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hyperid/-/hyperid-3.3.0.tgz", + "integrity": "sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "uuid": "^8.3.2", + "uuid-parse": "^1.1.0" + } + }, + "node_modules/hyperid/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/hyperid/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -7537,7 +8126,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8357,6 +8945,16 @@ "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", "license": "MIT" }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8633,6 +9231,12 @@ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -9005,6 +9609,137 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mqemitter": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-6.0.2.tgz", + "integrity": "sha512-8RGlznQx/Nb1xC3xKUFXHWov7pn7JdH++YVwlr6SLT6k3ft1h+ImGqZdVudbdKruFckIq9wheq9s4hgCivJDow==", + "license": "ISC", + "dependencies": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mqemitter/node_modules/qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/mqtt": { + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz", + "integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt/node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/mqtt/node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mqtt/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/mqtt/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/mqtt/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9091,6 +9826,13 @@ "node": ">=12" } }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -9319,6 +10061,16 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9399,7 +10151,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -10057,6 +10808,30 @@ "node": ">= 6" } }, + "node_modules/protobufjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -10076,6 +10851,12 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/proxy-protocol-js": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/proxy-protocol-js/-/proxy-protocol-js-4.0.6.tgz", + "integrity": "sha512-SjXgyBmr0dBbKUZ0jOzp0N9urTcDOI1cd1oEeE43W1vG4OMwYYLggCRcMJ0zv0gdTA8Imb4cAiYj8Ic/PWv1mw==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -10083,6 +10864,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10119,6 +10910,15 @@ ], "license": "MIT" }, + "node_modules/qlobber": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-7.0.1.tgz", + "integrity": "sha512-FsFg9lMuMEFNKmTO9nV7tlyPhx8BmskPPjH2akWycuYVTtWaVwhW5yCHLJQ6Q+3mvw5cFX2vMfW2l9z2SiYAbg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -10414,7 +11214,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10490,11 +11289,66 @@ "node": ">=10" } }, + "node_modules/retimer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retimer/-/retimer-4.0.0.tgz", + "integrity": "sha512-fZIVtvbOsQsxNSDhpdPOX4lx5Ss2ni+S72AUBitARpFhtA3UzrAjQ6gDtypB2/+l7L+1VQgAgpvAKY66mElH0w==", + "license": "MIT", + "dependencies": { + "worker-timers": "^7.0.75" + } + }, + "node_modules/retimer/node_modules/fast-unique-numbers": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.1.0" + } + }, + "node_modules/retimer/node_modules/worker-timers": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2", + "worker-timers-broker": "^6.1.8", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/retimer/node_modules/worker-timers-broker": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "fast-unique-numbers": "^8.0.13", + "tslib": "^2.6.2", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/retimer/node_modules/worker-timers-worker": { + "version": "7.0.71", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -11006,6 +11860,12 @@ "source-map": "^0.6.0" } }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "license": "ISC" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -11030,6 +11890,23 @@ "node": ">= 0.6" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -11117,7 +11994,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11132,7 +12008,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11264,6 +12139,83 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/tar-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -11561,6 +12513,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11767,6 +12725,12 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/uuid-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", + "integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==", + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -11971,11 +12935,57 @@ "dev": true, "license": "MIT" }, + "node_modules/worker-factory": { + "version": "7.0.49", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz", + "integrity": "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.31", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.31.tgz", + "integrity": "sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.16", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.16.tgz", + "integrity": "sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "broker-factory": "^3.1.14", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.14", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.14.tgz", + "integrity": "sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -11993,7 +13003,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -12133,7 +13142,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -12150,7 +13158,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -12169,7 +13176,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/backend-node/package.json b/backend-node/package.json index e827da0c..c9a0d026 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -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", diff --git a/backend-node/src/database/importEdgeConfig.ts b/backend-node/src/database/importEdgeConfig.ts new file mode 100644 index 00000000..ba0a1db7 --- /dev/null +++ b/backend-node/src/database/importEdgeConfig.ts @@ -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 = { + 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 { + 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; +} diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index db380ef7..cecf92e9 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -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 에이전트 마이그레이션 시작..."); diff --git a/backend-node/src/database/seedDataSources.ts b/backend-node/src/database/seedDataSources.ts new file mode 100644 index 00000000..1262f355 --- /dev/null +++ b/backend-node/src/database/seedDataSources.ts @@ -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; +} + +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 { + 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}`); + } +} diff --git a/backend-node/src/fleet/fleetAlertRuleService.ts b/backend-node/src/fleet/fleetAlertRuleService.ts new file mode 100644 index 00000000..d6dcc23a --- /dev/null +++ b/backend-node/src/fleet/fleetAlertRuleService.ts @@ -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( + `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(`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( + `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) { + 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( + `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( + `UPDATE fleet_alert_rules SET enabled = NOT enabled, updated_at = NOW() WHERE id = $1 RETURNING *`, + [id], + ); + return r[0]; + } +} diff --git a/backend-node/src/fleet/fleetAuditService.ts b/backend-node/src/fleet/fleetAuditService.ts new file mode 100644 index 00000000..0738878e --- /dev/null +++ b/backend-node/src/fleet/fleetAuditService.ts @@ -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( + `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( + `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( + `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( + `SELECT COUNT(*) as n FROM fleet_audit_logs WHERE ${timeClause} AND result = 'failed'`, + params, + ); + return { byEvent, byActor, failures: parseInt(failures?.n || 0) }; + } +} diff --git a/backend-node/src/fleet/fleetCommandService.ts b/backend-node/src/fleet/fleetCommandService.ts new file mode 100644 index 00000000..7c62766e --- /dev/null +++ b/backend-node/src/fleet/fleetCommandService.ts @@ -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; + status?: CommandStatus; + result?: Record; + 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 = {}, + issuedBy?: string, + timeoutSec = 300, + ): Promise { + // 커맨드 타입 검증 + const typeCheck = await queryOne( + `SELECT command_type FROM fleet_command_types WHERE command_type = $1`, + [commandType], + ); + if (!typeCheck) { + throw new Error(`알 수 없는 커맨드 타입: ${commandType}`); + } + + // 디바이스 확인 + const device = await queryOne( + `SELECT device_id, is_online FROM fleet_devices WHERE device_id = $1`, + [deviceId], + ); + if (!device) { + throw new Error(`존재하지 않는 디바이스: ${deviceId}`); + } + + // DB에 커맨드 기록 + const result = await query( + `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; + 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( + `SELECT * FROM fleet_commands ${whereClause} + ORDER BY issued_at DESC LIMIT $${idx}`, + params, + ); + } + + /** + * 타임아웃된 커맨드 정리 (주기적으로 호출) + */ + static async markTimedOutCommands() { + const result = await query( + `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( + `SELECT * FROM fleet_command_types ORDER BY category, command_type`, + ); + } +} diff --git a/backend-node/src/fleet/fleetDataService.ts b/backend-node/src/fleet/fleetDataService.ts new file mode 100644 index 00000000..23d4bbd5 --- /dev/null +++ b/backend-node/src/fleet/fleetDataService.ts @@ -0,0 +1,96 @@ +/** + * Fleet Edge Data Service + * - 엣지에서 수집된 실시간 데이터 조회 + * - 장비별/태그별 시계열 데이터 + */ + +import { query } from "../database/db"; + +export class FleetDataService { + /** + * 디바이스별 최신 태그 값 (각 태그의 가장 최근 값) + */ + static async getLatestValuesByDevice(deviceId: string) { + return await query( + `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( + `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( + `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( + `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]; + } +} diff --git a/backend-node/src/fleet/fleetDeploymentService.ts b/backend-node/src/fleet/fleetDeploymentService.ts new file mode 100644 index 00000000..ab1eb249 --- /dev/null +++ b/backend-node/src/fleet/fleetDeploymentService.ts @@ -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( + `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( + `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( + `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 { + 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( + `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 { + if (targetType === "all") { + const r = await query( + `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( + `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( + `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 { + 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( + `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 { + 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( + `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( + `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 { + 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( + `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( + `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 }; + } +} diff --git a/backend-node/src/fleet/fleetDeviceService.ts b/backend-node/src/fleet/fleetDeviceService.ts new file mode 100644 index 00000000..ba7e3f26 --- /dev/null +++ b/backend-node/src/fleet/fleetDeviceService.ts @@ -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; + hardware_info?: Record; + device_group?: string; + tags?: any[]; +} + +export class FleetDeviceService { + /** + * 디바이스 등록 (핑거프린트 기반 - DPS 패턴) + * - 동일한 핑거프린트가 있으면 기존 레코드 업데이트 + * - 없으면 신규 등록 + */ + static async registerDevice(data: Partial): Promise { + if (!data.device_id) throw new Error("device_id 필수"); + + // 핑거프린트 매칭 + if (data.hardware_fingerprint) { + const existing = await queryOne( + `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( + `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( + `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( + `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) { + 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( + `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( + `SELECT * FROM fleet_heartbeats + WHERE device_id = $1 + ORDER BY received_at DESC + LIMIT $2`, + [deviceId, limit], + ); + } +} diff --git a/backend-node/src/fleet/fleetEdgeConfigService.ts b/backend-node/src/fleet/fleetEdgeConfigService.ts new file mode 100644 index 00000000..3fb756d6 --- /dev/null +++ b/backend-node/src/fleet/fleetEdgeConfigService.ts @@ -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; // 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 { + // 디바이스 확인 + const device = await query( + `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( + `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( + `SELECT * FROM pipeline_tag_mappings + WHERE connection_id = $1 AND is_active = 'Y' + ORDER BY id`, + [conn.id], + ); + + const protocolMap: Record = { + 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( + `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 { + const r = await query( + `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"; + } +} diff --git a/backend-node/src/fleet/fleetHarborService.ts b/backend-node/src/fleet/fleetHarborService.ts new file mode 100644 index 00000000..0a2bbf30 --- /dev/null +++ b/backend-node/src/fleet/fleetHarborService.ts @@ -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 { + 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 { + 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 { + 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 }; + } + } +} diff --git a/backend-node/src/fleet/fleetMetricsService.ts b/backend-node/src/fleet/fleetMetricsService.ts new file mode 100644 index 00000000..16e60cf8 --- /dev/null +++ b/backend-node/src/fleet/fleetMetricsService.ts @@ -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 { + const lines: string[] = []; + + // 디바이스 상태 + const devices = await query( + `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( + `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( + `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( + `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( + `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( + `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( + `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"; + } +} diff --git a/backend-node/src/fleet/fleetPlcStatusService.ts b/backend-node/src/fleet/fleetPlcStatusService.ts new file mode 100644 index 00000000..816f6b31 --- /dev/null +++ b/backend-node/src/fleet/fleetPlcStatusService.ts @@ -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( + `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( + `SELECT + status, + COUNT(*) as n, + COUNT(DISTINCT device_id) as devices + FROM fleet_plc_connections + GROUP BY status`, + ); + const byStatus: Record = {}; + r.forEach((row: any) => { byStatus[row.status] = { count: parseInt(row.n), devices: parseInt(row.devices) }; }); + return byStatus; + } +} diff --git a/backend-node/src/fleet/fleetProvisionService.ts b/backend-node/src/fleet/fleetProvisionService.ts new file mode 100644 index 00000000..d934ddbe --- /dev/null +++ b/backend-node/src/fleet/fleetProvisionService.ts @@ -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; + os_info?: Record; + 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 { + if (!req.mac_address) throw new Error("mac_address 필수"); + + // 1. 하드웨어 핑거프린트 계산 + const fingerprint = this.computeFingerprint(req); + + // 2. 기존 디바이스 조회 (핑거프린트 또는 MAC) + let device = await queryOne( + `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( + `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( + `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( + `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]; + } +} diff --git a/backend-node/src/fleet/fleetReleaseService.ts b/backend-node/src/fleet/fleetReleaseService.ts new file mode 100644 index 00000000..107698be --- /dev/null +++ b/backend-node/src/fleet/fleetReleaseService.ts @@ -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( + `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(`SELECT * FROM fleet_releases WHERE id = $1`, [id]); + } + + static async create(data: FleetRelease): Promise { + if (!data.version) throw new Error("version 필수"); + const r = await query( + `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) { + 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( + `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 = { + 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 }); + } +} diff --git a/backend-node/src/fleet/fleetRoutes.ts b/backend-node/src/fleet/fleetRoutes.ts new file mode 100644 index 00000000..950b4259 --- /dev/null +++ b/backend-node/src/fleet/fleetRoutes.ts @@ -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( + `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(`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( + `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(`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( + `SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE is_online) as online, + COUNT(*) FILTER (WHERE NOT is_online) as offline + FROM fleet_devices ${companyFilter}`, + ), + query( + `SELECT COUNT(*) as open_count FROM fleet_alerts WHERE status = 'open'`, + ), + query( + `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; diff --git a/backend-node/src/fleet/fleetScriptService.ts b/backend-node/src/fleet/fleetScriptService.ts new file mode 100644 index 00000000..db30d741 --- /dev/null +++ b/backend-node/src/fleet/fleetScriptService.ts @@ -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( + `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( + `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 { + if (!data.script_name || !data.hook_type || !data.code) { + throw new Error("script_name, hook_type, code는 필수"); + } + + const result = await query( + `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): Promise { + 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( + `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( + `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( + `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( + `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 { + 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( + `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, + ); + } +} diff --git a/backend-node/src/fleet/fleetTagTemplateService.ts b/backend-node/src/fleet/fleetTagTemplateService.ts new file mode 100644 index 00000000..0b66df52 --- /dev/null +++ b/backend-node/src/fleet/fleetTagTemplateService.ts @@ -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( + `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(`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( + `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) { + 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( + `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( + `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 }; + } +} diff --git a/backend-node/src/fleet/fleetV1MappingService.ts b/backend-node/src/fleet/fleetV1MappingService.ts new file mode 100644 index 00000000..e3c983ce --- /dev/null +++ b/backend-node/src/fleet/fleetV1MappingService.ts @@ -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; + 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( + `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( + `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) { + 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( + `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( + `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], + ); + } +} diff --git a/backend-node/src/fleet/index.ts b/backend-node/src/fleet/index.ts new file mode 100644 index 00000000..25163481 --- /dev/null +++ b/backend-node/src/fleet/index.ts @@ -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 { + 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; + }, +): Promise { + 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 { + const rules = await query( + `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( + `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( + `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 { + // 같은 규칙 + 디바이스의 open 알림이 이미 있으면 무시 + const existing = await query( + `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})`); +} diff --git a/backend-node/src/fleet/mqttBroker.ts b/backend-node/src/fleet/mqttBroker.ts new file mode 100644 index 00000000..6147187d --- /dev/null +++ b/backend-node/src/fleet/mqttBroker.ts @@ -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; + +export class FleetMqttBroker { + private aedes: any; + private tcpServer: any; + private wsServer: any; + private started = false; + + private messageHandlers = new Map(); + + 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 { + if (this.started) return; + + await new Promise((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((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 { + 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 { + 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 { + 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; +} diff --git a/backend-node/src/routes/automationDashboardRoutes.ts b/backend-node/src/routes/automationDashboardRoutes.ts new file mode 100644 index 00000000..ca0d8263 --- /dev/null +++ b/backend-node/src/routes/automationDashboardRoutes.ts @@ -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( + `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( + `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( + `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; diff --git a/backend-node/src/routes/centralForwarderRoutes.ts b/backend-node/src/routes/centralForwarderRoutes.ts new file mode 100644 index 00000000..23e46cb0 --- /dev/null +++ b/backend-node/src/routes/centralForwarderRoutes.ts @@ -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) }; + 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; diff --git a/backend-node/src/routes/equipmentStateRoutes.ts b/backend-node/src/routes/equipmentStateRoutes.ts new file mode 100644 index 00000000..c379386d --- /dev/null +++ b/backend-node/src/routes/equipmentStateRoutes.ts @@ -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; diff --git a/backend-node/src/services/aiSchedulerService.ts b/backend-node/src/services/aiSchedulerService.ts index ea4a1c9a..48173538 100644 --- a/backend-node/src/services/aiSchedulerService.ts +++ b/backend-node/src/services/aiSchedulerService.ts @@ -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: `

${schedule.name} 분석 결과

${result.finalSummary.substring(0, 3000)}
`, - }).catch(() => {}); + } as any).catch(() => {}); } } catch (e) { logger.warn("이메일 발송 실패:", e); } } diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index b46f0a4d..6fa7ff07 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -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)] - ); - } catch { /* ignore */ } + 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( + `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( + `INSERT INTO messenger_rooms (company_code, room_type, created_by) VALUES ($1, 'dm', $2) RETURNING id`, + [companyCode, sender] + ); + roomId = created[0].id; + await dbQuery( + `INSERT INTO messenger_participants (room_id, user_id) VALUES ($1, $2), ($1, $3)`, + [roomId, sender, recipientId] + ); + } + await dbQuery( + `INSERT INTO messenger_messages (room_id, sender_id, company_code, content, message_type) + VALUES ($1, $2, $3, $4, 'text')`, + [roomId, sender, companyCode, `${title}\n\n${summary}`] + ); + await dbQuery(`UPDATE messenger_rooms SET updated_at = NOW() WHERE id = $1`, [roomId]); + } + } catch (e) { logger.warn("메신저 알림 실패:", e); } } + + // 이메일 알림 + if (notification.email && Array.isArray(notification.email) && notification.email.length > 0) { + try { + const { mailSendSimpleService } = await import("./mailSendSimpleService"); + for (const to of notification.email) { + await mailSendSimpleService.sendMail({ + to, + subject: title, + html: `

${config.batch_name}

${summary}
`, + } 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( + `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}`); diff --git a/backend-node/src/services/collector/centralForwarderConfigService.ts b/backend-node/src/services/collector/centralForwarderConfigService.ts new file mode 100644 index 00000000..16f374a3 --- /dev/null +++ b/backend-node/src/services/collector/centralForwarderConfigService.ts @@ -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, + 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); + } +} diff --git a/backend-node/src/services/collector/centralMqttForwarder.ts b/backend-node/src/services/collector/centralMqttForwarder.ts new file mode 100644 index 00000000..fba609ad --- /dev/null +++ b/backend-node/src/services/collector/centralMqttForwarder.ts @@ -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(); + +// ─── 시작/중지 ────────────────────────────────────── + +export async function startAllEnabled(): Promise { + const configs = await query( + `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 { + for (const id of Array.from(instances.keys())) { + await stopForwarder(id); + } +} + +export async function startForwarder(cfgOrId: ForwarderConfig | number): Promise { + 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 { + 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(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 { + // 회사별 인스턴스 찾기 (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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const rows = await query( + `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 { + return new Promise((resolve, reject) => { + client.publish(topic, payload, { qos }, err => { + if (err) return reject(err); + resolve(); + }); + }); +} diff --git a/backend-node/src/services/collector/equipmentStateService.ts b/backend-node/src/services/collector/equipmentStateService.ts new file mode 100644 index 00000000..99d09844 --- /dev/null +++ b/backend-node/src/services/collector/equipmentStateService.ts @@ -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 { + 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`); +} diff --git a/backend-node/src/services/collector/protocols/opcuaClient.ts b/backend-node/src/services/collector/protocols/opcuaClient.ts new file mode 100644 index 00000000..653fc558 --- /dev/null +++ b/backend-node/src/services/collector/protocols/opcuaClient.ts @@ -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 { + 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 { + 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 { + 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; + } + } +} diff --git a/backend-node/src/services/collector/protocols/s7Client.ts b/backend-node/src/services/collector/protocols/s7Client.ts new file mode 100644 index 00000000..65d05706 --- /dev/null +++ b/backend-node/src/services/collector/protocols/s7Client.ts @@ -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 { + const NodeS7 = loadS7(); + this.conn = new NodeS7(); + + await new Promise((resolve, reject) => { + const params: Record = { + 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 { + if (!this.connected || !this.conn) { + throw new Error("S7 연결이 없습니다."); + } + + // nodes7은 변수 이름 등록 → 읽기 방식 + const varMap: Record = {}; + 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((resolve, reject) => { + this.conn.readAllItems((err: Error | null, values: Record) => { + 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 { + 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; + } + } +} diff --git a/backend-node/src/services/collector/pythonHookRunner.ts b/backend-node/src/services/collector/pythonHookRunner.ts new file mode 100644 index 00000000..2fd775ea --- /dev/null +++ b/backend-node/src/services/collector/pythonHookRunner.ts @@ -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; + context?: Record; + timeout_ms?: number; +} + +export interface HookResult { + success: boolean; + value?: unknown; + skip?: boolean; + alarm?: { level: string; message: string } | null; + derived?: Record; + 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 { + const timeoutMs = input.timeout_ms ?? 1500; + const start = Date.now(); + + return new Promise((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 { + return new Promise((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); + }); +} diff --git a/backend-node/src/services/collector/scriptCache.ts b/backend-node/src/services/collector/scriptCache.ts new file mode 100644 index 00000000..77d94aaf --- /dev/null +++ b/backend-node/src/services/collector/scriptCache.ts @@ -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(); +let lastRefresh = 0; +const REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5분 + +async function refreshCache(): Promise { + const rows = await query( + `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 { + 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 { + 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), + }; +} diff --git a/backend-node/src/services/flowExternalDbConnectionService.ts b/backend-node/src/services/flowExternalDbConnectionService.ts index e12a81a2..7a302b3a 100644 --- a/backend-node/src/services/flowExternalDbConnectionService.ts +++ b/backend-node/src/services/flowExternalDbConnectionService.ts @@ -310,9 +310,9 @@ export class FlowExternalDbConnectionService { let query: string; switch (connection.dbType) { case "postgresql": - query = `SELECT column_name, data_type - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = $1 + query = `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`; break; case "mysql": diff --git a/backend-node/src/services/pipelineDeviceConnectionService.ts b/backend-node/src/services/pipelineDeviceConnectionService.ts index d31fa8c5..3d593537 100644 --- a/backend-node/src/services/pipelineDeviceConnectionService.ts +++ b/backend-node/src/services/pipelineDeviceConnectionService.ts @@ -53,9 +53,12 @@ export class PipelineDeviceConnectionService { const connections = await query( `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( `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) { - 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( `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); diff --git a/backend-node/src/services/targetDbIntrospection.ts b/backend-node/src/services/targetDbIntrospection.ts new file mode 100644 index 00000000..46c94235 --- /dev/null +++ b/backend-node/src/services/targetDbIntrospection.ts @@ -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 { + 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( + 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 { + 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 { + // 테이블명 sanity (identifier만 허용) + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) { + throw new Error("잘못된 테이블명"); + } + + if (dbId === 0) { + const rows = await internalQuery( + `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 }; diff --git a/docker/dev/backend.Dockerfile b/docker/dev/backend.Dockerfile index 10938378..56b61592 100644 --- a/docker/dev/backend.Dockerfile +++ b/docker/dev/backend.Dockerfile @@ -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 복사 및 의존성 설치 (개발 의존성 포함) diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index ff567689..a01395d9 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -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: diff --git a/docker/dev/docker-compose.frontend.mac.yml b/docker/dev/docker-compose.frontend.mac.yml index d9ca1e29..a1c7203c 100644 --- a/docker/dev/docker-compose.frontend.mac.yml +++ b/docker/dev/docker-compose.frontend.mac.yml @@ -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 diff --git a/docker/edge/.env.example b/docker/edge/.env.example new file mode 100644 index 00000000..2f073827 --- /dev/null +++ b/docker/edge/.env.example @@ -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 diff --git a/docker/edge/Dockerfile.backend.prod b/docker/edge/Dockerfile.backend.prod new file mode 100644 index 00000000..b6cda12b --- /dev/null +++ b/docker/edge/Dockerfile.backend.prod @@ -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"] diff --git a/docker/edge/Dockerfile.frontend.prod b/docker/edge/Dockerfile.frontend.prod new file mode 100644 index 00000000..d102fe75 --- /dev/null +++ b/docker/edge/Dockerfile.frontend.prod @@ -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"] diff --git a/docker/edge/README.md b/docker/edge/README.md new file mode 100644 index 00000000..db516d27 --- /dev/null +++ b/docker/edge/README.md @@ -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' +``` diff --git a/docker/edge/docker-compose.edge.yml b/docker/edge/docker-compose.edge.yml new file mode 100644 index 00000000..102897d8 --- /dev/null +++ b/docker/edge/docker-compose.edge.yml @@ -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 diff --git a/docs/EDGE_SERVER_STRUCTURE.md b/docs/EDGE_SERVER_STRUCTURE.md new file mode 100644 index 00000000..54b7da69 --- /dev/null +++ b/docs/EDGE_SERVER_STRUCTURE.md @@ -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//...` 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//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) diff --git a/docs/FLEET_COMPLETE.md b/docs/FLEET_COMPLETE.md new file mode 100644 index 00000000..ed1bef17 --- /dev/null +++ b/docs/FLEET_COMPLETE.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 적용 (보안 강화) diff --git a/docs/FLEET_EDGE_INTEGRATION.md b/docs/FLEET_EDGE_INTEGRATION.md new file mode 100644 index 00000000..9f045ec1 --- /dev/null +++ b/docs/FLEET_EDGE_INTEGRATION.md @@ -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://:8080 +MQTT_BROKER_URL=mqtt://: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 재시작 불필요** — 설정은 런타임에 동적 반영됩니다. diff --git a/docs/FLEET_HOOK_INTEGRATION.md b/docs/FLEET_HOOK_INTEGRATION.md new file mode 100644 index 00000000..4b541f55 --- /dev/null +++ b/docs/FLEET_HOOK_INTEGRATION.md @@ -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 재시작 불필요** diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx index fe33e725..0d078270 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx @@ -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([]); + const [aiNotifyEmail, setAiNotifyEmail] = useState(false); + const [aiEmailAddresses, setAiEmailAddresses] = useState(""); const [aiWebhookUrl, setAiWebhookUrl] = useState(""); + const [companyUsers, setCompanyUsers] = useState([]); // Step 3: Crawling state const [crawlConfigs, setCrawlConfigs] = useState([]); @@ -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 */}

알림 설정

-
-
-

시스템 공지

-

- 실행 결과를 시스템 공지로 전송해요 -

+ + {/* 메신저 */} +
+
+
+

메신저로 전송

+

+ 실행 결과를 시스템 내 메신저로 전달해요 +

+
+ { + 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 {} + } + }} + />
- + {aiNotifyMessenger && ( +
+ +
+ {companyUsers.length === 0 ? ( +

사용자 목록을 불러오는 중...

+ ) : ( + companyUsers.map((u: any) => ( + + )) + )} +
+
+ )}
+ + {/* 이메일 */} +
+
+
+

이메일로 전송

+

+ 실행 결과를 이메일로 전달해요 +

+
+ +
+ {aiNotifyEmail && ( +
+ + setAiEmailAddresses(e.target.value)} + placeholder="user1@example.com, user2@example.com" + className="h-9 text-sm" + /> +
+ )} +
+ + {/* 웹훅 */}