feat: DataPipeline 일별 차트 시각화 개선 — Stacked Bar + Duration Bar
- 백엔드 daily-stats SQL 재설계: Job별 writeCount, execCount, avgDuration 반환 - vesselJobs 항상 0 버그 수정 (vesselAggregation 매칭 실패 → Job별 분리 구조) - BarChart 다중 Series 지원 (stacked/grouped) + yFormatter - DataPipeline: LineChart 1개 → Stacked Bar(처리건수) + Grouped Bar(소요시간) 2열 grid - formatDuration null/undefined 가드 추가 (NaNh 표시 버그 수정) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
1544832899
커밋
5e6fdff787
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user