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>
138 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|