- 백엔드 daily-stats SQL 재설계: Job별 writeCount, execCount, avgDuration 반환 - vesselJobs 항상 0 버그 수정 (vesselAggregation 매칭 실패 → Job별 분리 구조) - BarChart 다중 Series 지원 (stacked/grouped) + yFormatter - DataPipeline: LineChart 1개 → Stacked Bar(처리건수) + Grouped Bar(소요시간) 2열 grid - formatDuration null/undefined 가드 추가 (NaNh 표시 버그 수정) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
220 lines
9.4 KiB
TypeScript
220 lines
9.4 KiB
TypeScript
import { useState } 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,
|
|
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 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<BatchStatistics | null>('dash.stats', null)
|
|
const [metrics, setMetrics] = useCachedState<MetricsSummary | null>('dash.metrics', null)
|
|
const [cache, setCache] = useCachedState<CacheStats | null>('dash.cache', null)
|
|
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('dash.delay', null)
|
|
const [daily, setDaily] = useCachedState<DailyStats | null>('dash.daily', null)
|
|
const [running, setRunning] = useCachedState<RunningJob[]>('dash.running', [])
|
|
const [days, setDays] = useState(7)
|
|
|
|
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(() => {})
|
|
}, POLL_INTERVAL, [days])
|
|
|
|
const memUsage = metrics
|
|
? Math.round((metrics.memory.used / metrics.memory.max) * 100)
|
|
: 0
|
|
|
|
return (
|
|
<div className="space-y-6 fade-in">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold">{t('dashboard.title')}</h1>
|
|
<TimeRangeSelector value={days} onChange={setDays} />
|
|
</div>
|
|
|
|
{/* Status Cards */}
|
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
<MetricCard
|
|
title={t('dashboard.totalExec')}
|
|
value={stats ? formatNumber(stats.summary.totalExecutions) : '-'}
|
|
subtitle={stats ? `${days}${t('dashboard.periodBasis')}` : undefined}
|
|
/>
|
|
<MetricCard
|
|
title={t('dashboard.successRate')}
|
|
value={stats ? formatPercent(stats.summary.successRate) : '-'}
|
|
trend={stats && stats.summary.successRate >= 95 ? 'up' : stats && stats.summary.successRate < 80 ? 'down' : 'neutral'}
|
|
/>
|
|
<MetricCard
|
|
title={t('dashboard.avgDuration')}
|
|
value={stats ? formatDuration(stats.summary.avgProcessingTimeSeconds) : '-'}
|
|
/>
|
|
<MetricCard
|
|
title={t('dashboard.totalProcessed')}
|
|
value={stats ? formatNumber(stats.summary.totalRecordsProcessed) : '-'}
|
|
subtitle={stats ? `${formatNumber(stats.summary.avgRecordsPerExecution)}${t('dashboard.avgPerJob')}` : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{/* Running Jobs + Processing Delay */}
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<div className="sb-card">
|
|
<div className="sb-card-header">{t('dashboard.runningJobs')}</div>
|
|
{running.length === 0 ? (
|
|
<div className="py-4 text-center text-sm text-muted">{t('dashboard.noRunningJobs')}</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{running.map(job => (
|
|
<div key={job.executionId} className="flex items-center justify-between rounded-lg bg-surface-hover p-3">
|
|
<div>
|
|
<div className="font-medium text-sm">{job.jobName}</div>
|
|
<div className="text-xs text-muted">#{job.executionId} · {formatDateTime(job.startTime)}</div>
|
|
</div>
|
|
<StatusBadge status={job.status} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="sb-card">
|
|
<div className="sb-card-header">{t('dashboard.delay')}</div>
|
|
{delay ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold">{delay.delayMinutes ?? 0}</span>
|
|
<span className="text-muted">{t('dashboard.delayMin')}</span>
|
|
<StatusBadge status={delay.status ?? 'NORMAL'} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<span className="text-muted">{t('dashboard.aisLatest')}</span>
|
|
<div className="font-mono text-xs">{formatDateTime(delay.aisLatestTime)}</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted">{t('dashboard.processLatest')}</span>
|
|
<div className="font-mono text-xs">{formatDateTime(delay.queryLatestTime)}</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted">{t('dashboard.aisReceived')}</span>
|
|
<div>{formatNumber(delay.recentAisCount)}{t('common.items')}</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted">{t('dashboard.vesselsProcessed')}</span>
|
|
<div>{formatNumber(delay.processedVessels)}{t('common.items')}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* System Metrics + Cache */}
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<div className="sb-card">
|
|
<div className="sb-card-header">{t('dashboard.systemMetrics')}</div>
|
|
{metrics ? (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="mb-1 flex justify-between text-sm">
|
|
<span>{t('dashboard.memory')} ({metrics.memory.used}MB / {metrics.memory.max}MB)</span>
|
|
<span className={memUsage > 85 ? 'text-danger' : memUsage > 70 ? 'text-warning' : 'text-success'}>
|
|
{memUsage}%
|
|
</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-surface-hover">
|
|
<div
|
|
className={`h-2 rounded-full ${memUsage > 85 ? 'bg-danger' : memUsage > 70 ? 'bg-warning' : 'bg-success'}`}
|
|
style={{ width: `${memUsage}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
|
<div>
|
|
<div className="text-lg font-bold">{metrics.threads}</div>
|
|
<div className="text-xs text-muted">{t('dashboard.threads')}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-bold">{metrics.database.activeConnections}</div>
|
|
<div className="text-xs text-muted">{t('dashboard.dbConn')}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-bold">{(metrics.processing?.recordsPerSecond ?? 0).toFixed(0)}</div>
|
|
<div className="text-xs text-muted">{t('dashboard.recordsSec')}</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('dashboard.cacheStatus')}</div>
|
|
{cache ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold">{cache.hitRate}</span>
|
|
<span className="text-muted">{t('dashboard.hitRate')}</span>
|
|
<StatusBadge status={cache.status} />
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
|
<div>
|
|
<div className="text-lg font-bold">{formatNumber(cache.currentSize)}</div>
|
|
<div className="text-xs text-muted">{t('dashboard.size')}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-bold">{formatNumber(cache.hitCount)}</div>
|
|
<div className="text-xs text-muted">{t('dashboard.hits')}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-bold">{formatNumber(cache.missCount)}</div>
|
|
<div className="text-xs text-muted">{t('dashboard.misses')}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Daily Throughput Chart */}
|
|
{daily && daily.dailyStats.length > 0 && (
|
|
<div className="sb-card">
|
|
<div className="sb-card-header">{t('dashboard.dailyVolume')}</div>
|
|
<BarChart
|
|
data={daily.dailyStats.map(d => ({
|
|
date: d.date.slice(5),
|
|
processed: d.totalWrite,
|
|
}))}
|
|
dataKey="processed"
|
|
xKey="date"
|
|
height={280}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|