Merge pull request 'feat: DataPipeline 일별 차트 시각화 개선 — Stacked Bar + Duration Bar' (#82) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m49s

This commit is contained in:
htlee 2026-02-21 12:29:12 +09:00
커밋 57226ef2a9
8개의 변경된 파일135개의 추가작업 그리고 59개의 파일을 삭제

파일 보기

@ -60,11 +60,16 @@ export interface DailyStats {
} }
} }
export interface JobDailyStat {
writeCount: number
execCount: number
avgDuration: number
}
export interface DailyStat { export interface DailyStat {
date: string date: string
totalProcessed: number totalWrite: number
vesselJobs: number jobs: Record<string, JobDailyStat>
trackJobs: number
} }
/* Monitor API 응답 타입 */ /* Monitor API 응답 타입 */

파일 보기

@ -5,26 +5,43 @@ import {
YAxis, YAxis,
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
Legend,
ResponsiveContainer, ResponsiveContainer,
} from 'recharts' } from 'recharts'
interface BarSeries {
dataKey: string
color: string
name?: string
stackId?: string
}
interface BarChartProps { interface BarChartProps {
data: Record<string, unknown>[] data: Record<string, unknown>[]
dataKey: string
xKey: string xKey: string
height?: number height?: number
color?: string
label?: string label?: string
/** 단일 Series (하위 호환) */
dataKey?: string
color?: string
/** 다중 Series */
series?: BarSeries[]
/** Y축 값 포맷터 */
yFormatter?: (value: number) => string
} }
export default function BarChart({ export default function BarChart({
data, data,
dataKey,
xKey, xKey,
height = 240, height = 240,
color = 'var(--sb-primary)',
label, label,
dataKey,
color = 'var(--sb-primary)',
series,
yFormatter,
}: BarChartProps) { }: BarChartProps) {
const bars: BarSeries[] = series ?? (dataKey ? [{ dataKey, color }] : [])
return ( return (
<div> <div>
{label && <div className="mb-2 text-sm font-medium text-muted">{label}</div>} {label && <div className="mb-2 text-sm font-medium text-muted">{label}</div>}
@ -41,6 +58,7 @@ export default function BarChart({
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }} tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tickFormatter={yFormatter}
/> />
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
@ -49,8 +67,21 @@ export default function BarChart({
borderRadius: 'var(--sb-radius)', borderRadius: 'var(--sb-radius)',
fontSize: 12, fontSize: 12,
}} }}
formatter={yFormatter ? (v: number) => yFormatter(v) : undefined}
/> />
<Bar dataKey={dataKey} fill={color} radius={[4, 4, 0, 0]} /> {bars.length > 1 && (
<Legend wrapperStyle={{ fontSize: 12, color: 'var(--sb-text-muted)' }} />
)}
{bars.map((b, i) => (
<Bar
key={b.dataKey}
dataKey={b.dataKey}
fill={b.color}
name={b.name ?? b.dataKey}
stackId={b.stackId}
radius={b.stackId && i < bars.length - 1 ? undefined : [4, 4, 0, 0]}
/>
))}
</RechartsBarChart> </RechartsBarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

파일 보기

@ -85,10 +85,8 @@ const en = {
'pipeline.cacheOverview': 'Cache Overview', 'pipeline.cacheOverview': 'Cache Overview',
'pipeline.cachedDays': ' days cached', 'pipeline.cachedDays': ' days cached',
'pipeline.totalHitRate': 'Total Hit Rate', 'pipeline.totalHitRate': 'Total Hit Rate',
'pipeline.dailyThroughput': 'Daily Throughput Trend', 'pipeline.writeByJob': 'Write Count by Job',
'pipeline.totalProcessed': 'Total Processed', 'pipeline.durationByJob': 'Avg Duration by Job',
'pipeline.vesselJobs': 'Vessel Jobs',
'pipeline.trackJobs': 'Track Jobs',
'pipeline.recentJobs': 'Recent Executions', 'pipeline.recentJobs': 'Recent Executions',
'pipeline.totalExec': 'Total Executions', 'pipeline.totalExec': 'Total Executions',
'pipeline.totalRecords': 'Total Records', 'pipeline.totalRecords': 'Total Records',

파일 보기

@ -85,10 +85,8 @@ const ko = {
'pipeline.cacheOverview': '캐시 현황', 'pipeline.cacheOverview': '캐시 현황',
'pipeline.cachedDays': '일 캐시', 'pipeline.cachedDays': '일 캐시',
'pipeline.totalHitRate': '전체 히트율', 'pipeline.totalHitRate': '전체 히트율',
'pipeline.dailyThroughput': '일별 처리량 추이', 'pipeline.writeByJob': 'Job별 처리 건수',
'pipeline.totalProcessed': '총 처리', 'pipeline.durationByJob': 'Job별 소요시간',
'pipeline.vesselJobs': 'Vessel Job',
'pipeline.trackJobs': 'Track Job',
'pipeline.recentJobs': '최근 실행 이력', 'pipeline.recentJobs': '최근 실행 이력',
'pipeline.totalExec': '총 실행', 'pipeline.totalExec': '총 실행',
'pipeline.totalRecords': '총 처리건수', 'pipeline.totalRecords': '총 처리건수',

파일 보기

@ -206,7 +206,7 @@ export default function Dashboard() {
<BarChart <BarChart
data={daily.dailyStats.map(d => ({ data={daily.dailyStats.map(d => ({
date: d.date.slice(5), date: d.date.slice(5),
processed: d.totalProcessed, processed: d.totalWrite,
}))} }))}
dataKey="processed" dataKey="processed"
xKey="date" xKey="date"

파일 보기

@ -12,7 +12,7 @@ import type {
} from '../api/types.ts' } from '../api/types.ts'
import type { CacheDetails } from '../api/types.ts' import type { CacheDetails } from '../api/types.ts'
import PipelineChart from '../components/charts/PipelineChart.tsx' 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 MetricCard from '../components/charts/MetricCard.tsx'
import StatusBadge from '../components/common/StatusBadge.tsx' import StatusBadge from '../components/common/StatusBadge.tsx'
import { formatNumber, formatDuration, formatDateTime, formatPercent } from '../utils/formatters.ts' import { formatNumber, formatDuration, formatDateTime, formatPercent } from '../utils/formatters.ts'
@ -26,6 +26,15 @@ const JOB_DISPLAY: Record<string, string> = {
dailyAggregationJob: 'Daily (1일)', dailyAggregationJob: 'Daily (1일)',
} }
const JOB_COLORS: Record<string, string> = {
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() { export default function DataPipeline() {
const { t } = useI18n() const { t } = useI18n()
const [stats, setStats] = useCachedState<BatchStatistics | null>('pipe.stats', null) const [stats, setStats] = useCachedState<BatchStatistics | null>('pipe.stats', null)
@ -82,13 +91,22 @@ export default function DataPipeline() {
}, },
] ]
/* 일별 처리량 라인 차트 데이터 */ /* Job별 차트 데이터 변환 */
const dailyChartData = daily?.dailyStats.map(d => ({ const writeChartData = daily?.dailyStats.map(d => {
date: d.date.slice(5), const row: Record<string, unknown> = { date: d.date.slice(5) }
processed: d.totalProcessed, for (const key of JOB_KEYS) {
vessel: d.vesselJobs, row[key] = d.jobs[key]?.writeCount ?? 0
track: d.trackJobs, }
})) ?? [] return row
}) ?? []
const durationChartData = daily?.dailyStats.map(d => {
const row: Record<string, unknown> = { date: d.date.slice(5) }
for (const key of JOB_KEYS) {
row[key] = d.jobs[key]?.avgDuration ?? 0
}
return row
}) ?? []
return ( return (
<div className="space-y-6 fade-in"> <div className="space-y-6 fade-in">
@ -177,20 +195,38 @@ export default function DataPipeline() {
</div> </div>
</div> </div>
{/* Daily Throughput Timeline */} {/* Daily Charts — Job별 처리 건수 + 소요시간 */}
{dailyChartData.length > 0 && ( {writeChartData.length > 0 && (
<div className="sb-card"> <div className="grid gap-4 lg:grid-cols-2">
<div className="sb-card-header">{t('pipeline.dailyThroughput')}</div> <div className="sb-card">
<LineChart <div className="sb-card-header">{t('pipeline.writeByJob')}</div>
data={dailyChartData} <BarChart
series={[ data={writeChartData}
{ dataKey: 'processed', color: 'var(--sb-primary)', name: t('pipeline.totalProcessed') }, xKey="date"
{ dataKey: 'vessel', color: 'var(--sb-success)', name: t('pipeline.vesselJobs') }, height={280}
{ dataKey: 'track', color: 'var(--sb-info)', name: t('pipeline.trackJobs') }, series={JOB_KEYS.map(key => ({
]} dataKey: key,
xKey="date" color: JOB_COLORS[key],
height={280} name: JOB_DISPLAY[key],
/> stackId: 'write',
}))}
yFormatter={(v) => formatNumber(v)}
/>
</div>
<div className="sb-card">
<div className="sb-card-header">{t('pipeline.durationByJob')}</div>
<BarChart
data={durationChartData}
xKey="date"
height={280}
series={JOB_KEYS.map(key => ({
dataKey: key,
color: JOB_COLORS[key],
name: JOB_DISPLAY[key],
}))}
yFormatter={(v) => formatDuration(v)}
/>
</div>
</div> </div>
)} )}

파일 보기

@ -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 < 60) return `${seconds}s`
if (seconds < 3600) { if (seconds < 3600) {
const m = Math.floor(seconds / 60) const m = Math.floor(seconds / 60)

파일 보기

@ -636,11 +636,12 @@ public class BatchAdminController {
/** /**
* 일별 처리 통계 (Dashboard 차트용, 단일 SQL 집계) * 일별 처리 통계 (Dashboard 차트용, 단일 SQL 집계)
* 기존: JobExplorer N+1 × 7일 루프 (~8800 쿼리, 9초) *
* 개선: batch 메타 테이블 직접 GROUP BY (2 쿼리) * Job별 처리 건수(writeCount) + 평균 소요시간(avgDuration) 반환.
* 프론트엔드에서 Stacked Bar (처리 건수) + Duration Line (소요시간) 차트 표시.
*/ */
@GetMapping("/daily-stats") @GetMapping("/daily-stats")
@Operation(summary = "일별 처리 통계", description = "최근 7일간 일별 배치 처리 통계를 조회합니다 (대시보드 차트용)") @Operation(summary = "일별 처리 통계", description = "최근 7일간 일별 Job별 배치 처리 통계를 조회합니다 (대시보드 차트용)")
public ResponseEntity<Map<String, Object>> getDailyStatistics() { public ResponseEntity<Map<String, Object>> getDailyStatistics() {
try { try {
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
@ -652,7 +653,8 @@ public class BatchAdminController {
DATE(je.START_TIME) as stat_date, DATE(je.START_TIME) as stat_date,
ji.JOB_NAME as job_name, ji.JOB_NAME as job_name,
COUNT(*) as execution_count, 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 FROM batch_job_execution je
JOIN batch_job_instance ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID JOIN batch_job_instance ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
LEFT JOIN ( LEFT JOIN (
@ -667,33 +669,38 @@ public class BatchAdminController {
""" """
); );
// 일별 그룹핑: date -> [totalProcessed, vesselJobs, trackJobs] // 일별 + Job별 그룹핑
Map<String, long[]> dailyMap = new LinkedHashMap<>(); // key: date, value: { jobName -> { writeCount, execCount, avgDuration } }
Map<String, Map<String, Map<String, Object>>> dailyMap = new LinkedHashMap<>();
for (Map<String, Object> row : rows) { for (Map<String, Object> row : rows) {
String date = row.get("stat_date").toString(); String date = row.get("stat_date").toString();
String jobName = (String) row.get("job_name"); String jobName = (String) row.get("job_name");
int execCount = ((Number) row.get("execution_count")).intValue(); 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]); dailyMap.computeIfAbsent(date, k -> new LinkedHashMap<>())
accum[0] += processed; .put(jobName, Map.of(
if (jobName.contains("vesselAggregation")) { "writeCount", writeCount,
accum[1] += execCount; "execCount", execCount,
} else if (jobName.contains("vesselTrack")) { "avgDuration", avgDuration
accum[2] += execCount; ));
}
} }
// 7일 전체 채우기 (데이터 없는 날도 포함) // 7일 전체 채우기 (데이터 없는 날도 포함)
List<Map<String, Object>> dailyStats = new ArrayList<>(); List<Map<String, Object>> dailyStats = new ArrayList<>();
for (int i = 6; i >= 0; i--) { for (int i = 6; i >= 0; i--) {
String date = LocalDate.now().minusDays(i).toString(); String date = LocalDate.now().minusDays(i).toString();
long[] accum = dailyMap.getOrDefault(date, new long[3]); Map<String, Map<String, Object>> jobs = dailyMap.getOrDefault(date, Map.of());
Map<String, Object> dailyStat = new HashMap<>();
long totalWrite = jobs.values().stream()
.mapToLong(j -> ((Number) j.get("writeCount")).longValue())
.sum();
Map<String, Object> dailyStat = new LinkedHashMap<>();
dailyStat.put("date", date); dailyStat.put("date", date);
dailyStat.put("totalProcessed", accum[0]); dailyStat.put("totalWrite", totalWrite);
dailyStat.put("vesselJobs", (int) accum[1]); dailyStat.put("jobs", jobs);
dailyStat.put("trackJobs", (int) accum[2]);
dailyStats.add(dailyStat); dailyStats.add(dailyStat);
} }