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

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