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

301 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { fleetApi, FleetDevice } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
RefreshCw, Search, Wifi, WifiOff, Cpu, HardDrive, MemoryStick,
Terminal, Trash2, Send, Loader2, Circle, Activity,
} from "lucide-react";
import { toast } from "sonner";
export default function FleetDevicesPage() {
const [devices, setDevices] = useState<FleetDevice[]>([]);
const [commandTypes, setCommandTypes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [filterOnline, setFilterOnline] = useState<"all" | "online" | "offline">("all");
const [commandModalOpen, setCommandModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<FleetDevice | null>(null);
const [commandForm, setCommandForm] = useState({
command_type: "health_check",
payload_text: "{}",
});
const [sending, setSending] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [dev, types] = await Promise.all([
fleetApi.getDevices(),
fleetApi.getCommandTypes(),
]);
setDevices(dev.data || []);
setCommandTypes(types.data || []);
} catch { toast.error("디바이스 목록 조회 실패"); }
setLoading(false);
}, []);
useEffect(() => {
load();
// 30초마다 자동 갱신
const t = setInterval(load, 30000);
return () => clearInterval(t);
}, [load]);
const filteredDevices = useMemo(() => {
return devices.filter((d) => {
if (filterOnline === "online" && !d.is_online) return false;
if (filterOnline === "offline" && d.is_online) return false;
if (searchTerm) {
const q = searchTerm.toLowerCase();
return (
(d.device_id || "").toLowerCase().includes(q) ||
(d.device_name || "").toLowerCase().includes(q) ||
(d.ip_address || "").toLowerCase().includes(q) ||
(d.equipment_name || "").toLowerCase().includes(q)
);
}
return true;
});
}, [devices, filterOnline, searchTerm]);
const openCommandModal = (device: FleetDevice) => {
setSelectedDevice(device);
setCommandForm({ command_type: "health_check", payload_text: "{}" });
setCommandModalOpen(true);
};
const sendCommand = async () => {
if (!selectedDevice) return;
let payload: any = {};
try { payload = JSON.parse(commandForm.payload_text || "{}"); }
catch { toast.error("Payload JSON 형식이 올바르지 않습니다."); return; }
setSending(true);
try {
await fleetApi.issueCommand({
device_id: selectedDevice.device_id,
command_type: commandForm.command_type,
payload,
});
toast.success(`커맨드 발행 완료: ${commandForm.command_type}`);
setCommandModalOpen(false);
} catch (e: any) {
toast.error(e.response?.data?.message || "커맨드 발행 실패");
}
setSending(false);
};
const deleteDevice = async (deviceId: string) => {
if (!confirm(`'${deviceId}' 디바이스를 삭제하시겠습니까?`)) return;
try {
await fleetApi.deleteDevice(deviceId);
toast.success("삭제 완료");
load();
} catch { toast.error("삭제 실패"); }
};
const onlineCount = devices.filter((d) => d.is_online).length;
const offlineCount = devices.length - onlineCount;
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">
Fleet
</p>
</div>
<Button size="sm" variant="outline" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 통계 */}
<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">
<Activity className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold">{devices.length}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-green-600">
<Wifi className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold text-green-600">{onlineCount}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-xs text-red-500">
<WifiOff className="h-4 w-4" />
</div>
<p className="mt-1 text-2xl font-bold text-red-500">{offlineCount}</p>
</div>
</div>
{/* 검색 + 필터 */}
<div className="mt-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="디바이스 ID, 이름, IP, 장비로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterOnline} onValueChange={(v: any) => setFilterOnline(v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="online"></SelectItem>
<SelectItem value="offline"></SelectItem>
</SelectContent>
</Select>
</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>
) : filteredDevices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground rounded-xl border border-dashed">
<WifiOff className="h-10 w-10 mb-3" />
<p className="text-sm"> </p>
<p className="text-xs mt-1"> MQTT로 </p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredDevices.map((d) => (
<div
key={d.device_id}
className="rounded-xl border bg-card p-4 hover:shadow-md transition-shadow"
>
{/* 상단 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Circle
className={`h-3 w-3 ${d.is_online ? "fill-green-500 text-green-500" : "fill-gray-400 text-gray-400"}`}
/>
<Badge variant="outline" className="text-[10px]">
{d.device_type || "edge"}
</Badge>
</div>
<span className={`text-[10px] ${d.is_online ? "text-green-600" : "text-gray-400"}`}>
{d.is_online ? "온라인" : "오프라인"}
</span>
</div>
{/* 디바이스 정보 */}
<div className="mb-3">
<p className="text-sm font-semibold truncate" title={d.device_id}>
{d.device_name || d.device_id}
</p>
<p className="text-[11px] text-muted-foreground font-mono truncate">{d.device_id}</p>
{d.equipment_name && (
<p className="text-[11px] text-muted-foreground mt-1">
🔗 {d.equipment_name}
</p>
)}
</div>
{/* 상세 정보 */}
<div className="space-y-1 text-[11px] text-muted-foreground">
{d.ip_address && <div>📍 {d.ip_address}</div>}
{d.last_seen_at && (
<div> {new Date(d.last_seen_at).toLocaleString("ko-KR")}</div>
)}
{d.agent_version && <div> v{d.agent_version}</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={() => openCommandModal(d)}
disabled={!d.is_online}
>
<Terminal className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => deleteDevice(d.device_id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 커맨드 모달 */}
<Dialog open={commandModalOpen} onOpenChange={setCommandModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> - {selectedDevice?.device_id}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={commandForm.command_type}
onValueChange={(v) => setCommandForm({ ...commandForm, command_type: v })}
>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{commandTypes.map((t) => (
<SelectItem key={t.command_type} value={t.command_type}>
{t.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
{commandTypes.find((t) => t.command_type === commandForm.command_type)?.description}
</p>
</div>
<div>
<Label className="text-xs">Payload (JSON)</Label>
<Textarea
value={commandForm.payload_text}
onChange={(e) => setCommandForm({ ...commandForm, payload_text: e.target.value })}
placeholder='{"container_name": "data-collector"}'
rows={5}
className="mt-1 font-mono text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCommandModalOpen(false)}></Button>
<Button onClick={sendCommand} disabled={sending} className="gap-1">
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}