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

138 lines
6.4 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { fleetApi } from "@/lib/api/fleet";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ShieldCheck, RefreshCw, Loader2, Search } from "lucide-react";
import { toast } from "sonner";
export default function FleetAuditPage() {
const [logs, setLogs] = useState<any[]>([]);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<any>({ event_type: "", result: "", search: "" });
const load = useCallback(async () => {
setLoading(true);
try {
const f: any = { limit: 200 };
if (filter.event_type) f.event_type = filter.event_type;
if (filter.result) f.result = filter.result;
const [r, s] = await Promise.all([fleetApi.getAuditLogs(f), fleetApi.getAuditStats()]);
let data = r.data || [];
if (filter.search) {
const q = filter.search.toLowerCase();
data = data.filter((l: any) =>
(l.target_id || "").toLowerCase().includes(q) ||
(l.actor_id || "").toLowerCase().includes(q) ||
(l.action || "").toLowerCase().includes(q),
);
}
setLogs(data);
setStats(s.data);
} catch { toast.error("조회 실패"); }
setLoading(false);
}, [filter]);
useEffect(() => { load(); }, [load]);
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 flex items-center gap-2"><ShieldCheck className="h-5 w-5" /> </h1>
<p className="text-sm text-muted-foreground"> Fleet ( · )</p>
</div>
<Button variant="outline" size="sm" onClick={load}><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
</div>
{/* 통계 */}
{stats && (
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"> </div>
<div className="text-lg font-bold">{(stats.byEvent || []).length}</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"></div>
<div className="text-lg font-bold">{(stats.byActor || []).length}</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="text-[11px] text-muted-foreground"></div>
<div className="text-lg font-bold text-red-500">{stats.failures || 0}</div>
</div>
</div>
)}
{/* 필터 */}
<div className="mt-4 flex 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="검색" value={filter.search} onChange={(e) => setFilter({...filter, search: e.target.value})} className="pl-9" />
</div>
<Select value={filter.event_type || "all"} onValueChange={(v) => setFilter({...filter, event_type: v === "all" ? "" : v})}>
<SelectTrigger className="w-40"><SelectValue placeholder="이벤트 타입" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="device_register"> </SelectItem>
<SelectItem value="command_issue"> </SelectItem>
<SelectItem value="deploy"></SelectItem>
<SelectItem value="script_edit"> </SelectItem>
<SelectItem value="alert_ack"> </SelectItem>
</SelectContent>
</Select>
<Select value={filter.result || "all"} onValueChange={(v) => setFilter({...filter, result: v === "all" ? "" : v})}>
<SelectTrigger className="w-32"><SelectValue placeholder="결과" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="success"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-6">
{loading ? <Loader2 className="mx-auto my-20 animate-spin" /> :
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={6} className="text-center py-10 text-muted-foreground"> </td></tr>
) : logs.map((l) => (
<tr key={l.id} className="border-t hover:bg-muted/30">
<td className="p-2 whitespace-nowrap">{new Date(l.created_at).toLocaleString("ko-KR")}</td>
<td className="p-2"><Badge variant="outline" className="text-[10px]">{l.event_type}</Badge></td>
<td className="p-2">{l.actor_name || l.actor_id || "-"}</td>
<td className="p-2 font-mono">{l.target_type ? `${l.target_type}#${l.target_id}` : "-"}</td>
<td className="p-2">{l.action}</td>
<td className="p-2">
{l.result === "success"
? <Badge className="bg-green-500/10 text-green-600 text-[10px]"></Badge>
: <Badge className="bg-red-500/10 text-red-600 text-[10px]" title={l.error_message}></Badge>
}
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
</div>
);
}