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>
154 lines
4.4 KiB
TypeScript
154 lines
4.4 KiB
TypeScript
/**
|
|
* Quartz 형식 Cron 표현식의 다음 실행 시간을 계산한다.
|
|
* 형식: 초 분 시 일 월 요일
|
|
*/
|
|
export function getNextExecutions(cron: string, count: number): Date[] {
|
|
const parts = cron.trim().split(/\s+/);
|
|
if (parts.length < 6) return [];
|
|
|
|
const [secField, minField, hourField, dayField, monthField, dowField] = parts;
|
|
|
|
if (hasUnsupportedToken(dayField) || hasUnsupportedToken(dowField)) {
|
|
return [];
|
|
}
|
|
|
|
const seconds = parseField(secField, 0, 59);
|
|
const minutes = parseField(minField, 0, 59);
|
|
const hours = parseField(hourField, 0, 23);
|
|
const daysOfMonth = parseField(dayField, 1, 31);
|
|
const months = parseField(monthField, 1, 12);
|
|
const daysOfWeek = parseDowField(dowField);
|
|
|
|
if (!seconds || !minutes || !hours || !months) return [];
|
|
|
|
const results: Date[] = [];
|
|
const now = new Date();
|
|
const cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() + 1);
|
|
cursor.setMilliseconds(0);
|
|
|
|
const limit = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
|
|
|
while (results.length < count && cursor.getTime() <= limit.getTime()) {
|
|
const month = cursor.getMonth() + 1;
|
|
if (!months.includes(month)) {
|
|
cursor.setMonth(cursor.getMonth() + 1, 1);
|
|
cursor.setHours(0, 0, 0, 0);
|
|
continue;
|
|
}
|
|
|
|
const day = cursor.getDate();
|
|
const dayMatches = daysOfMonth ? daysOfMonth.includes(day) : true;
|
|
const dowMatches = daysOfWeek ? daysOfWeek.includes(cursor.getDay()) : true;
|
|
|
|
const needDayCheck = dayField !== '?' && dowField !== '?';
|
|
const dayOk = needDayCheck ? dayMatches && dowMatches : dayMatches && dowMatches;
|
|
|
|
if (!dayOk) {
|
|
cursor.setDate(cursor.getDate() + 1);
|
|
cursor.setHours(0, 0, 0, 0);
|
|
continue;
|
|
}
|
|
|
|
const hour = cursor.getHours();
|
|
if (!hours.includes(hour)) {
|
|
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
|
|
continue;
|
|
}
|
|
|
|
const minute = cursor.getMinutes();
|
|
if (!minutes.includes(minute)) {
|
|
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
|
|
continue;
|
|
}
|
|
|
|
const second = cursor.getSeconds();
|
|
if (!seconds.includes(second)) {
|
|
cursor.setSeconds(cursor.getSeconds() + 1, 0);
|
|
continue;
|
|
}
|
|
|
|
results.push(new Date(cursor));
|
|
cursor.setSeconds(cursor.getSeconds() + 1);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function hasUnsupportedToken(field: string): boolean {
|
|
return /[LW#]/.test(field);
|
|
}
|
|
|
|
function parseField(field: string, min: number, max: number): number[] | null {
|
|
if (field === '?') return null;
|
|
if (field === '*') return range(min, max);
|
|
|
|
const values = new Set<number>();
|
|
|
|
for (const part of field.split(',')) {
|
|
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
|
if (stepMatch) {
|
|
const [, base, stepStr] = stepMatch;
|
|
const step = parseInt(stepStr, 10);
|
|
let start = min;
|
|
let end = max;
|
|
|
|
if (base === '*') {
|
|
start = min;
|
|
} else if (base.includes('-')) {
|
|
const [lo, hi] = base.split('-').map(Number);
|
|
start = lo;
|
|
end = hi;
|
|
} else {
|
|
start = parseInt(base, 10);
|
|
}
|
|
|
|
for (let v = start; v <= end; v += step) {
|
|
if (v >= min && v <= max) values.add(v);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
|
if (rangeMatch) {
|
|
const lo = parseInt(rangeMatch[1], 10);
|
|
const hi = parseInt(rangeMatch[2], 10);
|
|
for (let v = lo; v <= hi; v++) {
|
|
if (v >= min && v <= max) values.add(v);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const num = parseInt(part, 10);
|
|
if (!isNaN(num) && num >= min && num <= max) {
|
|
values.add(num);
|
|
}
|
|
}
|
|
|
|
return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : range(min, max);
|
|
}
|
|
|
|
function parseDowField(field: string): number[] | null {
|
|
if (field === '?' || field === '*') return null;
|
|
|
|
const dayMap: Record<string, string> = {
|
|
SUN: '0', MON: '1', TUE: '2', WED: '3', THU: '4', FRI: '5', SAT: '6',
|
|
};
|
|
|
|
let normalized = field.toUpperCase();
|
|
for (const [name, num] of Object.entries(dayMap)) {
|
|
normalized = normalized.replace(new RegExp(name, 'g'), num);
|
|
}
|
|
|
|
// Quartz uses 1=SUN..7=SAT, convert to JS 0=SUN..6=SAT
|
|
const parsed = parseField(normalized, 1, 7);
|
|
if (!parsed) return null;
|
|
|
|
return parsed.map((v) => v - 1);
|
|
}
|
|
|
|
function range(min: number, max: number): number[] {
|
|
const result: number[] = [];
|
|
for (let i = min; i <= max; i++) result.push(i);
|
|
return result;
|
|
}
|