kcg-ai-monitoring/frontend/src/features/admin/AuditLogs.tsx
htlee 2483174081 refactor(frontend): Badge className 위반 37건 전수 제거
- 4개 catalog(eventStatuses/enforcementResults/enforcementActions/patrolStatuses)에
  intent 필드 추가 + getXxxIntent() 헬퍼 신규
- statusIntent.ts 공통 유틸: 한글/영문 상태 문자열 → BadgeIntent 매핑
  + getRiskIntent(0-100) 점수 기반 매핑
- 모든 Badge className="..." 패턴을 intent prop으로 치환:
  - admin (AuditLogs/AccessControl/SystemConfig/NoticeManagement/DataHub)
  - ai-operations (AIModelManagement/MLOpsPage)
  - enforcement (EventList/EnforcementHistory)
  - field-ops (AIAlert)
  - detection (GearIdentification)
  - patrol (PatrolRoute/FleetOptimization)
  - parent-inference (ParentExclusion)
  - statistics (ExternalService/ReportManagement)
  - surveillance (MapControl)
  - risk-assessment (EnforcementPlan)
  - monitoring (SystemStatusPanel — ServiceCard statusColor → statusIntent 리팩토)
  - dashboard (Dashboard PatrolStatusBadge)

이제 Badge의 테마별 팔레트(라이트 파스텔 + 다크 translucent)가 자동 적용되며,
쇼케이스에서 palette 조정 시 모든 Badge 사용처에 일관되게 반영됨.
2026-04-08 12:28:23 +09:00

136 lines
5.9 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw, FileSearch } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi';
import { formatDateTime } from '@shared/utils/dateFormat';
/**
* 감사 로그 조회 + 메트릭 카드.
* 권한: admin:audit-logs (READ)
*/
export function AuditLogs() {
const [items, setItems] = useState<AuditLog[]>([]);
const [stats, setStats] = useState<AuditStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const [logs, st] = await Promise.all([fetchAuditLogs(0, 100), fetchAuditStats()]);
setItems(logs.content);
setStats(st);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<PageContainer size="lg">
<PageHeader
icon={FileSearch}
iconColor="text-blue-400"
title="감사 로그"
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
actions={
<Button variant="primary" size="sm" onClick={load} icon={<RefreshCw className="w-3.5 h-3.5" />}>
</Button>
}
/>
{/* 통계 카드 */}
{stats && (
<div className="grid grid-cols-4 gap-3">
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-red-400" />
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-purple-400" />
</div>
)}
{/* 액션별 분포 */}
{stats && stats.byAction.length > 0 && (
<Card>
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label"> ( 7)</CardTitle></CardHeader>
<CardContent className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{stats.byAction.map((a) => (
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">SN</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"> </th>
<th className="px-3 py-2 text-left">IP</th>
<th className="px-3 py-2 text-left"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={9} className="px-3 py-8 text-center text-hint"> .</td></tr>}
{items.map((it) => (
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
<td className="px-3 py-2">
<Badge intent={it.result === 'SUCCESS' ? 'success' : 'critical'} size="xs">
{it.result || '-'}
</Badge>
</td>
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
{it.detail ? JSON.stringify(it.detail) : '-'}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</PageContainer>
);
}
function MetricCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<Card>
<CardContent className="p-4">
<div className="text-[10px] text-hint">{label}</div>
<div className={`text-2xl font-bold ${color} mt-1`}>{value.toLocaleString()}</div>
</CardContent>
</Card>
);
}