snp-batch-validation/frontend/src/pages/Schedules.tsx
htlee 90ffe68be3 feat: 배치 모니터링 React SPA 전환 및 10대 기능 강화
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>
2026-02-17 12:53:54 +09:00

491 lines
18 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { batchApi, type ScheduleResponse } from '../api/batchApi';
import { formatDateTime } from '../utils/formatters';
import { useToastContext } from '../contexts/ToastContext';
import ConfirmModal from '../components/ConfirmModal';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
import { getNextExecutions } from '../utils/cronPreview';
type ScheduleMode = 'new' | 'existing';
interface ConfirmAction {
type: 'toggle' | 'delete';
schedule: ScheduleResponse;
}
const CRON_PRESETS = [
{ label: '매 분', cron: '0 * * * * ?' },
{ label: '매시 정각', cron: '0 0 * * * ?' },
{ label: '매 15분', cron: '0 0/15 * * * ?' },
{ label: '매일 00:00', cron: '0 0 0 * * ?' },
{ label: '매일 12:00', cron: '0 0 12 * * ?' },
{ label: '매주 월 00:00', cron: '0 0 0 ? * MON' },
];
function CronPreview({ cron }: { cron: string }) {
const nextDates = useMemo(() => getNextExecutions(cron, 5), [cron]);
if (nextDates.length === 0) {
return (
<div className="md:col-span-2">
<p className="text-xs text-wing-muted"> ( )</p>
</div>
);
}
const fmt = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
return (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-wing-text mb-1">
5
</label>
<div className="flex flex-wrap gap-2">
{nextDates.map((d, i) => (
<span
key={i}
className="inline-block bg-wing-accent/10 text-wing-accent text-xs font-mono px-2 py-1 rounded"
>
{fmt.format(d)}
</span>
))}
</div>
</div>
);
}
function getTriggerStateStyle(state: string | null): string {
switch (state) {
case 'NORMAL':
return 'bg-emerald-100 text-emerald-700';
case 'PAUSED':
return 'bg-amber-100 text-amber-700';
case 'BLOCKED':
return 'bg-red-100 text-red-700';
case 'ERROR':
return 'bg-red-100 text-red-700';
default:
return 'bg-wing-card text-wing-muted';
}
}
export default function Schedules() {
const { showToast } = useToastContext();
// Form state
const [jobs, setJobs] = useState<string[]>([]);
const [selectedJob, setSelectedJob] = useState('');
const [cronExpression, setCronExpression] = useState('');
const [description, setDescription] = useState('');
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('new');
const [formLoading, setFormLoading] = useState(false);
const [saving, setSaving] = useState(false);
// Schedule list state
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
const [listLoading, setListLoading] = useState(true);
// Confirm modal state
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
// 폼 영역 ref (편집 버튼 클릭 시 스크롤)
const formRef = useRef<HTMLDivElement>(null);
const loadSchedules = useCallback(async () => {
try {
const result = await batchApi.getSchedules();
setSchedules(result.schedules);
} catch (err) {
showToast('스케줄 목록 조회 실패', 'error');
console.error(err);
} finally {
setListLoading(false);
}
}, [showToast]);
const loadJobs = useCallback(async () => {
try {
const result = await batchApi.getJobs();
setJobs(result);
} catch (err) {
showToast('작업 목록 조회 실패', 'error');
console.error(err);
}
}, [showToast]);
useEffect(() => {
loadJobs();
loadSchedules();
}, [loadJobs, loadSchedules]);
const handleJobSelect = async (jobName: string) => {
setSelectedJob(jobName);
setCronExpression('');
setDescription('');
setScheduleMode('new');
if (!jobName) return;
setFormLoading(true);
try {
const schedule = await batchApi.getSchedule(jobName);
setCronExpression(schedule.cronExpression);
setDescription(schedule.description ?? '');
setScheduleMode('existing');
} catch {
// 404 = new schedule
setScheduleMode('new');
} finally {
setFormLoading(false);
}
};
const handleSave = async () => {
if (!selectedJob) {
showToast('작업을 선택해주세요', 'error');
return;
}
if (!cronExpression.trim()) {
showToast('Cron 표현식을 입력해주세요', 'error');
return;
}
setSaving(true);
try {
if (scheduleMode === 'existing') {
await batchApi.updateSchedule(selectedJob, {
cronExpression: cronExpression.trim(),
description: description.trim() || undefined,
});
showToast('스케줄이 수정되었습니다', 'success');
} else {
await batchApi.createSchedule({
jobName: selectedJob,
cronExpression: cronExpression.trim(),
description: description.trim() || undefined,
});
showToast('스케줄이 등록되었습니다', 'success');
}
await loadSchedules();
// Reload schedule info for current job
await handleJobSelect(selectedJob);
} catch (err) {
const message = err instanceof Error ? err.message : '저장 실패';
showToast(message, 'error');
} finally {
setSaving(false);
}
};
const handleToggle = async (schedule: ScheduleResponse) => {
try {
await batchApi.toggleSchedule(schedule.jobName, !schedule.active);
showToast(
`${schedule.jobName} 스케줄이 ${schedule.active ? '비활성화' : '활성화'}되었습니다`,
'success',
);
await loadSchedules();
} catch (err) {
const message = err instanceof Error ? err.message : '토글 실패';
showToast(message, 'error');
}
setConfirmAction(null);
};
const handleDelete = async (schedule: ScheduleResponse) => {
try {
await batchApi.deleteSchedule(schedule.jobName);
showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success');
await loadSchedules();
// Clear form if deleted schedule was selected
if (selectedJob === schedule.jobName) {
setSelectedJob('');
setCronExpression('');
setDescription('');
setScheduleMode('new');
}
} catch (err) {
const message = err instanceof Error ? err.message : '삭제 실패';
showToast(message, 'error');
}
setConfirmAction(null);
};
const handleEditFromCard = (schedule: ScheduleResponse) => {
setSelectedJob(schedule.jobName);
setCronExpression(schedule.cronExpression);
setDescription(schedule.description ?? '');
setScheduleMode('existing');
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div className="space-y-6">
{/* Form Section */}
<div ref={formRef} className="bg-wing-surface rounded-xl shadow-lg p-6">
<h2 className="text-lg font-bold text-wing-text mb-4"> / </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Job Select */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<div className="flex items-center gap-2">
<select
value={selectedJob}
onChange={(e) => handleJobSelect(e.target.value)}
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={formLoading}
>
<option value="">-- --</option>
{jobs.map((job) => (
<option key={job} value={job}>
{job}
</option>
))}
</select>
{selectedJob && (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
scheduleMode === 'existing'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}`}
>
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
</span>
)}
{formLoading && (
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
)}
</div>
</div>
{/* Cron Expression */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
Cron
</label>
<input
type="text"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="0 0/15 * * * ?"
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={!selectedJob || formLoading}
/>
</div>
{/* Cron Presets */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<div className="flex flex-wrap gap-2">
{CRON_PRESETS.map(({ label, cron }) => (
<button
key={cron}
type="button"
onClick={() => setCronExpression(cron)}
disabled={!selectedJob || formLoading}
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
>
{label}
</button>
))}
</div>
</div>
{/* Cron Preview */}
{cronExpression.trim() && (
<CronPreview cron={cronExpression.trim()} />
)}
{/* Description */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="스케줄 설명 (선택)"
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={!selectedJob || formLoading}
/>
</div>
</div>
{/* Save Button */}
<div className="mt-4 flex justify-end">
<button
onClick={handleSave}
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
{/* Schedule List */}
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-wing-text">
{schedules.length > 0 && (
<span className="ml-2 text-sm font-normal text-wing-muted">
({schedules.length})
</span>
)}
</h2>
<button
onClick={loadSchedules}
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</div>
{listLoading ? (
<LoadingSpinner />
) : schedules.length === 0 ? (
<EmptyState message="등록된 스케줄이 없습니다" sub="위 폼에서 새 스케줄을 등록하세요" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{schedules.map((schedule) => (
<div
key={schedule.id}
className="border border-wing-border rounded-xl p-4 hover:shadow-md transition-shadow"
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-bold text-wing-text truncate">
{schedule.jobName}
</h3>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
schedule.active
? 'bg-emerald-100 text-emerald-700'
: 'bg-wing-card text-wing-muted'
}`}
>
{schedule.active ? '활성' : '비활성'}
</span>
{schedule.triggerState && (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getTriggerStateStyle(schedule.triggerState)}`}
>
{schedule.triggerState}
</span>
)}
</div>
</div>
{/* Cron Expression */}
<div className="mb-2">
<span className="inline-block bg-wing-card text-wing-text font-mono text-xs px-2 py-1 rounded">
{schedule.cronExpression}
</span>
</div>
{/* Description */}
{schedule.description && (
<p className="text-sm text-wing-muted mb-3">{schedule.description}</p>
)}
{/* Time Info */}
<div className="grid grid-cols-2 gap-2 text-xs text-wing-muted mb-3">
<div>
<span className="font-medium text-wing-muted"> :</span>{' '}
{formatDateTime(schedule.nextFireTime)}
</div>
<div>
<span className="font-medium text-wing-muted"> :</span>{' '}
{formatDateTime(schedule.previousFireTime)}
</div>
<div>
<span className="font-medium text-wing-muted">:</span>{' '}
{formatDateTime(schedule.createdAt)}
</div>
<div>
<span className="font-medium text-wing-muted">:</span>{' '}
{formatDateTime(schedule.updatedAt)}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-2 border-t border-wing-border/50">
<button
onClick={() => handleEditFromCard(schedule)}
className="flex-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/20 transition-colors"
>
</button>
<button
onClick={() =>
setConfirmAction({ type: 'toggle', schedule })
}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
schedule.active
? 'text-amber-700 bg-amber-50 hover:bg-amber-100'
: 'text-emerald-700 bg-emerald-50 hover:bg-emerald-100'
}`}
>
{schedule.active ? '비활성화' : '활성화'}
</button>
<button
onClick={() =>
setConfirmAction({ type: 'delete', schedule })
}
className="flex-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Confirm Modal */}
{confirmAction?.type === 'toggle' && (
<ConfirmModal
open
title="스케줄 상태 변경"
message={`${confirmAction.schedule.jobName} 스케줄을 ${
confirmAction.schedule.active ? '비활성화' : '활성화'
}하시겠습니까?`}
confirmLabel={confirmAction.schedule.active ? '비활성화' : '활성화'}
onConfirm={() => handleToggle(confirmAction.schedule)}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction?.type === 'delete' && (
<ConfirmModal
open
title="스케줄 삭제"
message={`${confirmAction.schedule.jobName} 스케줄을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`}
confirmLabel="삭제"
confirmColor="bg-red-600 hover:bg-red-700"
onConfirm={() => handleDelete(confirmAction.schedule)}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
);
}