Merge pull request 'feat: DataPipeline 일별 차트 시각화 개선 — Stacked Bar + Duration Bar' (#81) from feature/dashboard-phase-1 into develop
This commit is contained in:
커밋
1e0656632a
@ -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,21 +195,39 @@ export default function DataPipeline() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Throughput Timeline */}
|
||||
{dailyChartData.length > 0 && (
|
||||
{/* 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.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') },
|
||||
]}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Recent Job Executions */}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user