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>
215 lines
8.7 KiB
TypeScript
215 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { RefreshCw, Cpu, Activity, ChevronDown, ChevronRight } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import {
|
|
EquipmentStateAPI,
|
|
ConnectionStatusSummary,
|
|
EquipmentTagState,
|
|
} from "@/lib/api/equipmentState";
|
|
|
|
export default function EquipmentStatePage() {
|
|
const { toast } = useToast();
|
|
const [summary, setSummary] = useState<ConnectionStatusSummary[]>([]);
|
|
const [expanded, setExpanded] = useState<Record<number, EquipmentTagState[]>>({});
|
|
const [loadingId, setLoadingId] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await EquipmentStateAPI.summary();
|
|
setSummary(data);
|
|
} catch (err) {
|
|
toast({
|
|
title: "조회 실패",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
const t = setInterval(load, 15000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
|
|
const toggleExpand = async (connectionId: number) => {
|
|
if (expanded[connectionId]) {
|
|
const next = { ...expanded };
|
|
delete next[connectionId];
|
|
setExpanded(next);
|
|
return;
|
|
}
|
|
setLoadingId(connectionId);
|
|
try {
|
|
const tags = await EquipmentStateAPI.tagsByConnection(connectionId);
|
|
setExpanded(prev => ({ ...prev, [connectionId]: tags }));
|
|
} catch (err) {
|
|
toast({
|
|
title: "태그 조회 실패",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const filtered = summary.filter(
|
|
s =>
|
|
!search ||
|
|
s.connection_name?.toLowerCase().includes(search.toLowerCase()) ||
|
|
s.host?.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
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">
|
|
<Activity className="h-4 w-4" />
|
|
장비 현재 상태
|
|
</h1>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
각 장비 연결의 최신 수집값과 연결 상태를 확인합니다 (15초마다 자동 새로고침)
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
className="h-8 w-48 text-xs"
|
|
placeholder="장비명/호스트 검색..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
/>
|
|
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={load}>
|
|
<RefreshCw className="h-3 w-3" />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="h-14 animate-pulse rounded-lg border bg-muted/30" />
|
|
))}
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="flex h-48 flex-col items-center justify-center rounded-lg border border-dashed">
|
|
<Cpu className="mb-2 h-5 w-5 text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">등록된 장비 연결이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filtered.map(s => {
|
|
const isOpen = !!expanded[s.connection_id];
|
|
const isHealthy = s.connection_status === "active" || s.connection_status === "connected";
|
|
return (
|
|
<div key={s.connection_id} className="rounded-lg border bg-card">
|
|
<button
|
|
onClick={() => toggleExpand(s.connection_id)}
|
|
className="flex w-full items-center gap-3 p-3 text-left hover:bg-muted/30"
|
|
>
|
|
{isOpen ? (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<Badge
|
|
variant={isHealthy ? "default" : "destructive"}
|
|
className="h-5 text-[10px]"
|
|
>
|
|
{s.connection_status || "unknown"}
|
|
</Badge>
|
|
<span className="text-xs font-semibold">{s.connection_name}</span>
|
|
<Badge variant="secondary" className="h-5 text-[10px]">
|
|
{s.protocol}
|
|
</Badge>
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{s.host}:{s.port}
|
|
</span>
|
|
<div className="ml-auto flex items-center gap-3 text-[11px]">
|
|
<span>
|
|
태그 <span className="font-medium">{s.tag_count}</span>
|
|
</span>
|
|
<span className="text-emerald-600">
|
|
정상 <span className="font-medium">{s.good_tag_count}</span>
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
최근 수집:{" "}
|
|
{s.last_collected_at
|
|
? new Date(s.last_collected_at).toLocaleString()
|
|
: "—"}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="border-t bg-muted/10 p-3">
|
|
{loadingId === s.connection_id ? (
|
|
<p className="text-[11px] text-muted-foreground">로딩 중...</p>
|
|
) : (expanded[s.connection_id] || []).length === 0 ? (
|
|
<p className="text-[11px] text-muted-foreground">수집된 태그가 없습니다</p>
|
|
) : (
|
|
<table className="w-full text-[11px]">
|
|
<thead className="text-muted-foreground">
|
|
<tr className="border-b">
|
|
<th className="p-1 text-left font-normal">Tag</th>
|
|
<th className="p-1 text-right font-normal">Value</th>
|
|
<th className="p-1 text-left font-normal">Unit</th>
|
|
<th className="p-1 text-left font-normal">Quality</th>
|
|
<th className="p-1 text-left font-normal">Last Collected</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(expanded[s.connection_id] || []).map(t => {
|
|
const v =
|
|
t.value_numeric ??
|
|
(t.value_boolean !== null
|
|
? String(t.value_boolean)
|
|
: t.value_text) ??
|
|
"—";
|
|
return (
|
|
<tr key={t.id} className="border-b last:border-b-0">
|
|
<td className="p-1 font-mono">{t.tag_name}</td>
|
|
<td className="p-1 text-right font-mono">{String(v)}</td>
|
|
<td className="p-1">{t.tag_unit || ""}</td>
|
|
<td className="p-1">
|
|
<Badge
|
|
variant={t.quality === "good" ? "default" : "destructive"}
|
|
className="h-4 text-[9px]"
|
|
>
|
|
{t.quality}
|
|
</Badge>
|
|
</td>
|
|
<td className="p-1 text-muted-foreground">
|
|
{new Date(t.last_collected_at).toLocaleString()}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|