signal-batch/frontend/src/pages/AreaStats.tsx
htlee 986ae7bc14 fix: MonitoringController 레거시 타일 쿼리 → AIS 위치/항적 기반 전환
- /delay: t_tile_summary → t_vessel_tracks_5min 기반 처리 지연 계산
- /haegu/realtime: t_tile_summary JOIN → t_ais_position + t_haegu_definitions 공간 조인
- /throughput: 타일 처리량 → 5분 항적 처리량 + vessel_tracks 테이블 크기
- /quality: 타일 중복/누락 → 항적 중복 + AIS 위치 갱신 지연 검증
- 프론트엔드 타입/라벨 동기화 (HaeguStat, DataQuality, ProcessingDelay)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:30:16 +09:00

164 lines
7.0 KiB
TypeScript

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">
<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.currentVessels')}</th>
<th className="text-right">{t('area.avgSpeed')}</th>
<th className="text-right">{t('area.avgDensityCol')}</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 font-bold">{formatNumber(h.current_vessels)}</td>
<td className="text-right">{(h.avg_speed ?? 0).toFixed(1)} kn</td>
<td className="text-right">{(h.avg_density ?? 0).toFixed(4)}</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>
)}
{throughput.partitionSizes && throughput.partitionSizes.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-muted">{t('area.tableSizes')}</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.stalePositions')}</div>
<div className="text-lg font-bold">{formatNumber(quality.stalePositions)}</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>
)
}