diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7151cc8..44ec5c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { }> }>} /> }>} /> - {/* Phase 2+ 페이지 추가 예정 */} + }>} /> + }>} /> + {/* Phase 3+ 페이지 추가 예정 */} diff --git a/frontend/src/api/monitorApi.ts b/frontend/src/api/monitorApi.ts index 87afb81..e487b51 100644 --- a/frontend/src/api/monitorApi.ts +++ b/frontend/src/api/monitorApi.ts @@ -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 { @@ -14,14 +22,26 @@ export const monitorApi = { return fetchJson('/api/monitoring/cache/stats') }, + getCacheDetails(): Promise { + return fetchJson('/api/monitoring/cache/details') + }, + getDailyCacheStatus(): Promise> { return fetchJson('/api/websocket/daily-cache') }, - getThroughput(): Promise> { + getThroughput(): Promise { return fetchJson('/monitor/throughput') }, + getQuality(): Promise { + return fetchJson('/monitor/quality') + }, + + getHaeguRealtimeStats(): Promise { + return fetchJson('/monitor/haegu/realtime') + }, + getHaeguStats(): Promise[]> { return fetchJson('/admin/haegu/stats') }, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 81c5d2d..3521754 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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[] + partitionSizes: PartitionSize[] +} + +/* Monitor — Data Quality */ + +export interface DataQuality { + duplicateRecords: number + missingTiles: number + qualityScore: 'GOOD' | 'NEEDS_ATTENTION' | 'ERROR' + checkedAt: string +} diff --git a/frontend/src/components/charts/LineChart.tsx b/frontend/src/components/charts/LineChart.tsx new file mode 100644 index 0000000..759550d --- /dev/null +++ b/frontend/src/components/charts/LineChart.tsx @@ -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[] + series: LineSeries[] + xKey: string + height?: number + label?: string +} + +export default function LineChart({ + data, + series, + xKey, + height = 240, + label, +}: LineChartProps) { + return ( +
+ {label &&
{label}
} + + + + + + + {series.length > 1 && ( + + )} + {series.map(s => ( + + ))} + + +
+ ) +} diff --git a/frontend/src/components/charts/PipelineChart.tsx b/frontend/src/components/charts/PipelineChart.tsx new file mode 100644 index 0000000..b82e9ca --- /dev/null +++ b/frontend/src/components/charts/PipelineChart.tsx @@ -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 = { + 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 ( +
+ {stages.map((stage, i) => { + const colors = STATUS_COLORS[stage.status] ?? STATUS_COLORS.idle + return ( +
+ {/* Stage Node */} +
+
+ + {stage.label} +
+ {stage.sublabel} + {stage.count} +
+ {/* Arrow */} + {i < stages.length - 1 && ( + + + + )} +
+ ) + })} +
+ ) +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 1ecdfd6..99d5ccd 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -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', diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts index 5d8dcc7..2eb6d7c 100644 --- a/frontend/src/i18n/ko.ts +++ b/frontend/src/i18n/ko.ts @@ -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일', diff --git a/frontend/src/pages/AreaStats.tsx b/frontend/src/pages/AreaStats.tsx new file mode 100644 index 0000000..c2b3dfa --- /dev/null +++ b/frontend/src/pages/AreaStats.tsx @@ -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([]) + const [throughput, setThroughput] = useState(null) + const [quality, setQuality] = useState(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 ( +
+ {/* Header */} +

{t('area.title')}

+ + {/* Summary Cards */} +
+ + sum + (h.current_vessels ?? 0), 0))} + /> + + 0 + ? (haegu.reduce((sum, h) => sum + (h.avg_density ?? 0), 0) / haegu.length).toFixed(2) + : '-'} + /> +
+ + {/* Haegu Stats Table */} +
+
{t('area.haeguStats')}
+ {haegu.length === 0 ? ( +
{t('common.noData')}
+ ) : ( +
+ + + + + + + + + + + + + + {haegu.map(h => ( + + + + + + + + + + ))} + +
{t('area.haeguNo')}{t('area.haeguName')}{t('area.activeTiles')}{t('area.currentVessels')}{t('area.avgDensityCol')}{t('area.maxTileVessels')}{t('area.lastUpdate')}
{h.haegu_no}{h.haegu_name}{formatNumber(h.active_tiles)}{formatNumber(h.current_vessels)}{(h.avg_density ?? 0).toFixed(2)}{formatNumber(h.max_tile_vessels)}{formatDateTime(h.last_update)}
+
+ )} +
+ + {/* Throughput + Quality */} +
+ {/* Throughput */} +
+
{t('area.throughput')}
+ {throughput ? ( +
+ {throughput.avgVesselsPerMinute != null && ( +
+
+
{Math.round(throughput.avgVesselsPerMinute)}
+
{t('area.vesselsPerMin')}
+
+
+
{formatNumber(Math.round(throughput.avgVesselsPerHour ?? 0))}
+
{t('area.vesselsPerHour')}
+
+
+ )} + {/* Partition Sizes */} + {throughput.partitionSizes && throughput.partitionSizes.length > 0 && ( +
+
{t('area.partitions')}
+
+ {throughput.partitionSizes.map((p, i) => ( +
+ {p.tablename} + {p.size} +
+ ))} +
+
+ )} + {(!throughput.avgVesselsPerMinute && (!throughput.partitionSizes || throughput.partitionSizes.length === 0)) && ( +
{t('common.noData')}
+ )} +
+ ) : ( +
{t('common.loading')}
+ )} +
+ + {/* Data Quality */} +
+
{t('area.dataQualityTitle')}
+ {quality ? ( +
+
+ + {quality.qualityScore} +
+
+
+
{t('area.duplicates')}
+
{formatNumber(quality.duplicateRecords)}
+
+
+
{t('area.missingTiles')}
+
{formatNumber(quality.missingTiles)}
+
+
+
+ {t('area.checkedAt')}: {formatDateTime(quality.checkedAt)} +
+
+ ) : ( +
{t('common.loading')}
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/DataPipeline.tsx b/frontend/src/pages/DataPipeline.tsx new file mode 100644 index 0000000..6ce4c2b --- /dev/null +++ b/frontend/src/pages/DataPipeline.tsx @@ -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 = { + aisTargetImportJob: 'AIS 수집 (1분)', + vesselTrackAggregationJob: 'Track (5분)', + hourlyAggregationJob: 'Hourly (1시간)', + dailyAggregationJob: 'Daily (1일)', +} + +export default function DataPipeline() { + const { t } = useI18n() + const [stats, setStats] = useState(null) + const [daily, setDaily] = useState(null) + const [delay, setDelay] = useState(null) + const [cache, setCache] = useState(null) + const [cacheDetails, setCacheDetails] = useState(null) + const [recentJobs, setRecentJobs] = useState([]) + + 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 ( +
+ {/* Header */} +

{t('pipeline.title')}

+ + {/* Pipeline Flow */} +
+
{t('pipeline.flowTitle')}
+ +
+ + {/* Stage Metric Cards */} +
+ {Object.entries(JOB_DISPLAY).map(([jobName, label]) => ( + + ))} +
+ + {/* Processing Delay + Cache Status */} +
+
+
{t('pipeline.processingDelay')}
+ {delay ? ( +
+
+ {delay.delayMinutes ?? 0} + {t('pipeline.delayMin')} + +
+
+
+ {t('pipeline.aisLatest')} +
{formatDateTime(delay.aisLatestTime)}
+
+
+ {t('pipeline.processLatest')} +
{formatDateTime(delay.queryLatestTime)}
+
+
+
+ ) : ( +
{t('common.loading')}
+ )} +
+ +
+
{t('pipeline.cacheOverview')}
+ {cacheDetails ? ( +
+
+
+
L1 (5min)
+
{formatNumber(cacheDetails.l1_fiveMin?.size)}
+
/ {formatNumber(cacheDetails.l1_fiveMin?.maxSize)}
+
+
+
L2 (Hourly)
+
{formatNumber(cacheDetails.l2_hourly?.size)}
+
/ {formatNumber(cacheDetails.l2_hourly?.maxSize)}
+
+
+
L3 (Daily)
+
{cacheDetails.l3_daily?.cachedDays ?? 0}
+
{t('pipeline.cachedDays')}
+
+
+ {cache && ( +
+ {t('pipeline.totalHitRate')} +
+ {cache.hitRate} + +
+
+ )} +
+ ) : ( +
{t('common.loading')}
+ )} +
+
+ + {/* Daily Throughput Timeline */} + {dailyChartData.length > 0 && ( +
+
{t('pipeline.dailyThroughput')}
+ +
+ )} + + {/* Recent Job Executions */} +
+
{t('pipeline.recentJobs')}
+
+ {recentJobs.length === 0 ? ( +
{t('common.noData')}
+ ) : ( + recentJobs.slice(0, 10).map(job => ( +
+
+ +
+
{JOB_DISPLAY[job.jobName] ?? job.jobName}
+
#{job.executionId} · {formatDateTime(job.startTime)}
+
+
+
+
{formatDuration(job.durationSeconds)}
+
+ R:{formatNumber(job.totalRead)} W:{formatNumber(job.totalWrite)} +
+
+
+ )) + )} +
+
+ + {/* Summary Stats */} + {stats && ( +
+ + + + +
+ )} +
+ ) +}