import { useState, useCallback } from 'react' import { usePoller } from '../hooks/usePoller.ts' import { useCachedState } from '../hooks/useCachedState.ts' import { useI18n } from '../hooks/useI18n.ts' import { batchApi } from '../api/batchApi.ts' import { monitorApi } from '../api/monitorApi.ts' import type { BatchStatistics, CacheStats, DailyStats, MetricsSummary, ProcessingDelay, QueryMetricsTimeSeries, RunningJob, } from '../api/types.ts' import MetricCard from '../components/charts/MetricCard.tsx' import StatusBadge from '../components/common/StatusBadge.tsx' import BarChart from '../components/charts/BarChart.tsx' import LineChart from '../components/charts/LineChart.tsx' import TimeRangeSelector from '../components/common/TimeRangeSelector.tsx' import { formatDuration, formatNumber, formatDateTime, formatPercent } from '../utils/formatters.ts' const POLL_INTERVAL = 30_000 export default function Dashboard() { const { t } = useI18n() const [stats, setStats] = useCachedState('dash.stats', null) const [metrics, setMetrics] = useCachedState('dash.metrics', null) const [cache, setCache] = useCachedState('dash.cache', null) const [delay, setDelay] = useCachedState('dash.delay', null) const [daily, setDaily] = useCachedState('dash.daily', null) const [running, setRunning] = useCachedState('dash.running', []) const [queryTs, setQueryTs] = useCachedState('dash.queryTs', null) const [days, setDays] = useState(7) const [isQueryChartsOpen, setIsQueryChartsOpen] = useState(() => localStorage.getItem('dashboard-query-charts') !== 'collapsed', ) const toggleQueryCharts = useCallback(() => { setIsQueryChartsOpen(prev => { const next = !prev localStorage.setItem('dashboard-query-charts', next ? 'expanded' : 'collapsed') return next }) }, []) usePoller(() => { batchApi.getStatistics(days).then(setStats).catch(() => {}) monitorApi.getMetricsSummary().then(setMetrics).catch(() => {}) monitorApi.getCacheStats().then(setCache).catch(() => {}) monitorApi.getDelay().then(setDelay).catch(() => {}) batchApi.getDailyStats().then(setDaily).catch(() => {}) batchApi.getRunningJobs().then(setRunning).catch(() => {}) monitorApi.getQueryMetricsTimeSeries(days).then(setQueryTs).catch(() => {}) }, POLL_INTERVAL, [days]) const memUsage = metrics ? Math.round((metrics.memory.used / metrics.memory.max) * 100) : 0 return (
{/* Header */}

{t('dashboard.title')}

{/* Status Cards */}
= 95 ? 'up' : stats && stats.summary.successRate < 80 ? 'down' : 'neutral'} />
{/* Running Jobs + Processing Delay */}
{t('dashboard.runningJobs')}
{running.length === 0 ? (
{t('dashboard.noRunningJobs')}
) : (
{running.map(job => (
{job.jobName}
#{job.executionId} · {formatDateTime(job.startTime)}
))}
)}
{t('dashboard.delay')}
{delay ? (
{delay.delayMinutes ?? 0} {t('dashboard.delayMin')}
{t('dashboard.aisLatest')}
{formatDateTime(delay.aisLatestTime)}
{t('dashboard.processLatest')}
{formatDateTime(delay.queryLatestTime)}
{t('dashboard.aisReceived')}
{formatNumber(delay.recentAisCount)}{t('common.items')}
{t('dashboard.vesselsProcessed')}
{formatNumber(delay.processedVessels)}{t('common.items')}
) : (
{t('common.loading')}
)}
{/* System Metrics + Cache */}
{t('dashboard.systemMetrics')}
{metrics ? (
{t('dashboard.memory')} ({metrics.memory.used}MB / {metrics.memory.max}MB) 85 ? 'text-danger' : memUsage > 70 ? 'text-warning' : 'text-success'}> {memUsage}%
85 ? 'bg-danger' : memUsage > 70 ? 'bg-warning' : 'bg-success'}`} style={{ width: `${memUsage}%` }} />
{metrics.threads}
{t('dashboard.threads')}
{metrics.database.activeConnections}
{t('dashboard.dbConn')}
{(metrics.processing?.recordsPerSecond ?? 0).toFixed(0)}
{t('dashboard.recordsSec')}
) : (
{t('common.loading')}
)}
{t('dashboard.cacheStatus')}
{cache ? (
{cache.hitRate} {t('dashboard.hitRate')}
{formatNumber(cache.currentSize)}
{t('dashboard.size')}
{formatNumber(cache.hitCount)}
{t('dashboard.hits')}
{formatNumber(cache.missCount)}
{t('dashboard.misses')}
) : (
{t('common.loading')}
)}
{/* Daily Throughput Chart */} {daily && daily.dailyStats.length > 0 && (
{t('dashboard.dailyVolume')}
({ date: d.date.slice(5), processed: d.totalWrite, }))} dataKey="processed" xKey="date" height={280} />
)} {/* Query Performance Charts */}
{isQueryChartsOpen && (
{queryTs && queryTs.buckets.length > 0 ? ( <> {/* Row 1: Response Time + Query Volume */}
({ time: formatBucket(b.bucket, queryTs.granularity), avg: Math.round(b.avg_elapsed_ms), max: Math.round(b.max_elapsed_ms), }))} series={[ { dataKey: 'avg', color: 'var(--sb-primary)', name: t('dashboard.avgElapsed') }, { dataKey: 'max', color: 'var(--sb-danger)', name: t('dashboard.maxElapsed') }, ]} xKey="time" height={220} yFormatter={v => `${v}ms`} />
({ time: formatBucket(b.bucket, queryTs.granularity), WS: b.ws_count, REST: b.rest_count, }))} xKey="time" height={220} series={[ { dataKey: 'WS', color: 'var(--sb-primary)', name: 'WebSocket', stackId: 'q' }, { dataKey: 'REST', color: 'var(--sb-success)', name: 'REST', stackId: 'q' }, ]} />
{/* Row 2: Cache Path + Response Size */}
({ time: formatBucket(b.bucket, queryTs.granularity), Cache: b.cache_count, DB: b.db_count, Hybrid: b.hybrid_count, }))} xKey="time" height={220} series={[ { dataKey: 'Cache', color: 'var(--sb-success)', stackId: 'p' }, { dataKey: 'DB', color: 'var(--sb-warning)', stackId: 'p' }, { dataKey: 'Hybrid', color: 'var(--sb-primary)', stackId: 'p' }, ]} />
({ time: formatBucket(b.bucket, queryTs.granularity), size: Math.round(b.avg_response_bytes / 1024), }))} series={[ { dataKey: 'size', color: 'var(--sb-primary)', name: 'KB' }, ]} xKey="time" height={220} yFormatter={v => `${v}KB`} />
{/* Top Clients */} {queryTs.topClients.length > 0 && (
{t('dashboard.topClients')}
{queryTs.topClients.map((c, i) => { const maxCount = queryTs.topClients[0].query_count const pct = maxCount > 0 ? (c.query_count / maxCount) * 100 : 0 return (
{c.client_ip ?? '-'}
{c.query_count}{t('dashboard.queries')} · {Math.round(c.avg_elapsed_ms)}ms
) })}
)} ) : (
{t('dashboard.noChartData')}
)}
)}
) } function formatBucket(bucket: string, granularity: 'HOURLY' | 'DAILY'): string { if (granularity === 'HOURLY') { // "2026-03-10T14:00:00" → "14:00" const timePart = bucket.includes('T') ? bucket.split('T')[1] : bucket return timePart.slice(0, 5) } // "2026-03-10" → "03-10" return bucket.slice(5, 10) }