Merge pull request 'feat: Phase 2 — 데이터 파이프라인 + 해구 통계 페이지' (#14) from feature/dashboard-phase-1 into develop
This commit is contained in:
커밋
53e8c2eb02
@ -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
|
||||
}
|
||||
|
||||
79
frontend/src/components/charts/LineChart.tsx
Normal file
79
frontend/src/components/charts/LineChart.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
46
frontend/src/components/charts/PipelineChart.tsx
Normal file
46
frontend/src/components/charts/PipelineChart.tsx
Normal file
@ -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일',
|
||||
|
||||
167
frontend/src/pages/AreaStats.tsx
Normal file
167
frontend/src/pages/AreaStats.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
257
frontend/src/pages/DataPipeline.tsx
Normal file
257
frontend/src/pages/DataPipeline.tsx
Normal file
@ -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} · {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>
|
||||
)
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user