Files
pipeline/frontend/app/(main)/admin/automaticMng/centralForwarder/page.tsx
T
chpark 4c1dc4082e
Build and Push Images / build-and-push (push) Has been cancelled
feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
이전 세션들에서 작업된 아래 범위를 모두 포함:

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>
2026-04-23 20:00:06 +09:00

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>
);
}