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

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

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

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

Routes / API
- automationDashboardRoutes, centralForwarderRoutes, equipmentStateRoutes

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

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

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

docs/
- EDGE_SERVER_STRUCTURE, FLEET_COMPLETE, FLEET_EDGE_INTEGRATION, FLEET_HOOK_INTEGRATION

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-23 20:00:06 +09:00
parent 01625d9efd
commit 4c1dc4082e
77 changed files with 14639 additions and 205 deletions
+81
View File
@@ -0,0 +1,81 @@
// 자동화 통합 대시보드 API (조회 전용)
import { apiClient } from "./client";
export interface AutomationStats {
batches_total: number;
batches_active: number;
pollings_total: number;
pollings_active: number;
pollings_connected: number;
total_tags: number;
forwarders_total: number;
forwarders_enabled: number;
messages_forwarded_total: number;
}
export interface BatchRow {
id: number;
batch_name: string;
cron_schedule: string;
is_active: string | boolean;
company_code?: string;
last_run_date?: string;
last_run_result?: string;
next_run_date?: string;
}
export interface PollingRow {
id: number;
connection_name: string;
protocol: string;
host: string;
port: number;
polling_interval_ms: number;
is_active: string;
status: string;
last_test_result?: string;
last_test_date?: string;
target_db_connection_id?: number | null;
target_table_name?: string | null;
tag_count: number;
last_collected_at?: string | null;
}
export interface ForwarderRow {
id: number;
config_name: string;
company_code: string;
company_id: string;
edge_id: string;
broker_host: string;
broker_port: number;
topic_pattern: string;
batch_size: number;
batch_timeout_ms: number;
is_enabled: string;
messages_forwarded: number | null;
messages_failed: number | null;
messages_dropped: number | null;
batches_sent: number | null;
last_published_at?: string | null;
last_error?: string | null;
is_connected: string | null;
reconnect_attempts: number | null;
}
export interface DashboardOverview {
stats: AutomationStats;
batches: BatchRow[];
pollings: PollingRow[];
forwarders: ForwarderRow[];
}
export const AutomationDashboardAPI = {
async overview(): Promise<DashboardOverview> {
const r = await apiClient.get<{ success: boolean; data: DashboardOverview }>(
"/api/automation-dashboard/overview"
);
return r.data.data;
},
};
+88
View File
@@ -0,0 +1,88 @@
// Central MQTT Forwarder 관리 API 클라이언트
import { apiClient } from "./client";
export interface CentralForwarderConfig {
id?: number;
config_name: string;
company_code?: string;
company_id: string;
edge_id: string;
broker_host: string;
broker_port: number;
username?: string;
password?: string;
use_tls?: string;
client_id_prefix?: string;
topic_pattern?: string;
status_topic_pattern?: string;
batch_size?: number;
batch_timeout_ms?: number;
heartbeat_interval_sec?: number;
qos?: number;
is_enabled?: string;
description?: string;
created_date?: string;
updated_date?: string;
}
export interface ForwarderRuntimeStatus {
config_id: number;
config_name: string;
company_code: string;
edge_id: string;
broker: string;
connected: boolean;
buffered: number;
messagesForwarded: number;
messagesFailed: number;
messagesDropped: number;
batchesSent: number;
lastPublishedAt: string | null;
startedAt: string;
isConnected: boolean;
reconnectAttempts: number;
lastError: string | null;
lastErrorAt: string | null;
}
const BASE = "/api/central-forwarder";
export const CentralForwarderAPI = {
async list(companyCode?: string): Promise<CentralForwarderConfig[]> {
const url = companyCode ? `${BASE}?company_code=${companyCode}` : BASE;
const r = await apiClient.get<{ success: boolean; data: CentralForwarderConfig[] }>(url);
return r.data.data || [];
},
async get(id: number): Promise<CentralForwarderConfig> {
const r = await apiClient.get<{ success: boolean; data: CentralForwarderConfig }>(
`${BASE}/${id}`
);
return r.data.data;
},
async create(input: CentralForwarderConfig): Promise<{ id: number }> {
const r = await apiClient.post<{ success: boolean; data: { id: number } }>(BASE, input);
return r.data.data;
},
async update(id: number, input: Partial<CentralForwarderConfig>): Promise<void> {
await apiClient.put(`${BASE}/${id}`, input);
},
async delete(id: number): Promise<void> {
await apiClient.delete(`${BASE}/${id}`);
},
async toggle(id: number, enabled: boolean): Promise<void> {
await apiClient.post(`${BASE}/${id}/toggle`, { enabled });
},
async runtimeStatus(): Promise<ForwarderRuntimeStatus[]> {
const r = await apiClient.get<{ success: boolean; data: ForwarderRuntimeStatus[] }>(
`${BASE}/runtime/status`
);
return r.data.data || [];
},
};
+51
View File
@@ -0,0 +1,51 @@
// Equipment Current State API 클라이언트
import { apiClient } from "./client";
export interface EquipmentTagState {
id: number;
connection_id: number;
company_code: string;
tag_name: string;
tag_display_name: string | null;
tag_unit: string | null;
value_numeric: number | null;
value_text: string | null;
value_boolean: boolean | null;
quality: string;
last_collected_at: string;
updated_at: string;
}
export interface ConnectionStatusSummary {
connection_id: number;
connection_name: string;
protocol: string;
host: string;
port: number;
connection_status: string;
last_test_result: string | null;
last_test_message: string | null;
last_test_date: string | null;
company_code: string;
tag_count: number;
last_collected_at: string | null;
good_tag_count: number;
}
const BASE = "/api/equipment-state";
export const EquipmentStateAPI = {
async summary(companyCode?: string): Promise<ConnectionStatusSummary[]> {
const url = companyCode ? `${BASE}/summary?company_code=${companyCode}` : `${BASE}/summary`;
const r = await apiClient.get<{ success: boolean; data: ConnectionStatusSummary[] }>(url);
return r.data.data || [];
},
async tagsByConnection(connectionId: number): Promise<EquipmentTagState[]> {
const r = await apiClient.get<{ success: boolean; data: EquipmentTagState[] }>(
`${BASE}/${connectionId}`
);
return r.data.data || [];
},
};
+213
View File
@@ -0,0 +1,213 @@
import { apiClient } from "./client";
const BASE = "/fleet";
export interface FleetDevice {
id?: number;
device_id: string;
company_code?: string;
device_name?: string;
device_type?: string;
ip_address?: string;
mac_address?: string;
hardware_fingerprint?: string;
last_seen_at?: string;
is_online?: boolean;
equipment_id?: number | null;
equipment_name?: string;
equipment_code?: string;
agent_version?: string;
os_info?: Record<string, any>;
hardware_info?: Record<string, any>;
device_group?: string;
tags?: any[];
}
export interface FleetCommand {
id?: number;
device_id: string;
command_type: string;
payload?: Record<string, any>;
status?: string;
result?: Record<string, any>;
error_message?: string;
issued_by?: string;
issued_at?: string;
sent_at?: string;
responded_at?: string;
}
export interface FleetAlert {
id: number;
rule_id: number;
rule_name?: string;
device_id: string;
severity: string;
title: string;
message: string;
metric: string;
value: number;
threshold: number;
status: "open" | "acknowledged" | "resolved";
created_at: string;
}
export const fleetApi = {
// 디바이스
getDevices: (filter?: { is_online?: boolean; search?: string }) =>
apiClient.get(`${BASE}/devices`, { params: filter }).then((r) => r.data),
getDevice: (deviceId: string) =>
apiClient.get(`${BASE}/devices/${deviceId}`).then((r) => r.data),
registerDevice: (data: Partial<FleetDevice>) =>
apiClient.post(`${BASE}/devices/register`, data).then((r) => r.data),
updateDevice: (deviceId: string, data: Partial<FleetDevice>) =>
apiClient.patch(`${BASE}/devices/${deviceId}`, data).then((r) => r.data),
deleteDevice: (deviceId: string) =>
apiClient.delete(`${BASE}/devices/${deviceId}`).then((r) => r.data),
getMetrics: (deviceId: string, limit = 100) =>
apiClient.get(`${BASE}/devices/${deviceId}/metrics`, { params: { limit } }).then((r) => r.data),
// 커맨드
getCommands: (filter?: { device_id?: string; status?: string; limit?: number }) =>
apiClient.get(`${BASE}/commands`, { params: filter }).then((r) => r.data),
getCommandTypes: () =>
apiClient.get(`${BASE}/commands/types`).then((r) => r.data),
issueCommand: (data: { device_id: string; command_type: string; payload?: any; timeout_sec?: number }) =>
apiClient.post(`${BASE}/commands`, data).then((r) => r.data),
// 알림
getAlerts: (status: string = "open") =>
apiClient.get(`${BASE}/alerts`, { params: { status } }).then((r) => r.data),
ackAlert: (id: number) =>
apiClient.post(`${BASE}/alerts/${id}/ack`).then((r) => r.data),
resolveAlert: (id: number) =>
apiClient.post(`${BASE}/alerts/${id}/resolve`).then((r) => r.data),
getAlertRules: () =>
apiClient.get(`${BASE}/alert-rules`).then((r) => r.data),
// 배포
getDeployments: () =>
apiClient.get(`${BASE}/deployments`).then((r) => r.data),
getReleases: () =>
apiClient.get(`${BASE}/releases`).then((r) => r.data),
// 통계
getStats: () =>
apiClient.get(`${BASE}/stats`).then((r) => r.data),
// 실시간 데이터
getLatestValues: (deviceId: string) =>
apiClient.get(`${BASE}/devices/${deviceId}/latest-values`).then((r) => r.data),
getLatestValuesByEquipment: (equipmentId: number) =>
apiClient.get(`${BASE}/equipment/${equipmentId}/latest-values`).then((r) => r.data),
getTagTimeseries: (deviceId: string, tagName: string, limit = 500) =>
apiClient
.get(`${BASE}/devices/${deviceId}/tags/${encodeURIComponent(tagName)}/timeseries`, {
params: { limit },
})
.then((r) => r.data),
getDataStats: (deviceId?: string) =>
apiClient.get(`${BASE}/data/stats`, { params: { device_id: deviceId } }).then((r) => r.data),
// ===== Python Hook 스크립트 =====
getHookTypes: () =>
apiClient.get(`${BASE}/scripts/hook-types`).then((r) => r.data),
listScripts: (filter?: any) =>
apiClient.get(`${BASE}/scripts`, { params: filter }).then((r) => r.data),
getScript: (id: number) =>
apiClient.get(`${BASE}/scripts/${id}`).then((r) => r.data),
createScript: (data: any) =>
apiClient.post(`${BASE}/scripts`, data).then((r) => r.data),
updateScript: (id: number, data: any) =>
apiClient.put(`${BASE}/scripts/${id}`, data).then((r) => r.data),
deleteScript: (id: number) =>
apiClient.delete(`${BASE}/scripts/${id}`).then((r) => r.data),
dryRunScript: (code: string, hook_type: string, test_input: any, timeout_ms?: number) =>
apiClient.post(`${BASE}/scripts/dry-run`, { code, hook_type, test_input, timeout_ms }).then((r) => r.data),
getScriptVersions: (id: number) =>
apiClient.get(`${BASE}/scripts/${id}/versions`).then((r) => r.data),
getScriptVersion: (id: number, version: number) =>
apiClient.get(`${BASE}/scripts/${id}/versions/${version}`).then((r) => r.data),
rollbackScript: (id: number, version: number) =>
apiClient.post(`${BASE}/scripts/${id}/rollback/${version}`).then((r) => r.data),
// ===== 릴리즈 =====
getReleases: (filter?: any) => apiClient.get(`${BASE}/releases`, { params: filter }).then(r => r.data),
getRelease: (id: number) => apiClient.get(`${BASE}/releases/${id}`).then(r => r.data),
createRelease: (data: any) => apiClient.post(`${BASE}/releases`, data).then(r => r.data),
updateRelease: (id: number, data: any) => apiClient.put(`${BASE}/releases/${id}`, data).then(r => r.data),
deleteRelease: (id: number) => apiClient.delete(`${BASE}/releases/${id}`).then(r => r.data),
transitionRelease: (id: number, status: string) =>
apiClient.post(`${BASE}/releases/${id}/transition`, { status }).then(r => r.data),
// ===== 배포 =====
createDeployment: (data: any) => apiClient.post(`${BASE}/deployments`, data).then(r => r.data),
getDeploymentDetail: (id: number) => apiClient.get(`${BASE}/deployments/${id}`).then(r => r.data),
getDeploymentStatus: (id: number) => apiClient.get(`${BASE}/deployments/${id}/status`).then(r => r.data),
startDeployment: (id: number) => apiClient.post(`${BASE}/deployments/${id}/start`).then(r => r.data),
cancelDeployment: (id: number) => apiClient.post(`${BASE}/deployments/${id}/cancel`).then(r => r.data),
rollbackDeployment: (id: number) => apiClient.post(`${BASE}/deployments/${id}/rollback`).then(r => r.data),
// ===== Harbor =====
getHarborProjects: () => apiClient.get(`${BASE}/harbor/projects`).then(r => r.data),
getHarborRepos: (project: string) => apiClient.get(`${BASE}/harbor/projects/${project}/repos`).then(r => r.data),
getHarborTags: (project: string, repo: string) =>
apiClient.get(`${BASE}/harbor/projects/${project}/repos/${repo}/tags`).then(r => r.data),
pingHarbor: () => apiClient.get(`${BASE}/harbor/ping`).then(r => r.data),
// ===== 태그 템플릿 =====
getTagTemplates: (filter?: any) => apiClient.get(`${BASE}/tag-templates`, { params: filter }).then(r => r.data),
getTagTemplate: (id: number) => apiClient.get(`${BASE}/tag-templates/${id}`).then(r => r.data),
createTagTemplate: (data: any) => apiClient.post(`${BASE}/tag-templates`, data).then(r => r.data),
updateTagTemplate: (id: number, data: any) => apiClient.put(`${BASE}/tag-templates/${id}`, data).then(r => r.data),
deleteTagTemplate: (id: number) => apiClient.delete(`${BASE}/tag-templates/${id}`).then(r => r.data),
applyTagTemplate: (templateId: number, connectionId: number, overwrite = false) =>
apiClient.post(`${BASE}/tag-templates/${templateId}/apply/${connectionId}`, { overwrite }).then(r => r.data),
// ===== 알림 규칙 =====
createAlertRule: (data: any) => apiClient.post(`${BASE}/alert-rules`, data).then(r => r.data),
updateAlertRule: (id: number, data: any) => apiClient.put(`${BASE}/alert-rules/${id}`, data).then(r => r.data),
deleteAlertRule: (id: number) => apiClient.delete(`${BASE}/alert-rules/${id}`).then(r => r.data),
toggleAlertRule: (id: number) => apiClient.post(`${BASE}/alert-rules/${id}/toggle`).then(r => r.data),
// ===== V1 매핑 =====
getV1Mappings: (filter?: any) => apiClient.get(`${BASE}/v1-mappings`, { params: filter }).then(r => r.data),
createV1Mapping: (data: any) => apiClient.post(`${BASE}/v1-mappings`, data).then(r => r.data),
updateV1Mapping: (id: number, data: any) => apiClient.put(`${BASE}/v1-mappings/${id}`, data).then(r => r.data),
deleteV1Mapping: (id: number) => apiClient.delete(`${BASE}/v1-mappings/${id}`).then(r => r.data),
// ===== PLC 상태 =====
getPlcStatus: (filter?: any) => apiClient.get(`${BASE}/plc-status`, { params: filter }).then(r => r.data),
getPlcSummary: () => apiClient.get(`${BASE}/plc-status/summary`).then(r => r.data),
// ===== Audit =====
getAuditLogs: (filter?: any) => apiClient.get(`${BASE}/audit-logs`, { params: filter }).then(r => r.data),
getAuditStats: () => apiClient.get(`${BASE}/audit-logs/stats`).then(r => r.data),
// ===== Provisioning =====
getPreRegistered: () => apiClient.get(`${BASE}/provision/pre-registered`).then(r => r.data),
preRegister: (data: any) => apiClient.post(`${BASE}/provision/pre-register`, data).then(r => r.data),
};
+34
View File
@@ -7,6 +7,10 @@ export const pipelineDeviceApi = {
getProtocols: () =>
apiClient.get(`${BASE}/protocols`).then((r) => r.data),
// 장비 목록 (pipeline_equipment)
getEquipmentList: (search?: string) =>
apiClient.get(`${BASE}/equipment-list`, { params: search ? { search } : {} }).then((r) => r.data),
// 연결 CRUD
getConnections: (params?: { protocol?: string; is_active?: string; search?: string; status?: string }) =>
apiClient.get(BASE, { params }).then((r) => r.data),
@@ -21,6 +25,36 @@ export const pipelineDeviceApi = {
testConnection: (id: number) =>
apiClient.post(`${BASE}/${id}/test`).then((r) => r.data),
// 훅 체인 테스트 (원본값 → 체인 실행 → 결과 + DB 저장 옵션)
testChain: (
id: number,
payload: { tag_name: string; raw_value: unknown; save_to_db?: boolean }
) =>
apiClient.post(`${BASE}/${id}/test-chain`, payload).then((r) => r.data),
// 수동 1회 수집 (실제 PLC에서 읽기 + 훅 적용 + DB 저장)
collectOnce: (id: number) =>
apiClient.post(`${BASE}/${id}/collect-once`).then((r) => r.data),
// Target DB introspection
listTargetDatabases: () =>
apiClient.get(`${BASE}/target-databases`).then((r) => r.data),
listTargetTables: (dbId: number) =>
apiClient.get(`${BASE}/target-databases/${dbId}/tables`).then((r) => r.data),
listTargetColumns: (dbId: number, tableName: string) =>
apiClient
.get(`${BASE}/target-databases/${dbId}/tables/${tableName}/columns`)
.then((r) => r.data),
// 태그 컬럼 매핑 일괄 업데이트
updateTagColumnMapping: (
connectionId: number,
mapping: Array<{ tag_id: number; target_column_name: string | null }>
) =>
apiClient
.put(`${BASE}/${connectionId}/tag-column-mapping`, { mapping })
.then((r) => r.data),
// 태그 매핑
getTagMappings: (connectionId: number) =>
apiClient.get(`${BASE}/${connectionId}/tags`).then((r) => r.data),