129건 하드코딩 Tailwind 색상 → 시맨틱 토큰 치환: - text-cyan-400 (45건) → text-label - text-green-400/500 (51건) → text-label + Badge intent="success" - text-red-400/500 (31건) → text-heading + Badge intent="critical" - text-blue-400 (33건) → text-label + Badge intent="info" - text-purple-400 (20건) → text-heading - text-yellow/orange/amber (32건) → text-heading + Badge intent="warning" raw <button> → <Button> 컴포넌트 교체 (DataHub/NoticeManagement/SystemConfig 등) 미사용 import 정리 (SaveButton/DataTable/lucide 아이콘) 대상: AIAgentSecurityPage, AISecurityPage, AccessControl, AccessLogs, AdminPanel, AuditLogs, DataHub, LoginHistoryView, NoticeManagement, PermissionsPanel, SystemConfig 검증: tsc 0 errors, eslint 0 errors, 하드코딩 색상 잔여 0건
136 lines
5.9 KiB
TypeScript
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-label"
|
|
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-label" />
|
|
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-heading" />
|
|
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-heading" />
|
|
</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-label font-bold ml-1">{a.count}</span>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{error && <div className="text-xs text-heading">에러: {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-label">{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-heading 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>
|
|
);
|
|
}
|