## 시간 표시 KST 통일 - shared/utils/dateFormat.ts 공통 유틸 신규 (formatDateTime/formatDate/formatTime/toDateParam) - 14개 파일에서 인라인 toLocaleString → 공통 유틸 교체 ## i18n 'group.parentInference' 사이드바 미번역 수정 - ko/en common.json의 'group' 키 중복 정의를 병합 (95행 두번째 group 객체가 35행을 덮어써서 parentInference 누락) ## Dashboard/MonitoringDashboard/Statistics 더미→실 API - 백엔드 GET /api/stats/hourly 신규 (PredictionStatsHourly 엔티티/리포지토리) - Dashboard: HOURLY_DETECTION/VESSEL_TYPE/AREA_RISK 하드코딩 제거 → getHourlyStats(24) + getDailyStats(today) 결과로 useMemo 변환 - MonitoringDashboard: TREND Math.random() 제거 → getHourlyStats 기반 위험도 가중평균 + 경보 카운트 - Statistics: KPI_DATA 하드코딩 제거 → getKpiMetrics() 결과를 표 행으로 ## Store mock 의존성 제거 - eventStore.alerts/MOCK_ALERTS 제거 (MobileService는 events에서 직접 추출) - enforcementStore.plans 제거 (EnforcementPlan은 이미 직접 API 호출) - transferStore + MOCK_TRANSFERS 완전 제거 (ChinaFishing/TransferDetection은 RealTransshipSuspects 컴포넌트 사용) - mock/events.ts, mock/enforcement.ts, mock/transfers.ts 파일 삭제 ## RiskMap 랜덤 격자 제거 - generateGrid() Math.random() 제거 → 빈 배열 + 'AI 분석 데이터 수집 중' 안내 - MTIS 외부 통계 5개 탭에 [MTIS 외부 통계] 배지 추가 ## 12개 mock 화면에 '데모 데이터' 노란색 배지 추가 - patrol/PatrolRoute, FleetOptimization - admin/AdminPanel, DataHub, NoticeManagement, SystemConfig - ai-operations/AIModelManagement, MLOpsPage - field-ops/ShipAgent - statistics/ReportManagement, ExternalService - surveillance/MapControl ## 백엔드 NUMERIC precision 동기화 - PredictionKpi.deltaPct: 5,2 → 12,2 - PredictionStatsDaily/Monthly.aiAccuracyPct: 5,2 → 12,2 - (V015 마이그레이션과 동기화) 44 files changed, +346 / -787 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
141 lines
6.2 KiB
TypeScript
141 lines
6.2 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { Loader2, RefreshCw } from 'lucide-react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
|
|
/**
|
|
* 접근 이력 조회 + 메트릭 카드.
|
|
* 권한: admin:access-logs (READ)
|
|
*/
|
|
export function AccessLogs() {
|
|
const [items, setItems] = useState<AccessLog[]>([]);
|
|
const [stats, setStats] = useState<AccessStats | 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([fetchAccessLogs(0, 100), fetchAccessStats()]);
|
|
setItems(logs.content);
|
|
setStats(st);
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'unknown');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const statusColor = (s: number) =>
|
|
s >= 500 ? 'bg-red-500/20 text-red-400'
|
|
: s >= 400 ? 'bg-orange-500/20 text-orange-400'
|
|
: 'bg-green-500/20 text-green-400';
|
|
|
|
return (
|
|
<div className="p-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-heading">접근 이력</h1>
|
|
<p className="text-xs text-hint mt-1">AccessLogFilter가 모든 HTTP 요청 비동기 기록</p>
|
|
</div>
|
|
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
|
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
|
</button>
|
|
</div>
|
|
|
|
{stats && (
|
|
<div className="grid grid-cols-5 gap-3">
|
|
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
|
|
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
|
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-orange-400" />
|
|
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-red-400" />
|
|
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-purple-400" />
|
|
</div>
|
|
)}
|
|
|
|
{stats && stats.topPaths.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label">호출 빈도 Top 10 (24시간)</CardTitle></CardHeader>
|
|
<CardContent className="px-4 pb-4">
|
|
<table className="w-full text-[11px]">
|
|
<thead className="text-hint">
|
|
<tr>
|
|
<th className="text-left py-1">경로</th>
|
|
<th className="text-right py-1 w-24">호출수</th>
|
|
<th className="text-right py-1 w-28">평균(ms)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{stats.topPaths.map((p) => (
|
|
<tr key={p.path} className="border-t border-border">
|
|
<td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td>
|
|
<td className="py-1.5 text-right text-cyan-400 font-bold">{p.count}</td>
|
|
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</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-center">상태</th>
|
|
<th className="px-3 py-2 text-right">시간(ms)</th>
|
|
<th className="px-3 py-2 text-left">IP</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.length === 0 && <tr><td colSpan={8} className="px-3 py-8 text-center text-hint">접근 로그가 없습니다.</td></tr>}
|
|
{items.map((it) => (
|
|
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
|
|
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</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-purple-400 font-mono">{it.httpMethod}</td>
|
|
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<Badge className={`border-0 text-[9px] ${statusColor(it.statusCode)}`}>{it.statusCode}</Badge>
|
|
</td>
|
|
<td className="px-3 py-2 text-right text-muted-foreground">{it.durationMs}</td>
|
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|