feat(jobs): 배치 작업 목록 UX 개선 및 즉시 실행 버튼 이동 (#33) #35
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- 자동 재수집 및 재수집 프로세스 전면 개선 (#30)
|
- 자동 재수집 및 재수집 프로세스 전면 개선 (#30)
|
||||||
|
- 배치 작업 목록 UX 개선: 상태 필터, 카드/테이블 뷰, 정렬, 실행 중 강조 (#33)
|
||||||
|
|
||||||
### 기타
|
### 기타
|
||||||
- 팀 워크플로우 v1.6.1 동기화
|
- 팀 워크플로우 v1.6.1 동기화
|
||||||
|
|||||||
@ -46,13 +46,6 @@ export default function Dashboard() {
|
|||||||
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Execute Job modal
|
|
||||||
const [showExecuteModal, setShowExecuteModal] = useState(false);
|
|
||||||
const [jobs, setJobs] = useState<string[]>([]);
|
|
||||||
const [selectedJob, setSelectedJob] = useState('');
|
|
||||||
const [executing, setExecuting] = useState(false);
|
|
||||||
const [startDate, setStartDate] = useState('');
|
|
||||||
const [stopDate, setStopDate] = useState('');
|
|
||||||
const [abandoning, setAbandoning] = useState(false);
|
const [abandoning, setAbandoning] = useState(false);
|
||||||
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
||||||
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null);
|
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null);
|
||||||
@ -96,44 +89,6 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
usePoller(loadDashboard, POLLING_INTERVAL);
|
usePoller(loadDashboard, POLLING_INTERVAL);
|
||||||
|
|
||||||
const handleOpenExecuteModal = async () => {
|
|
||||||
try {
|
|
||||||
const jobList = await batchApi.getJobs();
|
|
||||||
setJobs(jobList);
|
|
||||||
setSelectedJob(jobList[0] ?? '');
|
|
||||||
setShowExecuteModal(true);
|
|
||||||
} catch (err) {
|
|
||||||
showToast('작업 목록을 불러올 수 없습니다.', 'error');
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExecuteJob = async () => {
|
|
||||||
if (!selectedJob) return;
|
|
||||||
setExecuting(true);
|
|
||||||
try {
|
|
||||||
const params: Record<string, string> = {};
|
|
||||||
if (startDate) params.startDate = startDate;
|
|
||||||
if (stopDate) params.stopDate = stopDate;
|
|
||||||
const result = await batchApi.executeJob(
|
|
||||||
selectedJob,
|
|
||||||
Object.keys(params).length > 0 ? params : undefined,
|
|
||||||
);
|
|
||||||
showToast(
|
|
||||||
result.message || `${selectedJob} 실행 요청 완료`,
|
|
||||||
'success',
|
|
||||||
);
|
|
||||||
setShowExecuteModal(false);
|
|
||||||
setStartDate('');
|
|
||||||
setStopDate('');
|
|
||||||
await loadDashboard();
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setExecuting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAbandonAllStale = async () => {
|
const handleAbandonAllStale = async () => {
|
||||||
setAbandoning(true);
|
setAbandoning(true);
|
||||||
try {
|
try {
|
||||||
@ -173,13 +128,6 @@ export default function Dashboard() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<h1 className="text-2xl font-bold text-wing-text">대시보드</h1>
|
<h1 className="text-2xl font-bold text-wing-text">대시보드</h1>
|
||||||
<button
|
|
||||||
onClick={handleOpenExecuteModal}
|
|
||||||
className="px-4 py-2 bg-wing-accent text-white font-semibold rounded-lg shadow
|
|
||||||
hover:bg-wing-accent/80 hover:shadow-lg transition-all text-sm"
|
|
||||||
>
|
|
||||||
작업 즉시 실행
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* F1: Stale Execution Warning Banner */}
|
{/* F1: Stale Execution Warning Banner */}
|
||||||
@ -517,73 +465,6 @@ export default function Dashboard() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Execute Job Modal */}
|
|
||||||
{showExecuteModal && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
|
||||||
onClick={() => setShowExecuteModal(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-semibold text-wing-text mb-4">작업 즉시 실행</h3>
|
|
||||||
<label className="block text-sm font-medium text-wing-text mb-2">
|
|
||||||
실행할 작업 선택
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedJob}
|
|
||||||
onChange={(e) => setSelectedJob(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
|
||||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
|
|
||||||
>
|
|
||||||
{jobs.map((job) => (
|
|
||||||
<option key={job} value={job}>{job}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
|
||||||
시작일시 <span className="text-wing-muted font-normal">(선택)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
|
||||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
|
||||||
종료일시 <span className="text-wing-muted font-normal">(선택)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={stopDate}
|
|
||||||
onChange={(e) => setStopDate(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
|
||||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowExecuteModal(false)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
|
||||||
hover:bg-wing-hover transition-colors"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleExecuteJob}
|
|
||||||
disabled={executing || !selectedJob}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
|
|
||||||
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{executing ? '실행 중...' : '실행'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,44 @@ import { useToastContext } from '../contexts/ToastContext';
|
|||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import EmptyState from '../components/EmptyState';
|
import EmptyState from '../components/EmptyState';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
import { formatDateTime } from '../utils/formatters';
|
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||||
|
|
||||||
const POLLING_INTERVAL = 30000;
|
const POLLING_INTERVAL = 30000;
|
||||||
|
|
||||||
|
type StatusFilterKey = 'ALL' | 'STARTED' | 'COMPLETED' | 'FAILED' | 'NONE';
|
||||||
|
type SortKey = 'name' | 'recent' | 'status';
|
||||||
|
type ViewMode = 'card' | 'table';
|
||||||
|
|
||||||
|
interface StatusTabConfig {
|
||||||
|
key: StatusFilterKey;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TABS: StatusTabConfig[] = [
|
||||||
|
{ key: 'ALL', label: '전체' },
|
||||||
|
{ key: 'STARTED', label: '실행 중' },
|
||||||
|
{ key: 'COMPLETED', label: '성공' },
|
||||||
|
{ key: 'FAILED', label: '실패' },
|
||||||
|
{ key: 'NONE', label: '미실행' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_ORDER: Record<string, number> = {
|
||||||
|
FAILED: 0,
|
||||||
|
STARTED: 1,
|
||||||
|
COMPLETED: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusOrder(job: JobDetailDto): number {
|
||||||
|
if (!job.lastExecution) return 3;
|
||||||
|
return STATUS_ORDER[job.lastExecution.status] ?? 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesStatusFilter(job: JobDetailDto, filter: StatusFilterKey): boolean {
|
||||||
|
if (filter === 'ALL') return true;
|
||||||
|
if (filter === 'NONE') return job.lastExecution === null;
|
||||||
|
return job.lastExecution?.status === filter;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Jobs() {
|
export default function Jobs() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
@ -18,14 +52,18 @@ export default function Jobs() {
|
|||||||
const [jobs, setJobs] = useState<JobDetailDto[]>([]);
|
const [jobs, setJobs] = useState<JobDetailDto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>('ALL');
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('card');
|
||||||
|
|
||||||
// Execute modal
|
// Execute modal (individual card)
|
||||||
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||||
const [targetJob, setTargetJob] = useState('');
|
const [targetJob, setTargetJob] = useState('');
|
||||||
const [executing, setExecuting] = useState(false);
|
const [executing, setExecuting] = useState(false);
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [stopDate, setStopDate] = useState('');
|
const [stopDate, setStopDate] = useState('');
|
||||||
|
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await batchApi.getJobsDetail();
|
const data = await batchApi.getJobsDetail();
|
||||||
@ -39,12 +77,48 @@ export default function Jobs() {
|
|||||||
|
|
||||||
usePoller(loadJobs, POLLING_INTERVAL);
|
usePoller(loadJobs, POLLING_INTERVAL);
|
||||||
|
|
||||||
const filteredJobs = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
if (!searchTerm.trim()) return jobs;
|
const searchFiltered = searchTerm.trim()
|
||||||
const term = searchTerm.toLowerCase();
|
? jobs.filter((job) => job.jobName.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
return jobs.filter((job) => job.jobName.toLowerCase().includes(term));
|
: jobs;
|
||||||
|
|
||||||
|
return STATUS_TABS.reduce<Record<StatusFilterKey, number>>(
|
||||||
|
(acc, tab) => {
|
||||||
|
acc[tab.key] = searchFiltered.filter((job) => matchesStatusFilter(job, tab.key)).length;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ ALL: 0, STARTED: 0, COMPLETED: 0, FAILED: 0, NONE: 0 },
|
||||||
|
);
|
||||||
}, [jobs, searchTerm]);
|
}, [jobs, searchTerm]);
|
||||||
|
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
let result = jobs;
|
||||||
|
|
||||||
|
if (searchTerm.trim()) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
result = result.filter((job) => job.jobName.toLowerCase().includes(term));
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.filter((job) => matchesStatusFilter(job, statusFilter));
|
||||||
|
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
if (sortKey === 'name') {
|
||||||
|
return a.jobName.localeCompare(b.jobName);
|
||||||
|
}
|
||||||
|
if (sortKey === 'recent') {
|
||||||
|
const aTime = a.lastExecution?.startTime ? new Date(a.lastExecution.startTime).getTime() : 0;
|
||||||
|
const bTime = b.lastExecution?.startTime ? new Date(b.lastExecution.startTime).getTime() : 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
}
|
||||||
|
if (sortKey === 'status') {
|
||||||
|
return getStatusOrder(a) - getStatusOrder(b);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [jobs, searchTerm, statusFilter, sortKey]);
|
||||||
|
|
||||||
const handleExecuteClick = (jobName: string) => {
|
const handleExecuteClick = (jobName: string) => {
|
||||||
setTargetJob(jobName);
|
setTargetJob(jobName);
|
||||||
setStartDate('');
|
setStartDate('');
|
||||||
@ -95,38 +169,115 @@ export default function Jobs() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Filter */}
|
{/* Status Filter Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{STATUS_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setStatusFilter(tab.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
statusFilter === tab.key
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold ${
|
||||||
|
statusFilter === tab.key
|
||||||
|
? 'bg-white/25 text-white'
|
||||||
|
: 'bg-wing-border text-wing-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusCounts[tab.key]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Sort + View Toggle */}
|
||||||
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
<div className="relative">
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
{/* Search */}
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
<path
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||||
strokeLinecap="round"
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
strokeLinejoin="round"
|
<path
|
||||||
strokeWidth={2}
|
strokeLinecap="round"
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
strokeLinejoin="round"
|
||||||
/>
|
strokeWidth={2}
|
||||||
</svg>
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
</span>
|
/>
|
||||||
<input
|
</svg>
|
||||||
type="text"
|
</span>
|
||||||
placeholder="작업명 검색..."
|
<input
|
||||||
value={searchTerm}
|
type="text"
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
placeholder="작업명 검색..."
|
||||||
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort dropdown */}
|
||||||
|
<select
|
||||||
|
value={sortKey}
|
||||||
|
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||||
|
className="px-3 py-2 border border-wing-border rounded-lg text-sm bg-wing-surface text-wing-text
|
||||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
/>
|
>
|
||||||
{searchTerm && (
|
<option value="name">작업명순</option>
|
||||||
|
<option value="recent">최신 실행순</option>
|
||||||
|
<option value="status">상태별(실패 우선)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchTerm('')}
|
onClick={() => setViewMode('card')}
|
||||||
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
title="카드 보기"
|
||||||
|
className={`px-3 py-2 transition-colors ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
title="테이블 보기"
|
||||||
|
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<p className="mt-2 text-xs text-wing-muted">
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
{filteredJobs.length}개 작업 검색됨
|
{filteredJobs.length}개 작업 검색됨
|
||||||
@ -134,69 +285,206 @@ export default function Jobs() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Job Cards Grid */}
|
{/* Job List */}
|
||||||
{filteredJobs.length === 0 ? (
|
{filteredJobs.length === 0 ? (
|
||||||
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="🔍"
|
icon="🔍"
|
||||||
message={searchTerm ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
|
message={searchTerm || statusFilter !== 'ALL' ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
|
||||||
sub={searchTerm ? '다른 검색어를 입력해 보세요.' : undefined}
|
sub={searchTerm || statusFilter !== 'ALL' ? '다른 검색어나 필터를 사용해 보세요.' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : viewMode === 'card' ? (
|
||||||
|
/* Card View */
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredJobs.map((job) => (
|
{filteredJobs.map((job) => {
|
||||||
<div
|
const isRunning = job.lastExecution?.status === 'STARTED';
|
||||||
key={job.jobName}
|
const duration = job.lastExecution
|
||||||
className="bg-wing-surface rounded-xl shadow-md p-6
|
? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime)
|
||||||
hover:shadow-lg hover:-translate-y-0.5 transition-all"
|
: null;
|
||||||
>
|
const showDuration =
|
||||||
<div className="flex items-start justify-between mb-3">
|
job.lastExecution?.endTime != null && duration !== null && duration !== '-';
|
||||||
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
|
||||||
{job.jobName}
|
|
||||||
</h3>
|
|
||||||
{job.lastExecution && (
|
|
||||||
<StatusBadge status={job.lastExecution.status} className="ml-2 shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Job detail info */}
|
return (
|
||||||
<div className="mb-4 space-y-1">
|
<div
|
||||||
{job.lastExecution ? (
|
key={job.jobName}
|
||||||
<p className="text-xs text-wing-muted">
|
className={`bg-wing-surface rounded-xl shadow-md p-6
|
||||||
마지막 실행: {formatDateTime(job.lastExecution.startTime)}
|
hover:shadow-lg hover:-translate-y-0.5 transition-all
|
||||||
</p>
|
${isRunning ? 'border-l-4 border-emerald-500' : ''}`}
|
||||||
) : (
|
>
|
||||||
<p className="text-wing-muted text-xs">실행 이력 없음</p>
|
<div className="flex items-start justify-between mb-3">
|
||||||
)}
|
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
|
||||||
{job.scheduleCron && (
|
{job.jobName}
|
||||||
<p className="text-xs text-wing-muted">
|
</h3>
|
||||||
스케줄:{' '}
|
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||||
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">
|
{isRunning && (
|
||||||
{job.scheduleCron}
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
</span>
|
)}
|
||||||
</p>
|
{job.lastExecution && (
|
||||||
)}
|
<StatusBadge status={job.lastExecution.status} />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{/* Job detail info */}
|
||||||
<button
|
<div className="mb-4 space-y-1">
|
||||||
onClick={() => handleExecuteClick(job.jobName)}
|
{job.lastExecution ? (
|
||||||
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-wing-accent rounded-lg
|
<>
|
||||||
hover:bg-wing-accent/80 transition-colors"
|
<p className="text-xs text-wing-muted">
|
||||||
>
|
마지막 실행: {formatDateTime(job.lastExecution.startTime)}
|
||||||
실행
|
</p>
|
||||||
</button>
|
{showDuration && (
|
||||||
<button
|
<p className="text-xs text-wing-muted">
|
||||||
onClick={() => handleViewHistory(job.jobName)}
|
소요 시간: {duration}
|
||||||
className="flex-1 px-3 py-2 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
|
</p>
|
||||||
hover:bg-wing-accent/15 transition-colors"
|
)}
|
||||||
>
|
{isRunning && !showDuration && (
|
||||||
이력 보기
|
<p className="text-xs text-emerald-500">
|
||||||
</button>
|
소요 시간: 실행 중...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-wing-muted text-xs">실행 이력 없음</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-0.5">
|
||||||
|
{job.scheduleCron ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
|
||||||
|
자동
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-wing-card text-wing-muted">
|
||||||
|
수동
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{job.scheduleCron && (
|
||||||
|
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded text-wing-muted">
|
||||||
|
{job.scheduleCron}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExecuteClick(job.jobName)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-wing-accent rounded-lg
|
||||||
|
hover:bg-wing-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
실행
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewHistory(job.jobName)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
|
||||||
|
hover:bg-wing-accent/15 transition-colors"
|
||||||
|
>
|
||||||
|
이력 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Table View */
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
작업명
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
마지막 실행
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
소요시간
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
스케줄
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
액션
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{filteredJobs.map((job) => {
|
||||||
|
const isRunning = job.lastExecution?.status === 'STARTED';
|
||||||
|
const duration = job.lastExecution
|
||||||
|
? calculateDuration(job.lastExecution.startTime, job.lastExecution.endTime)
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={job.jobName}
|
||||||
|
className={`hover:bg-wing-hover transition-colors ${
|
||||||
|
isRunning ? 'border-l-4 border-emerald-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium text-wing-text break-all">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isRunning && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
|
||||||
|
)}
|
||||||
|
{job.jobName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{job.lastExecution ? (
|
||||||
|
<StatusBadge status={job.lastExecution.status} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-wing-muted">미실행</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted">
|
||||||
|
{job.lastExecution
|
||||||
|
? formatDateTime(job.lastExecution.startTime)
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-wing-muted">
|
||||||
|
{job.lastExecution ? duration : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{job.scheduleCron ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
|
||||||
|
자동
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-wing-card text-wing-muted">
|
||||||
|
수동
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExecuteClick(job.jobName)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-white bg-wing-accent rounded-lg
|
||||||
|
hover:bg-wing-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
실행
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewHistory(job.jobName)}
|
||||||
|
className="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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -268,6 +556,7 @@ export default function Jobs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user