Merge pull request 'Release: Phase 2 — DataPipeline + AreaStats' (#15) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m31s

This commit is contained in:
htlee 2026-02-19 17:56:58 +09:00
커밋 318d2aefbb
9개의 변경된 파일743개의 추가작업 그리고 3개의 파일을 삭제

파일 보기

@ -7,6 +7,8 @@ import LoadingSpinner from './components/common/LoadingSpinner.tsx'
const Dashboard = lazy(() => import('./pages/Dashboard.tsx'))
const JobMonitor = lazy(() => import('./pages/JobMonitor.tsx'))
const DataPipeline = lazy(() => import('./pages/DataPipeline.tsx'))
const AreaStats = lazy(() => import('./pages/AreaStats.tsx'))
const BASE_URL = import.meta.env.VITE_BASE_URL || '/signal-batch'
@ -47,7 +49,9 @@ export default function App() {
<Route element={<AppLayout />}>
<Route index element={<Suspense fallback={<LoadingSpinner />}><Dashboard /></Suspense>} />
<Route path="jobs" element={<Suspense fallback={<LoadingSpinner />}><JobMonitor /></Suspense>} />
{/* Phase 2+ 페이지 추가 예정 */}
<Route path="pipeline" element={<Suspense fallback={<LoadingSpinner />}><DataPipeline /></Suspense>} />
<Route path="area-stats" element={<Suspense fallback={<LoadingSpinner />}><AreaStats /></Suspense>} />
{/* Phase 3+ 페이지 추가 예정 */}
</Route>
</Routes>
</ErrorBoundary>

파일 보기

@ -1,5 +1,13 @@
import { fetchJson } from './httpClient.ts'
import type { CacheStats, MetricsSummary, ProcessingDelay } from './types.ts'
import type {
CacheDetails,
CacheStats,
DataQuality,
HaeguStat,
MetricsSummary,
ProcessingDelay,
ThroughputMetrics,
} from './types.ts'
export const monitorApi = {
getDelay(): Promise<ProcessingDelay> {
@ -14,14 +22,26 @@ export const monitorApi = {
return fetchJson('/api/monitoring/cache/stats')
},
getCacheDetails(): Promise<CacheDetails> {
return fetchJson('/api/monitoring/cache/details')
},
getDailyCacheStatus(): Promise<Record<string, unknown>> {
return fetchJson('/api/websocket/daily-cache')
},
getThroughput(): Promise<Record<string, unknown>> {
getThroughput(): Promise<ThroughputMetrics> {
return fetchJson('/monitor/throughput')
},
getQuality(): Promise<DataQuality> {
return fetchJson('/monitor/quality')
},
getHaeguRealtimeStats(): Promise<HaeguStat[]> {
return fetchJson('/monitor/haegu/realtime')
},
getHaeguStats(): Promise<Record<string, unknown>[]> {
return fetchJson('/admin/haegu/stats')
},

파일 보기

@ -124,3 +124,70 @@ export interface RunningJob {
skipCount: number
}[]
}
/* Cache Details (계층별 상세) */
export interface CacheLayerStats {
size: number
maxSize: number
hitCount: number
missCount: number
hitRate: number
}
export interface DailyCacheStats {
status: string
enabled: boolean
retentionDays: number
maxMemoryGb: number
cachedDays: number
totalVessels: number
totalMemoryMb: number
days: unknown[]
}
export interface CacheDetails {
l1_fiveMin: CacheLayerStats
l2_hourly: CacheLayerStats
l3_daily: DailyCacheStats
aisTarget: CacheLayerStats & { ttlMinutes: number; estimatedSize: number }
latestPosition: CacheLayerStats & { ttlMinutes: number; estimatedSize: number }
}
/* Monitor — Haegu Realtime */
export interface HaeguStat {
haegu_no: number
haegu_name: string
active_tiles: number
current_vessels: number
avg_density: number
max_tile_vessels: number
last_update: string
center_lon: number | null
center_lat: number | null
}
/* Monitor — Throughput */
export interface PartitionSize {
tablename: string
size: string
size_bytes: number
}
export interface ThroughputMetrics {
avgVesselsPerMinute: number | null
avgVesselsPerHour: number | null
hourlyDetails: Record<string, unknown>[]
partitionSizes: PartitionSize[]
}
/* Monitor — Data Quality */
export interface DataQuality {
duplicateRecords: number
missingTiles: number
qualityScore: 'GOOD' | 'NEEDS_ATTENTION' | 'ERROR'
checkedAt: string
}

파일 보기

@ -0,0 +1,79 @@
import {
LineChart as RechartsLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts'
interface LineSeries {
dataKey: string
color: string
name?: string
}
interface LineChartProps {
data: Record<string, unknown>[]
series: LineSeries[]
xKey: string
height?: number
label?: string
}
export default function LineChart({
data,
series,
xKey,
height = 240,
label,
}: LineChartProps) {
return (
<div>
{label && <div className="mb-2 text-sm font-medium text-muted">{label}</div>}
<ResponsiveContainer width="100%" height={height}>
<RechartsLineChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--sb-border)" />
<XAxis
dataKey={xKey}
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
axisLine={{ stroke: 'var(--sb-border)' }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--sb-surface)',
border: '1px solid var(--sb-border)',
borderRadius: 'var(--sb-radius)',
fontSize: 12,
}}
/>
{series.length > 1 && (
<Legend
wrapperStyle={{ fontSize: 12, color: 'var(--sb-text-muted)' }}
/>
)}
{series.map(s => (
<Line
key={s.dataKey}
type="monotone"
dataKey={s.dataKey}
stroke={s.color}
name={s.name ?? s.dataKey}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
</div>
)
}

파일 보기

@ -0,0 +1,46 @@
interface StageInfo {
label: string
sublabel: string
count: number | string
status: 'active' | 'idle' | 'warning' | 'error'
}
interface PipelineChartProps {
stages: StageInfo[]
}
const STATUS_COLORS: Record<string, { bg: string; border: string; dot: string }> = {
active: { bg: 'bg-success/10', border: 'border-success', dot: 'bg-success' },
idle: { bg: 'bg-surface-hover', border: 'border-border', dot: 'bg-muted' },
warning: { bg: 'bg-warning/10', border: 'border-warning', dot: 'bg-warning' },
error: { bg: 'bg-danger/10', border: 'border-danger', dot: 'bg-danger' },
}
export default function PipelineChart({ stages }: PipelineChartProps) {
return (
<div className="flex items-center gap-1 overflow-x-auto py-2">
{stages.map((stage, i) => {
const colors = STATUS_COLORS[stage.status] ?? STATUS_COLORS.idle
return (
<div key={i} className="flex items-center">
{/* Stage Node */}
<div className={`flex min-w-[140px] flex-col items-center rounded-lg border-2 px-4 py-3 ${colors.bg} ${colors.border}`}>
<div className="flex items-center gap-1.5">
<span className={`inline-block h-2 w-2 rounded-full ${colors.dot}`} />
<span className="text-sm font-semibold">{stage.label}</span>
</div>
<span className="mt-0.5 text-xs text-muted">{stage.sublabel}</span>
<span className="mt-1 text-lg font-bold">{stage.count}</span>
</div>
{/* Arrow */}
{i < stages.length - 1 && (
<svg width="32" height="20" viewBox="0 0 32 20" className="shrink-0 text-muted">
<path d="M0 10h24M20 4l8 6-8 6" fill="none" stroke="currentColor" strokeWidth="2" />
</svg>
)}
</div>
)
})}
</div>
)
}

파일 보기

@ -69,6 +69,56 @@ const en = {
'jobs.commits': 'Commits',
'jobs.errors': 'Errors',
// Pipeline
'pipeline.title': 'Data Pipeline',
'pipeline.flowTitle': 'Processing Flow',
'pipeline.collect1min': '1min Collect',
'pipeline.aggregate5min': '5min Aggregate',
'pipeline.mergeHourly': 'Hourly Merge',
'pipeline.mergeDaily': 'Daily Merge',
'pipeline.executions': ' executions',
'pipeline.totalTime': 'total time',
'pipeline.processingDelay': 'Processing Delay',
'pipeline.delayMin': 'min delay',
'pipeline.aisLatest': 'AIS Latest',
'pipeline.processLatest': 'Process Latest',
'pipeline.cacheOverview': 'Cache Overview',
'pipeline.cachedDays': 'days cached',
'pipeline.totalHitRate': 'Total Hit Rate',
'pipeline.dailyThroughput': 'Daily Throughput Trend',
'pipeline.totalProcessed': 'Total Processed',
'pipeline.vesselJobs': 'Vessel Jobs',
'pipeline.trackJobs': 'Track Jobs',
'pipeline.recentJobs': 'Recent Executions',
'pipeline.totalExec': 'Total Executions',
'pipeline.totalRecords': 'Total Records',
'pipeline.avgDuration': 'Avg Duration',
'pipeline.successRate': 'success rate',
// Area Stats
'area.title': 'Area Statistics',
'area.activeHaegu': 'Active Areas',
'area.activeHaeguDesc': 'Areas with vessels',
'area.totalVessels': 'Total Vessels',
'area.dataQuality': 'Data Quality',
'area.avgDensity': 'Avg Density',
'area.haeguStats': 'Area Status',
'area.haeguNo': 'Area No.',
'area.haeguName': 'Area Name',
'area.activeTiles': 'Active Tiles',
'area.currentVessels': 'Vessels',
'area.avgDensityCol': 'Avg Density',
'area.maxTileVessels': 'Max Tile Vessels',
'area.lastUpdate': 'Last Update',
'area.throughput': 'Throughput',
'area.vesselsPerMin': 'vessels/min',
'area.vesselsPerHour': 'vessels/hour',
'area.partitions': 'Partition Sizes',
'area.dataQualityTitle': 'Data Quality Check',
'area.duplicates': 'Duplicates',
'area.missingTiles': 'Missing Tiles',
'area.checkedAt': 'Checked at',
// Time Range
'range.1d': '1D',
'range.3d': '3D',

파일 보기

@ -69,6 +69,56 @@ const ko = {
'jobs.commits': '커밋',
'jobs.errors': '에러',
// Pipeline
'pipeline.title': '데이터 파이프라인',
'pipeline.flowTitle': '처리 흐름',
'pipeline.collect1min': '1분 수집',
'pipeline.aggregate5min': '5분 집계',
'pipeline.mergeHourly': '시간 병합',
'pipeline.mergeDaily': '일 병합',
'pipeline.executions': '회 실행',
'pipeline.totalTime': '총 소요',
'pipeline.processingDelay': '처리 지연',
'pipeline.delayMin': '분 지연',
'pipeline.aisLatest': 'AIS 최신',
'pipeline.processLatest': '처리 최신',
'pipeline.cacheOverview': '캐시 현황',
'pipeline.cachedDays': '일 캐시',
'pipeline.totalHitRate': '전체 히트율',
'pipeline.dailyThroughput': '일별 처리량 추이',
'pipeline.totalProcessed': '총 처리',
'pipeline.vesselJobs': 'Vessel Job',
'pipeline.trackJobs': 'Track Job',
'pipeline.recentJobs': '최근 실행 이력',
'pipeline.totalExec': '총 실행',
'pipeline.totalRecords': '총 처리건수',
'pipeline.avgDuration': '평균 소요',
'pipeline.successRate': '성공률',
// Area Stats
'area.title': '해구/구역 통계',
'area.activeHaegu': '활성 해구',
'area.activeHaeguDesc': '선박이 있는 해구',
'area.totalVessels': '총 선박수',
'area.dataQuality': '데이터 품질',
'area.avgDensity': '평균 밀도',
'area.haeguStats': '대해구별 현황',
'area.haeguNo': '해구번호',
'area.haeguName': '해구명',
'area.activeTiles': '활성 타일',
'area.currentVessels': '현재 선박',
'area.avgDensityCol': '평균 밀도',
'area.maxTileVessels': '최대 타일 선박',
'area.lastUpdate': '최종 갱신',
'area.throughput': '처리량',
'area.vesselsPerMin': '선박/분',
'area.vesselsPerHour': '선박/시간',
'area.partitions': '파티션 크기',
'area.dataQualityTitle': '데이터 품질 검증',
'area.duplicates': '중복 레코드',
'area.missingTiles': '누락 타일',
'area.checkedAt': '검증 시각',
// Time Range
'range.1d': '1일',
'range.3d': '3일',

파일 보기

@ -0,0 +1,167 @@
import { useState } from 'react'
import { usePoller } from '../hooks/usePoller.ts'
import { useI18n } from '../hooks/useI18n.ts'
import { monitorApi } from '../api/monitorApi.ts'
import type { ThroughputMetrics, DataQuality, HaeguStat } from '../api/types.ts'
import MetricCard from '../components/charts/MetricCard.tsx'
import StatusBadge from '../components/common/StatusBadge.tsx'
import { formatNumber, formatDateTime } from '../utils/formatters.ts'
const POLL_INTERVAL = 30_000
export default function AreaStats() {
const { t } = useI18n()
const [haegu, setHaegu] = useState<HaeguStat[]>([])
const [throughput, setThroughput] = useState<ThroughputMetrics | null>(null)
const [quality, setQuality] = useState<DataQuality | null>(null)
usePoller(async () => {
const [h, tp, q] = await Promise.allSettled([
monitorApi.getHaeguRealtimeStats(),
monitorApi.getThroughput(),
monitorApi.getQuality(),
])
if (h.status === 'fulfilled') setHaegu(h.value)
if (tp.status === 'fulfilled') setThroughput(tp.value)
if (q.status === 'fulfilled') setQuality(q.value)
}, POLL_INTERVAL)
return (
<div className="space-y-6 fade-in">
{/* Header */}
<h1 className="text-2xl font-bold">{t('area.title')}</h1>
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard
title={t('area.activeHaegu')}
value={haegu.length}
subtitle={t('area.activeHaeguDesc')}
/>
<MetricCard
title={t('area.totalVessels')}
value={formatNumber(haegu.reduce((sum, h) => sum + (h.current_vessels ?? 0), 0))}
/>
<MetricCard
title={t('area.dataQuality')}
value={quality?.qualityScore ?? '-'}
trend={quality?.qualityScore === 'GOOD' ? 'up' : quality?.qualityScore === 'NEEDS_ATTENTION' ? 'down' : 'neutral'}
/>
<MetricCard
title={t('area.avgDensity')}
value={haegu.length > 0
? (haegu.reduce((sum, h) => sum + (h.avg_density ?? 0), 0) / haegu.length).toFixed(2)
: '-'}
/>
</div>
{/* Haegu Stats Table */}
<div className="sb-card">
<div className="sb-card-header">{t('area.haeguStats')}</div>
{haegu.length === 0 ? (
<div className="py-8 text-center text-sm text-muted">{t('common.noData')}</div>
) : (
<div className="sb-table-wrapper">
<table className="sb-table">
<thead>
<tr>
<th>{t('area.haeguNo')}</th>
<th>{t('area.haeguName')}</th>
<th className="text-right">{t('area.activeTiles')}</th>
<th className="text-right">{t('area.currentVessels')}</th>
<th className="text-right">{t('area.avgDensityCol')}</th>
<th className="text-right">{t('area.maxTileVessels')}</th>
<th>{t('area.lastUpdate')}</th>
</tr>
</thead>
<tbody>
{haegu.map(h => (
<tr key={h.haegu_no}>
<td className="font-mono">{h.haegu_no}</td>
<td>{h.haegu_name}</td>
<td className="text-right">{formatNumber(h.active_tiles)}</td>
<td className="text-right font-bold">{formatNumber(h.current_vessels)}</td>
<td className="text-right">{(h.avg_density ?? 0).toFixed(2)}</td>
<td className="text-right">{formatNumber(h.max_tile_vessels)}</td>
<td className="text-xs text-muted">{formatDateTime(h.last_update)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Throughput + Quality */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Throughput */}
<div className="sb-card">
<div className="sb-card-header">{t('area.throughput')}</div>
{throughput ? (
<div className="space-y-3">
{throughput.avgVesselsPerMinute != null && (
<div className="grid grid-cols-2 gap-3 text-center text-sm">
<div>
<div className="text-lg font-bold">{Math.round(throughput.avgVesselsPerMinute)}</div>
<div className="text-xs text-muted">{t('area.vesselsPerMin')}</div>
</div>
<div>
<div className="text-lg font-bold">{formatNumber(Math.round(throughput.avgVesselsPerHour ?? 0))}</div>
<div className="text-xs text-muted">{t('area.vesselsPerHour')}</div>
</div>
</div>
)}
{/* Partition Sizes */}
{throughput.partitionSizes && throughput.partitionSizes.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-muted">{t('area.partitions')}</div>
<div className="space-y-1">
{throughput.partitionSizes.map((p, i) => (
<div key={i} className="flex items-center justify-between rounded bg-surface-hover px-3 py-1.5 text-sm">
<span className="font-mono text-xs">{p.tablename}</span>
<span className="font-medium">{p.size}</span>
</div>
))}
</div>
</div>
)}
{(!throughput.avgVesselsPerMinute && (!throughput.partitionSizes || throughput.partitionSizes.length === 0)) && (
<div className="py-4 text-center text-sm text-muted">{t('common.noData')}</div>
)}
</div>
) : (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
)}
</div>
{/* Data Quality */}
<div className="sb-card">
<div className="sb-card-header">{t('area.dataQualityTitle')}</div>
{quality ? (
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<StatusBadge status={quality.qualityScore === 'GOOD' ? 'COMPLETED' : quality.qualityScore === 'ERROR' ? 'FAILED' : 'STOPPED'} />
<span className="text-lg font-bold">{quality.qualityScore}</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg bg-surface-hover p-3">
<div className="text-xs text-muted">{t('area.duplicates')}</div>
<div className="text-lg font-bold">{formatNumber(quality.duplicateRecords)}</div>
</div>
<div className="rounded-lg bg-surface-hover p-3">
<div className="text-xs text-muted">{t('area.missingTiles')}</div>
<div className="text-lg font-bold">{formatNumber(quality.missingTiles)}</div>
</div>
</div>
<div className="text-xs text-muted">
{t('area.checkedAt')}: {formatDateTime(quality.checkedAt)}
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
)}
</div>
</div>
</div>
)
}

파일 보기

@ -0,0 +1,257 @@
import { useState } from 'react'
import { usePoller } from '../hooks/usePoller.ts'
import { useI18n } from '../hooks/useI18n.ts'
import { batchApi } from '../api/batchApi.ts'
import { monitorApi } from '../api/monitorApi.ts'
import type {
BatchStatistics,
CacheStats,
DailyStats,
JobExecution,
ProcessingDelay,
} from '../api/types.ts'
import type { CacheDetails } from '../api/types.ts'
import PipelineChart from '../components/charts/PipelineChart.tsx'
import LineChart from '../components/charts/LineChart.tsx'
import MetricCard from '../components/charts/MetricCard.tsx'
import StatusBadge from '../components/common/StatusBadge.tsx'
import { formatNumber, formatDuration, formatDateTime, formatPercent } from '../utils/formatters.ts'
const POLL_INTERVAL = 30_000
const JOB_DISPLAY: Record<string, string> = {
aisTargetImportJob: 'AIS 수집 (1분)',
vesselTrackAggregationJob: 'Track (5분)',
hourlyAggregationJob: 'Hourly (1시간)',
dailyAggregationJob: 'Daily (1일)',
}
export default function DataPipeline() {
const { t } = useI18n()
const [stats, setStats] = useState<BatchStatistics | null>(null)
const [daily, setDaily] = useState<DailyStats | null>(null)
const [delay, setDelay] = useState<ProcessingDelay | null>(null)
const [cache, setCache] = useState<CacheStats | null>(null)
const [cacheDetails, setCacheDetails] = useState<CacheDetails | null>(null)
const [recentJobs, setRecentJobs] = useState<JobExecution[]>([])
usePoller(async () => {
const [s, d, dl, c, cd, rj] = await Promise.allSettled([
batchApi.getStatistics(7),
batchApi.getDailyStats(),
monitorApi.getDelay(),
monitorApi.getCacheStats(),
monitorApi.getCacheDetails(),
batchApi.getJobHistory(undefined, 20),
])
if (s.status === 'fulfilled') setStats(s.value)
if (d.status === 'fulfilled') setDaily(d.value)
if (dl.status === 'fulfilled') setDelay(dl.value)
if (c.status === 'fulfilled') setCache(c.value)
if (cd.status === 'fulfilled') setCacheDetails(cd.value)
if (rj.status === 'fulfilled') setRecentJobs(rj.value)
}, POLL_INTERVAL)
const jobCounts = stats?.byJob.executionCounts ?? {}
const jobTimes = stats?.byJob.processingTimes ?? {}
const getStageStatus = (jobName: string) => {
const recent = recentJobs.find(j => j.jobName === jobName)
if (!recent) return 'idle' as const
if (recent.status === 'STARTED' || recent.status === 'STARTING') return 'active' as const
if (recent.status === 'FAILED') return 'error' as const
return 'active' as const
}
const stages = [
{
label: 'AIS API',
sublabel: t('pipeline.collect1min'),
count: formatNumber(jobCounts['aisTargetImportJob'] ?? 0),
status: getStageStatus('aisTargetImportJob'),
},
{
label: 'Track 5min',
sublabel: t('pipeline.aggregate5min'),
count: formatNumber(jobCounts['vesselTrackAggregationJob'] ?? 0),
status: getStageStatus('vesselTrackAggregationJob'),
},
{
label: 'Hourly',
sublabel: t('pipeline.mergeHourly'),
count: formatNumber(jobCounts['hourlyAggregationJob'] ?? 0),
status: getStageStatus('hourlyAggregationJob'),
},
{
label: 'Daily',
sublabel: t('pipeline.mergeDaily'),
count: formatNumber(jobCounts['dailyAggregationJob'] ?? 0),
status: getStageStatus('dailyAggregationJob'),
},
]
/* 일별 처리량 라인 차트 데이터 */
const dailyChartData = daily?.dailyStats.map(d => ({
date: d.date.slice(5),
processed: d.totalProcessed,
vessel: d.vesselJobs,
track: d.trackJobs,
})) ?? []
return (
<div className="space-y-6 fade-in">
{/* Header */}
<h1 className="text-2xl font-bold">{t('pipeline.title')}</h1>
{/* Pipeline Flow */}
<div className="sb-card">
<div className="sb-card-header">{t('pipeline.flowTitle')}</div>
<PipelineChart stages={stages} />
</div>
{/* Stage Metric Cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{Object.entries(JOB_DISPLAY).map(([jobName, label]) => (
<MetricCard
key={jobName}
title={label}
value={`${jobCounts[jobName] ?? 0}${t('pipeline.executions')}`}
subtitle={`${formatDuration(jobTimes[jobName] ?? 0)} ${t('pipeline.totalTime')}`}
/>
))}
</div>
{/* Processing Delay + Cache Status */}
<div className="grid gap-4 lg:grid-cols-2">
<div className="sb-card">
<div className="sb-card-header">{t('pipeline.processingDelay')}</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('pipeline.delayMin')}</span>
<StatusBadge status={delay.status ?? 'NORMAL'} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted">{t('pipeline.aisLatest')}</span>
<div className="font-mono text-xs">{formatDateTime(delay.aisLatestTime)}</div>
</div>
<div>
<span className="text-muted">{t('pipeline.processLatest')}</span>
<div className="font-mono text-xs">{formatDateTime(delay.queryLatestTime)}</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('pipeline.cacheOverview')}</div>
{cacheDetails ? (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3 text-center text-sm">
<div>
<div className="text-xs text-muted">L1 (5min)</div>
<div className="text-lg font-bold">{formatNumber(cacheDetails.l1_fiveMin?.size)}</div>
<div className="text-xs text-muted">/ {formatNumber(cacheDetails.l1_fiveMin?.maxSize)}</div>
</div>
<div>
<div className="text-xs text-muted">L2 (Hourly)</div>
<div className="text-lg font-bold">{formatNumber(cacheDetails.l2_hourly?.size)}</div>
<div className="text-xs text-muted">/ {formatNumber(cacheDetails.l2_hourly?.maxSize)}</div>
</div>
<div>
<div className="text-xs text-muted">L3 (Daily)</div>
<div className="text-lg font-bold">{cacheDetails.l3_daily?.cachedDays ?? 0}</div>
<div className="text-xs text-muted">{t('pipeline.cachedDays')}</div>
</div>
</div>
{cache && (
<div className="flex items-center justify-between rounded-lg bg-surface-hover px-3 py-2 text-sm">
<span className="text-muted">{t('pipeline.totalHitRate')}</span>
<div className="flex items-center gap-2">
<span className="font-bold">{cache.hitRate}</span>
<StatusBadge status={cache.status} />
</div>
</div>
)}
</div>
) : (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
)}
</div>
</div>
{/* Daily Throughput Timeline */}
{dailyChartData.length > 0 && (
<div className="sb-card">
<div className="sb-card-header">{t('pipeline.dailyThroughput')}</div>
<LineChart
data={dailyChartData}
series={[
{ dataKey: 'processed', color: 'var(--sb-primary)', name: t('pipeline.totalProcessed') },
{ dataKey: 'vessel', color: 'var(--sb-success)', name: t('pipeline.vesselJobs') },
{ dataKey: 'track', color: 'var(--sb-info)', name: t('pipeline.trackJobs') },
]}
xKey="date"
height={280}
/>
</div>
)}
{/* Recent Job Executions */}
<div className="sb-card">
<div className="sb-card-header">{t('pipeline.recentJobs')}</div>
<div className="space-y-1">
{recentJobs.length === 0 ? (
<div className="py-4 text-center text-sm text-muted">{t('common.noData')}</div>
) : (
recentJobs.slice(0, 10).map(job => (
<div key={job.executionId} className="flex items-center justify-between rounded-lg bg-surface-hover px-3 py-2">
<div className="flex items-center gap-3">
<StatusBadge status={job.status} />
<div>
<div className="text-sm font-medium">{JOB_DISPLAY[job.jobName] ?? job.jobName}</div>
<div className="text-xs text-muted">#{job.executionId} &middot; {formatDateTime(job.startTime)}</div>
</div>
</div>
<div className="text-right text-sm">
<div>{formatDuration(job.durationSeconds)}</div>
<div className="text-xs text-muted">
R:{formatNumber(job.totalRead)} W:{formatNumber(job.totalWrite)}
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Summary Stats */}
{stats && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard
title={t('pipeline.totalExec')}
value={formatNumber(stats.summary.totalExecutions)}
subtitle={`${formatPercent(stats.summary.successRate)} ${t('pipeline.successRate')}`}
/>
<MetricCard
title={t('pipeline.totalRecords')}
value={formatNumber(stats.summary.totalRecordsProcessed)}
/>
<MetricCard
title={t('pipeline.avgDuration')}
value={formatDuration(stats.summary.avgProcessingTimeSeconds)}
/>
<MetricCard
title={t('pipeline.totalTime')}
value={formatDuration(stats.summary.totalProcessingTimeSeconds)}
/>
</div>
)}
</div>
)
}