백엔드: - haegu/realtime: DB 공간 JOIN(12s) → 인메모리 캐시 순회(~50ms) - batch/statistics: N+1 JobExplorer(1.1s) → 단일 SQL 집계(~100ms) - batch/daily-stats: N+1×7일(9s) → 직접 SQL 2쿼리(~200ms) - throughput: pg_total_relation_size 매번 호출(1.4s) → Caffeine 5분 캐시 - quality: 풀스캔(0.6s) → 24시간 범위 제한 프론트엔드: - Promise.allSettled 차단 → 개별 .then() 점진적 렌더링 - useCachedState 훅: 페이지 전환 시 이전 데이터 즉시 표시 - AreaStats: 해구 폴리곤 choropleth 지도 + 선박수 범례 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
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,
|
|
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] = useCachedState<BatchStatistics | null>('pipe.stats', null)
|
|
const [daily, setDaily] = useCachedState<DailyStats | null>('pipe.daily', null)
|
|
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('pipe.delay', null)
|
|
const [cache, setCache] = useCachedState<CacheStats | null>('pipe.cache', null)
|
|
const [cacheDetails, setCacheDetails] = useCachedState<CacheDetails | null>('pipe.cacheDetail', null)
|
|
const [recentJobs, setRecentJobs] = useCachedState<JobExecution[]>('pipe.jobs', [])
|
|
|
|
usePoller(() => {
|
|
batchApi.getStatistics(7).then(setStats).catch(() => {})
|
|
batchApi.getDailyStats().then(setDaily).catch(() => {})
|
|
monitorApi.getDelay().then(setDelay).catch(() => {})
|
|
monitorApi.getCacheStats().then(setCache).catch(() => {})
|
|
monitorApi.getCacheDetails().then(setCacheDetails).catch(() => {})
|
|
batchApi.getJobHistory(undefined, 20).then(setRecentJobs).catch(() => {})
|
|
}, 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>
|
|
)
|
|
}
|