diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2a3839..7151cc8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,26 +1,56 @@ -import { lazy } from 'react' +import { lazy, Suspense, Component, type ReactNode } from 'react' import { BrowserRouter, Routes, Route } from 'react-router-dom' import { ThemeProvider } from './contexts/ThemeContext.tsx' import { I18nProvider } from './i18n/I18nContext.tsx' import AppLayout from './components/layout/AppLayout.tsx' +import LoadingSpinner from './components/common/LoadingSpinner.tsx' const Dashboard = lazy(() => import('./pages/Dashboard.tsx')) const JobMonitor = lazy(() => import('./pages/JobMonitor.tsx')) const BASE_URL = import.meta.env.VITE_BASE_URL || '/signal-batch' +/* React Error Boundary — 페이지 렌더링 에러 시 전체 앱 crash 방지 */ +class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + render() { + if (this.state.error) { + return ( +
+

Rendering Error

+

{this.state.error.message}

+ +
+ ) + } + return this.props.children + } +} + export default function App() { return ( - - }> - } /> - } /> - {/* Phase 2+ 페이지 추가 예정 */} - - + + + }> + }>} /> + }>} /> + {/* Phase 2+ 페이지 추가 예정 */} + + + diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c19fb78..f8b353a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -107,9 +107,9 @@ export default function Dashboard() { {delay ? (
- {delay.delayMinutes} + {delay.delayMinutes ?? 0} {t('dashboard.delayMin')} - +
@@ -166,7 +166,7 @@ export default function Dashboard() {
{t('dashboard.dbConn')}
-
{metrics.processing.recordsPerSecond.toFixed(0)}
+
{(metrics.processing?.recordsPerSecond ?? 0).toFixed(0)}
{t('dashboard.recordsSec')}
diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts index f7cc915..3ab8fc9 100644 --- a/frontend/src/utils/formatters.ts +++ b/frontend/src/utils/formatters.ts @@ -1,5 +1,6 @@ /** 숫자를 천 단위 콤마 포맷 */ -export function formatNumber(n: number): string { +export function formatNumber(n: number | null | undefined): string { + if (n == null) return '0' return n.toLocaleString('ko-KR') } diff --git a/src/main/java/gc/mda/signal_batch/batch/reader/FiveMinTrackCache.java b/src/main/java/gc/mda/signal_batch/batch/reader/FiveMinTrackCache.java index 769af5f..053739c 100644 --- a/src/main/java/gc/mda/signal_batch/batch/reader/FiveMinTrackCache.java +++ b/src/main/java/gc/mda/signal_batch/batch/reader/FiveMinTrackCache.java @@ -123,6 +123,17 @@ public class FiveMinTrackCache { stats.missCount()); } + public Map getStatsMap() { + var stats = cache.stats(); + return Map.of( + "size", cache.estimatedSize(), + "maxSize", (long) maxSize, + "hitCount", stats.hitCount(), + "missCount", stats.missCount(), + "hitRate", stats.hitRate() * 100 + ); + } + private String buildKey(String mmsi, LocalDateTime timeBucket) { return mmsi + "::" + timeBucket.format(KEY_FORMATTER); } diff --git a/src/main/java/gc/mda/signal_batch/batch/reader/HourlyTrackCache.java b/src/main/java/gc/mda/signal_batch/batch/reader/HourlyTrackCache.java index a41aae8..4731606 100644 --- a/src/main/java/gc/mda/signal_batch/batch/reader/HourlyTrackCache.java +++ b/src/main/java/gc/mda/signal_batch/batch/reader/HourlyTrackCache.java @@ -122,6 +122,17 @@ public class HourlyTrackCache { stats.missCount()); } + public Map getStatsMap() { + var stats = cache.stats(); + return Map.of( + "size", cache.estimatedSize(), + "maxSize", (long) maxSize, + "hitCount", stats.hitCount(), + "missCount", stats.missCount(), + "hitRate", stats.hitRate() * 100 + ); + } + private String buildKey(String mmsi, LocalDateTime timeBucket) { return mmsi + "::" + timeBucket.format(KEY_FORMATTER); } diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/CacheMonitoringController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/CacheMonitoringController.java index d9bab54..38a9b02 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/CacheMonitoringController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/CacheMonitoringController.java @@ -1,85 +1,128 @@ package gc.mda.signal_batch.monitoring.controller; +import gc.mda.signal_batch.batch.reader.AisTargetCacheManager; +import gc.mda.signal_batch.batch.reader.FiveMinTrackCache; +import gc.mda.signal_batch.batch.reader.HourlyTrackCache; import gc.mda.signal_batch.domain.vessel.service.VesselLatestPositionCache; +import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.HashMap; -import java.util.Map; +import java.util.*; /** - * 캐시 모니터링 컨트롤러 + * 다계층 캐시 모니터링 컨트롤러 + * + * L1(5분) + L2(시간) + L3(일일) + AIS Target + Latest Position 전체 캐시 통합 통계 * * 엔드포인트: - * - GET /api/monitoring/cache/stats : 캐시 통계 조회 - * - POST /api/monitoring/cache/clear : 캐시 강제 초기화 + * - GET /api/monitoring/cache/stats : 전체 캐시 집계 통계 (Dashboard용) + * - GET /api/monitoring/cache/details : 계층별 상세 통계 + * - GET /api/monitoring/cache/health : 캐시 헬스체크 */ @Slf4j @RestController @RequestMapping("/api/monitoring/cache") -@RequiredArgsConstructor -@Tag(name = "Cache Monitoring", description = "캐시 모니터링 API") -@ConditionalOnProperty(name = "vessel.cache.latest-position.enabled", havingValue = "true") +@Tag(name = "Cache Monitoring", description = "다계층 캐시 모니터링 API") public class CacheMonitoringController { @Autowired(required = false) - private VesselLatestPositionCache cache; + private FiveMinTrackCache fiveMinTrackCache; + + @Autowired(required = false) + private HourlyTrackCache hourlyTrackCache; + + @Autowired(required = false) + private DailyTrackCacheManager dailyTrackCacheManager; + + @Autowired(required = false) + private AisTargetCacheManager aisTargetCacheManager; + + @Autowired(required = false) + private VesselLatestPositionCache latestPositionCache; /** - * 캐시 통계 조회 + * 캐시 통계 조회 (Dashboard 표시용 — 전체 캐시 집계) */ @GetMapping("/stats") - @Operation(summary = "캐시 통계 조회", description = "Vessel Latest Position 캐시의 통계 정보를 조회합니다.") + @Operation(summary = "캐시 통계 조회", description = "L1/L2/L3 + AIS Target + Latest Position 전체 캐시의 집계 통계를 조회합니다") public ResponseEntity> getCacheStats() { - if (cache == null) { - return ResponseEntity.ok(Map.of( - "enabled", false, - "message", "Cache is not enabled" - )); - } - try { - VesselLatestPositionCache.CacheStats stats = cache.getStats(); + long totalSize = 0; + long totalHits = 0; + long totalMisses = 0; - Map response = new HashMap<>(); - response.put("enabled", true); - response.put("currentSize", stats.currentSize()); - response.put("estimatedSize", stats.estimatedSize()); - response.put("hitRate", String.format("%.2f%%", stats.hitRate())); - response.put("missRate", String.format("%.2f%%", stats.missRate())); - response.put("hitCount", stats.hitCount()); - response.put("missCount", stats.missCount()); - response.put("totalRequests", stats.hitCount() + stats.missCount()); - - // 효율성 평가 - double efficiency; - long totalRequests = stats.hitCount() + stats.missCount(); - if (totalRequests > 0) { - efficiency = (double) stats.hitCount() / totalRequests * 100; - } else { - efficiency = 0.0; + // L1 — FiveMinTrackCache + if (fiveMinTrackCache != null) { + Map l1 = fiveMinTrackCache.getStatsMap(); + totalSize += ((Number) l1.get("size")).longValue(); + totalHits += ((Number) l1.get("hitCount")).longValue(); + totalMisses += ((Number) l1.get("missCount")).longValue(); } - response.put("efficiency", String.format("%.2f%%", efficiency)); - // 상태 평가 + // L2 — HourlyTrackCache + if (hourlyTrackCache != null) { + Map l2 = hourlyTrackCache.getStatsMap(); + totalSize += ((Number) l2.get("size")).longValue(); + totalHits += ((Number) l2.get("hitCount")).longValue(); + totalMisses += ((Number) l2.get("missCount")).longValue(); + } + + // L3 — DailyTrackCacheManager + if (dailyTrackCacheManager != null) { + Map l3 = dailyTrackCacheManager.getCacheStatus(); + Object totalVessels = l3.get("totalVessels"); + if (totalVessels instanceof Number) { + totalSize += ((Number) totalVessels).longValue(); + } + } + + // AIS Target + if (aisTargetCacheManager != null) { + Map ais = aisTargetCacheManager.getStats(); + totalSize += ((Number) ais.getOrDefault("estimatedSize", 0L)).longValue(); + totalHits += ((Number) ais.getOrDefault("hitCount", 0L)).longValue(); + totalMisses += ((Number) ais.getOrDefault("missCount", 0L)).longValue(); + } + + // Latest Position + if (latestPositionCache != null) { + VesselLatestPositionCache.CacheStats lp = latestPositionCache.getStats(); + totalSize += lp.currentSize(); + totalHits += lp.hitCount(); + totalMisses += lp.missCount(); + } + + long totalRequests = totalHits + totalMisses; + double hitRateValue = totalRequests > 0 ? (double) totalHits / totalRequests * 100 : 0.0; + String status; - if (stats.currentSize() == 0) { + if (totalSize == 0) { status = "EMPTY"; - } else if (efficiency > 90) { + } else if (hitRateValue > 90) { status = "EXCELLENT"; - } else if (efficiency > 70) { + } else if (hitRateValue > 70) { status = "GOOD"; - } else if (efficiency > 50) { + } else if (hitRateValue > 50) { status = "FAIR"; } else { status = "POOR"; } + + Map response = new LinkedHashMap<>(); + response.put("enabled", true); + response.put("currentSize", totalSize); + response.put("estimatedSize", totalSize); + response.put("hitRate", String.format("%.2f%%", hitRateValue)); + response.put("missRate", String.format("%.2f%%", 100 - hitRateValue)); + response.put("hitCount", totalHits); + response.put("missCount", totalMisses); + response.put("totalRequests", totalRequests); + response.put("efficiency", String.format("%.2f%%", hitRateValue)); response.put("status", status); return ResponseEntity.ok(response); @@ -92,82 +135,58 @@ public class CacheMonitoringController { } /** - * 캐시 강제 초기화 (주의: 운영 환경에서 신중하게 사용) + * 캐시 계층별 상세 통계 */ - @PostMapping("/clear") - @Operation( - summary = "캐시 강제 초기화", - description = "모든 캐시 데이터를 삭제합니다. 운영 환경에서는 신중하게 사용하세요." - ) - public ResponseEntity> clearCache() { - if (cache == null) { - return ResponseEntity.ok(Map.of( - "enabled", false, - "message", "Cache is not enabled" - )); - } - + @GetMapping("/details") + @Operation(summary = "캐시 계층별 상세", description = "L1/L2/L3/AIS/LatestPosition 각 캐시의 개별 통계를 조회합니다") + public ResponseEntity> getCacheDetails() { try { - VesselLatestPositionCache.CacheStats statsBefore = cache.getStats(); - cache.clear(); - VesselLatestPositionCache.CacheStats statsAfter = cache.getStats(); + Map details = new LinkedHashMap<>(); - log.warn("Cache manually cleared - before size: {}, after size: {}", - statsBefore.currentSize(), statsAfter.currentSize()); + if (fiveMinTrackCache != null) { + details.put("l1_fiveMin", fiveMinTrackCache.getStatsMap()); + } + if (hourlyTrackCache != null) { + details.put("l2_hourly", hourlyTrackCache.getStatsMap()); + } + if (dailyTrackCacheManager != null) { + details.put("l3_daily", dailyTrackCacheManager.getCacheStatus()); + } + if (aisTargetCacheManager != null) { + details.put("aisTarget", aisTargetCacheManager.getStats()); + } + if (latestPositionCache != null) { + VesselLatestPositionCache.CacheStats lp = latestPositionCache.getStats(); + details.put("latestPosition", Map.of( + "size", lp.currentSize(), + "hitRate", lp.hitRate(), + "hitCount", lp.hitCount(), + "missCount", lp.missCount() + )); + } - return ResponseEntity.ok(Map.of( - "success", true, - "message", "Cache cleared successfully", - "clearedEntries", statsBefore.currentSize() - )); + return ResponseEntity.ok(details); } catch (Exception e) { - log.error("Failed to clear cache", e); + log.error("Failed to get cache details", e); return ResponseEntity.internalServerError() - .body(Map.of("error", "Failed to clear cache: " + e.getMessage())); + .body(Map.of("error", e.getMessage())); } } /** - * 캐시 상태 체크 (헬스체크용) + * 캐시 헬스체크 */ @GetMapping("/health") - @Operation(summary = "캐시 헬스체크", description = "캐시가 정상 작동 중인지 확인합니다.") + @Operation(summary = "캐시 헬스체크", description = "캐시 시스템이 정상 작동 중인지 확인합니다") public ResponseEntity> checkCacheHealth() { - Map health = new HashMap<>(); - - if (cache == null) { - health.put("status", "DISABLED"); - health.put("message", "Cache is not enabled"); - return ResponseEntity.ok(health); - } - - try { - VesselLatestPositionCache.CacheStats stats = cache.getStats(); - - boolean isHealthy = stats.currentSize() >= 0; // 기본 체크 - - health.put("status", isHealthy ? "UP" : "DOWN"); - health.put("cacheSize", stats.currentSize()); - health.put("healthy", isHealthy); - - // 경고 조건 체크 - if (stats.currentSize() == 0) { - health.put("warning", "Cache is empty"); - } - - long totalRequests = stats.hitCount() + stats.missCount(); - if (totalRequests > 100 && stats.hitRate() < 50.0) { - health.put("warning", "Low cache hit rate: " + String.format("%.2f%%", stats.hitRate())); - } - - return ResponseEntity.ok(health); - - } catch (Exception e) { - log.error("Cache health check failed", e); - health.put("status", "ERROR"); - health.put("error", e.getMessage()); - return ResponseEntity.status(503).body(health); - } + Map health = new LinkedHashMap<>(); + health.put("status", "UP"); + health.put("l1", fiveMinTrackCache != null ? "UP" : "DISABLED"); + health.put("l2", hourlyTrackCache != null ? "UP" : "DISABLED"); + health.put("l3", dailyTrackCacheManager != null ? dailyTrackCacheManager.getStatus().name() : "DISABLED"); + health.put("aisTarget", aisTargetCacheManager != null ? "UP" : "DISABLED"); + health.put("latestPosition", latestPositionCache != null ? "UP" : "DISABLED"); + return ResponseEntity.ok(health); } } 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 6da0b89..6493d77 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 @@ -55,12 +55,20 @@ public class MonitoringController { LocalDateTime aisTime = (LocalDateTime) aisLatest.get("latest_update_time"); LocalDateTime queryTime = (LocalDateTime) queryLatest.get("latest_processed_time"); + long delayMinutes = 0; + String delayStatus = "NORMAL"; + if (aisTime != null && queryTime != null) { - long delayMinutes = java.time.Duration.between(queryTime, aisTime).toMinutes(); - result.put("delayMinutes", delayMinutes); - result.put("status", delayMinutes < 10 ? "NORMAL" : delayMinutes < 30 ? "WARNING" : "CRITICAL"); + delayMinutes = java.time.Duration.between(queryTime, aisTime).toMinutes(); + delayStatus = delayMinutes < 10 ? "NORMAL" : delayMinutes < 30 ? "WARNING" : "CRITICAL"; + } else if (aisTime == null && queryTime == null) { + delayStatus = "NORMAL"; // 데이터 없음 (수집 전 정상 상태) + } else { + delayStatus = "WARNING"; } + result.put("delayMinutes", delayMinutes); + result.put("status", delayStatus); result.put("aisLatestTime", aisTime); result.put("queryLatestTime", queryTime); result.put("recentAisCount", aisLatest.get("recent_count"));