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>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { fleetApi, FleetDevice } from "@/lib/api/fleet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { RefreshCw, Activity, TrendingUp, Loader2, Database, Wifi } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
export default function FleetDataPage() {
|
|
const [devices, setDevices] = useState<FleetDevice[]>([]);
|
|
const [selectedDevice, setSelectedDevice] = useState<string>("");
|
|
const [latestValues, setLatestValues] = useState<any[]>([]);
|
|
const [selectedTag, setSelectedTag] = useState<string>("");
|
|
const [timeseries, setTimeseries] = useState<any[]>([]);
|
|
const [stats, setStats] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [chartLoading, setChartLoading] = useState(false);
|
|
|
|
// 디바이스 목록 + 수집 통계
|
|
const loadDevices = useCallback(async () => {
|
|
try {
|
|
const [dev, st] = await Promise.all([
|
|
fleetApi.getDevices({ is_online: true } as any),
|
|
fleetApi.getDataStats(),
|
|
]);
|
|
const online = (dev.data || []).filter((d: any) => d.is_online);
|
|
setDevices(online);
|
|
setStats(st.data);
|
|
if (online.length > 0 && !selectedDevice) {
|
|
setSelectedDevice(online[0].device_id);
|
|
}
|
|
setLoading(false);
|
|
} catch { toast.error("디바이스 조회 실패"); setLoading(false); }
|
|
}, [selectedDevice]);
|
|
|
|
const loadLatestValues = useCallback(async () => {
|
|
if (!selectedDevice) return;
|
|
try {
|
|
const r = await fleetApi.getLatestValues(selectedDevice);
|
|
setLatestValues(r.data || []);
|
|
if (r.data?.length > 0 && !selectedTag) {
|
|
const firstNumeric = r.data.find((v: any) => v.value !== null);
|
|
if (firstNumeric) setSelectedTag(firstNumeric.tag_name);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}, [selectedDevice, selectedTag]);
|
|
|
|
const loadTimeseries = useCallback(async () => {
|
|
if (!selectedDevice || !selectedTag) return;
|
|
setChartLoading(true);
|
|
try {
|
|
const r = await fleetApi.getTagTimeseries(selectedDevice, selectedTag, 200);
|
|
setTimeseries((r.data || []).reverse()); // 시간 오름차순
|
|
} catch { /* ignore */ }
|
|
setChartLoading(false);
|
|
}, [selectedDevice, selectedTag]);
|
|
|
|
useEffect(() => { loadDevices(); }, [loadDevices]);
|
|
useEffect(() => { loadLatestValues(); }, [loadLatestValues]);
|
|
useEffect(() => { loadTimeseries(); }, [loadTimeseries]);
|
|
|
|
// 3초마다 실시간 갱신
|
|
useEffect(() => {
|
|
const t = setInterval(() => {
|
|
loadLatestValues();
|
|
loadTimeseries();
|
|
}, 3000);
|
|
return () => clearInterval(t);
|
|
}, [loadLatestValues, loadTimeseries]);
|
|
|
|
// 간단한 SVG 차트 (라이브러리 없이)
|
|
const renderChart = () => {
|
|
if (timeseries.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
|
데이터 없음
|
|
</div>
|
|
);
|
|
}
|
|
const values = timeseries.map((p) => p.value).filter((v) => v !== null);
|
|
if (values.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
|
숫자 데이터 아님 (text: {timeseries[timeseries.length - 1]?.value_text})
|
|
</div>
|
|
);
|
|
}
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
const range = max - min || 1;
|
|
const w = 800;
|
|
const h = 280;
|
|
const pad = 30;
|
|
|
|
const points = timeseries
|
|
.map((p, i) => {
|
|
if (p.value === null) return null;
|
|
const x = pad + (i * (w - pad * 2)) / Math.max(timeseries.length - 1, 1);
|
|
const y = h - pad - ((p.value - min) / range) * (h - pad * 2);
|
|
return `${x},${y}`;
|
|
})
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
|
|
return (
|
|
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} className="rounded">
|
|
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="#e5e7eb" />
|
|
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="#e5e7eb" />
|
|
<text x={pad - 5} y={pad + 5} fontSize="10" textAnchor="end" fill="#6b7280">
|
|
{max.toFixed(2)}
|
|
</text>
|
|
<text x={pad - 5} y={h - pad} fontSize="10" textAnchor="end" fill="#6b7280">
|
|
{min.toFixed(2)}
|
|
</text>
|
|
<polyline points={points} fill="none" stroke="#3b82f6" strokeWidth="2" />
|
|
{timeseries.map((p, i) => {
|
|
if (p.value === null) return null;
|
|
const x = pad + (i * (w - pad * 2)) / Math.max(timeseries.length - 1, 1);
|
|
const y = h - pad - ((p.value - min) / range) * (h - pad * 2);
|
|
return <circle key={i} cx={x} cy={y} r="2" fill="#3b82f6" />;
|
|
})}
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
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">
|
|
엣지 Data Collector에서 수집한 PLC/장비 데이터 (3초마다 자동 갱신)
|
|
</p>
|
|
</div>
|
|
<Button size="sm" variant="outline" onClick={loadDevices} disabled={loading} className="gap-1">
|
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 통계 */}
|
|
{stats && (
|
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Database className="h-4 w-4" /> 지난 24시간 레코드
|
|
</div>
|
|
<p className="mt-1 text-2xl font-bold">
|
|
{parseInt(stats.total_records || 0).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Wifi className="h-4 w-4" /> 수집 디바이스
|
|
</div>
|
|
<p className="mt-1 text-2xl font-bold">{stats.device_count || 0}</p>
|
|
</div>
|
|
<div className="rounded-lg border bg-card p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Activity className="h-4 w-4" /> 고유 태그
|
|
</div>
|
|
<p className="mt-1 text-2xl font-bold">{stats.tag_count || 0}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 디바이스 선택 */}
|
|
<div className="mt-4">
|
|
<Select value={selectedDevice} onValueChange={setSelectedDevice}>
|
|
<SelectTrigger className="w-full md:w-96">
|
|
<SelectValue placeholder="온라인 디바이스를 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{devices.map((d) => (
|
|
<SelectItem key={d.device_id} value={d.device_id}>
|
|
{d.device_name || d.device_id} ({d.equipment_name || "장비 미연결"})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto px-6 pb-6 grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{/* 좌측: 태그 목록 */}
|
|
<div className="lg:col-span-1 space-y-2">
|
|
<h2 className="text-sm font-bold">태그 목록 ({latestValues.length})</h2>
|
|
{latestValues.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed p-6 text-center text-xs text-muted-foreground">
|
|
데이터 없음
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1 max-h-[calc(100vh-400px)] overflow-y-auto">
|
|
{latestValues.map((v) => (
|
|
<button
|
|
key={v.tag_name}
|
|
onClick={() => setSelectedTag(v.tag_name)}
|
|
className={`w-full text-left rounded-md border p-3 transition-colors ${
|
|
selectedTag === v.tag_name
|
|
? "bg-primary/10 border-primary"
|
|
: "hover:bg-muted/50"
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">{v.tag_name}</span>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{v.quality || "good"}
|
|
</Badge>
|
|
</div>
|
|
<div className="mt-1 flex items-baseline gap-2">
|
|
<span className="text-lg font-bold font-mono">
|
|
{v.value !== null
|
|
? typeof v.value === "number"
|
|
? v.value.toFixed(2)
|
|
: v.value
|
|
: v.value_text}
|
|
</span>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
{new Date(v.time).toLocaleString("ko-KR")}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측: 차트 */}
|
|
<div className="lg:col-span-2 rounded-lg border bg-card p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<h2 className="text-sm font-bold flex items-center gap-2">
|
|
<TrendingUp className="h-4 w-4" />
|
|
시계열 차트 {selectedTag && `- ${selectedTag}`}
|
|
</h2>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
최근 200개 · 3초마다 갱신
|
|
</p>
|
|
</div>
|
|
{chartLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
</div>
|
|
|
|
<div className="h-[280px]">{renderChart()}</div>
|
|
|
|
{timeseries.length > 0 && (
|
|
<div className="mt-3 text-[11px] text-muted-foreground grid grid-cols-3 gap-2">
|
|
<div>최신: {timeseries[timeseries.length - 1]?.value?.toFixed(2) || "-"}</div>
|
|
<div>
|
|
최소: {Math.min(...timeseries.filter((p) => p.value !== null).map((p) => p.value)).toFixed(2)}
|
|
</div>
|
|
<div>
|
|
최대: {Math.max(...timeseries.filter((p) => p.value !== null).map((p) => p.value)).toFixed(2)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|