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

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