signal-batch/frontend/src/pages/ApiMetrics.tsx
htlee 7a17d8e1d8 feat: Phase 4 — 비정상 항적 + 시스템 메트릭 페이지 (7/7 완성)
- AbnormalTracks: 유형별 통계, 일별 추이 차트, 검출 목록 테이블
  - abnormalApi 클라이언트 (recent, summary, types)
- ApiMetrics: 시스템 메트릭, 캐시 상세(L1/L2/L3/AIS), 처리 지연, 히트율
  - 10초 폴링으로 실시간 갱신
- i18n: abnormal.* 17키 + metrics.* 21키 한/영 추가
- 전체 7개 페이지 라우팅 완성 (Navbar 메뉴 전부 활성)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:19:51 +09:00

197 lines
8.5 KiB
TypeScript

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>
)
}