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('api.metrics', null) const [cache, setCache] = useCachedState('api.cache', null) const [cacheDetails, setCacheDetails] = useCachedState('api.cacheDetail', null) const [delay, setDelay] = useCachedState('api.delay', null) // Query History state const [filter, setFilter] = useState({ page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc', }) const [historyData, setHistoryData] = useState(null) const [summaryData, setSummaryData] = useState(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) => { 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[] = [ { 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 {isWs ? 'WS' : 'REST'} }, }, { 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 {path} }, }, { key: 'status', label: t('metrics.queryStatus'), sortable: false, render: (row) => { const ok = row.status === 'COMPLETED' return {row.status} }, }, { 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 {ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`} }, }, { 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 ? {row.client_ip} : '-', }, ] return (

{t('metrics.title')}

{/* System Metrics */}
85 ? 'down' : memPct > 70 ? 'neutral' : 'up'} />
{/* Cache Detail Grid */}
{t('metrics.cacheDetail')}
{cacheDetails ? (
{cacheDetails.l1_fiveMin && ( )} {cacheDetails.l2_hourly && ( )} {cacheDetails.l3_daily && ( )} {cacheDetails.aisTarget && ( )}
{t('metrics.cacheLayer')} {t('metrics.size')} {t('metrics.maxSize')} {t('metrics.utilization')} {t('metrics.hitRate')}
L1 (5min) {formatNumber(cacheDetails.l1_fiveMin.size)} {formatNumber(cacheDetails.l1_fiveMin.maxSize)} {((cacheDetails.l1_fiveMin.size / Math.max(cacheDetails.l1_fiveMin.maxSize, 1)) * 100).toFixed(1)}% {cacheDetails.l1_fiveMin.hitRate?.toFixed(1) ?? '-'}%
L2 (Hourly) {formatNumber(cacheDetails.l2_hourly.size)} {formatNumber(cacheDetails.l2_hourly.maxSize)} {((cacheDetails.l2_hourly.size / Math.max(cacheDetails.l2_hourly.maxSize, 1)) * 100).toFixed(1)}% {cacheDetails.l2_hourly.hitRate?.toFixed(1) ?? '-'}%
L3 (Daily) {cacheDetails.l3_daily.cachedDays ?? 0} days {cacheDetails.l3_daily.retentionDays ?? '-'} days {cacheDetails.l3_daily.totalMemoryMb?.toFixed(0) ?? 0} MB {cacheDetails.l3_daily.totalVessels ?? 0} vessels
AIS Target {formatNumber(cacheDetails.aisTarget.estimatedSize)} {formatNumber(cacheDetails.aisTarget.maxSize)} {((cacheDetails.aisTarget.estimatedSize / Math.max(cacheDetails.aisTarget.maxSize, 1)) * 100).toFixed(1)}% {cacheDetails.aisTarget.hitRate?.toFixed(1) ?? '-'}%
) : (
{t('common.loading')}
)}
{/* Processing & Cache Summary */}
{t('metrics.processingDelay')}
{delay ? (
{t('metrics.delayMinutes')}
{delay.delayMinutes ?? 0} min
{t('metrics.aisCount')}
{formatNumber(delay.recentAisCount)}
{t('metrics.processedVessels')}
{formatNumber(delay.processedVessels)}
{t('metrics.status')}
{delay.status}
) : (
{t('common.loading')}
)}
{t('metrics.cacheHitSummary')}
{cache ? (
{cache.hitRate}
{t('metrics.hitRate')}
{formatNumber(cache.hitCount)}
{t('metrics.hits')}
{formatNumber(cache.missCount)}
{t('metrics.misses')}
) : (
{t('common.loading')}
)}
{/* Query History Section */}
{t('metrics.queryHistory')}
{/* Summary Cards */}
{/* Filters */}
{/* Query Type toggle */}
{t('metrics.queryType')}: {[undefined, 'WEBSOCKET', 'REST_V2'].map((val) => ( ))}
{/* Data Path toggle */}
{t('metrics.dataPath')}: {[undefined, 'CACHE', 'DB', 'HYBRID'].map((val) => ( ))}
{/* Elapsed Time select */} {/* Reset */}
{/* History Table */} 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 }))} />
) }