- aistarget, aistargetdbsync 패키지 전체 삭제 (34개 파일) - Kafka, JTS 의존성 제거 - API URL 환경별 중복 제거 (application.yml 공통 관리) - 프론트엔드 AIS 필터 버튼 제거 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
660 lines
33 KiB
TypeScript
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"
|
|
>
|
|
×
|
|
</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>
|
|
);
|
|
}
|