signal-batch/frontend/src/pages/DataPipeline.tsx
htlee 23e33fe4bb feat: Phase 2 — 데이터 파이프라인 + 해구 통계 페이지
- DataPipeline: 4단계 흐름도(PipelineChart), L1/L2/L3 캐시 현황, 일별 처리량 추이(LineChart), 최근 실행 이력
- AreaStats: 대해구별 선박 통계 테이블, 처리량(파티션 크기), 데이터 품질 검증
- LineChart, PipelineChart 차트 컴포넌트 신규
- API 타입 추가 (CacheDetails, HaeguStat, ThroughputMetrics, DataQuality)
- monitorApi에 getCacheDetails, getHaeguRealtimeStats, getQuality 추가
- i18n pipeline.*, area.* 번역 키 추가 (ko/en)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:55:52 +09:00

258 lines
10 KiB
TypeScript

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>
)
}