491 lines
18 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|