signal-batch/frontend/src/pages/DataPipeline.tsx
htlee 0cdb46d063 perf: API 응답 최적화 + 점진적 렌더링 + 해구 choropleth 지도
백엔드:
- 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>
2026-02-19 20:24:28 +09:00

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