snp-batch-validation/frontend/src/pages/Executions.tsx
HYOJIN 875ef2b7bc refactor: AIS 수집 및 서비스 API 제거 (#99)
- aistarget, aistargetdbsync 패키지 전체 삭제 (34개 파일)
- Kafka, JTS 의존성 제거
- API URL 환경별 중복 제거 (application.yml 공통 관리)
- 프론트엔드 AIS 필터 버튼 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:55:11 +09:00

660 lines
33 KiB
TypeScript

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<string[]>([]);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const [executions, setExecutions] = useState<JobExecutionDto[]>([]);
const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []);
const [jobDropdownOpen, setJobDropdownOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [loading, setLoading] = useState(true);
const [stopTarget, setStopTarget] = useState<JobExecutionDto | null>(null);
// F1: 강제 종료
const [abandonTarget, setAbandonTarget] = useState<JobExecutionDto | null>(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<JobExecutionDto | null>(null);
const [guideOpen, setGuideOpen] = useState(false);
const { showToast } = useToastContext();
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
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 (
<div className="space-y-6">
{/* 헤더 */}
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<p className="mt-1 text-sm text-wing-muted">
.
</p>
</div>
{/* 필터 영역 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="space-y-3">
{/* Job 멀티 선택 */}
<div>
<div className="flex items-center gap-3 mb-2">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="relative">
<button
onClick={() => setJobDropdownOpen((v) => !v)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
>
{selectedJobs.length === 0
? `전체 (최근 ${RECENT_LIMIT}건)`
: `${selectedJobs.length}개 선택됨`}
<svg className={`w-4 h-4 text-wing-muted transition-transform ${jobDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{jobDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setJobDropdownOpen(false)} />
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
{jobs.map((job) => (
<label
key={job}
className="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-wing-hover transition-colors"
>
<input
type="checkbox"
checked={selectedJobs.includes(job)}
onChange={() => toggleJob(job)}
className="rounded border-wing-border text-wing-accent focus:ring-wing-accent"
/>
<span className="text-wing-text truncate">{displayNameMap[job] || job}</span>
</label>
))}
</div>
</>
)}
</div>
<div className="flex gap-1.5">
<button
onClick={() => setSelectedJobs([])}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
selectedJobs.length === 0
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
</button>
</div>
{selectedJobs.length > 0 && (
<button
onClick={clearSelectedJobs}
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
>
</button>
)}
</div>
{/* 선택된 Job 칩 */}
{selectedJobs.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedJobs.map((job) => (
<span
key={job}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full"
>
{displayNameMap[job] || job}
<button
onClick={() => toggleJob(job)}
className="hover:text-wing-text transition-colors"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* 상태 필터 버튼 그룹 */}
<div className="flex flex-wrap gap-1">
{STATUS_FILTERS.map(({ value, label }) => (
<button
key={value}
onClick={() => {
setStatusFilter(value);
if (useSearch) {
setPage(0);
}
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
statusFilter === value
? 'bg-wing-accent text-white shadow-sm'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* F4: 날짜 범위 필터 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => 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"
/>
<span className="text-wing-muted text-sm">~</span>
<input
type="date"
value={endDate}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSearch}
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
>
</button>
{useSearch && (
<button
onClick={handleResetSearch}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
)}
</div>
</div>
</div>
{/* 실행 이력 테이블 */}
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
{loading ? (
<LoadingSpinner />
) : filteredExecutions.length === 0 ? (
<EmptyState
message="실행 이력이 없습니다."
sub={
statusFilter !== 'ALL'
? '다른 상태 필터를 선택해 보세요.'
: selectedJobs.length > 0
? '선택한 작업의 실행 이력이 없습니다.'
: undefined
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
<tr>
<th className="px-6 py-3 font-medium"> ID</th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium text-right">
</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{filteredExecutions.map((exec) => (
<tr
key={exec.executionId}
className="hover:bg-wing-hover transition-colors"
>
<td className="px-6 py-4 font-mono text-wing-text">
#{exec.executionId}
</td>
<td className="px-6 py-4 text-wing-text">
{displayNameMap[exec.jobName] || exec.jobName}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5">
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
{exec.status === 'FAILED' ? (
<button
onClick={() => setFailLogTarget(exec)}
className="cursor-pointer"
title="클릭하여 실패 로그 확인"
>
<StatusBadge status={exec.status} />
</button>
) : (
<StatusBadge status={exec.status} />
)}
{exec.status === 'COMPLETED' && exec.failedRecordCount != null && exec.failedRecordCount > 0 && (
<span
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 rounded-full"
title={`미해결 실패 레코드 ${exec.failedRecordCount}`}
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{exec.failedRecordCount}
</span>
)}
</div>
</td>
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
{formatDateTime(exec.startTime)}
</td>
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
{formatDateTime(exec.endTime)}
</td>
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
{calculateDuration(
exec.startTime,
exec.endTime,
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{isRunning(exec.status) && (
<>
<button
onClick={() =>
setStopTarget(exec)
}
className="inline-flex items-center gap-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>
<button
onClick={() =>
setAbandonTarget(exec)
}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-colors"
>
</button>
</>
)}
<button
onClick={() =>
navigate(
`/executions/${exec.executionId}`,
)
}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 결과 건수 표시 + F4: 페이지네이션 */}
{!loading && filteredExecutions.length > 0 && (
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
<div className="text-xs text-wing-muted">
{useSearch ? (
<> {totalCount}</>
) : (
<>
{filteredExecutions.length}
{statusFilter !== 'ALL' && (
<span className="ml-1">
( {executions.length} )
</span>
)}
</>
)}
</div>
{/* F4: 페이지네이션 UI */}
{useSearch && totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
>
</button>
<span className="text-xs text-wing-muted">
{page + 1} / {totalPages}
</span>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
>
</button>
</div>
)}
</div>
)}
</div>
{/* 중지 확인 모달 */}
<ConfirmModal
open={stopTarget !== null}
title="실행 중지"
message={
stopTarget
? `실행 #${stopTarget.executionId} (${stopTarget.jobName})을 중지하시겠습니까?`
: ''
}
confirmLabel="중지"
confirmColor="bg-red-600 hover:bg-red-700"
onConfirm={handleStop}
onCancel={() => setStopTarget(null)}
/>
{/* F1: 강제 종료 확인 모달 */}
<ConfirmModal
open={abandonTarget !== null}
title="강제 종료"
message={
abandonTarget
? `실행 #${abandonTarget.executionId} (${abandonTarget.jobName})을 강제 종료하시겠습니까?\n\n강제 종료는 실행 상태를 ABANDONED로 변경합니다.`
: ''
}
confirmLabel="강제 종료"
confirmColor="bg-amber-600 hover:bg-amber-700"
onConfirm={handleAbandon}
onCancel={() => setAbandonTarget(null)}
/>
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="실행 이력"
sections={EXECUTIONS_GUIDE}
/>
{/* F9: 실패 로그 뷰어 모달 */}
<InfoModal
open={failLogTarget !== null}
title={
failLogTarget
? `실패 로그 - #${failLogTarget.executionId} (${failLogTarget.jobName})`
: '실패 로그'
}
onClose={() => setFailLogTarget(null)}
>
{failLogTarget && (
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
Exit Code
</h4>
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
{failLogTarget.exitCode || '-'}
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
Exit Message
</h4>
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{failLogTarget.exitMessage || '메시지 없음'}
</pre>
</div>
</div>
)}
</InfoModal>
</div>
);
}