signal-batch/frontend/src/pages/ApiMetrics.tsx
htlee c58aaca2ad feat: 다중구역/STS API 최적화 + ChnPrmShip 전용 필터
- AreaSearch/VesselContact 동시성·메모리 관리 통합 (ActiveQueryManager + MemoryBudget)
- 순차 통과 SQL 동적 N-구역(2~10) 확장
- 성능 최적화: ArrayList 사전 할당, Coordinate 재사용, equirectangular 근사
- 3개 API에 chnPrmShipOnly 파라미터 추가 (~1,400 MMSI 필터링)
- 대시보드 DataPipeline 차트 개선
2026-03-13 10:12:22 +09:00

405 lines
18 KiB
TypeScript

import { useState, useCallback } from 'react'
import { usePoller } from '../hooks/usePoller.ts'
import { useCachedState } from '../hooks/useCachedState.ts'
import { useI18n } from '../hooks/useI18n.ts'
import { monitorApi } from '../api/monitorApi.ts'
import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails, QueryMetricsPage, QueryMetricsSummary, QueryMetricsParams, QueryMetricRow } from '../api/types.ts'
import MetricCard from '../components/charts/MetricCard.tsx'
import DataTable, { type Column } from '../components/common/DataTable.tsx'
import { formatNumber, formatBytes } from '../utils/formatters.ts'
const POLL_INTERVAL = 10_000
const QUERY_POLL_INTERVAL = 30_000
const ELAPSED_RANGES = [
{ label: '< 1s', min: undefined, max: 999 },
{ label: '1-5s', min: 1000, max: 5000 },
{ label: '5-30s', min: 5000, max: 30000 },
{ label: '> 30s', min: 30000, max: undefined },
] as const
export default function ApiMetrics() {
const { t } = useI18n()
const [metrics, setMetrics] = useCachedState<MetricsSummary | null>('api.metrics', null)
const [cache, setCache] = useCachedState<CacheStats | null>('api.cache', null)
const [cacheDetails, setCacheDetails] = useCachedState<CacheDetails | null>('api.cacheDetail', null)
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('api.delay', null)
// Query History state
const [filter, setFilter] = useState<QueryMetricsParams>({
page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc',
})
const [historyData, setHistoryData] = useState<QueryMetricsPage | null>(null)
const [summaryData, setSummaryData] = useState<QueryMetricsSummary | null>(null)
usePoller(() => {
monitorApi.getMetricsSummary().then(setMetrics).catch(() => {})
monitorApi.getCacheStats().then(setCache).catch(() => {})
monitorApi.getCacheDetails().then(setCacheDetails).catch(() => {})
monitorApi.getDelay().then(setDelay).catch(() => {})
}, POLL_INTERVAL)
const fetchQueryData = useCallback(() => {
monitorApi.getQueryMetricsHistory(filter).then(setHistoryData).catch(() => {})
monitorApi.getQueryMetricsSummary(24).then(setSummaryData).catch(() => {})
}, [filter])
usePoller(fetchQueryData, QUERY_POLL_INTERVAL, [filter])
const updateFilter = (patch: Partial<QueryMetricsParams>) => {
setFilter(prev => ({ ...prev, page: 0, ...patch }))
}
const resetFilters = () => {
setFilter({ page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc' })
}
const memUsed = metrics?.memory.used ?? 0
const memMax = metrics?.memory.max ?? 1
const memPct = Math.round((memUsed / memMax) * 100)
// Summary computed values
const totalQueries = summaryData?.total_queries ?? 0
const cacheHitRate = totalQueries > 0
? ((summaryData?.cache_only_count ?? 0) / totalQueries * 100).toFixed(1)
: '0.0'
const historyColumns: Column<QueryMetricRow>[] = [
{
key: 'created_at', label: t('metrics.queryTime'), sortable: false,
render: (row) => {
if (!row.created_at) return '-'
const d = new Date(row.created_at)
// UTC → KST (+9h)
const kst = new Date(d.getTime() + 9 * 60 * 60 * 1000)
const mm = String(kst.getUTCMonth() + 1).padStart(2, '0')
const dd = String(kst.getUTCDate()).padStart(2, '0')
const hh = String(kst.getUTCHours()).padStart(2, '0')
const mi = String(kst.getUTCMinutes()).padStart(2, '0')
const ss = String(kst.getUTCSeconds()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${mi}:${ss}`
},
},
{
key: 'query_type', label: t('metrics.queryType'), sortable: false,
render: (row) => {
const isWs = row.query_type === 'WEBSOCKET'
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${isWs ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'}`}>{isWs ? 'WS' : 'REST'}</span>
},
},
{
key: 'data_path', label: t('metrics.dataPath'), sortable: false,
render: (row) => {
const path = row.data_path ?? ''
const color = path === 'CACHE' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'
: path === 'DB' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'
: 'bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300'
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${color}`}>{path}</span>
},
},
{
key: 'status', label: t('metrics.queryStatus'), sortable: false,
render: (row) => {
const ok = row.status === 'COMPLETED'
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${ok ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'}`}>{row.status}</span>
},
},
{ key: 'unique_vessels', label: t('metrics.vessels'), align: 'right' as const, sortable: false,
render: (row) => formatNumber(row.unique_vessels) },
{ key: 'total_points', label: t('metrics.pointsBefore'), align: 'right' as const, sortable: false,
render: (row) => formatNumber(row.total_points) },
{ key: 'points_after_simplify', label: t('metrics.pointsAfter'), align: 'right' as const, sortable: false,
render: (row) => formatNumber(row.points_after_simplify) },
{
key: 'reduction', label: t('metrics.simplification'), align: 'right' as const, sortable: false,
render: (row) => {
const before = row.total_points || 0
const after = row.points_after_simplify || 0
if (before === 0) return '-'
return `${((1 - after / before) * 100).toFixed(0)}%`
},
},
{ key: 'total_chunks', label: t('metrics.chunks'), align: 'right' as const, sortable: false },
{
key: 'elapsed_ms', label: t('metrics.elapsed'), align: 'right' as const, sortable: false,
render: (row) => {
const ms = row.elapsed_ms || 0
const color = ms < 1000 ? 'text-success' : ms < 5000 ? 'text-warning' : 'text-danger'
return <span className={`font-mono font-medium ${color}`}>{ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`}</span>
},
},
{
key: 'response_bytes', label: t('metrics.responseSize'), align: 'right' as const, sortable: false,
render: (row) => row.response_bytes ? formatBytes(row.response_bytes) : '-',
},
{
key: 'client_ip', label: t('metrics.clientIp'), sortable: false,
render: (row) => row.client_ip ? <span className="font-mono text-xs">{row.client_ip}</span> : '-',
},
]
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>
{/* Query History Section */}
<div className="sb-card">
<div className="sb-card-header">{t('metrics.queryHistory')}</div>
{/* Summary Cards */}
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
title={t('metrics.totalQueries')}
value={summaryData ? formatNumber(totalQueries) : '-'}
subtitle={summaryData ? `WS:${summaryData.ws_count} / REST:${summaryData.rest_count}` : undefined}
/>
<MetricCard
title={t('metrics.avgElapsed')}
value={summaryData ? `${((summaryData.avg_elapsed_ms ?? 0) / 1000).toFixed(1)}s` : '-'}
/>
<MetricCard
title={t('metrics.p95Elapsed')}
value={summaryData ? `${((summaryData.p95_elapsed_ms ?? 0) / 1000).toFixed(1)}s` : '-'}
/>
<MetricCard
title={t('metrics.cacheHitRate')}
value={summaryData ? `${cacheHitRate}%` : '-'}
subtitle={summaryData ? `C:${summaryData.cache_only_count}/DB:${summaryData.db_only_count}/H:${summaryData.hybrid_count}` : undefined}
/>
</div>
{/* Filters */}
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm">
{/* Query Type toggle */}
<div className="flex items-center gap-1">
<span className="text-muted mr-1">{t('metrics.queryType')}:</span>
{[undefined, 'WEBSOCKET', 'REST_V2'].map((val) => (
<button
type="button"
key={val ?? 'all'}
onClick={() => updateFilter({ queryType: val })}
className={`rounded px-2 py-1 text-xs font-medium transition ${
filter.queryType === val
? 'bg-primary text-white'
: 'bg-surface-secondary text-muted hover:bg-surface-tertiary'
}`}
>
{val == null ? t('metrics.allTypes') : val === 'WEBSOCKET' ? 'WS' : 'REST'}
</button>
))}
</div>
{/* Data Path toggle */}
<div className="flex items-center gap-1">
<span className="text-muted mr-1">{t('metrics.dataPath')}:</span>
{[undefined, 'CACHE', 'DB', 'HYBRID'].map((val) => (
<button
type="button"
key={val ?? 'all'}
onClick={() => updateFilter({ dataPath: val })}
className={`rounded px-2 py-1 text-xs font-medium transition ${
filter.dataPath === val
? 'bg-primary text-white'
: 'bg-surface-secondary text-muted hover:bg-surface-tertiary'
}`}
>
{val ?? t('metrics.allPaths')}
</button>
))}
</div>
{/* Elapsed Time select */}
<select
title={t('metrics.elapsed')}
value={filter.elapsedMsMin != null ? `${filter.elapsedMsMin}-${filter.elapsedMsMax ?? ''}` : ''}
onChange={(e) => {
if (!e.target.value) {
updateFilter({ elapsedMsMin: undefined, elapsedMsMax: undefined })
} else {
const range = ELAPSED_RANGES.find(r =>
`${r.min ?? ''}-${r.max ?? ''}` === e.target.value
)
if (range) updateFilter({ elapsedMsMin: range.min, elapsedMsMax: range.max })
}
}}
className="rounded border border-border bg-surface px-2 py-1 text-xs"
>
<option value="">{t('metrics.elapsed')}: {t('metrics.allTypes')}</option>
{ELAPSED_RANGES.map((r) => (
<option key={r.label} value={`${r.min ?? ''}-${r.max ?? ''}`}>{r.label}</option>
))}
</select>
{/* Reset */}
<button
type="button"
onClick={resetFilters}
className="rounded border border-border px-2 py-1 text-xs text-muted hover:bg-surface-secondary"
>
{t('metrics.resetFilters')}
</button>
</div>
{/* History Table */}
<DataTable<QueryMetricRow>
columns={historyColumns}
data={historyData?.content ?? []}
keyExtractor={(row) => row.query_id}
pageSize={filter.size ?? 20}
totalElements={historyData?.totalElements}
currentPage={historyData?.currentPage}
onPageChange={(p) => setFilter(prev => ({ ...prev, page: p }))}
/>
</div>
</div>
)
}