snp-batch-validation/frontend/src/utils/cronPreview.ts
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

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;
}