Merge pull request 'fix: Dashboard API 연동 오류 수정 — 캐시 모니터링 + 렌더링 안전성' (#12) from feature/dashboard-phase-1 into develop
This commit is contained in:
커밋
dca887b292
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-20">
|
||||
<h2 className="text-xl font-bold text-danger">Rendering Error</h2>
|
||||
<p className="text-sm text-muted">{this.state.error.message}</p>
|
||||
<button
|
||||
className="rounded bg-primary px-4 py-2 text-sm text-white"
|
||||
onClick={() => this.setState({ error: null })}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter basename={BASE_URL}>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="jobs" element={<JobMonitor />} />
|
||||
{/* Phase 2+ 페이지 추가 예정 */}
|
||||
</Route>
|
||||
</Routes>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Suspense fallback={<LoadingSpinner />}><Dashboard /></Suspense>} />
|
||||
<Route path="jobs" element={<Suspense fallback={<LoadingSpinner />}><JobMonitor /></Suspense>} />
|
||||
{/* Phase 2+ 페이지 추가 예정 */}
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
|
||||
@ -107,9 +107,9 @@ export default function Dashboard() {
|
||||
{delay ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{delay.delayMinutes}</span>
|
||||
<span className="text-3xl font-bold">{delay.delayMinutes ?? 0}</span>
|
||||
<span className="text-muted">{t('dashboard.delayMin')}</span>
|
||||
<StatusBadge status={delay.status} />
|
||||
<StatusBadge status={delay.status ?? 'NORMAL'} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
@ -166,7 +166,7 @@ export default function Dashboard() {
|
||||
<div className="text-xs text-muted">{t('dashboard.dbConn')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold">{metrics.processing.recordsPerSecond.toFixed(0)}</div>
|
||||
<div className="text-lg font-bold">{(metrics.processing?.recordsPerSecond ?? 0).toFixed(0)}</div>
|
||||
<div className="text-xs text-muted">{t('dashboard.recordsSec')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
|
||||
@ -123,6 +123,17 @@ public class FiveMinTrackCache {
|
||||
stats.missCount());
|
||||
}
|
||||
|
||||
public Map<String, Object> 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);
|
||||
}
|
||||
|
||||
@ -122,6 +122,17 @@ public class HourlyTrackCache {
|
||||
stats.missCount());
|
||||
}
|
||||
|
||||
public Map<String, Object> 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);
|
||||
}
|
||||
|
||||
@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> l3 = dailyTrackCacheManager.getCacheStatus();
|
||||
Object totalVessels = l3.get("totalVessels");
|
||||
if (totalVessels instanceof Number) {
|
||||
totalSize += ((Number) totalVessels).longValue();
|
||||
}
|
||||
}
|
||||
|
||||
// AIS Target
|
||||
if (aisTargetCacheManager != null) {
|
||||
Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> getCacheDetails() {
|
||||
try {
|
||||
VesselLatestPositionCache.CacheStats statsBefore = cache.getStats();
|
||||
cache.clear();
|
||||
VesselLatestPositionCache.CacheStats statsAfter = cache.getStats();
|
||||
Map<String, Object> 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<Map<String, Object>> checkCacheHealth() {
|
||||
Map<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user