import { useState, useMemo, useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { batchApi, type JobExecutionDto, type ExecutionSearchResponse } from '../api/batchApi'; import { formatDateTime, calculateDuration } from '../utils/formatters'; import { usePoller } from '../hooks/usePoller'; import { useToastContext } from '../contexts/ToastContext'; import StatusBadge from '../components/StatusBadge'; import ConfirmModal from '../components/ConfirmModal'; import InfoModal from '../components/InfoModal'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED' | 'STOPPED'; const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [ { value: 'ALL', label: '전체' }, { value: 'COMPLETED', label: '완료' }, { value: 'FAILED', label: '실패' }, { value: 'STARTED', label: '실행중' }, { value: 'STOPPED', label: '중지됨' }, ]; const POLLING_INTERVAL_MS = 5000; const RECENT_LIMIT = 50; const PAGE_SIZE = 50; export default function Executions() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const jobFromQuery = searchParams.get('job') || ''; const [jobs, setJobs] = useState([]); const [executions, setExecutions] = useState([]); const [selectedJobs, setSelectedJobs] = useState(jobFromQuery ? [jobFromQuery] : []); const [jobDropdownOpen, setJobDropdownOpen] = useState(false); const [statusFilter, setStatusFilter] = useState('ALL'); const [loading, setLoading] = useState(true); const [stopTarget, setStopTarget] = useState(null); // F1: 강제 종료 const [abandonTarget, setAbandonTarget] = useState(null); // F4: 날짜 범위 필터 + 페이지네이션 const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [page, setPage] = useState(0); const [totalPages, setTotalPages] = useState(0); const [totalCount, setTotalCount] = useState(0); const [useSearch, setUseSearch] = useState(false); // F9: 실패 로그 뷰어 const [failLogTarget, setFailLogTarget] = useState(null); const { showToast } = useToastContext(); const loadJobs = useCallback(async () => { try { const data = await batchApi.getJobs(); setJobs(data); } catch { /* Job 목록 로드 실패는 무시 */ } }, []); const loadSearchExecutions = useCallback(async (targetPage: number) => { try { setLoading(true); const params: { jobNames?: string[]; status?: string; startDate?: string; endDate?: string; page?: number; size?: number; } = { page: targetPage, size: PAGE_SIZE, }; if (selectedJobs.length > 0) params.jobNames = selectedJobs; if (statusFilter !== 'ALL') params.status = statusFilter; if (startDate) params.startDate = `${startDate}T00:00:00`; if (endDate) params.endDate = `${endDate}T23:59:59`; const data: ExecutionSearchResponse = await batchApi.searchExecutions(params); setExecutions(data.executions); setTotalPages(data.totalPages); setTotalCount(data.totalCount); setPage(data.page); } catch { setExecutions([]); setTotalPages(0); setTotalCount(0); } finally { setLoading(false); } }, [selectedJobs, statusFilter, startDate, endDate]); const loadExecutions = useCallback(async () => { // 검색 모드에서는 폴링하지 않음 (검색 버튼 클릭 시에만 1회 조회) if (useSearch) return; try { let data: JobExecutionDto[]; if (selectedJobs.length === 1) { data = await batchApi.getJobExecutions(selectedJobs[0]); } else if (selectedJobs.length > 1) { // 복수 Job 선택 시 search API 사용 const result = await batchApi.searchExecutions({ jobNames: selectedJobs, size: RECENT_LIMIT, }); data = result.executions; } else { try { data = await batchApi.getRecentExecutions(RECENT_LIMIT); } catch { data = []; } } setExecutions(data); } catch { setExecutions([]); } finally { setLoading(false); } }, [selectedJobs, useSearch, page, loadSearchExecutions]); /* 마운트 시 Job 목록 1회 로드 */ usePoller(loadJobs, 60_000, []); /* 실행 이력 5초 폴링 */ usePoller(loadExecutions, POLLING_INTERVAL_MS, [selectedJobs, useSearch, page]); const filteredExecutions = useMemo(() => { // 검색 모드에서는 서버 필터링 사용 if (useSearch) return executions; if (statusFilter === 'ALL') return executions; return executions.filter((e) => e.status === statusFilter); }, [executions, statusFilter, useSearch]); const toggleJob = (jobName: string) => { setSelectedJobs((prev) => { const next = prev.includes(jobName) ? prev.filter((j) => j !== jobName) : [...prev, jobName]; if (next.length === 1) { setSearchParams({ job: next[0] }); } else { setSearchParams({}); } return next; }); setLoading(true); if (useSearch) { setPage(0); } }; const clearSelectedJobs = () => { setSelectedJobs([]); setSearchParams({}); setLoading(true); if (useSearch) { setPage(0); } }; const handleStop = async () => { if (!stopTarget) return; try { const result = await batchApi.stopExecution(stopTarget.executionId); showToast(result.message || '실행이 중지되었습니다.', 'success'); } catch (err) { showToast( err instanceof Error ? err.message : '중지 요청에 실패했습니다.', 'error', ); } finally { setStopTarget(null); } }; // F1: 강제 종료 핸들러 const handleAbandon = async () => { if (!abandonTarget) return; try { const result = await batchApi.abandonExecution(abandonTarget.executionId); showToast(result.message || '실행이 강제 종료되었습니다.', 'success'); } catch (err) { showToast( err instanceof Error ? err.message : '강제 종료 요청에 실패했습니다.', 'error', ); } finally { setAbandonTarget(null); } }; // F4: 검색 핸들러 const handleSearch = async () => { setUseSearch(true); setPage(0); await loadSearchExecutions(0); }; // F4: 초기화 핸들러 const handleResetSearch = () => { setUseSearch(false); setStartDate(''); setEndDate(''); setPage(0); setTotalPages(0); setTotalCount(0); setLoading(true); }; // F4: 페이지 이동 핸들러 const handlePageChange = (newPage: number) => { if (newPage < 0 || newPage >= totalPages) return; setPage(newPage); loadSearchExecutions(newPage); }; const isRunning = (status: string) => status === 'STARTED' || status === 'STARTING'; return (
{/* 헤더 */}

실행 이력

배치 작업 실행 이력을 조회하고 관리합니다.

{/* 필터 영역 */}
{/* Job 멀티 선택 */}
{jobDropdownOpen && ( <>
setJobDropdownOpen(false)} />
{jobs.map((job) => ( ))}
)}
{selectedJobs.length > 0 && ( )}
{/* 선택된 Job 칩 */} {selectedJobs.length > 0 && (
{selectedJobs.map((job) => ( {job} ))}
)}
{/* 상태 필터 버튼 그룹 */}
{STATUS_FILTERS.map(({ value, label }) => ( ))}
{/* F4: 날짜 범위 필터 */}
setStartDate(e.target.value)} className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" /> ~ setEndDate(e.target.value)} className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" />
{useSearch && ( )}
{/* 실행 이력 테이블 */}
{loading ? ( ) : filteredExecutions.length === 0 ? ( 0 ? '선택한 작업의 실행 이력이 없습니다.' : undefined } /> ) : (
{filteredExecutions.map((exec) => ( ))}
실행 ID 작업명 상태 시작시간 종료시간 소요시간 액션
#{exec.executionId} {exec.jobName} {/* F9: FAILED 상태 클릭 시 실패 로그 모달 */} {exec.status === 'FAILED' ? ( ) : ( )} {formatDateTime(exec.startTime)} {formatDateTime(exec.endTime)} {calculateDuration( exec.startTime, exec.endTime, )}
{isRunning(exec.status) ? ( <> {/* F1: 강제 종료 버튼 */} ) : ( )}
)} {/* 결과 건수 표시 + F4: 페이지네이션 */} {!loading && filteredExecutions.length > 0 && (
{useSearch ? ( <>총 {totalCount}건 ) : ( <> 총 {filteredExecutions.length}건 {statusFilter !== 'ALL' && ( (전체 {executions.length}건 중) )} )}
{/* F4: 페이지네이션 UI */} {useSearch && totalPages > 1 && (
{page + 1} / {totalPages}
)}
)}
{/* 중지 확인 모달 */} setStopTarget(null)} /> {/* F1: 강제 종료 확인 모달 */} setAbandonTarget(null)} /> {/* F9: 실패 로그 뷰어 모달 */} setFailLogTarget(null)} > {failLogTarget && (

Exit Code

{failLogTarget.exitCode || '-'}

Exit Message

                                {failLogTarget.exitMessage || '메시지 없음'}
                            
)}
); }