snp-batch-validation/frontend/src/pages/Timeline.tsx

467 lines
16 KiB
TypeScript
Raw Normal View 히스토리

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"
>
&larr;
</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"
>
&rarr;
</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>
);
}