diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index cd6a57a..419fecb 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -60,11 +60,16 @@ export interface DailyStats { } } +export interface JobDailyStat { + writeCount: number + execCount: number + avgDuration: number +} + export interface DailyStat { date: string - totalProcessed: number - vesselJobs: number - trackJobs: number + totalWrite: number + jobs: Record } /* Monitor API 응답 타입 */ diff --git a/frontend/src/components/charts/BarChart.tsx b/frontend/src/components/charts/BarChart.tsx index 6de1def..2e499ec 100644 --- a/frontend/src/components/charts/BarChart.tsx +++ b/frontend/src/components/charts/BarChart.tsx @@ -5,26 +5,43 @@ import { YAxis, CartesianGrid, Tooltip, + Legend, ResponsiveContainer, } from 'recharts' +interface BarSeries { + dataKey: string + color: string + name?: string + stackId?: string +} + interface BarChartProps { data: Record[] - dataKey: string xKey: string height?: number - color?: string label?: string + /** 단일 Series (하위 호환) */ + dataKey?: string + color?: string + /** 다중 Series */ + series?: BarSeries[] + /** Y축 값 포맷터 */ + yFormatter?: (value: number) => string } export default function BarChart({ data, - dataKey, xKey, height = 240, - color = 'var(--sb-primary)', label, + dataKey, + color = 'var(--sb-primary)', + series, + yFormatter, }: BarChartProps) { + const bars: BarSeries[] = series ?? (dataKey ? [{ dataKey, color }] : []) + return (
{label &&
{label}
} @@ -41,6 +58,7 @@ export default function BarChart({ tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }} axisLine={false} tickLine={false} + tickFormatter={yFormatter} /> yFormatter(v) : undefined} /> - + {bars.length > 1 && ( + + )} + {bars.map((b, i) => ( + + ))}
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index bcd9e5b..c45de59 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -85,10 +85,8 @@ const en = { 'pipeline.cacheOverview': 'Cache Overview', 'pipeline.cachedDays': ' days cached', 'pipeline.totalHitRate': 'Total Hit Rate', - 'pipeline.dailyThroughput': 'Daily Throughput Trend', - 'pipeline.totalProcessed': 'Total Processed', - 'pipeline.vesselJobs': 'Vessel Jobs', - 'pipeline.trackJobs': 'Track Jobs', + 'pipeline.writeByJob': 'Write Count by Job', + 'pipeline.durationByJob': 'Avg Duration by Job', 'pipeline.recentJobs': 'Recent Executions', 'pipeline.totalExec': 'Total Executions', 'pipeline.totalRecords': 'Total Records', diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts index 8bae93d..5a2e0cc 100644 --- a/frontend/src/i18n/ko.ts +++ b/frontend/src/i18n/ko.ts @@ -85,10 +85,8 @@ const ko = { 'pipeline.cacheOverview': '캐시 현황', 'pipeline.cachedDays': '일 캐시', 'pipeline.totalHitRate': '전체 히트율', - 'pipeline.dailyThroughput': '일별 처리량 추이', - 'pipeline.totalProcessed': '총 처리', - 'pipeline.vesselJobs': 'Vessel Job', - 'pipeline.trackJobs': 'Track Job', + 'pipeline.writeByJob': 'Job별 처리 건수', + 'pipeline.durationByJob': 'Job별 소요시간', 'pipeline.recentJobs': '최근 실행 이력', 'pipeline.totalExec': '총 실행', 'pipeline.totalRecords': '총 처리건수', diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 06d90c3..2b12834 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -206,7 +206,7 @@ export default function Dashboard() { ({ date: d.date.slice(5), - processed: d.totalProcessed, + processed: d.totalWrite, }))} dataKey="processed" xKey="date" diff --git a/frontend/src/pages/DataPipeline.tsx b/frontend/src/pages/DataPipeline.tsx index dbc57da..21e9291 100644 --- a/frontend/src/pages/DataPipeline.tsx +++ b/frontend/src/pages/DataPipeline.tsx @@ -12,7 +12,7 @@ import type { } from '../api/types.ts' import type { CacheDetails } from '../api/types.ts' import PipelineChart from '../components/charts/PipelineChart.tsx' -import LineChart from '../components/charts/LineChart.tsx' +import BarChart from '../components/charts/BarChart.tsx' import MetricCard from '../components/charts/MetricCard.tsx' import StatusBadge from '../components/common/StatusBadge.tsx' import { formatNumber, formatDuration, formatDateTime, formatPercent } from '../utils/formatters.ts' @@ -26,6 +26,15 @@ const JOB_DISPLAY: Record = { dailyAggregationJob: 'Daily (1일)', } +const JOB_COLORS: Record = { + aisTargetImportJob: 'var(--sb-primary)', + vesselTrackAggregationJob: 'var(--sb-success)', + hourlyAggregationJob: 'var(--sb-info)', + dailyAggregationJob: 'var(--sb-warning)', +} + +const JOB_KEYS = Object.keys(JOB_DISPLAY) + export default function DataPipeline() { const { t } = useI18n() const [stats, setStats] = useCachedState('pipe.stats', null) @@ -82,13 +91,22 @@ export default function DataPipeline() { }, ] - /* 일별 처리량 라인 차트 데이터 */ - const dailyChartData = daily?.dailyStats.map(d => ({ - date: d.date.slice(5), - processed: d.totalProcessed, - vessel: d.vesselJobs, - track: d.trackJobs, - })) ?? [] + /* Job별 차트 데이터 변환 */ + const writeChartData = daily?.dailyStats.map(d => { + const row: Record = { date: d.date.slice(5) } + for (const key of JOB_KEYS) { + row[key] = d.jobs[key]?.writeCount ?? 0 + } + return row + }) ?? [] + + const durationChartData = daily?.dailyStats.map(d => { + const row: Record = { date: d.date.slice(5) } + for (const key of JOB_KEYS) { + row[key] = d.jobs[key]?.avgDuration ?? 0 + } + return row + }) ?? [] return (
@@ -177,20 +195,38 @@ export default function DataPipeline() {
- {/* Daily Throughput Timeline */} - {dailyChartData.length > 0 && ( -
-
{t('pipeline.dailyThroughput')}
- + {/* Daily Charts — Job별 처리 건수 + 소요시간 */} + {writeChartData.length > 0 && ( +
+
+
{t('pipeline.writeByJob')}
+ ({ + dataKey: key, + color: JOB_COLORS[key], + name: JOB_DISPLAY[key], + stackId: 'write', + }))} + yFormatter={(v) => formatNumber(v)} + /> +
+
+
{t('pipeline.durationByJob')}
+ ({ + dataKey: key, + color: JOB_COLORS[key], + name: JOB_DISPLAY[key], + }))} + yFormatter={(v) => formatDuration(v)} + /> +
)} diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts index 3ab8fc9..44b59e5 100644 --- a/frontend/src/utils/formatters.ts +++ b/frontend/src/utils/formatters.ts @@ -13,7 +13,8 @@ export function formatBytes(bytes: number): string { } /** 초를 사람이 읽을 수 있는 시간으로 */ -export function formatDuration(seconds: number): string { +export function formatDuration(seconds: number | null | undefined): string { + if (seconds == null || isNaN(seconds)) return '-' if (seconds < 60) return `${seconds}s` if (seconds < 3600) { const m = Math.floor(seconds / 60) diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/BatchAdminController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/BatchAdminController.java index 2925099..9214dbe 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/BatchAdminController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/BatchAdminController.java @@ -636,11 +636,12 @@ public class BatchAdminController { /** * 일별 처리 통계 (Dashboard 차트용, 단일 SQL 집계) - * 기존: JobExplorer N+1 × 7일 루프 (~8800 쿼리, 9초) - * 개선: batch 메타 테이블 직접 GROUP BY (2 쿼리) + * + * Job별 처리 건수(writeCount) + 평균 소요시간(avgDuration) 반환. + * 프론트엔드에서 Stacked Bar (처리 건수) + Duration Line (소요시간) 차트 표시. */ @GetMapping("/daily-stats") - @Operation(summary = "일별 처리 통계", description = "최근 7일간 일별 배치 처리 통계를 조회합니다 (대시보드 차트용)") + @Operation(summary = "일별 처리 통계", description = "최근 7일간 일별 Job별 배치 처리 통계를 조회합니다 (대시보드 차트용)") public ResponseEntity> getDailyStatistics() { try { long start = System.currentTimeMillis(); @@ -652,7 +653,8 @@ public class BatchAdminController { DATE(je.START_TIME) as stat_date, ji.JOB_NAME as job_name, COUNT(*) as execution_count, - COALESCE(SUM(se.total_write), 0) as total_processed + COALESCE(SUM(se.total_write), 0) as total_write, + COALESCE(AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME))), 0)::int as avg_duration_sec FROM batch_job_execution je JOIN batch_job_instance ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID LEFT JOIN ( @@ -667,33 +669,38 @@ public class BatchAdminController { """ ); - // 일별 그룹핑: date -> [totalProcessed, vesselJobs, trackJobs] - Map dailyMap = new LinkedHashMap<>(); + // 일별 + Job별 그룹핑 + // key: date, value: { jobName -> { writeCount, execCount, avgDuration } } + Map>> dailyMap = new LinkedHashMap<>(); for (Map row : rows) { String date = row.get("stat_date").toString(); String jobName = (String) row.get("job_name"); int execCount = ((Number) row.get("execution_count")).intValue(); - long processed = ((Number) row.get("total_processed")).longValue(); + long writeCount = ((Number) row.get("total_write")).longValue(); + int avgDuration = ((Number) row.get("avg_duration_sec")).intValue(); - long[] accum = dailyMap.computeIfAbsent(date, k -> new long[3]); - accum[0] += processed; - if (jobName.contains("vesselAggregation")) { - accum[1] += execCount; - } else if (jobName.contains("vesselTrack")) { - accum[2] += execCount; - } + dailyMap.computeIfAbsent(date, k -> new LinkedHashMap<>()) + .put(jobName, Map.of( + "writeCount", writeCount, + "execCount", execCount, + "avgDuration", avgDuration + )); } // 7일 전체 채우기 (데이터 없는 날도 포함) List> dailyStats = new ArrayList<>(); for (int i = 6; i >= 0; i--) { String date = LocalDate.now().minusDays(i).toString(); - long[] accum = dailyMap.getOrDefault(date, new long[3]); - Map dailyStat = new HashMap<>(); + Map> jobs = dailyMap.getOrDefault(date, Map.of()); + + long totalWrite = jobs.values().stream() + .mapToLong(j -> ((Number) j.get("writeCount")).longValue()) + .sum(); + + Map dailyStat = new LinkedHashMap<>(); dailyStat.put("date", date); - dailyStat.put("totalProcessed", accum[0]); - dailyStat.put("vesselJobs", (int) accum[1]); - dailyStat.put("trackJobs", (int) accum[2]); + dailyStat.put("totalWrite", totalWrite); + dailyStat.put("jobs", jobs); dailyStats.add(dailyStat); }