feat: Phase 4 — 비정상 항적 + 시스템 메트릭 (7/7 완성) #37
@ -10,6 +10,8 @@ const JobMonitor = lazy(() => import('./pages/JobMonitor.tsx'))
|
||||
const DataPipeline = lazy(() => import('./pages/DataPipeline.tsx'))
|
||||
const AreaStats = lazy(() => import('./pages/AreaStats.tsx'))
|
||||
const ApiExplorer = lazy(() => import('./pages/ApiExplorer.tsx'))
|
||||
const AbnormalTracks = lazy(() => import('./pages/AbnormalTracks.tsx'))
|
||||
const ApiMetrics = lazy(() => import('./pages/ApiMetrics.tsx'))
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_BASE_URL || '/signal-batch'
|
||||
|
||||
@ -53,6 +55,8 @@ export default function App() {
|
||||
<Route path="pipeline" element={<Suspense fallback={<LoadingSpinner />}><DataPipeline /></Suspense>} />
|
||||
<Route path="area-stats" element={<Suspense fallback={<LoadingSpinner />}><AreaStats /></Suspense>} />
|
||||
<Route path="api-explorer" element={<Suspense fallback={<LoadingSpinner />}><ApiExplorer /></Suspense>} />
|
||||
<Route path="abnormal" element={<Suspense fallback={<LoadingSpinner />}><AbnormalTracks /></Suspense>} />
|
||||
<Route path="metrics" element={<Suspense fallback={<LoadingSpinner />}><ApiMetrics /></Suspense>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
|
||||
52
frontend/src/api/abnormalApi.ts
Normal file
52
frontend/src/api/abnormalApi.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { fetchJson } from './httpClient.ts'
|
||||
|
||||
export interface AbnormalTrack {
|
||||
id: number
|
||||
mmsi: string
|
||||
timeBucket: string
|
||||
abnormalType: string
|
||||
typeDescription: string
|
||||
abnormalDescription: string
|
||||
distanceNm: number
|
||||
avgSpeed: number
|
||||
maxSpeed: number
|
||||
pointCount: number
|
||||
sourceTable: string
|
||||
detectedAt: string
|
||||
details: Record<string, unknown>
|
||||
trackGeoJson: { type: string; coordinates: number[][] } | null
|
||||
}
|
||||
|
||||
export interface AbnormalStats {
|
||||
statDate: string
|
||||
abnormalType: string
|
||||
vesselCount: number
|
||||
trackCount: number
|
||||
totalPoints: number
|
||||
avgDeviation: number
|
||||
maxDeviation: number
|
||||
}
|
||||
|
||||
export interface AbnormalSummary {
|
||||
type_count: number
|
||||
total_tracks: number
|
||||
vessel_count: number
|
||||
avg_distance: number
|
||||
max_speed_detected: number
|
||||
typeStatistics: { abnormal_type: string; count: number; vessel_count: number }[]
|
||||
dailyTrend: { date: string; count: number }[]
|
||||
}
|
||||
|
||||
export const abnormalApi = {
|
||||
getRecent(hours = 24): Promise<AbnormalTrack[]> {
|
||||
return fetchJson(`/api/v1/abnormal-tracks/recent?hours=${hours}`)
|
||||
},
|
||||
|
||||
getStatisticsSummary(days = 7): Promise<AbnormalSummary> {
|
||||
return fetchJson(`/api/v1/abnormal-tracks/statistics/summary?days=${days}`)
|
||||
},
|
||||
|
||||
getTypes(): Promise<Record<string, string>> {
|
||||
return fetchJson('/api/v1/abnormal-tracks/types')
|
||||
},
|
||||
}
|
||||
@ -131,6 +131,48 @@ const en = {
|
||||
'explorer.comingSoon': 'Detailed API Demo (Coming Soon)',
|
||||
'explorer.comingSoonDesc': 'Request/Response panels, track layers, replay',
|
||||
|
||||
// Abnormal Tracks
|
||||
'abnormal.title': 'Abnormal Tracks',
|
||||
'abnormal.totalDetected': 'Total Detected',
|
||||
'abnormal.last7days': 'Last 7 days',
|
||||
'abnormal.affectedVessels': 'Affected Vessels',
|
||||
'abnormal.typeCount': 'Type Count',
|
||||
'abnormal.maxSpeed': 'Max Speed',
|
||||
'abnormal.byType': 'By Type',
|
||||
'abnormal.allTypes': 'All',
|
||||
'abnormal.vessels': ' vessels',
|
||||
'abnormal.dailyTrend': 'Daily Trend',
|
||||
'abnormal.recentList': 'Recent Detections',
|
||||
'abnormal.type': 'Type',
|
||||
'abnormal.time': 'Time',
|
||||
'abnormal.distance': 'Distance',
|
||||
'abnormal.avgSpeedCol': 'Avg Speed',
|
||||
'abnormal.maxSpeedCol': 'Max Speed',
|
||||
'abnormal.points': 'Points',
|
||||
|
||||
// Metrics
|
||||
'metrics.title': 'System Metrics',
|
||||
'metrics.heapMemory': 'Heap Memory',
|
||||
'metrics.threads': 'Threads',
|
||||
'metrics.dbActive': 'DB Active Conn.',
|
||||
'metrics.recordsPerSec': 'Records/sec',
|
||||
'metrics.cacheDetail': 'Cache Details',
|
||||
'metrics.cacheLayer': 'Cache Layer',
|
||||
'metrics.size': 'Size',
|
||||
'metrics.maxSize': 'Max',
|
||||
'metrics.utilization': 'Utilization',
|
||||
'metrics.hitRate': 'Hit Rate',
|
||||
'metrics.processingDelay': 'Processing Delay',
|
||||
'metrics.delayMinutes': 'Delay',
|
||||
'metrics.aisCount': 'AIS Received',
|
||||
'metrics.processedVessels': 'Processed',
|
||||
'metrics.status': 'Status',
|
||||
'metrics.cacheHitSummary': 'Cache Hit Summary',
|
||||
'metrics.hits': 'Hits',
|
||||
'metrics.misses': 'Misses',
|
||||
'metrics.dbMetricsPlaceholder': 'API/WS History Metrics (Coming Soon)',
|
||||
'metrics.dbMetricsDesc': 'REST/WebSocket request history, response sizes, latency DB storage + query',
|
||||
|
||||
// Time Range
|
||||
'range.1d': '1D',
|
||||
'range.3d': '3D',
|
||||
|
||||
@ -131,6 +131,48 @@ const ko = {
|
||||
'explorer.comingSoon': '상세 API 시연 (향후 구현)',
|
||||
'explorer.comingSoonDesc': 'Request/Response 패널, 항적 레이어, 리플레이',
|
||||
|
||||
// Abnormal Tracks
|
||||
'abnormal.title': '비정상 항적',
|
||||
'abnormal.totalDetected': '총 검출',
|
||||
'abnormal.last7days': '최근 7일',
|
||||
'abnormal.affectedVessels': '영향 선박',
|
||||
'abnormal.typeCount': '유형 수',
|
||||
'abnormal.maxSpeed': '최대 속도',
|
||||
'abnormal.byType': '유형별 통계',
|
||||
'abnormal.allTypes': '전체',
|
||||
'abnormal.vessels': '척',
|
||||
'abnormal.dailyTrend': '일별 추이',
|
||||
'abnormal.recentList': '최근 검출 목록',
|
||||
'abnormal.type': '유형',
|
||||
'abnormal.time': '시간',
|
||||
'abnormal.distance': '거리',
|
||||
'abnormal.avgSpeedCol': '평균 속도',
|
||||
'abnormal.maxSpeedCol': '최대 속도',
|
||||
'abnormal.points': '포인트',
|
||||
|
||||
// Metrics
|
||||
'metrics.title': '시스템 메트릭',
|
||||
'metrics.heapMemory': '힙 메모리',
|
||||
'metrics.threads': '스레드',
|
||||
'metrics.dbActive': 'DB 활성 연결',
|
||||
'metrics.recordsPerSec': '초당 처리',
|
||||
'metrics.cacheDetail': '캐시 상세',
|
||||
'metrics.cacheLayer': '캐시 계층',
|
||||
'metrics.size': '크기',
|
||||
'metrics.maxSize': '최대',
|
||||
'metrics.utilization': '사용률',
|
||||
'metrics.hitRate': '히트율',
|
||||
'metrics.processingDelay': '처리 지연',
|
||||
'metrics.delayMinutes': '지연 시간',
|
||||
'metrics.aisCount': 'AIS 수신',
|
||||
'metrics.processedVessels': '처리 선박',
|
||||
'metrics.status': '상태',
|
||||
'metrics.cacheHitSummary': '캐시 히트 요약',
|
||||
'metrics.hits': '히트',
|
||||
'metrics.misses': '미스',
|
||||
'metrics.dbMetricsPlaceholder': 'API/WS 이력 메트릭 (향후 구현)',
|
||||
'metrics.dbMetricsDesc': 'REST/WebSocket 요청 이력, 응답 크기, 소요시간 DB 저장 + 조회',
|
||||
|
||||
// Time Range
|
||||
'range.1d': '1일',
|
||||
'range.3d': '3일',
|
||||
|
||||
191
frontend/src/pages/AbnormalTracks.tsx
Normal file
191
frontend/src/pages/AbnormalTracks.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { useState } from 'react'
|
||||
import { usePoller } from '../hooks/usePoller.ts'
|
||||
import { useI18n } from '../hooks/useI18n.ts'
|
||||
import { abnormalApi } from '../api/abnormalApi.ts'
|
||||
import type { AbnormalTrack, AbnormalSummary } from '../api/abnormalApi.ts'
|
||||
import StatusBadge from '../components/common/StatusBadge.tsx'
|
||||
import MetricCard from '../components/charts/MetricCard.tsx'
|
||||
import BarChart from '../components/charts/BarChart.tsx'
|
||||
import { formatNumber, formatDateTime } from '../utils/formatters.ts'
|
||||
|
||||
const POLL_INTERVAL = 60_000
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
excessive_speed: 'danger',
|
||||
teleport: 'warning',
|
||||
gap_jump: 'info',
|
||||
excessive_acceleration: 'secondary',
|
||||
extreme_avg_speed_5min: 'danger',
|
||||
user_detected: 'primary',
|
||||
}
|
||||
|
||||
const HOURS_OPTIONS = [6, 12, 24, 48, 72]
|
||||
|
||||
export default function AbnormalTracks() {
|
||||
const { t } = useI18n()
|
||||
const [hours, setHours] = useState(24)
|
||||
const [tracks, setTracks] = useState<AbnormalTrack[]>([])
|
||||
const [summary, setSummary] = useState<AbnormalSummary | null>(null)
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||
|
||||
usePoller(async () => {
|
||||
const [tr, sm] = await Promise.allSettled([
|
||||
abnormalApi.getRecent(hours),
|
||||
abnormalApi.getStatisticsSummary(7),
|
||||
])
|
||||
if (tr.status === 'fulfilled') setTracks(tr.value)
|
||||
if (sm.status === 'fulfilled') setSummary(sm.value)
|
||||
}, POLL_INTERVAL, [hours])
|
||||
|
||||
const filteredTracks = typeFilter === 'all'
|
||||
? tracks
|
||||
: tracks.filter(t => t.abnormalType === typeFilter)
|
||||
|
||||
const typeStats = summary?.typeStatistics ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t('abnormal.title')}</h1>
|
||||
<div className="flex gap-1">
|
||||
{HOURS_OPTIONS.map(h => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setHours(h)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition ${
|
||||
hours === h
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-hover text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
title={t('abnormal.totalDetected')}
|
||||
value={summary ? formatNumber(summary.total_tracks) : '-'}
|
||||
subtitle={t('abnormal.last7days')}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('abnormal.affectedVessels')}
|
||||
value={summary ? formatNumber(summary.vessel_count) : '-'}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('abnormal.typeCount')}
|
||||
value={summary ? String(summary.type_count) : '-'}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('abnormal.maxSpeed')}
|
||||
value={summary ? `${summary.max_speed_detected?.toFixed(1) ?? '0'} kn` : '-'}
|
||||
trend="down"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filter + Daily Trend */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Type Statistics */}
|
||||
<div className="sb-card">
|
||||
<div className="sb-card-header">{t('abnormal.byType')}</div>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setTypeFilter('all')}
|
||||
className={`w-full rounded-md px-3 py-2 text-left text-sm transition ${
|
||||
typeFilter === 'all'
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{t('abnormal.allTypes')} ({tracks.length})
|
||||
</button>
|
||||
{typeStats.map(ts => (
|
||||
<button
|
||||
key={ts.abnormal_type}
|
||||
onClick={() => setTypeFilter(ts.abnormal_type)}
|
||||
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition ${
|
||||
typeFilter === ts.abnormal_type
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full bg-${TYPE_COLORS[ts.abnormal_type] ?? 'secondary'}`}
|
||||
/>
|
||||
{ts.abnormal_type}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{ts.count}{t('common.items')} / {ts.vessel_count}{t('abnormal.vessels')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Trend */}
|
||||
<div className="sb-card">
|
||||
<div className="sb-card-header">{t('abnormal.dailyTrend')}</div>
|
||||
{summary && summary.dailyTrend?.length > 0 ? (
|
||||
<BarChart
|
||||
data={summary.dailyTrend.map(d => ({
|
||||
date: d.date.slice(5),
|
||||
count: d.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
xKey="date"
|
||||
height={220}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-muted">{t('common.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track List */}
|
||||
<div className="sb-card">
|
||||
<div className="sb-card-header">
|
||||
{t('abnormal.recentList')} ({filteredTracks.length})
|
||||
</div>
|
||||
{filteredTracks.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted">{t('common.noData')}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="sb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MMSI</th>
|
||||
<th>{t('abnormal.type')}</th>
|
||||
<th>{t('abnormal.time')}</th>
|
||||
<th>{t('abnormal.distance')}</th>
|
||||
<th>{t('abnormal.avgSpeedCol')}</th>
|
||||
<th>{t('abnormal.maxSpeedCol')}</th>
|
||||
<th>{t('abnormal.points')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTracks.slice(0, 50).map(track => (
|
||||
<tr key={track.id}>
|
||||
<td className="font-mono text-xs">{track.mmsi}</td>
|
||||
<td>
|
||||
<StatusBadge status={track.abnormalType} />
|
||||
</td>
|
||||
<td className="text-xs">{formatDateTime(track.timeBucket)}</td>
|
||||
<td className="text-right">{track.distanceNm?.toFixed(1) ?? '-'} nm</td>
|
||||
<td className="text-right">{track.avgSpeed?.toFixed(1) ?? '-'} kn</td>
|
||||
<td className="text-right">{track.maxSpeed?.toFixed(1) ?? '-'} kn</td>
|
||||
<td className="text-center">{track.pointCount ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
frontend/src/pages/ApiMetrics.tsx
Normal file
196
frontend/src/pages/ApiMetrics.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { useState } from 'react'
|
||||
import { usePoller } from '../hooks/usePoller.ts'
|
||||
import { useI18n } from '../hooks/useI18n.ts'
|
||||
import { monitorApi } from '../api/monitorApi.ts'
|
||||
import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails } from '../api/types.ts'
|
||||
import MetricCard from '../components/charts/MetricCard.tsx'
|
||||
import { formatNumber } from '../utils/formatters.ts'
|
||||
|
||||
const POLL_INTERVAL = 10_000
|
||||
|
||||
export default function ApiMetrics() {
|
||||
const { t } = useI18n()
|
||||
const [metrics, setMetrics] = useState<MetricsSummary | null>(null)
|
||||
const [cache, setCache] = useState<CacheStats | null>(null)
|
||||
const [cacheDetails, setCacheDetails] = useState<CacheDetails | null>(null)
|
||||
const [delay, setDelay] = useState<ProcessingDelay | null>(null)
|
||||
|
||||
usePoller(async () => {
|
||||
const [m, c, cd, d] = await Promise.allSettled([
|
||||
monitorApi.getMetricsSummary(),
|
||||
monitorApi.getCacheStats(),
|
||||
monitorApi.getCacheDetails(),
|
||||
monitorApi.getDelay(),
|
||||
])
|
||||
if (m.status === 'fulfilled') setMetrics(m.value)
|
||||
if (c.status === 'fulfilled') setCache(c.value)
|
||||
if (cd.status === 'fulfilled') setCacheDetails(cd.value)
|
||||
if (d.status === 'fulfilled') setDelay(d.value)
|
||||
}, POLL_INTERVAL)
|
||||
|
||||
const memUsed = metrics?.memory.used ?? 0
|
||||
const memMax = metrics?.memory.max ?? 1
|
||||
const memPct = Math.round((memUsed / memMax) * 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 fade-in">
|
||||
<h1 className="text-2xl font-bold">{t('metrics.title')}</h1>
|
||||
|
||||
{/* System Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
title={t('metrics.heapMemory')}
|
||||
value={metrics ? `${memUsed}MB / ${memMax}MB` : '-'}
|
||||
subtitle={metrics ? `${memPct}%` : undefined}
|
||||
trend={memPct > 85 ? 'down' : memPct > 70 ? 'neutral' : 'up'}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('metrics.threads')}
|
||||
value={metrics ? String(metrics.threads) : '-'}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('metrics.dbActive')}
|
||||
value={metrics ? String(metrics.database.activeConnections) : '-'}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('metrics.recordsPerSec')}
|
||||
value={metrics ? `${(metrics.processing?.recordsPerSecond ?? 0).toFixed(0)}/s` : '-'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cache Detail Grid */}
|
||||
<div className="sb-card">
|
||||
<div className="sb-card-header">{t('metrics.cacheDetail')}</div>
|
||||
{cacheDetails ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="sb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('metrics.cacheLayer')}</th>
|
||||
<th className="text-right">{t('metrics.size')}</th>
|
||||
<th className="text-right">{t('metrics.maxSize')}</th>
|
||||
<th className="text-right">{t('metrics.utilization')}</th>
|
||||
<th className="text-right">{t('metrics.hitRate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cacheDetails.l1_fiveMin && (
|
||||
<tr>
|
||||
<td>L1 (5min)</td>
|
||||
<td className="text-right">{formatNumber(cacheDetails.l1_fiveMin.size)}</td>
|
||||
<td className="text-right">{formatNumber(cacheDetails.l1_fiveMin.maxSize)}</td>
|
||||
<td className="text-right">
|
||||
{((cacheDetails.l1_fiveMin.size / Math.max(cacheDetails.l1_fiveMin.maxSize, 1)) * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td className="text-right">{cacheDetails.l1_fiveMin.hitRate?.toFixed(1) ?? '-'}%</td>
|
||||
</tr>
|
||||
)}
|
||||
{cacheDetails.l2_hourly && (
|
||||
<tr>
|
||||
<td>L2 (Hourly)</td>
|
||||
<td className="text-right">{formatNumber(cacheDetails.l2_hourly.size)}</td>
|
||||
<td className="text-right">{formatNumber(cacheDetails.l2_hourly.maxSize)}</td>
|
||||
<td className="text-right">
|
||||
{((cacheDetails.l2_hourly.size / Math.max(cacheDetails.l2_hourly.maxSize, 1)) * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td className="text-right">{cacheDetails.l2_hourly.hitRate?.toFixed(1) ?? '-'}%</td>
|
||||
</tr>
|
||||
)}
|
||||
{cacheDetails.l3_daily && (
|
||||
<tr>
|
||||
<td>L3 (Daily)</td>
|
||||
<td className="text-right">{cacheDetails.l3_daily.cachedDays ?? 0} days</td>
|
||||
<td className="text-right">{cacheDetails.l3_daily.retentionDays ?? '-'} days</td>
|
||||
<td className="text-right">
|
||||
{cacheDetails.l3_daily.totalMemoryMb?.toFixed(0) ?? 0} MB
|
||||
</td>
|
||||
<td className="text-right">{cacheDetails.l3_daily.totalVessels ?? 0} vessels</td>
|
||||
</tr>
|
||||
)}
|
||||
{cacheDetails.aisTarget && (
|
||||
<tr>
|
||||
<td>AIS Target</td>
|
||||
<td className="text-right">{formatNumber(cacheDetails.aisTarget.estimatedSize)}</td>
|
||||
<td className="text-right">{formatNumber(cacheDetails.aisTarget.maxSize)}</td>
|
||||
<td className="text-right">
|
||||
{((cacheDetails.aisTarget.estimatedSize / Math.max(cacheDetails.aisTarget.maxSize, 1)) * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td className="text-right">{cacheDetails.aisTarget.hitRate?.toFixed(1) ?? '-'}%</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processing & Cache Summary */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="sb-card">
|
||||
<div className="sb-card-header">{t('metrics.processingDelay')}</div>
|
||||
{delay ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted">{t('metrics.delayMinutes')}</span>
|
||||
<div className="text-2xl font-bold">{delay.delayMinutes ?? 0}<span className="text-sm text-muted"> min</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted">{t('metrics.aisCount')}</span>
|
||||
<div className="text-2xl font-bold">{formatNumber(delay.recentAisCount)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted">{t('metrics.processedVessels')}</span>
|
||||
<div className="font-bold">{formatNumber(delay.processedVessels)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted">{t('metrics.status')}</span>
|
||||
<div className={`font-bold ${
|
||||
delay.status === 'NORMAL' ? 'text-success' :
|
||||
delay.status === 'WARNING' ? 'text-warning' : 'text-danger'
|
||||
}`}>{delay.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sb-card">
|
||||
<div className="sb-card-header">{t('metrics.cacheHitSummary')}</div>
|
||||
{cache ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{cache.hitRate}</div>
|
||||
<div className="text-xs text-muted">{t('metrics.hitRate')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{formatNumber(cache.hitCount)}</div>
|
||||
<div className="text-xs text-muted">{t('metrics.hits')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{formatNumber(cache.missCount)}</div>
|
||||
<div className="text-xs text-muted">{t('metrics.misses')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for future DB-based metrics */}
|
||||
<div className="sb-card border-dashed">
|
||||
<div className="py-6 text-center text-sm text-muted">
|
||||
<p>{t('metrics.dbMetricsPlaceholder')}</p>
|
||||
<p className="mt-1 text-xs opacity-60">{t('metrics.dbMetricsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user