- AreaSearch/VesselContact 동시성·메모리 관리 통합 (ActiveQueryManager + MemoryBudget) - 순차 통과 SQL 동적 N-구역(2~10) 확장 - 성능 최적화: ArrayList 사전 할당, Coordinate 재사용, equirectangular 근사 - 3개 API에 chnPrmShipOnly 파라미터 추가 (~1,400 MMSI 필터링) - 대시보드 DataPipeline 차트 개선
405 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|