Merge pull request 'fix: MonitoringController 레거시 타일 쿼리 → AIS 위치/항적 기반 전환' (#18) from feature/dashboard-phase-1 into develop
This commit is contained in:
커밋
1f209954bf
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -28,7 +28,6 @@ export default function AreaStats() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* Header */}
|
||||
<h1 className="text-2xl font-bold">{t('area.title')}</h1>
|
||||
|
||||
{/* Summary Cards */}
|
||||
@ -67,10 +66,9 @@ export default function AreaStats() {
|
||||
<tr>
|
||||
<th>{t('area.haeguNo')}</th>
|
||||
<th>{t('area.haeguName')}</th>
|
||||
<th className="text-right">{t('area.activeTiles')}</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 className="text-right">{t('area.maxTileVessels')}</th>
|
||||
<th>{t('area.lastUpdate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -79,10 +77,9 @@ export default function AreaStats() {
|
||||
<tr key={h.haegu_no}>
|
||||
<td className="font-mono">{h.haegu_no}</td>
|
||||
<td>{h.haegu_name}</td>
|
||||
<td className="text-right">{formatNumber(h.active_tiles)}</td>
|
||||
<td className="text-right font-bold">{formatNumber(h.current_vessels)}</td>
|
||||
<td className="text-right">{(h.avg_density ?? 0).toFixed(2)}</td>
|
||||
<td className="text-right">{formatNumber(h.max_tile_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>
|
||||
))}
|
||||
@ -111,10 +108,9 @@ export default function AreaStats() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Partition Sizes */}
|
||||
{throughput.partitionSizes && throughput.partitionSizes.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-muted">{t('area.partitions')}</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">
|
||||
@ -149,8 +145,8 @@ export default function AreaStats() {
|
||||
<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.missingTiles')}</div>
|
||||
<div className="text-lg font-bold">{formatNumber(quality.missingTiles)}</div>
|
||||
<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">
|
||||
|
||||
@ -125,8 +125,8 @@ export default function Dashboard() {
|
||||
<div>{formatNumber(delay.recentAisCount)}{t('common.items')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted">{t('dashboard.tileProcessed')}</span>
|
||||
<div>{formatNumber(delay.processedTiles)}{t('common.items')}</div>
|
||||
<span className="text-muted">{t('dashboard.vesselsProcessed')}</span>
|
||||
<div>{formatNumber(delay.processedVessels)}{t('common.items')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<String, Object> getProcessingDelay() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
|
||||
try {
|
||||
// AIS 최신 위치 데이터 (캐시 스냅샷)
|
||||
Map<String, Object> aisLatest = queryJdbcTemplate.queryForMap(
|
||||
"""
|
||||
SELECT
|
||||
@ -41,18 +40,23 @@ public class MonitoringController {
|
||||
"""
|
||||
);
|
||||
|
||||
// 집계 데이터의 최신 처리 시간
|
||||
Map<String, Object> 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<Map<String, Object>> 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<String, Object> getThroughputMetrics() {
|
||||
Map<String, Object> metrics = new HashMap<>();
|
||||
|
||||
|
||||
try {
|
||||
// 최근 1시간 처리량
|
||||
List<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> checkDataQuality() {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user