- 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>
258 lines
10 KiB
TypeScript
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} · {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>
|
|
)
|
|
}
|