Thymeleaf → React 19 + Vite + Tailwind CSS 4 SPA 전환 - frontend-maven-plugin으로 단일 JAR 배포 유지 - 6개 페이지 lazy 로딩, 5초/30초 폴링 자동 갱신 10대 신규 기능: - F1: 강제 종료(Abandon) - stale 실행 단건/전체 강제 종료 - F2: Job 실행 날짜 파라미터 (startDate/stopDate) - F3: Step API 호출 정보 표시 (apiUrl, method, calls) - F4: 실행 이력 검색 (멀티 Job 필터, 날짜 범위, 페이지네이션) - F5: Cron 표현식 도우미 (프리셋 + 다음 5회 미리보기) - F6: 대시보드 실패 통계 (24h/7d, 최근 실패 목록, stale 경고) - F7: Job 상세 카드 (마지막 실행 상태/시간 + 스케줄 cron) - F8: 실행 통계 차트 (CSS-only 30일 일별 막대그래프) - F9: 실패 로그 뷰어 (exitCode/exitMessage 모달) - F10: 다크모드 (data-theme + CSS 변수 + Tailwind @theme) 추가 개선: - 실행 이력 멀티 Job 선택 (체크박스 드롭다운 + 칩) - 스케줄 카드 편집 버튼 (폼 자동 채움 + 수정 모드) - 검색 모드 폴링 비활성화 (1회 조회 후 수동 갱신) - pre-commit hook: 프론트엔드 빌드 스킵 플래그 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi';
|
|
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
|
import { usePoller } from '../hooks/usePoller';
|
|
import { useToastContext } from '../contexts/ToastContext';
|
|
import { getStatusColor } from '../components/StatusBadge';
|
|
import StatusBadge from '../components/StatusBadge';
|
|
import LoadingSpinner from '../components/LoadingSpinner';
|
|
import EmptyState from '../components/EmptyState';
|
|
|
|
type ViewType = 'day' | 'week' | 'month';
|
|
|
|
interface TooltipData {
|
|
jobName: string;
|
|
period: PeriodInfo;
|
|
execution: ExecutionInfo;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
interface SelectedCell {
|
|
jobName: string;
|
|
periodKey: string;
|
|
periodLabel: string;
|
|
}
|
|
|
|
const VIEW_OPTIONS: { value: ViewType; label: string }[] = [
|
|
{ value: 'day', label: 'Day' },
|
|
{ value: 'week', label: 'Week' },
|
|
{ value: 'month', label: 'Month' },
|
|
];
|
|
|
|
const LEGEND_ITEMS = [
|
|
{ status: 'COMPLETED', color: '#10b981', label: '완료' },
|
|
{ status: 'FAILED', color: '#ef4444', label: '실패' },
|
|
{ status: 'STARTED', color: '#3b82f6', label: '실행중' },
|
|
{ status: 'SCHEDULED', color: '#8b5cf6', label: '예정' },
|
|
{ status: 'NONE', color: '#e5e7eb', label: '없음' },
|
|
];
|
|
|
|
const JOB_COL_WIDTH = 200;
|
|
const CELL_MIN_WIDTH = 80;
|
|
const POLLING_INTERVAL = 30000;
|
|
|
|
function formatDateStr(date: Date): string {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
function shiftDate(date: Date, view: ViewType, delta: number): Date {
|
|
const next = new Date(date);
|
|
switch (view) {
|
|
case 'day':
|
|
next.setDate(next.getDate() + delta);
|
|
break;
|
|
case 'week':
|
|
next.setDate(next.getDate() + delta * 7);
|
|
break;
|
|
case 'month':
|
|
next.setMonth(next.getMonth() + delta);
|
|
break;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function isRunning(status: string): boolean {
|
|
return status === 'STARTED' || status === 'STARTING';
|
|
}
|
|
|
|
export default function Timeline() {
|
|
const { showToast } = useToastContext();
|
|
|
|
const [view, setView] = useState<ViewType>('day');
|
|
const [currentDate, setCurrentDate] = useState(() => new Date());
|
|
const [periodLabel, setPeriodLabel] = useState('');
|
|
const [periods, setPeriods] = useState<PeriodInfo[]>([]);
|
|
const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Tooltip
|
|
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
|
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Selected cell & detail panel
|
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
|
const [detailExecutions, setDetailExecutions] = useState<JobExecutionDto[]>([]);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
const loadTimeline = useCallback(async () => {
|
|
try {
|
|
const dateStr = formatDateStr(currentDate);
|
|
const result = await batchApi.getTimeline(view, dateStr);
|
|
setPeriodLabel(result.periodLabel);
|
|
setPeriods(result.periods);
|
|
setSchedules(result.schedules);
|
|
} catch (err) {
|
|
showToast('타임라인 조회 실패', 'error');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [view, currentDate, showToast]);
|
|
|
|
usePoller(loadTimeline, POLLING_INTERVAL, [view, currentDate]);
|
|
|
|
const handlePrev = () => setCurrentDate((d) => shiftDate(d, view, -1));
|
|
const handleNext = () => setCurrentDate((d) => shiftDate(d, view, 1));
|
|
const handleToday = () => setCurrentDate(new Date());
|
|
|
|
const handleRefresh = async () => {
|
|
setLoading(true);
|
|
await loadTimeline();
|
|
};
|
|
|
|
// Tooltip handlers
|
|
const handleCellMouseEnter = (
|
|
e: React.MouseEvent,
|
|
jobName: string,
|
|
period: PeriodInfo,
|
|
execution: ExecutionInfo,
|
|
) => {
|
|
if (tooltipTimeoutRef.current) {
|
|
clearTimeout(tooltipTimeoutRef.current);
|
|
}
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
setTooltip({
|
|
jobName,
|
|
period,
|
|
execution,
|
|
x: rect.left + rect.width / 2,
|
|
y: rect.top,
|
|
});
|
|
};
|
|
|
|
const handleCellMouseLeave = () => {
|
|
tooltipTimeoutRef.current = setTimeout(() => {
|
|
setTooltip(null);
|
|
}, 100);
|
|
};
|
|
|
|
// Clean up tooltip timeout
|
|
useEffect(() => {
|
|
return () => {
|
|
if (tooltipTimeoutRef.current) {
|
|
clearTimeout(tooltipTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Cell click -> detail panel
|
|
const handleCellClick = async (jobName: string, periodKey: string, periodLabel: string) => {
|
|
// Toggle off if clicking same cell
|
|
if (selectedCell?.jobName === jobName && selectedCell?.periodKey === periodKey) {
|
|
setSelectedCell(null);
|
|
setDetailExecutions([]);
|
|
return;
|
|
}
|
|
|
|
setSelectedCell({ jobName, periodKey, periodLabel });
|
|
setDetailLoading(true);
|
|
setDetailExecutions([]);
|
|
|
|
try {
|
|
const executions = await batchApi.getPeriodExecutions(jobName, view, periodKey);
|
|
setDetailExecutions(executions);
|
|
} catch (err) {
|
|
showToast('구간 실행 이력 조회 실패', 'error');
|
|
console.error(err);
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
};
|
|
|
|
const closeDetail = () => {
|
|
setSelectedCell(null);
|
|
setDetailExecutions([]);
|
|
};
|
|
|
|
const gridTemplateColumns = `${JOB_COL_WIDTH}px repeat(${periods.length}, minmax(${CELL_MIN_WIDTH}px, 1fr))`;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Controls */}
|
|
<div className="bg-wing-surface rounded-xl shadow-lg p-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* View Toggle */}
|
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
|
{VIEW_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => {
|
|
setView(opt.value);
|
|
setLoading(true);
|
|
}}
|
|
className={`px-4 py-1.5 text-sm font-medium transition-colors ${
|
|
view === opt.value
|
|
? 'bg-wing-accent text-white'
|
|
: 'bg-wing-surface text-wing-muted hover:bg-wing-accent/10'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={handlePrev}
|
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
|
>
|
|
← 이전
|
|
</button>
|
|
<button
|
|
onClick={handleToday}
|
|
className="px-3 py-1.5 text-sm font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
|
|
>
|
|
오늘
|
|
</button>
|
|
<button
|
|
onClick={handleNext}
|
|
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
|
>
|
|
다음 →
|
|
</button>
|
|
</div>
|
|
|
|
{/* Period Label */}
|
|
<span className="text-sm font-semibold text-wing-text">
|
|
{periodLabel}
|
|
</span>
|
|
|
|
{/* Refresh */}
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="ml-auto px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
|
>
|
|
새로고침
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="flex flex-wrap items-center gap-4 px-2">
|
|
{LEGEND_ITEMS.map((item) => (
|
|
<div key={item.status} className="flex items-center gap-1.5">
|
|
<div
|
|
className="w-4 h-4 rounded"
|
|
style={{ backgroundColor: item.color }}
|
|
/>
|
|
<span className="text-xs text-wing-muted">{item.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Timeline Grid */}
|
|
<div className="bg-wing-surface rounded-xl shadow-lg overflow-hidden">
|
|
{loading ? (
|
|
<LoadingSpinner />
|
|
) : schedules.length === 0 ? (
|
|
<EmptyState message="타임라인 데이터가 없습니다" sub="등록된 스케줄이 없거나 해당 기간에 실행 이력이 없습니다" />
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<div
|
|
className="grid min-w-max"
|
|
style={{ gridTemplateColumns }}
|
|
>
|
|
{/* Header Row */}
|
|
<div className="sticky left-0 z-20 bg-wing-card border-b border-r border-wing-border px-3 py-2 text-xs font-semibold text-wing-muted">
|
|
작업명
|
|
</div>
|
|
{periods.map((period) => (
|
|
<div
|
|
key={period.key}
|
|
className="bg-wing-card border-b border-r border-wing-border px-2 py-2 text-xs font-medium text-wing-muted text-center whitespace-nowrap"
|
|
>
|
|
{period.label}
|
|
</div>
|
|
))}
|
|
|
|
{/* Data Rows */}
|
|
{schedules.map((schedule) => (
|
|
<>
|
|
{/* Job Name (sticky) */}
|
|
<div
|
|
key={`name-${schedule.jobName}`}
|
|
className="sticky left-0 z-10 bg-wing-surface border-b border-r border-wing-border px-3 py-2 text-xs font-medium text-wing-text truncate flex items-center"
|
|
title={schedule.jobName}
|
|
>
|
|
{schedule.jobName}
|
|
</div>
|
|
|
|
{/* Execution Cells */}
|
|
{periods.map((period) => {
|
|
const exec = schedule.executions[period.key];
|
|
const hasExec = exec !== null && exec !== undefined;
|
|
const isSelected =
|
|
selectedCell?.jobName === schedule.jobName &&
|
|
selectedCell?.periodKey === period.key;
|
|
const running = hasExec && isRunning(exec.status);
|
|
|
|
return (
|
|
<div
|
|
key={`cell-${schedule.jobName}-${period.key}`}
|
|
className={`border-b border-r border-wing-border/50 p-1 cursor-pointer transition-all hover:opacity-80 ${
|
|
isSelected ? 'ring-2 ring-yellow-400 ring-inset' : ''
|
|
}`}
|
|
onClick={() =>
|
|
handleCellClick(schedule.jobName, period.key, period.label)
|
|
}
|
|
onMouseEnter={
|
|
hasExec
|
|
? (e) => handleCellMouseEnter(e, schedule.jobName, period, exec)
|
|
: undefined
|
|
}
|
|
onMouseLeave={hasExec ? handleCellMouseLeave : undefined}
|
|
>
|
|
{hasExec && (
|
|
<div
|
|
className={`w-full h-6 rounded ${running ? 'animate-pulse' : ''}`}
|
|
style={{ backgroundColor: getStatusColor(exec.status) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tooltip */}
|
|
{tooltip && (
|
|
<div
|
|
className="fixed z-50 pointer-events-none"
|
|
style={{
|
|
left: tooltip.x,
|
|
top: tooltip.y - 8,
|
|
transform: 'translate(-50%, -100%)',
|
|
}}
|
|
>
|
|
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
|
|
<div className="font-semibold mb-1">{tooltip.jobName}</div>
|
|
<div className="space-y-0.5 text-gray-300">
|
|
<div>기간: {tooltip.period.label}</div>
|
|
<div>
|
|
상태:{' '}
|
|
<span
|
|
className="font-medium"
|
|
style={{ color: getStatusColor(tooltip.execution.status) }}
|
|
>
|
|
{tooltip.execution.status}
|
|
</span>
|
|
</div>
|
|
{tooltip.execution.startTime && (
|
|
<div>시작: {formatDateTime(tooltip.execution.startTime)}</div>
|
|
)}
|
|
{tooltip.execution.endTime && (
|
|
<div>종료: {formatDateTime(tooltip.execution.endTime)}</div>
|
|
)}
|
|
{tooltip.execution.executionId && (
|
|
<div>실행 ID: {tooltip.execution.executionId}</div>
|
|
)}
|
|
</div>
|
|
{/* Arrow */}
|
|
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-gray-900" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detail Panel */}
|
|
{selectedCell && (
|
|
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-sm font-bold text-wing-text">
|
|
{selectedCell.jobName}
|
|
</h3>
|
|
<p className="text-xs text-wing-muted mt-0.5">
|
|
구간: {selectedCell.periodLabel}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={closeDetail}
|
|
className="px-3 py-1.5 text-xs text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
|
>
|
|
닫기
|
|
</button>
|
|
</div>
|
|
|
|
{detailLoading ? (
|
|
<LoadingSpinner className="py-6" />
|
|
) : detailExecutions.length === 0 ? (
|
|
<EmptyState
|
|
message="해당 구간에 실행 이력이 없습니다"
|
|
icon="📭"
|
|
/>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-wing-border">
|
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
|
실행 ID
|
|
</th>
|
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
|
상태
|
|
</th>
|
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
|
시작 시간
|
|
</th>
|
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
|
종료 시간
|
|
</th>
|
|
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
|
|
소요 시간
|
|
</th>
|
|
<th className="text-right py-2 px-3 text-xs font-semibold text-wing-muted">
|
|
상세
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detailExecutions.map((exec) => (
|
|
<tr
|
|
key={exec.executionId}
|
|
className="border-b border-wing-border/50 hover:bg-wing-hover"
|
|
>
|
|
<td className="py-2 px-3 text-xs font-mono text-wing-text">
|
|
#{exec.executionId}
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
<StatusBadge status={exec.status} />
|
|
</td>
|
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
|
{formatDateTime(exec.startTime)}
|
|
</td>
|
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
|
{formatDateTime(exec.endTime)}
|
|
</td>
|
|
<td className="py-2 px-3 text-xs text-wing-muted">
|
|
{calculateDuration(exec.startTime, exec.endTime)}
|
|
</td>
|
|
<td className="py-2 px-3 text-right">
|
|
<a
|
|
href={`/executions/${exec.executionId}`}
|
|
className="text-xs text-wing-accent hover:text-wing-accent font-medium"
|
|
>
|
|
상세
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|