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>
1478 lines
63 KiB
TypeScript
1478 lines
63 KiB
TypeScript
"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->>'device_id'</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>
|
||
);
|
||
}
|