From 986ae7bc14f06df22008229e9c6f45d66e44f6e1 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Feb 2026 18:30:16 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20MonitoringController=20=EB=A0=88?= =?UTF-8?q?=EA=B1=B0=EC=8B=9C=20=ED=83=80=EC=9D=BC=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=E2=86=92=20AIS=20=EC=9C=84=EC=B9=98/=ED=95=AD=EC=A0=81=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- frontend/src/api/types.ts | 7 +- frontend/src/i18n/en.ts | 11 +- frontend/src/i18n/ko.ts | 11 +- frontend/src/pages/AreaStats.tsx | 16 +- frontend/src/pages/Dashboard.tsx | 4 +- .../controller/MonitoringController.java | 185 ++++++++---------- 6 files changed, 105 insertions(+), 129 deletions(-) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3521754..cd6a57a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -75,7 +75,7 @@ export interface ProcessingDelay { aisLatestTime: string queryLatestTime: string recentAisCount: number - processedTiles: number + processedVessels: number } export interface MetricsSummary { @@ -159,10 +159,9 @@ export interface CacheDetails { export interface HaeguStat { haegu_no: number haegu_name: string - active_tiles: number current_vessels: number + avg_speed: number avg_density: number - max_tile_vessels: number last_update: string center_lon: number | null center_lat: number | null @@ -187,7 +186,7 @@ export interface ThroughputMetrics { export interface DataQuality { duplicateRecords: number - missingTiles: number + stalePositions: number qualityScore: 'GOOD' | 'NEEDS_ATTENTION' | 'ERROR' checkedAt: string } diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 99d5ccd..70c9d86 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -37,7 +37,7 @@ const en = { 'dashboard.aisLatest': 'AIS Latest', 'dashboard.processLatest': 'Process Latest', 'dashboard.aisReceived': 'AIS Received', - 'dashboard.tileProcessed': 'Tiles Processed', + 'dashboard.vesselsProcessed': 'Vessels Processed', 'dashboard.systemMetrics': 'System Metrics', 'dashboard.memory': 'Memory', 'dashboard.threads': 'Threads', @@ -105,18 +105,17 @@ const en = { 'area.haeguStats': 'Area Status', 'area.haeguNo': 'Area No.', 'area.haeguName': 'Area Name', - 'area.activeTiles': 'Active Tiles', 'area.currentVessels': 'Vessels', + 'area.avgSpeed': 'Avg Speed', '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.tableSizes': 'Table Sizes', 'area.dataQualityTitle': 'Data Quality Check', - 'area.duplicates': 'Duplicates', - 'area.missingTiles': 'Missing Tiles', + 'area.duplicates': 'Duplicate Tracks', + 'area.stalePositions': 'Stale Positions', 'area.checkedAt': 'Checked at', // Time Range diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts index 2eb6d7c..66f4e87 100644 --- a/frontend/src/i18n/ko.ts +++ b/frontend/src/i18n/ko.ts @@ -37,7 +37,7 @@ const ko = { 'dashboard.aisLatest': 'AIS 최신', 'dashboard.processLatest': '처리 최신', 'dashboard.aisReceived': 'AIS 수신', - 'dashboard.tileProcessed': '타일 처리', + 'dashboard.vesselsProcessed': '선박 집계', 'dashboard.systemMetrics': '시스템 메트릭', 'dashboard.memory': '메모리', 'dashboard.threads': '스레드', @@ -105,18 +105,17 @@ const ko = { 'area.haeguStats': '대해구별 현황', 'area.haeguNo': '해구번호', 'area.haeguName': '해구명', - 'area.activeTiles': '활성 타일', 'area.currentVessels': '현재 선박', + 'area.avgSpeed': '평균 속력', 'area.avgDensityCol': '평균 밀도', - 'area.maxTileVessels': '최대 타일 선박', 'area.lastUpdate': '최종 갱신', 'area.throughput': '처리량', 'area.vesselsPerMin': '선박/분', 'area.vesselsPerHour': '선박/시간', - 'area.partitions': '파티션 크기', + 'area.tableSizes': '테이블 크기', 'area.dataQualityTitle': '데이터 품질 검증', - 'area.duplicates': '중복 레코드', - 'area.missingTiles': '누락 타일', + 'area.duplicates': '중복 항적', + 'area.stalePositions': '갱신 지연 위치', 'area.checkedAt': '검증 시각', // Time Range diff --git a/frontend/src/pages/AreaStats.tsx b/frontend/src/pages/AreaStats.tsx index c2b3dfa..bc64e4f 100644 --- a/frontend/src/pages/AreaStats.tsx +++ b/frontend/src/pages/AreaStats.tsx @@ -28,7 +28,6 @@ export default function AreaStats() { return (
- {/* Header */}

{t('area.title')}

{/* Summary Cards */} @@ -67,10 +66,9 @@ export default function AreaStats() { {t('area.haeguNo')} {t('area.haeguName')} - {t('area.activeTiles')} {t('area.currentVessels')} + {t('area.avgSpeed')} {t('area.avgDensityCol')} - {t('area.maxTileVessels')} {t('area.lastUpdate')} @@ -79,10 +77,9 @@ export default function AreaStats() { {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)} + {(h.avg_speed ?? 0).toFixed(1)} kn + {(h.avg_density ?? 0).toFixed(4)} {formatDateTime(h.last_update)} ))} @@ -111,10 +108,9 @@ export default function AreaStats() {
)} - {/* Partition Sizes */} {throughput.partitionSizes && throughput.partitionSizes.length > 0 && (
-
{t('area.partitions')}
+
{t('area.tableSizes')}
{throughput.partitionSizes.map((p, i) => (
@@ -149,8 +145,8 @@ export default function AreaStats() {
{formatNumber(quality.duplicateRecords)}
-
{t('area.missingTiles')}
-
{formatNumber(quality.missingTiles)}
+
{t('area.stalePositions')}
+
{formatNumber(quality.stalePositions)}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f8b353a..21a1b03 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -125,8 +125,8 @@ export default function Dashboard() {
{formatNumber(delay.recentAisCount)}{t('common.items')}
- {t('dashboard.tileProcessed')} -
{formatNumber(delay.processedTiles)}{t('common.items')}
+ {t('dashboard.vesselsProcessed')} +
{formatNumber(delay.processedVessels)}{t('common.items')}
diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/MonitoringController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/MonitoringController.java index 6493d77..cdf2bc6 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/MonitoringController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/MonitoringController.java @@ -22,15 +22,14 @@ public class MonitoringController { private final JdbcTemplate queryJdbcTemplate; /** - * 데이터 처리 지연 상태 확인 + * 데이터 처리 지연 상태 확인 (AIS 수집 vs 5분 집계 간 지연) */ @GetMapping("/delay") - @Operation(summary = "데이터 처리 지연 상태", description = "수집DB와 조회DB 간의 데이터 처리 지연 시간을 확인합니다") + @Operation(summary = "데이터 처리 지연 상태", description = "AIS 수집 시점과 5분 집계 시점 간의 처리 지연을 확인합니다") public Map getProcessingDelay() { Map result = new HashMap<>(); - + try { - // AIS 최신 위치 데이터 (캐시 스냅샷) Map aisLatest = queryJdbcTemplate.queryForMap( """ SELECT @@ -41,18 +40,23 @@ public class MonitoringController { """ ); - // 집계 데이터의 최신 처리 시간 Map queryLatest = queryJdbcTemplate.queryForMap( """ SELECT MAX(time_bucket) as latest_processed_time, - COUNT(DISTINCT tile_id) as processed_tiles - FROM signal.t_tile_summary + COUNT(DISTINCT mmsi) as processed_vessels + FROM signal.t_vessel_tracks_5min WHERE time_bucket > NOW() - INTERVAL '10 minutes' """ ); - LocalDateTime aisTime = (LocalDateTime) aisLatest.get("latest_update_time"); + Object aisRaw = aisLatest.get("latest_update_time"); + LocalDateTime aisTime = null; + if (aisRaw instanceof java.time.OffsetDateTime odt) { + aisTime = odt.toLocalDateTime(); + } else if (aisRaw instanceof LocalDateTime ldt) { + aisTime = ldt; + } LocalDateTime queryTime = (LocalDateTime) queryLatest.get("latest_processed_time"); long delayMinutes = 0; @@ -62,7 +66,7 @@ public class MonitoringController { delayMinutes = java.time.Duration.between(queryTime, aisTime).toMinutes(); delayStatus = delayMinutes < 10 ? "NORMAL" : delayMinutes < 30 ? "WARNING" : "CRITICAL"; } else if (aisTime == null && queryTime == null) { - delayStatus = "NORMAL"; // 데이터 없음 (수집 전 정상 상태) + delayStatus = "NORMAL"; } else { delayStatus = "WARNING"; } @@ -72,57 +76,48 @@ public class MonitoringController { result.put("aisLatestTime", aisTime); result.put("queryLatestTime", queryTime); result.put("recentAisCount", aisLatest.get("recent_count")); - result.put("processedTiles", queryLatest.get("processed_tiles")); - + result.put("processedVessels", queryLatest.get("processed_vessels")); + } catch (Exception e) { log.error("Failed to get processing delay", e); result.put("error", e.getMessage()); result.put("status", "ERROR"); } - + return result; } /** - * 대해구별 실시간 처리 현황 + * 대해구별 실시간 선박 현황 (t_ais_position + t_haegu_definitions 공간 조인) */ @GetMapping("/haegu/realtime") - @Operation(summary = "대해구별 실시간 현황", description = "최신 타일 데이터 기준 대해구별 선박 통계를 조회합니다") + @Operation(summary = "대해구별 실시간 현황", description = "최신 AIS 위치 데이터 기준 대해구별 선박 통계를 조회합니다") public List> getRealtimeHaeguStatus() { String sql = """ - WITH recent_data AS ( - SELECT - g.haegu_no, - t.tile_id, - t.tile_level, - t.vessel_count, - t.vessel_density, - t.time_bucket, - public.ST_X(public.ST_Centroid(g.tile_geom))::numeric(10,6) as center_lon, - public.ST_Y(public.ST_Centroid(g.tile_geom))::numeric(10,6) as center_lat - FROM signal.t_tile_summary t - JOIN signal.t_grid_tiles g ON t.tile_id = g.tile_id AND t.tile_level = g.tile_level - WHERE t.time_bucket = (SELECT MAX(time_bucket) FROM signal.t_tile_summary) - ) - SELECT - haegu_no, - CONCAT('대해구 ', haegu_no) as haegu_name, - COUNT(DISTINCT tile_id) as active_tiles, - COALESCE(SUM(vessel_count), 0) as current_vessels, - COALESCE(AVG(vessel_density), 0) as avg_density, - COALESCE(MAX(vessel_count), 0) as max_tile_vessels, - MAX(time_bucket) as last_update, - -- 대해구 중심점 (tile_level=0인 경우만) - MAX(CASE WHEN tile_level = 0 THEN center_lon END) as center_lon, - MAX(CASE WHEN tile_level = 0 THEN center_lat END) as center_lat - FROM recent_data - WHERE tile_level = 0 - GROUP BY haegu_no - HAVING SUM(vessel_count) > 0 + SELECT + h.haegu_no, + CONCAT('대해구 ', h.haegu_no) as haegu_name, + COUNT(DISTINCT a.mmsi) as current_vessels, + ROUND(AVG(a.sog)::numeric, 1) as avg_speed, + ROUND(COUNT(DISTINCT a.mmsi)::numeric / + GREATEST((h.max_lat - h.min_lat) * (h.max_lon - h.min_lon) * 12321, 0.01), + 4) as avg_density, + MAX(a.last_update) as last_update, + h.center_lon, + h.center_lat + FROM signal.t_haegu_definitions h + JOIN signal.t_ais_position a + ON a.lat BETWEEN h.min_lat AND h.max_lat + AND a.lon BETWEEN h.min_lon AND h.max_lon + AND public.ST_Contains(h.geom, a.geom) + WHERE a.last_update > NOW() - INTERVAL '30 minutes' + GROUP BY h.haegu_no, h.min_lat, h.min_lon, h.max_lat, h.max_lon, + h.center_lon, h.center_lat, h.geom + HAVING COUNT(DISTINCT a.mmsi) > 0 ORDER BY current_vessels DESC LIMIT 50 """; - + try { return queryJdbcTemplate.queryForList(sql); } catch (Exception e) { @@ -132,122 +127,110 @@ public class MonitoringController { } /** - * 시스템 처리량 메트릭 + * 시스템 처리량 메트릭 (5분 항적 기반) */ @GetMapping("/throughput") - @Operation(summary = "시스템 처리량 메트릭", description = "최근 1시간 처리량 및 파티션 크기 정보를 조회합니다") + @Operation(summary = "시스템 처리량 메트릭", description = "최근 1시간 5분 집계 처리량 및 테이블 크기 정보를 조회합니다") public Map getThroughputMetrics() { Map metrics = new HashMap<>(); - + try { - // 최근 1시간 처리량 List> hourlyStats = queryJdbcTemplate.queryForList( """ - SELECT - DATE_TRUNC('minute', time_bucket) as minute, - COUNT(DISTINCT tile_id) as tiles_processed, - SUM(vessel_count) as vessels_processed - FROM signal.t_tile_summary + SELECT + time_bucket, + COUNT(DISTINCT mmsi) as vessels_processed, + COUNT(*) as tracks_count, + COALESCE(SUM(point_count), 0) as points_processed + FROM signal.t_vessel_tracks_5min WHERE time_bucket > NOW() - INTERVAL '1 hour' - GROUP BY DATE_TRUNC('minute', time_bucket) - ORDER BY minute DESC - LIMIT 60 + GROUP BY time_bucket + ORDER BY time_bucket DESC """ ); - - // 평균 처리량 계산 + if (!hourlyStats.isEmpty()) { long totalVessels = hourlyStats.stream() .mapToLong(m -> ((Number) m.get("vessels_processed")).longValue()) .sum(); - double avgVesselsPerMinute = totalVessels / (double) hourlyStats.size(); - - metrics.put("avgVesselsPerMinute", avgVesselsPerMinute); - metrics.put("avgVesselsPerHour", avgVesselsPerMinute * 60); + double avgVesselsPerBucket = totalVessels / (double) hourlyStats.size(); + + metrics.put("avgVesselsPerMinute", avgVesselsPerBucket / 5.0); + metrics.put("avgVesselsPerHour", avgVesselsPerBucket * 12); metrics.put("hourlyDetails", hourlyStats); } - - // 파티션별 크기 + List> partitionSizes = queryJdbcTemplate.queryForList( """ - SELECT + SELECT tablename, pg_size_pretty(pg_total_relation_size('signal.' || tablename)) as size, pg_total_relation_size('signal.' || tablename) as size_bytes FROM pg_tables WHERE schemaname = 'signal' - AND tablename LIKE 't_tile_summary_%' - AND tablename ~ '\\\\d{6}$' - ORDER BY tablename DESC - LIMIT 7 + AND tablename LIKE 't_vessel_tracks_%' + ORDER BY tablename """ ); - + metrics.put("partitionSizes", partitionSizes); - + } catch (Exception e) { log.error("Failed to get throughput metrics", e); metrics.put("error", e.getMessage()); } - + return metrics; } /** - * 데이터 품질 검증 + * 데이터 품질 검증 (5분 항적 중복 + AIS 위치 갱신 누락) */ @SuppressWarnings("null") @GetMapping("/quality") - @Operation(summary = "데이터 품질 검증", description = "중복 데이터 및 누락 타일을 확인하여 데이터 품질을 검증합니다") + @Operation(summary = "데이터 품질 검증", description = "5분 항적 중복 및 AIS 위치 갱신 누락을 확인하여 데이터 품질을 검증합니다") public Map checkDataQuality() { Map quality = new HashMap<>(); - + try { - // 중복 데이터 확인 Integer duplicates = queryJdbcTemplate.queryForObject( """ - SELECT COUNT(*) + SELECT COUNT(*) FROM ( - SELECT tile_id, time_bucket, COUNT(*) as cnt - FROM signal.t_tile_summary + SELECT mmsi, time_bucket, COUNT(*) as cnt + FROM signal.t_vessel_tracks_5min WHERE time_bucket > NOW() - INTERVAL '1 hour' - GROUP BY tile_id, time_bucket + GROUP BY mmsi, time_bucket HAVING COUNT(*) > 1 ) dup """, Integer.class ); - - // 누락된 타일 확인 - Integer missingTiles = queryJdbcTemplate.queryForObject( + + Integer stalePositions = queryJdbcTemplate.queryForObject( """ - WITH expected_tiles AS ( - SELECT DISTINCT tile_id FROM signal.t_grid_tiles - ), - recent_tiles AS ( - SELECT DISTINCT tile_id - FROM signal.t_tile_summary - WHERE time_bucket > NOW() - INTERVAL '10 minutes' - ) SELECT COUNT(*) - FROM expected_tiles e - LEFT JOIN recent_tiles r ON e.tile_id = r.tile_id - WHERE r.tile_id IS NULL + FROM signal.t_ais_position + WHERE last_update < NOW() - INTERVAL '30 minutes' """, Integer.class ); - - quality.put("duplicateRecords", duplicates != null ? duplicates : 0); - quality.put("missingTiles", missingTiles != null ? missingTiles : 0); - quality.put("qualityScore", duplicates == 0 && missingTiles < 100 ? "GOOD" : "NEEDS_ATTENTION"); + + int dupCount = duplicates != null ? duplicates : 0; + int staleCount = stalePositions != null ? stalePositions : 0; + + quality.put("duplicateRecords", dupCount); + quality.put("stalePositions", staleCount); + quality.put("qualityScore", + dupCount == 0 && staleCount < 1000 ? "GOOD" : "NEEDS_ATTENTION"); quality.put("checkedAt", LocalDateTime.now()); - + } catch (Exception e) { log.error("Failed to check data quality", e); quality.put("error", e.getMessage()); quality.put("qualityScore", "ERROR"); } - + return quality; } -} \ No newline at end of file +}