Files
pipeline/frontend/app/(main)/admin/pipeline-device/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

1478 lines
63 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { pipelineDeviceApi } from "@/lib/api/pipelineDevice";
import { fleetApi } from "@/lib/api/fleet";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import { ScriptsManagerDialog } from "@/components/admin/ScriptsManagerDialog";
import { ArrowUp, ArrowDown, Database as DatabaseIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Pencil, Trash2, Loader2, TestTube, Cable, CheckCircle2, XCircle, AlertCircle, Search, Cpu, RefreshCw } from "lucide-react";
import { toast } from "sonner";
const PROTOCOL_OPTIONS = [
{ value: "MODBUS_TCP", label: "Modbus TCP", color: "#F59E0B", defaultPort: 502 },
{ value: "MODBUS_RTU", label: "Modbus RTU", color: "#F97316", defaultPort: 0 },
{ value: "OPCUA", label: "OPC UA", color: "#10B981", defaultPort: 4840 },
{ value: "SIEMENS_S7", label: "Siemens S7", color: "#6366F1", defaultPort: 102 },
{ value: "LS_XGT", label: "LS XGT", color: "#8B5CF6", defaultPort: 2004 },
{ value: "MQTT", label: "MQTT", color: "#EC4899", defaultPort: 1883 },
{ value: "REST_API", label: "REST API", color: "#3B82F6", defaultPort: 443 },
];
const PROTOCOL_DEFAULTS: Record<string, any> = {
MODBUS_TCP: { port: 502, config: { unit_id: 1 } },
MODBUS_RTU: { port: 0, config: { serial_port: "/dev/ttyUSB0", baudrate: 9600, parity: "N", stopbits: 1, unit_id: 1 } },
OPCUA: { port: 4840, config: { security_policy: "None", use_subscription: false } },
SIEMENS_S7: { port: 102, config: { rack: 0, slot: 1, cpu_type: "S7-1200" } },
LS_XGT: { port: 2004, config: { cpu_type: "XGK", slot: 0 } },
MQTT: { port: 1883, config: { qos: 0, keepalive_sec: 60, use_tls: false } },
REST_API: { port: 443, config: {} },
};
const emptyForm = {
equipment_id: null as number | null,
connection_name: "",
description: "",
protocol: "MODBUS_TCP",
host: "",
port: 502,
protocol_config: { unit_id: 1 } as any,
polling_interval_ms: 1000,
timeout_ms: 5000,
retry_count: 3,
target_db_connection_id: null as number | null,
target_table_name: "" as string,
target_time_column: "timestamp" as string,
target_insert_mode: "append" as "append" | "upsert",
edge_identifier: "" as string,
device_identifier: "" as string,
};
export default function PipelineDevicePage() {
const [connections, setConnections] = useState<any[]>([]);
const [equipments, setEquipments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [testingId, setTestingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [equipmentSearch, setEquipmentSearch] = useState("");
const [allScripts, setAllScripts] = useState<any[]>([]);
const [linkedScriptIds, setLinkedScriptIds] = useState<number[]>([]);
const [scriptsDialogOpen, setScriptsDialogOpen] = useState(false);
const [externalDbs, setExternalDbs] = useState<any[]>([]);
const [targetTables, setTargetTables] = useState<string[]>([]);
const [targetColumns, setTargetColumns] = useState<any[]>([]);
const [tagsOfConn, setTagsOfConn] = useState<any[]>([]);
const [columnMapping, setColumnMapping] = useState<Record<number, string>>({});
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [chainTestTag, setChainTestTag] = useState("test_tag");
const [chainTestValue, setChainTestValue] = useState("10");
const [chainTestSave, setChainTestSave] = useState(false);
const [chainTestRunning, setChainTestRunning] = useState(false);
const [chainTestResult, setChainTestResult] = useState<any>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [connRes, eqRes] = await Promise.all([
pipelineDeviceApi.getConnections(),
pipelineDeviceApi.getEquipmentList(),
]);
setConnections(connRes.data || []);
setEquipments(eqRes.data || []);
} catch { toast.error("데이터 로드 실패"); }
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const filteredConnections = useMemo(() => {
if (!searchTerm) return connections;
const q = searchTerm.toLowerCase();
return connections.filter((c) =>
(c.connection_name || "").toLowerCase().includes(q) ||
(c.equipment_name || "").toLowerCase().includes(q) ||
(c.host || "").toLowerCase().includes(q),
);
}, [connections, searchTerm]);
const filteredEquipments = useMemo(() => {
if (!equipmentSearch) return equipments;
const q = equipmentSearch.toLowerCase();
return equipments.filter((e) =>
(e.equipment_name || "").toLowerCase().includes(q) ||
(e.equipment_code || "").toLowerCase().includes(q),
);
}, [equipments, equipmentSearch]);
const openCreate = () => {
setEditing(null);
setForm({ ...emptyForm });
setEquipmentSearch("");
setLinkedScriptIds([]);
setModalOpen(true);
};
const openEdit = async (c: any) => {
setEditing(c);
setForm({
equipment_id: c.equipment_id || null,
connection_name: c.connection_name,
description: c.description || "",
protocol: c.protocol,
host: c.host,
port: c.port,
protocol_config: c.protocol_config || {},
polling_interval_ms: c.polling_interval_ms,
timeout_ms: c.timeout_ms,
retry_count: c.retry_count,
target_db_connection_id: c.target_db_connection_id || null,
target_table_name: c.target_table_name || "",
target_time_column: c.target_time_column || "timestamp",
target_insert_mode: c.target_insert_mode || "append",
edge_identifier: c.edge_identifier || "",
device_identifier: c.device_identifier || "",
});
// 이 연결에 이미 붙어있는 스크립트 ID 로드
try {
const res = await fleetApi.listScripts({ scope: "connection", connection_id: c.id });
const list = (res?.data || res || []) as any[];
setLinkedScriptIds(list.map((s) => s.id));
} catch {
setLinkedScriptIds([]);
}
setModalOpen(true);
};
// 전체 스크립트 + target DB 목록 로딩
useEffect(() => {
(async () => {
try {
const res = await fleetApi.listScripts();
setAllScripts((res?.data || res || []) as any[]);
} catch {
setAllScripts([]);
}
try {
const dbs = await pipelineDeviceApi.listTargetDatabases();
setExternalDbs((dbs?.data || []) as any[]);
} catch {
setExternalDbs([]);
}
})();
}, []);
// DB 선택 시 테이블 로드
useEffect(() => {
const id = form.target_db_connection_id;
if (id === null || id === undefined) {
setTargetTables([]);
setTargetColumns([]);
return;
}
(async () => {
setLoadingTables(true);
try {
const res = await pipelineDeviceApi.listTargetTables(id);
setTargetTables((res?.data || []) as string[]);
} catch (e: any) {
toast.error(e?.response?.data?.message || "테이블 조회 실패");
setTargetTables([]);
} finally {
setLoadingTables(false);
}
})();
}, [form.target_db_connection_id]);
// 테이블 선택 시 컬럼 로드
useEffect(() => {
const id = form.target_db_connection_id;
const t = form.target_table_name;
if (id === null || id === undefined || !t) {
setTargetColumns([]);
return;
}
(async () => {
setLoadingColumns(true);
try {
const res = await pipelineDeviceApi.listTargetColumns(id, t);
setTargetColumns((res?.data || []) as any[]);
} catch {
setTargetColumns([]);
} finally {
setLoadingColumns(false);
}
})();
}, [form.target_db_connection_id, form.target_table_name]);
// 편집 중인 연결의 태그 목록 로드
useEffect(() => {
if (!editing?.id) {
setTagsOfConn([]);
setColumnMapping({});
return;
}
(async () => {
try {
const res = await pipelineDeviceApi.getTagMappings(editing.id);
const tags = (res?.data || []) as any[];
setTagsOfConn(tags);
const m: Record<number, string> = {};
tags.forEach((t) => {
m[t.id] = t.target_column_name || "";
});
setColumnMapping(m);
} catch {
setTagsOfConn([]);
}
})();
}, [editing?.id]);
const saveColumnMapping = async () => {
if (!editing?.id) return;
try {
const mapping = Object.entries(columnMapping).map(([id, col]) => ({
tag_id: Number(id),
target_column_name: col || null,
}));
await pipelineDeviceApi.updateTagColumnMapping(editing.id, mapping);
toast.success("태그↔컬럼 매핑 저장됨");
} catch (e: any) {
toast.error(e?.response?.data?.message || "매핑 저장 실패");
}
};
const runChainTest = async () => {
if (!editing?.id) return;
setChainTestRunning(true);
try {
// raw_value가 JSON인지 숫자인지 문자열인지 자동 판별
let rawValue: unknown = chainTestValue;
try {
rawValue = JSON.parse(chainTestValue);
} catch {
// 숫자 시도
const n = Number(chainTestValue);
if (!Number.isNaN(n) && chainTestValue.trim() !== "") rawValue = n;
}
const res = await pipelineDeviceApi.testChain(editing.id, {
tag_name: chainTestTag,
raw_value: rawValue,
save_to_db: chainTestSave,
});
setChainTestResult(res?.data || res);
if (chainTestSave && (res?.data?.saved || res?.saved)) {
toast.success("테스트 결과를 equipment_current_state에 저장했습니다");
}
} catch (e: any) {
toast.error(e?.response?.data?.message || "체인 실행 실패");
setChainTestResult(null);
} finally {
setChainTestRunning(false);
}
};
const reloadAllScripts = async () => {
try {
const res = await fleetApi.listScripts();
setAllScripts((res?.data || res || []) as any[]);
} catch {
/* ignore */
}
};
const updateScriptPriority = async (scriptId: number, newPriority: number) => {
const script = allScripts.find((s) => s.id === scriptId);
if (!script) return;
try {
await fleetApi.updateScript(scriptId, { ...script, priority: newPriority });
await reloadAllScripts();
toast.success(`우선순위 변경: ${newPriority}`);
} catch (e: any) {
toast.error(e?.response?.data?.message || "우선순위 변경 실패");
}
};
const moveScriptUp = async (scriptId: number, hookType: string) => {
// 같은 hook_type 내에서만 순서 변경
const sameType = allScripts
.filter((s) => s.hook_type === hookType && linkedScriptIds.includes(s.id))
.sort((a, b) => a.priority - b.priority);
const idx = sameType.findIndex((s) => s.id === scriptId);
if (idx <= 0) return;
const target = sameType[idx];
const above = sameType[idx - 1];
// priority swap
await fleetApi.updateScript(target.id, { ...target, priority: above.priority });
await fleetApi.updateScript(above.id, { ...above, priority: target.priority });
await reloadAllScripts();
};
const moveScriptDown = async (scriptId: number, hookType: string) => {
const sameType = allScripts
.filter((s) => s.hook_type === hookType && linkedScriptIds.includes(s.id))
.sort((a, b) => a.priority - b.priority);
const idx = sameType.findIndex((s) => s.id === scriptId);
if (idx < 0 || idx >= sameType.length - 1) return;
const target = sameType[idx];
const below = sameType[idx + 1];
await fleetApi.updateScript(target.id, { ...target, priority: below.priority });
await fleetApi.updateScript(below.id, { ...below, priority: target.priority });
await reloadAllScripts();
};
const toggleScriptLink = async (scriptId: number, checked: boolean) => {
if (!editing?.id) return;
try {
const script = allScripts.find((s) => s.id === scriptId);
if (!script) return;
if (checked) {
await fleetApi.updateScript(scriptId, {
...script,
scope: "connection",
connection_id: editing.id,
});
setLinkedScriptIds((prev) => [...prev, scriptId]);
} else {
await fleetApi.updateScript(scriptId, {
...script,
scope: "global",
connection_id: null,
});
setLinkedScriptIds((prev) => prev.filter((id) => id !== scriptId));
}
toast.success(checked ? "스크립트 연결" : "스크립트 해제");
} catch (e: any) {
toast.error(e?.response?.data?.message || "변경 실패");
}
};
const handleProtocolChange = (protocol: string) => {
const defaults = PROTOCOL_DEFAULTS[protocol];
setForm((prev) => ({
...prev,
protocol,
port: defaults?.port || prev.port,
protocol_config: defaults?.config || {},
}));
};
const handleEquipmentSelect = (equipmentIdStr: string) => {
if (equipmentIdStr === "none") {
setForm((prev) => ({ ...prev, equipment_id: null }));
return;
}
const id = parseInt(equipmentIdStr);
const eq = equipments.find((e) => e.id === id);
setForm((prev) => ({
...prev,
equipment_id: id,
// 연결명 비어있으면 장비명으로 자동 채움
connection_name: prev.connection_name || (eq ? `${eq.equipment_name} 통신` : prev.connection_name),
}));
};
const handleSave = async () => {
if (!form.connection_name || !form.host) {
toast.error("연결명과 호스트를 입력하세요.");
return;
}
setSaving(true);
try {
if (editing) {
await pipelineDeviceApi.updateConnection(editing.id, form);
toast.success("수정 완료");
} else {
await pipelineDeviceApi.createConnection(form);
toast.success("생성 완료");
}
setModalOpen(false);
load();
} catch (e: any) { toast.error(e.response?.data?.message || "저장 실패"); }
setSaving(false);
};
const handleDelete = async (id: number) => {
if (!confirm("이 장비 통신을 삭제하시겠습니까?")) return;
try { await pipelineDeviceApi.deleteConnection(id); toast.success("삭제 완료"); load(); }
catch { toast.error("삭제 실패"); }
};
const handleTest = async (id: number) => {
setTestingId(id);
try {
const res = await pipelineDeviceApi.testConnection(id);
if (res.data?.success) toast.success(res.data.message);
else toast.error(res.data?.message || "연결 실패");
load();
} catch { toast.error("테스트 실패"); }
setTestingId(null);
};
const handleToggle = async (id: number, active: boolean) => {
try { await pipelineDeviceApi.updateConnection(id, { is_active: active ? "Y" : "N" }); load(); }
catch { toast.error("상태 변경 실패"); }
};
const handleCollectOnce = async (id: number) => {
const t = toast.loading("수집 중...");
try {
const res = await pipelineDeviceApi.collectOnce(id);
const d = res?.data || res;
toast.dismiss(t);
if (d?.plc_state === "connected") {
toast.success(
`✅ 수집 성공: ${d.tags_count}개 태그${d.target_db_error ? ` (DB 경고: ${d.target_db_error})` : ""}`
);
} else {
toast.error(`수집 실패: ${d?.error_message || "PLC 연결 실패"}`);
}
} catch (e: any) {
toast.dismiss(t);
toast.error(e?.response?.data?.message || "수집 실패");
}
};
const getProto = (protocol: string) =>
PROTOCOL_OPTIONS.find((p) => p.value === protocol) || { label: protocol, color: "#666" };
const updateConfig = (key: string, value: any) => {
setForm((prev) => ({ ...prev, protocol_config: { ...prev.protocol_config, [key]: value } }));
};
return (
<div className="flex h-full flex-col bg-background">
{/* 헤더 */}
<div className="shrink-0 p-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"> </h1>
<p className="text-sm text-muted-foreground">
AAS PLC/Modbus/OPC-UA/MQTT
</p>
</div>
<Button onClick={openCreate} size="sm" className="gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 검색바 */}
<div className="mt-4 relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="연결명, 장비명, 호스트로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
</div>
{/* 카드 뷰 */}
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : filteredConnections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<Cable className="h-10 w-10 mb-3" />
<p className="text-sm"> </p>
<Button variant="link" onClick={openCreate} className="mt-2">
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredConnections.map((c) => {
const proto = getProto(c.protocol);
return (
<div
key={c.id}
className="rounded-xl border bg-card p-4 hover:shadow-md transition-shadow"
>
{/* 상단: 프로토콜 + 상태 */}
<div className="flex items-center justify-between mb-3">
<Badge
variant="outline"
className="text-[10px]"
style={{ borderColor: proto.color, color: proto.color }}
>
{proto.label}
</Badge>
<div className="flex items-center gap-1.5">
{c.status === "active" && (
<span className="flex items-center gap-0.5 text-[10px] text-green-600">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
{c.status === "error" && (
<span className="flex items-center gap-0.5 text-[10px] text-red-500">
<AlertCircle className="h-3 w-3" />
</span>
)}
{c.status === "inactive" && (
<span className="flex items-center gap-0.5 text-[10px] text-gray-400">
<XCircle className="h-3 w-3" />
</span>
)}
<Switch
checked={c.is_active === "Y"}
onCheckedChange={(v) => handleToggle(c.id, v)}
/>
</div>
</div>
{/* 연결명 + 장비 */}
<div className="mb-3">
<p className="text-sm font-semibold truncate" title={c.connection_name}>
{c.connection_name}
</p>
{c.equipment_name && (
<p className="text-[11px] text-muted-foreground flex items-center gap-1 mt-0.5">
<Cpu className="h-3 w-3" />
{c.equipment_name}
</p>
)}
</div>
{/* 연결 정보 */}
<div className="space-y-1 text-[11px] text-muted-foreground font-mono">
<div>{c.host}:{c.port}</div>
<div> {c.polling_interval_ms}ms · {c.tag_count || 0}</div>
{c.last_test_result && (
<div>
:{" "}
<span className={c.last_test_result === "success" ? "text-green-600" : "text-red-500"}>
{c.last_test_result === "success" ? "성공" : "실패"}
</span>
</div>
)}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1 mt-3 pt-3 border-t">
<Button
variant="ghost"
size="sm"
className="h-8 flex-1 gap-1"
onClick={() => handleTest(c.id)}
disabled={testingId === c.id}
title="연결 테스트"
>
{testingId === c.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TestTube className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="default"
size="sm"
className="h-8 flex-1 gap-1"
onClick={() => handleCollectOnce(c.id)}
title="지금 1회 수집 (훅 적용 + DB 저장)"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(c)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleDelete(c.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 생성/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? "장비 통신 수정" : "장비 통신 추가"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* 장비 선택 */}
<div>
<Label className="text-xs"> (AAS )</Label>
<Select
value={form.equipment_id?.toString() || "none"}
onValueChange={handleEquipmentSelect}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="장비를 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">( )</SelectItem>
{filteredEquipments.slice(0, 100).map((eq) => (
<SelectItem key={eq.id} value={eq.id.toString()}>
<span className="font-medium">{eq.equipment_name}</span>
<span className="text-muted-foreground text-xs ml-2">
{eq.equipment_type} · {eq.manufacturer}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
{/* 연결명 */}
<div>
<Label className="text-xs"> *</Label>
<Input
value={form.connection_name}
onChange={(e) => setForm({ ...form, connection_name: e.target.value })}
placeholder="예: 프레스 A라인 PLC"
className="mt-1"
/>
</div>
{/* 프로토콜 */}
<div>
<Label className="text-xs"> *</Label>
<Select value={form.protocol} onValueChange={handleProtocolChange}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{PROTOCOL_OPTIONS.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 호스트 + 포트 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">
{form.protocol === "MODBUS_RTU" ? "시리얼 포트" : "호스트 *"}
</Label>
<Input
value={form.host}
onChange={(e) => setForm({ ...form, host: e.target.value })}
placeholder={form.protocol === "MODBUS_RTU" ? "/dev/ttyUSB0" : "192.168.1.100"}
className="mt-1 font-mono"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: parseInt(e.target.value) || 0 })}
className="mt-1"
/>
</div>
</div>
{/* 프로토콜별 세부 설정 */}
<div className="rounded-lg border bg-muted/30 p-3 space-y-3">
<Label className="text-xs font-semibold"> </Label>
{form.protocol === "MODBUS_TCP" && (
<div>
<Label className="text-[11px]">Unit ID (Slave ID)</Label>
<Input
type="number"
value={form.protocol_config.unit_id || 1}
onChange={(e) => updateConfig("unit_id", parseInt(e.target.value) || 1)}
className="mt-1 h-8 text-sm"
/>
</div>
)}
{form.protocol === "MODBUS_RTU" && (
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[11px]">Baudrate</Label>
<Select
value={(form.protocol_config.baudrate || 9600).toString()}
onValueChange={(v) => updateConfig("baudrate", parseInt(v))}
>
<SelectTrigger className="mt-1 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{[9600, 19200, 38400, 57600, 115200].map((b) => (
<SelectItem key={b} value={b.toString()}>{b}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px]">Parity</Label>
<Select
value={form.protocol_config.parity || "N"}
onValueChange={(v) => updateConfig("parity", v)}
>
<SelectTrigger className="mt-1 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="N">None</SelectItem>
<SelectItem value="E">Even</SelectItem>
<SelectItem value="O">Odd</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px]">Stop bits</Label>
<Select
value={(form.protocol_config.stopbits || 1).toString()}
onValueChange={(v) => updateConfig("stopbits", parseInt(v))}
>
<SelectTrigger className="mt-1 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px]">Unit ID</Label>
<Input
type="number"
value={form.protocol_config.unit_id || 1}
onChange={(e) => updateConfig("unit_id", parseInt(e.target.value) || 1)}
className="mt-1 h-8 text-sm"
/>
</div>
</div>
)}
{form.protocol === "OPCUA" && (
<div className="space-y-2">
<div>
<Label className="text-[11px]"> </Label>
<Select
value={form.protocol_config.security_policy || "None"}
onValueChange={(v) => updateConfig("security_policy", v)}
>
<SelectTrigger className="mt-1 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="None">None ()</SelectItem>
<SelectItem value="Basic128Rsa15">Basic128Rsa15</SelectItem>
<SelectItem value="Basic256">Basic256</SelectItem>
<SelectItem value="Basic256Sha256">Basic256Sha256</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[11px]"></Label>
<Input
value={form.protocol_config.username || ""}
onChange={(e) => updateConfig("username", e.target.value)}
className="mt-1 h-8 text-sm"
/>
</div>
<div>
<Label className="text-[11px]"></Label>
<Input
type="password"
value={form.protocol_config.password || ""}
onChange={(e) => updateConfig("password", e.target.value)}
className="mt-1 h-8 text-sm"
/>
</div>
</div>
</div>
)}
{form.protocol === "SIEMENS_S7" && (
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-[11px]">Rack</Label>
<Input
type="number"
value={form.protocol_config.rack ?? 0}
onChange={(e) => updateConfig("rack", parseInt(e.target.value) || 0)}
className="mt-1 h-8 text-sm"
/>
</div>
<div>
<Label className="text-[11px]">Slot</Label>
<Input
type="number"
value={form.protocol_config.slot ?? 1}
onChange={(e) => updateConfig("slot", parseInt(e.target.value) || 1)}
className="mt-1 h-8 text-sm"
/>
</div>
<div>
<Label className="text-[11px]">CPU</Label>
<Select
value={form.protocol_config.cpu_type || "S7-1200"}
onValueChange={(v) => updateConfig("cpu_type", v)}
>
<SelectTrigger className="mt-1 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="S7-300">S7-300</SelectItem>
<SelectItem value="S7-400">S7-400</SelectItem>
<SelectItem value="S7-1200">S7-1200</SelectItem>
<SelectItem value="S7-1500">S7-1500</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{form.protocol === "LS_XGT" && (
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[11px]">CPU </Label>
<Select
value={form.protocol_config.cpu_type || "XGK"}
onValueChange={(v) => updateConfig("cpu_type", v)}
>
<SelectTrigger className="mt-1 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="XGK">XGK</SelectItem>
<SelectItem value="XGI">XGI</SelectItem>
<SelectItem value="XGR">XGR</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px]">Slot</Label>
<Input
type="number"
value={form.protocol_config.slot ?? 0}
onChange={(e) => updateConfig("slot", parseInt(e.target.value) || 0)}
className="mt-1 h-8 text-sm"
/>
</div>
</div>
)}
{form.protocol === "MQTT" && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[11px]"></Label>
<Input
value={form.protocol_config.username || ""}
onChange={(e) => updateConfig("username", e.target.value)}
className="mt-1 h-8 text-sm"
/>
</div>
<div>
<Label className="text-[11px]"></Label>
<Input
type="password"
value={form.protocol_config.password || ""}
onChange={(e) => updateConfig("password", e.target.value)}
className="mt-1 h-8 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[11px]">QoS</Label>
<Select
value={(form.protocol_config.qos ?? 0).toString()}
onValueChange={(v) => updateConfig("qos", parseInt(v))}
>
<SelectTrigger className="mt-1 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">0 (At most once)</SelectItem>
<SelectItem value="1">1 (At least once)</SelectItem>
<SelectItem value="2">2 (Exactly once)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px]">Keepalive ()</Label>
<Input
type="number"
value={form.protocol_config.keepalive_sec ?? 60}
onChange={(e) => updateConfig("keepalive_sec", parseInt(e.target.value) || 60)}
className="mt-1 h-8 text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={!!form.protocol_config.use_tls}
onChange={(e) => updateConfig("use_tls", e.target.checked)}
id="use_tls"
/>
<Label htmlFor="use_tls" className="text-[11px] cursor-pointer">TLS </Label>
</div>
</div>
)}
</div>
{/* 통신 파라미터 */}
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-xs"> (ms)</Label>
<Input
type="number"
value={form.polling_interval_ms}
onChange={(e) => setForm({ ...form, polling_interval_ms: parseInt(e.target.value) || 1000 })}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs"> (ms)</Label>
<Input
type="number"
value={form.timeout_ms}
onChange={(e) => setForm({ ...form, timeout_ms: parseInt(e.target.value) || 5000 })}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
value={form.retry_count}
onChange={(e) => setForm({ ...form, retry_count: parseInt(e.target.value) || 3 })}
className="mt-1"
/>
</div>
</div>
{/* 설명 */}
<div>
<Label className="text-xs"></Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="선택"
className="mt-1 h-16"
/>
</div>
{/* 💾 데이터 저장 대상 */}
<div className="border-t pt-3">
<div className="mb-2 flex items-center gap-2">
<DatabaseIcon className="h-3.5 w-3.5" />
<Label className="text-xs font-semibold"> </Label>
</div>
<p className="mb-2 text-[10px] text-muted-foreground">
DB + + .
</p>
{/* IDC edge_telemetry 호환용 식별자 */}
<div className="mb-3 grid grid-cols-1 gap-2 rounded-md border bg-blue-50/30 p-2 sm:grid-cols-2 dark:bg-blue-950/20">
<div>
<Label className="text-[10px]">
Edge ID <span className="text-muted-foreground">(edge_telemetry.edge_id )</span>
</Label>
<Input
className="mt-0.5 h-7 text-[11px]"
value={form.edge_identifier}
onChange={(e) => setForm({ ...form, edge_identifier: e.target.value })}
placeholder="예: edge-0f4d04ed (비우면 conn-N fallback)"
/>
</div>
<div>
<Label className="text-[10px]">
Device ID <span className="text-muted-foreground">(metadata.device_id JSON에 )</span>
</Label>
<Input
className="mt-0.5 h-7 text-[11px]"
value={form.device_identifier}
onChange={(e) => setForm({ ...form, device_identifier: e.target.value })}
placeholder="예: cup-press-master-01 또는 장비 UUID"
/>
</div>
<p className="text-[9px] text-muted-foreground sm:col-span-2">
IDC <code className="font-mono">edge_telemetry</code> . (/)
<code className="mx-1 font-mono">metadata-&gt;&gt;&#39;device_id&#39;</code> .
</p>
</div>
{/* DB + 테이블 선택 */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<Label className="text-[10px]"> DB</Label>
<Select
value={form.target_db_connection_id !== null && form.target_db_connection_id !== undefined ? String(form.target_db_connection_id) : "_none"}
onValueChange={(v) =>
setForm({
...form,
target_db_connection_id: v === "_none" ? null : Number(v),
target_table_name: "", // DB 바꾸면 테이블 초기화
})
}
>
<SelectTrigger className="mt-0.5 h-8 text-xs">
<SelectValue placeholder="DB 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none" className="text-xs">
( )
</SelectItem>
{externalDbs.map((db: any) => (
<SelectItem key={db.id} value={String(db.id)} className="text-xs">
{db.is_internal
? `${db.name}`
: `${db.name} (${db.db_type}${db.host}:${db.port}/${db.database_name})`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"></Label>
{form.target_db_connection_id !== null && form.target_db_connection_id !== undefined ? (
<Select
value={form.target_table_name || "_none"}
onValueChange={(v) =>
setForm({ ...form, target_table_name: v === "_none" ? "" : v })
}
>
<SelectTrigger className="mt-0.5 h-8 text-xs">
<SelectValue placeholder={loadingTables ? "로딩..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none" className="text-xs">
( )
</SelectItem>
{targetTables.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="mt-0.5 h-8 text-xs" disabled placeholder="DB 먼저 선택" />
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={form.target_time_column || "timestamp"}
onValueChange={(v) => setForm({ ...form, target_time_column: v })}
disabled={!form.target_table_name}
>
<SelectTrigger className="mt-0.5 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{targetColumns.map((c: any) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name} ({c.data_type})
</SelectItem>
))}
{!targetColumns.some((c: any) => c.column_name === "timestamp") && (
<SelectItem value="timestamp" className="text-xs">
timestamp ()
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={form.target_insert_mode}
onValueChange={(v) =>
setForm({ ...form, target_insert_mode: v as "append" | "upsert" })
}
>
<SelectTrigger className="mt-0.5 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append" className="text-xs">
append ( )
</SelectItem>
<SelectItem value="upsert" className="text-xs">
upsert ( )
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 태그 ↔ 컬럼 매핑 (테이블 선택됐을 때) */}
{form.target_table_name && tagsOfConn.length > 0 && (
<div className="mt-3 rounded-md border bg-muted/20 p-2">
<div className="mb-2 flex items-center justify-between">
<Label className="text-[11px] font-semibold">
({tagsOfConn.length} / {targetColumns.length} )
</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={saveColumnMapping}
className="h-6 text-[10px]"
>
</Button>
</div>
{loadingColumns && <p className="text-[10px] text-muted-foreground"> ...</p>}
<div className="max-h-60 space-y-1 overflow-y-auto">
{tagsOfConn.map((tag) => {
const current = columnMapping[tag.id] ?? "";
const autoMatch = targetColumns.find(
(c: any) =>
c.column_name === tag.tag_name ||
c.column_name?.toLowerCase() === tag.tag_name?.toLowerCase()
);
return (
<div
key={tag.id}
className="grid grid-cols-12 items-center gap-1 rounded bg-background px-1.5 py-1 text-[10px]"
>
<span className="col-span-5 truncate font-mono" title={tag.tag_name}>
{tag.tag_name}
</span>
<span className="col-span-1 text-center text-muted-foreground"></span>
<div className="col-span-6">
<Select
value={current || "_none"}
onValueChange={(v) =>
setColumnMapping({
...columnMapping,
[tag.id]: v === "_none" ? "" : v,
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="컬럼 선택 (없으면 tag_name)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none" className="text-[10px]">
( tag_name : {tag.tag_name})
</SelectItem>
{autoMatch && (
<SelectItem
value={autoMatch.column_name}
className="text-[10px] text-emerald-600"
>
{autoMatch.column_name} ( )
</SelectItem>
)}
{targetColumns
.filter((c: any) => c.column_name !== autoMatch?.column_name)
.map((c: any) => (
<SelectItem
key={c.column_name}
value={c.column_name}
className="text-[10px]"
>
{c.column_name} ({c.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
</div>
<p className="mt-1 text-[9px] text-muted-foreground">
DB INSERT .
</p>
</div>
)}
{form.target_db_connection_id !== null && form.target_db_connection_id !== undefined && !form.target_table_name && (
<p className="mt-1 text-[10px] text-destructive">
</p>
)}
</div>
{/* 연결된 Python 훅 스크립트 (편집 모드에서만) */}
{editing && (
<div className="border-t pt-3">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-semibold"> Python </Label>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 gap-1 text-[10px]"
onClick={() => setScriptsDialogOpen(true)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
{/* 🔥 실행 순서 — 체크된 훅만 우선순위 순 */}
{linkedScriptIds.length > 0 && (
<div className="mb-2 rounded-md border bg-blue-50/40 p-2 dark:bg-blue-900/10">
<div className="mb-1 text-[10px] font-semibold text-blue-700 dark:text-blue-300">
( priority )
</div>
{(["transform", "filter", "derived_tags", "alarm", "pre_send"] as const).map((ht) => {
const activeInType = allScripts
.filter((s) => linkedScriptIds.includes(s.id) && s.hook_type === ht)
.sort((a, b) => a.priority - b.priority);
if (activeInType.length === 0) return null;
return (
<div key={ht} className="mb-1 last:mb-0">
<div className="mb-0.5 text-[9px] uppercase text-muted-foreground">
{ht} ({activeInType.length} {ht === "transform" ? "체인" : ht === "filter" ? "AND" : "실행"})
</div>
<div className="space-y-0.5">
{activeInType.map((s, i) => (
<div
key={s.id}
className="flex items-center gap-1.5 rounded bg-white px-1.5 py-0.5 text-[10px] dark:bg-background"
>
<span className="font-mono text-[10px] text-blue-600">
{i + 1}.
</span>
<span className="flex-1 truncate">{s.script_name}</span>
<span className="font-mono text-[9px] text-muted-foreground">
p={s.priority}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 px-1"
disabled={i === 0}
onClick={() => moveScriptUp(s.id, ht)}
title="위로"
>
<ArrowUp className="h-2.5 w-2.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 px-1"
disabled={i === activeInType.length - 1}
onClick={() => moveScriptDown(s.id, ht)}
title="아래로"
>
<ArrowDown className="h-2.5 w-2.5" />
</Button>
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* 전체 스크립트 체크 리스트 */}
{allScripts.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
{" "}
<button
type="button"
className="text-blue-600 underline"
onClick={() => setScriptsDialogOpen(true)}
>
</button>
</p>
) : (
<div className="max-h-40 space-y-1 overflow-y-auto rounded-md border p-2">
{allScripts.map((s) => {
const checked = linkedScriptIds.includes(s.id);
return (
<label
key={s.id}
className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-muted/50"
>
<input
type="checkbox"
checked={checked}
onChange={(e) => toggleScriptLink(s.id, e.target.checked)}
className="h-3 w-3"
/>
<span className="flex-1 truncate text-[11px] font-medium">
{s.script_name}
</span>
<Input
type="number"
value={s.priority ?? 10}
onChange={(e) =>
updateScriptPriority(s.id, Number(e.target.value))
}
className="h-5 w-12 text-[10px]"
title="우선순위 (낮을수록 먼저)"
/>
<Badge variant="outline" className="h-4 text-[9px]">
{s.hook_type}
</Badge>
<Badge variant="secondary" className="h-4 text-[9px]">
{s.scope}
</Badge>
</label>
);
})}
</div>
)}
<p className="mt-1 text-[10px] text-muted-foreground">
. <strong>transform</strong>
( ), <strong>filter</strong> , DB + IDC .
</p>
</div>
)}
{/* 🧪 체인 테스트 (편집 모드 전용) */}
{editing && (
<div className="border-t pt-3">
<div className="mb-2">
<Label className="text-xs font-semibold">🧪 </Label>
<p className="mt-0.5 text-[10px] text-muted-foreground">
PLC + . DB에 .
</p>
</div>
<div className="space-y-2 rounded-md border p-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div>
<Label className="text-[10px]"></Label>
<Input
className="mt-0.5 h-7 text-[11px]"
value={chainTestTag}
onChange={(e) => setChainTestTag(e.target.value)}
/>
</div>
<div>
<Label className="text-[10px]"> (//JSON)</Label>
<Input
className="mt-0.5 h-7 font-mono text-[11px]"
value={chainTestValue}
onChange={(e) => setChainTestValue(e.target.value)}
placeholder="예: 4660 또는 0x1234 또는 true"
/>
</div>
<div className="flex items-end gap-2">
<label className="flex items-center gap-1 text-[10px]">
<input
type="checkbox"
checked={chainTestSave}
onChange={(e) => setChainTestSave(e.target.checked)}
className="h-3 w-3"
/>
DB
</label>
<Button
type="button"
size="sm"
onClick={runChainTest}
disabled={chainTestRunning}
className="h-7 flex-1 gap-1 text-xs"
>
{chainTestRunning ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<TestTube className="h-3 w-3" />
)}
</Button>
</div>
</div>
{/* 결과 */}
{chainTestResult && (
<div className="rounded-md bg-muted/50 p-2">
<div className="mb-1 text-[10px] font-semibold">
:{" "}
<span className="font-mono text-blue-700 dark:text-blue-300">
{JSON.stringify(chainTestResult.final_value)}
</span>
{" · "}
{" "}
<span
className={
chainTestResult.filter_kept
? "text-emerald-600"
: "text-destructive"
}
>
{chainTestResult.filter_kept ? "통과" : "DROP"}
</span>
{chainTestResult.saved && (
<span className="ml-1 text-emerald-600">
· DB ({chainTestResult.saved_where})
</span>
)}
</div>
{chainTestResult.steps?.length > 0 && (
<div className="space-y-0.5 text-[10px]">
{chainTestResult.steps.map((s: any, i: number) => (
<div
key={i}
className={`rounded px-1.5 py-0.5 font-mono ${
s.error
? "bg-destructive/10 text-destructive"
: "bg-white dark:bg-background"
}`}
>
<span className="text-muted-foreground">
[{s.stage}] {s.hook_name ?? ""} ({s.duration_ms}ms):
</span>{" "}
<span>{JSON.stringify(s.input)}</span>{" "}
{" "}
<span className="font-semibold">
{s.error ? `ERR: ${s.error.slice(0, 60)}` : JSON.stringify(s.output)}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* 스크립트 관리 모달 */}
<ScriptsManagerDialog
open={scriptsDialogOpen}
onClose={() => setScriptsDialogOpen(false)}
onAfterChange={reloadAllScripts}
defaultConnectionId={editing?.id}
/>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{editing ? "수정" : "추가"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}