4c1dc4082e
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>
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { Plus, Pencil, Trash2, Power, RefreshCw, Radio } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import {
|
|
CentralForwarderAPI,
|
|
CentralForwarderConfig,
|
|
ForwarderRuntimeStatus,
|
|
} from "@/lib/api/centralForwarder";
|
|
|
|
const emptyForm: CentralForwarderConfig = {
|
|
config_name: "",
|
|
company_code: "*",
|
|
company_id: "",
|
|
edge_id: "",
|
|
broker_host: "211.115.91.170",
|
|
broker_port: 31883,
|
|
username: "ingestion",
|
|
password: "",
|
|
use_tls: "N",
|
|
client_id_prefix: "pipeline-forwarder",
|
|
topic_pattern: "dt/v1/data/{company_id}/{edge_id}",
|
|
status_topic_pattern: "dt/v1/status/{company_id}/{edge_id}",
|
|
batch_size: 50,
|
|
batch_timeout_ms: 3000,
|
|
heartbeat_interval_sec: 60,
|
|
qos: 1,
|
|
is_enabled: "N",
|
|
description: "",
|
|
};
|
|
|
|
export default function CentralForwarderPage() {
|
|
const { toast } = useToast();
|
|
const [configs, setConfigs] = useState<CentralForwarderConfig[]>([]);
|
|
const [runtime, setRuntime] = useState<ForwarderRuntimeStatus[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [form, setForm] = useState<CentralForwarderConfig>({ ...emptyForm });
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [list, rt] = await Promise.all([
|
|
CentralForwarderAPI.list(),
|
|
CentralForwarderAPI.runtimeStatus(),
|
|
]);
|
|
setConfigs(list);
|
|
setRuntime(rt);
|
|
} catch (err) {
|
|
toast({
|
|
title: "조회 실패",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
const t = setInterval(load, 10000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
|
|
const rtMap = new Map(runtime.map(r => [r.config_id, r]));
|
|
|
|
const openCreate = () => {
|
|
setEditingId(null);
|
|
setForm({ ...emptyForm });
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEdit = async (id: number) => {
|
|
try {
|
|
const cfg = await CentralForwarderAPI.get(id);
|
|
setEditingId(id);
|
|
setForm({ ...cfg, password: "" }); // 비밀번호는 비움 (필요 시 새로 입력)
|
|
setModalOpen(true);
|
|
} catch (err) {
|
|
toast({
|
|
title: "조회 실패",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
if (editingId) {
|
|
const payload = { ...form };
|
|
if (!payload.password) delete (payload as { password?: string }).password;
|
|
await CentralForwarderAPI.update(editingId, payload);
|
|
} else {
|
|
await CentralForwarderAPI.create(form);
|
|
}
|
|
toast({ title: "저장 완료" });
|
|
setModalOpen(false);
|
|
await load();
|
|
} catch (err) {
|
|
toast({
|
|
title: "저장 실패",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const toggle = async (id: number, enabled: boolean) => {
|
|
try {
|
|
await CentralForwarderAPI.toggle(id, enabled);
|
|
toast({ title: enabled ? "포워더 시작" : "포워더 중지" });
|
|
await load();
|
|
} catch (err) {
|
|
toast({
|
|
title: "상태 변경 실패",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const remove = async (id: number) => {
|
|
if (!confirm("이 포워더 설정을 삭제하시겠습니까?")) return;
|
|
try {
|
|
await CentralForwarderAPI.delete(id);
|
|
toast({ title: "삭제 완료" });
|
|
await load();
|
|
} catch (err) {
|
|
toast({
|
|
title: "삭제 실패",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto bg-background">
|
|
<div className="space-y-4 p-4 sm:p-5">
|
|
<div className="flex items-center justify-between border-b pb-3">
|
|
<div>
|
|
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
|
|
<Radio className="h-4 w-4" />
|
|
중앙 MQTT 포워더
|
|
</h1>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
수집한 장비 데이터를 IDC 중앙 EMQX로 전송 (Pipeline = Edge 역할)
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={load}>
|
|
<RefreshCw className="h-3 w-3" />
|
|
새로고침
|
|
</Button>
|
|
<Button size="sm" className="h-8 gap-1 text-xs" onClick={openCreate}>
|
|
<Plus className="h-3.5 w-3.5" />새 설정
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="h-44 animate-pulse rounded-lg border bg-muted/30" />
|
|
))}
|
|
</div>
|
|
) : configs.length === 0 ? (
|
|
<div className="flex h-48 flex-col items-center justify-center rounded-lg border border-dashed">
|
|
<Radio className="mb-2 h-5 w-5 text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">등록된 포워더 설정이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{configs.map(cfg => {
|
|
const rt = rtMap.get(cfg.id!);
|
|
const enabled = cfg.is_enabled === "Y";
|
|
return (
|
|
<div key={cfg.id} className="flex flex-col rounded-lg border bg-card p-3.5">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<Badge variant={enabled ? "default" : "secondary"} className="h-5 text-[10px]">
|
|
{enabled ? "활성" : "비활성"}
|
|
</Badge>
|
|
{rt && (
|
|
<Badge
|
|
variant={rt.connected ? "default" : "destructive"}
|
|
className="h-5 text-[10px]"
|
|
>
|
|
{rt.connected ? "연결됨" : "연결끊김"}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<h3 className="mb-0.5 truncate text-xs font-semibold">{cfg.config_name}</h3>
|
|
<p className="mb-2 truncate text-[11px] text-muted-foreground">
|
|
{cfg.company_code === "*" ? "공통" : cfg.company_code}
|
|
<span className="mx-1">·</span>
|
|
{cfg.edge_id}
|
|
</p>
|
|
<div className="mb-3 space-y-0.5 rounded-md bg-muted/50 px-2 py-1.5">
|
|
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
|
{cfg.broker_host}:{cfg.broker_port}
|
|
</p>
|
|
<p className="truncate font-mono text-[10px]">{cfg.topic_pattern}</p>
|
|
{rt && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
전송 {rt.messagesForwarded} · 실패 {rt.messagesFailed} · 버퍼 {rt.buffered}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="mt-auto flex items-center gap-1">
|
|
<Button
|
|
variant={enabled ? "secondary" : "default"}
|
|
size="sm"
|
|
onClick={() => toggle(cfg.id!, !enabled)}
|
|
className="h-6 flex-1 gap-1 text-[10px]"
|
|
>
|
|
<Power className="h-3 w-3" />
|
|
{enabled ? "중지" : "시작"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => openEdit(cfg.id!)}
|
|
className="h-6 px-2"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => remove(cfg.id!)}
|
|
className="h-6 px-2 text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[640px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-sm">
|
|
{editingId ? "포워더 설정 수정" : "새 포워더 설정"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
IDC 중앙 MQTT(EMQX)로 수집 데이터를 전송하는 설정입니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<Field
|
|
label="설정명"
|
|
value={form.config_name}
|
|
onChange={v => setForm({ ...form, config_name: v })}
|
|
/>
|
|
<Field
|
|
label="회사 코드"
|
|
value={form.company_code || "*"}
|
|
onChange={v => setForm({ ...form, company_code: v })}
|
|
/>
|
|
<Field
|
|
label="Company ID (MQTT 토픽)"
|
|
value={form.company_id}
|
|
onChange={v => setForm({ ...form, company_id: v })}
|
|
placeholder="예: 7f5c058c-ef65-45e3-..."
|
|
/>
|
|
<Field
|
|
label="Edge ID"
|
|
value={form.edge_id}
|
|
onChange={v => setForm({ ...form, edge_id: v })}
|
|
placeholder="예: edge-0f4d04ed"
|
|
/>
|
|
<Field
|
|
label="Broker Host"
|
|
value={form.broker_host}
|
|
onChange={v => setForm({ ...form, broker_host: v })}
|
|
/>
|
|
<Field
|
|
type="number"
|
|
label="Broker Port"
|
|
value={String(form.broker_port)}
|
|
onChange={v => setForm({ ...form, broker_port: Number(v) })}
|
|
/>
|
|
<Field
|
|
label="Username"
|
|
value={form.username || ""}
|
|
onChange={v => setForm({ ...form, username: v })}
|
|
/>
|
|
<Field
|
|
type="password"
|
|
label={editingId ? "Password (변경 시 입력)" : "Password"}
|
|
value={form.password || ""}
|
|
onChange={v => setForm({ ...form, password: v })}
|
|
/>
|
|
<Field
|
|
label="토픽 패턴"
|
|
value={form.topic_pattern || ""}
|
|
onChange={v => setForm({ ...form, topic_pattern: v })}
|
|
className="sm:col-span-2"
|
|
/>
|
|
<Field
|
|
type="number"
|
|
label="배치 크기"
|
|
value={String(form.batch_size || 50)}
|
|
onChange={v => setForm({ ...form, batch_size: Number(v) })}
|
|
/>
|
|
<Field
|
|
type="number"
|
|
label="배치 타임아웃 (ms)"
|
|
value={String(form.batch_timeout_ms || 3000)}
|
|
onChange={v => setForm({ ...form, batch_timeout_ms: Number(v) })}
|
|
/>
|
|
<Field
|
|
type="number"
|
|
label="하트비트 (초)"
|
|
value={String(form.heartbeat_interval_sec || 60)}
|
|
onChange={v => setForm({ ...form, heartbeat_interval_sec: Number(v) })}
|
|
/>
|
|
<Field
|
|
type="number"
|
|
label="QoS (0/1/2)"
|
|
value={String(form.qos ?? 1)}
|
|
onChange={v => setForm({ ...form, qos: Number(v) })}
|
|
/>
|
|
|
|
<div className="flex items-center gap-2 sm:col-span-2">
|
|
<Switch
|
|
checked={form.is_enabled === "Y"}
|
|
onCheckedChange={c => setForm({ ...form, is_enabled: c ? "Y" : "N" })}
|
|
/>
|
|
<Label className="text-xs">활성화 (저장 즉시 연결 시작)</Label>
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<Label className="text-xs">설명</Label>
|
|
<Input
|
|
className="mt-1 h-8 text-xs"
|
|
value={form.description || ""}
|
|
onChange={e => setForm({ ...form, description: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setModalOpen(false)}
|
|
className="h-8 text-xs"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button size="sm" onClick={save} disabled={saving} className="h-8 text-xs">
|
|
{saving ? "저장 중..." : "저장"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
value,
|
|
onChange,
|
|
type = "text",
|
|
placeholder,
|
|
className,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
type?: string;
|
|
placeholder?: string;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<div className={className}>
|
|
<Label className="text-xs">{label}</Label>
|
|
<Input
|
|
className="mt-1 h-8 text-xs"
|
|
type={type}
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|