Merge pull request 'feat: DataPipeline 일별 차트 시각화 개선 — Stacked Bar + Duration Bar' (#81) from feature/dashboard-phase-1 into develop

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

파일 보기

@ -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<string, JobDailyStat>
}
/* Monitor API 응답 타입 */

파일 보기

@ -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<string, unknown>[]
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 (
<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)' }}
axisLine={false}
tickLine={false}
tickFormatter={yFormatter}
/>
<Tooltip
contentStyle={{
@ -49,8 +67,21 @@ export default function BarChart({
borderRadius: 'var(--sb-radius)',
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>
</ResponsiveContainer>
</div>

파일 보기

@ -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',

파일 보기

@ -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': '총 처리건수',

파일 보기

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

파일 보기

@ -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<string, string> = {
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() {
const { t } = useI18n()
const [stats, setStats] = useCachedState<BatchStatistics | null>('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<string, unknown> = { 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<string, unknown> = { date: d.date.slice(5) }
for (const key of JOB_KEYS) {
row[key] = d.jobs[key]?.avgDuration ?? 0
}
return row
}) ?? []
return (
<div className="space-y-6 fade-in">
@ -177,20 +195,38 @@ export default function DataPipeline() {
</div>
</div>
{/* Daily Throughput Timeline */}
{dailyChartData.length > 0 && (
<div className="sb-card">
<div className="sb-card-header">{t('pipeline.dailyThroughput')}</div>
<LineChart
data={dailyChartData}
series={[
{ dataKey: 'processed', color: 'var(--sb-primary)', name: t('pipeline.totalProcessed') },
{ dataKey: 'vessel', color: 'var(--sb-success)', name: t('pipeline.vesselJobs') },
{ dataKey: 'track', color: 'var(--sb-info)', name: t('pipeline.trackJobs') },
]}
xKey="date"
height={280}
/>
{/* Daily Charts — Job별 처리 건수 + 소요시간 */}
{writeChartData.length > 0 && (
<div className="grid gap-4 lg:grid-cols-2">
<div className="sb-card">
<div className="sb-card-header">{t('pipeline.writeByJob')}</div>
<BarChart
data={writeChartData}
xKey="date"
height={280}
series={JOB_KEYS.map(key => ({
dataKey: key,
color: JOB_COLORS[key],
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>
)}

파일 보기

@ -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)

파일 보기

@ -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<Map<String, Object>> 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<String, long[]> dailyMap = new LinkedHashMap<>();
// 일별 + Job별 그룹핑
// key: date, value: { jobName -> { writeCount, execCount, avgDuration } }
Map<String, Map<String, Map<String, Object>>> dailyMap = new LinkedHashMap<>();
for (Map<String, Object> 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<Map<String, Object>> 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<String, Object> dailyStat = new HashMap<>();
Map<String, Map<String, Object>> jobs = dailyMap.getOrDefault(date, Map.of());
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("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);
}