- /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>
164 lines
7.0 KiB
TypeScript
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>
|
|
)
|
|
}
|