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>
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|