import { useState, useMemo, useCallback, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { batchApi, type JobExecutionDto, type ExecutionSearchResponse, type JobDisplayName } 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'; import GuideModal, { HelpButton } from '../components/GuideModal'; 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; const EXECUTIONS_GUIDE = [ { title: '작업 필터', content: '상단의 드롭다운에서 조회할 작업을 선택할 수 있습니다.\n여러 작업을 동시에 선택할 수 있으며, 단축 버튼으로 빠르게 필터링할 수 있습니다.\n• 전체: 모든 작업 표시\n• AIS 제외: AIS 관련 작업을 제외하고 표시\n• AIS만: AIS 관련 작업만 표시', }, { title: '상태 필터', content: '완료 / 실패 / 실행중 / 중지됨 버튼으로 상태별 필터링이 가능합니다.\n"전체"를 선택하면 모든 상태의 실행 이력을 볼 수 있습니다.', }, { title: '날짜 검색', content: '시작일과 종료일을 지정하여 특정 기간의 실행 이력을 조회할 수 있습니다.\n"검색" 버튼을 클릭하면 조건에 맞는 결과가 표시됩니다.\n"초기화" 버튼으로 검색 조건을 제거하고 최신 이력으로 돌아갑니다.', }, { title: '실행 중인 작업 제어', content: '실행 중인 작업의 행에서 "중지" 또는 "강제 종료" 버튼을 사용할 수 있습니다.\n• 중지: 현재 Step 완료 후 안전하게 종료\n• 강제 종료: 즉시 중단 (데이터 정합성 주의)', }, { title: '실패 로그 확인', content: '상태가 "FAILED"인 행을 클릭하면 실패 상세 정보를 확인할 수 있습니다.\n종료 코드(Exit Code)와 에러 메시지로 실패 원인을 파악하세요.\n상태가 "COMPLETED"이지만 실패 건수가 있으면 경고 아이콘이 표시됩니다.', }, ]; export default function Executions() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const jobFromQuery = searchParams.get('job') || ''; const [jobs, setJobs] = useState([]); const [displayNames, setDisplayNames] = 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 [guideOpen, setGuideOpen] = useState(false); const { showToast } = useToastContext(); useEffect(() => { batchApi.getDisplayNames().then(setDisplayNames).catch(() => {}); }, []); const displayNameMap = useMemo>(() => { const map: Record = {}; for (const dn of displayNames) { map[dn.jobName] = dn.displayName; } return map; }, [displayNames]); 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 (
{/* 헤더 */}

실행 이력

setGuideOpen(true)} />

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

{/* 필터 영역 */}
{/* Job 멀티 선택 */}
{jobDropdownOpen && ( <>
setJobDropdownOpen(false)} />
{jobs.map((job) => ( ))}
)}
{selectedJobs.length > 0 && ( )}
{/* 선택된 Job 칩 */} {selectedJobs.length > 0 && (
{selectedJobs.map((job) => ( {displayNameMap[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} {displayNameMap[exec.jobName] || exec.jobName}
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */} {exec.status === 'FAILED' ? ( ) : ( )} {exec.status === 'COMPLETED' && exec.failedRecordCount != null && exec.failedRecordCount > 0 && ( {exec.failedRecordCount} )}
{formatDateTime(exec.startTime)} {formatDateTime(exec.endTime)} {calculateDuration( exec.startTime, exec.endTime, )}
{isRunning(exec.status) && ( <> )}
)} {/* 결과 건수 표시 + 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)} /> setGuideOpen(false)} pageTitle="실행 이력" sections={EXECUTIONS_GUIDE} /> {/* F9: 실패 로그 뷰어 모달 */} setFailLogTarget(null)} > {failLogTarget && (

Exit Code

{failLogTarget.exitCode || '-'}

Exit Message

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