snp-batch-validation/frontend/src/pages/Dashboard.tsx
HYOJIN cdbd7eb702 feat(jobs): 배치 작업 목록 UX 개선 및 즉시 실행 버튼 이동 (#33)
- 대시보드에서 작업 즉시 실행 버튼/모달 제거 (Jobs 페이지 개별 실행으로 통합)
- 상태별 필터 탭 추가 (전체/실행 중/성공/실패/미실행 + 카운트 뱃지)
- 카드 정보 보강 (소요 시간, 실행 중 pulse 인디케이터, 자동/수동 스케줄 뱃지)
- 카드 뷰/테이블 뷰 토글 추가
- 정렬 옵션 추가 (작업명순/최신 실행순/상태별)
- 실행 중 Job 시각적 강조 (좌측 emerald 테두리 + pulse 도트)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:27:06 +09:00

471 lines
18 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
batchApi,
type DashboardResponse,
type DashboardStats,
type ExecutionStatisticsDto,
type RecollectionStatsResponse,
} from '../api/batchApi';
import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
import StatusBadge from '../components/StatusBadge';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
import BarChart from '../components/BarChart';
import { formatDateTime, calculateDuration } from '../utils/formatters';
const POLLING_INTERVAL = 5000;
interface StatCardProps {
label: string;
value: number;
gradient: string;
to?: string;
}
function StatCard({ label, value, gradient, to }: StatCardProps) {
const content = (
<div
className={`${gradient} rounded-xl shadow-md p-6 text-white
hover:shadow-lg hover:-translate-y-0.5 transition-all cursor-pointer`}
>
<p className="text-3xl font-bold">{value}</p>
<p className="text-sm mt-1 opacity-90">{label}</p>
</div>
);
if (to) {
return <Link to={to} className="no-underline">{content}</Link>;
}
return content;
}
export default function Dashboard() {
const { showToast } = useToastContext();
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [abandoning, setAbandoning] = useState(false);
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null);
const loadStatistics = useCallback(async () => {
try {
const data = await batchApi.getStatistics(30);
setStatistics(data);
} catch {
/* 통계 로드 실패는 무시 */
}
}, []);
useEffect(() => {
loadStatistics();
}, [loadStatistics]);
const loadRecollectionStats = useCallback(async () => {
try {
const data = await batchApi.getRecollectionStats();
setRecollectionStats(data);
} catch {
/* 통계 로드 실패는 무시 */
}
}, []);
useEffect(() => {
loadRecollectionStats();
}, [loadRecollectionStats]);
const loadDashboard = useCallback(async () => {
try {
const data = await batchApi.getDashboard();
setDashboard(data);
} catch (err) {
console.error('Dashboard load failed:', err);
} finally {
setLoading(false);
}
}, []);
usePoller(loadDashboard, POLLING_INTERVAL);
const handleAbandonAllStale = async () => {
setAbandoning(true);
try {
const result = await batchApi.abandonAllStale();
showToast(
result.message || `${result.abandonedCount ?? 0}건 강제 종료 완료`,
'success',
);
await loadDashboard();
} catch (err) {
showToast(
`강제 종료 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
'error',
);
} finally {
setAbandoning(false);
}
};
if (loading) return <LoadingSpinner />;
const stats: DashboardStats = dashboard?.stats ?? {
totalSchedules: 0,
activeSchedules: 0,
inactiveSchedules: 0,
totalJobs: 0,
};
const runningJobs = dashboard?.runningJobs ?? [];
const recentExecutions = dashboard?.recentExecutions ?? [];
const recentFailures = dashboard?.recentFailures ?? [];
const staleExecutionCount = dashboard?.staleExecutionCount ?? 0;
const failureStats = dashboard?.failureStats ?? { last24h: 0, last7d: 0 };
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-wing-text"></h1>
</div>
{/* F1: Stale Execution Warning Banner */}
{staleExecutionCount > 0 && (
<div className="flex items-center justify-between bg-amber-100 border border-amber-300 rounded-xl px-5 py-3">
<span className="text-amber-800 font-medium text-sm">
{staleExecutionCount}
</span>
<button
onClick={handleAbandonAllStale}
disabled={abandoning}
className="px-4 py-1.5 text-sm font-medium text-white bg-amber-600 rounded-lg
hover:bg-amber-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{abandoning ? '처리 중...' : '전체 강제 종료'}
</button>
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
label="전체 스케줄"
value={stats.totalSchedules}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
to="/schedules"
/>
<StatCard
label="활성 스케줄"
value={stats.activeSchedules}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
/>
<StatCard
label="비활성 스케줄"
value={stats.inactiveSchedules}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
/>
<StatCard
label="전체 작업"
value={stats.totalJobs}
gradient="bg-gradient-to-br from-violet-500 to-violet-600"
to="/jobs"
/>
<StatCard
label="실패 (24h)"
value={failureStats.last24h}
gradient="bg-gradient-to-br from-red-500 to-red-600"
/>
</div>
{/* Quick Navigation */}
<div className="flex flex-wrap gap-2">
<Link
to="/jobs"
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
>
</Link>
<Link
to="/executions"
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
>
</Link>
<Link
to="/schedules"
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
>
</Link>
<Link
to="/schedule-timeline"
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
>
</Link>
</div>
{/* Running Jobs */}
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-wing-text mb-4">
{runningJobs.length > 0 && (
<span className="ml-2 text-sm font-normal text-wing-accent">
({runningJobs.length})
</span>
)}
</h2>
{runningJobs.length === 0 ? (
<EmptyState
icon="💤"
message="현재 실행 중인 작업이 없습니다."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border text-left text-wing-muted">
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{runningJobs.map((job) => (
<tr key={job.executionId} className="border-b border-wing-border/50">
<td className="py-3 font-medium text-wing-text">{job.jobName}</td>
<td className="py-3 text-wing-muted">#{job.executionId}</td>
<td className="py-3 text-wing-muted">{formatDateTime(job.startTime)}</td>
<td className="py-3">
<StatusBadge status={job.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Recent Executions */}
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-wing-text"> </h2>
<Link
to="/executions"
className="text-sm text-wing-accent hover:text-wing-accent no-underline"
>
&rarr;
</Link>
</div>
{recentExecutions.length === 0 ? (
<EmptyState
icon="📋"
message="실행 이력이 없습니다."
sub="작업을 실행하면 여기에 표시됩니다."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border text-left text-wing-muted">
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{recentExecutions.slice(0, 5).map((exec) => (
<tr key={exec.executionId} className="border-b border-wing-border/50">
<td className="py-3">
<Link
to={`/executions/${exec.executionId}`}
className="text-wing-accent hover:text-wing-accent no-underline font-medium"
>
#{exec.executionId}
</Link>
</td>
<td className="py-3 text-wing-text">{exec.jobName}</td>
<td className="py-3 text-wing-muted">{formatDateTime(exec.startTime)}</td>
<td className="py-3 text-wing-muted">{formatDateTime(exec.endTime)}</td>
<td className="py-3 text-wing-muted">
{calculateDuration(exec.startTime, exec.endTime)}
</td>
<td className="py-3">
<StatusBadge status={exec.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* F6: Recent Failures */}
{recentFailures.length > 0 && (
<section className="bg-wing-surface rounded-xl shadow-md p-6 border border-red-200">
<h2 className="text-lg font-semibold text-red-700 mb-4">
<span className="ml-2 text-sm font-normal text-red-500">
({recentFailures.length})
</span>
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-red-200 text-left text-red-500">
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"> </th>
</tr>
</thead>
<tbody>
{recentFailures.map((fail) => (
<tr key={fail.executionId} className="border-b border-red-100">
<td className="py-3">
<Link
to={`/executions/${fail.executionId}`}
className="text-red-600 hover:text-red-800 no-underline font-medium"
>
#{fail.executionId}
</Link>
</td>
<td className="py-3 text-wing-text">{fail.jobName}</td>
<td className="py-3 text-wing-muted">{formatDateTime(fail.startTime)}</td>
<td className="py-3 text-wing-muted" title={fail.exitMessage ?? ''}>
{fail.exitMessage
? fail.exitMessage.length > 50
? `${fail.exitMessage.slice(0, 50)}...`
: fail.exitMessage
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* 재수집 현황 */}
{recollectionStats && recollectionStats.totalCount > 0 && (
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-wing-text"> </h2>
<Link to="/recollects" className="text-sm text-wing-accent hover:text-wing-accent no-underline">
&rarr;
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-4">
<div className="bg-blue-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-blue-700">{recollectionStats.totalCount}</p>
<p className="text-xs text-blue-500 mt-1"></p>
</div>
<div className="bg-emerald-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-emerald-700">{recollectionStats.completedCount}</p>
<p className="text-xs text-emerald-500 mt-1"></p>
</div>
<div className="bg-red-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-red-700">{recollectionStats.failedCount}</p>
<p className="text-xs text-red-500 mt-1"></p>
</div>
<div className="bg-amber-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-amber-700">{recollectionStats.runningCount}</p>
<p className="text-xs text-amber-500 mt-1"> </p>
</div>
<div className="bg-violet-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-violet-700">{recollectionStats.overlapCount}</p>
<p className="text-xs text-violet-500 mt-1"></p>
</div>
</div>
{/* 최근 재수집 이력 (최대 5건) */}
{recollectionStats.recentHistories.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border text-left text-wing-muted">
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{recollectionStats.recentHistories.slice(0, 5).map((h) => (
<tr key={h.historyId} className="border-b border-wing-border/50">
<td className="py-3">
<Link to={`/recollects/${h.historyId}`} className="text-wing-accent hover:text-wing-accent no-underline font-medium">
#{h.historyId}
</Link>
</td>
<td className="py-3 text-wing-text">{h.apiKeyName || h.jobName}</td>
<td className="py-3 text-wing-muted">{h.executor || '-'}</td>
<td className="py-3 text-wing-muted">{formatDateTime(h.executionStartTime)}</td>
<td className="py-3">
<StatusBadge status={h.executionStatus} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{/* F8: Execution Statistics Chart */}
{statistics && statistics.dailyStats.length > 0 && (
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-wing-text">
( 30)
</h2>
<div className="flex gap-4 text-xs text-wing-muted">
<span>
<strong className="text-wing-text">{statistics.totalExecutions}</strong>
</span>
<span>
<strong className="text-emerald-600">{statistics.totalSuccess}</strong>
</span>
<span>
<strong className="text-red-600">{statistics.totalFailed}</strong>
</span>
</div>
</div>
<BarChart
data={statistics.dailyStats.map((d) => ({
label: d.date.slice(5),
values: [
{ color: 'green', value: d.successCount },
{ color: 'red', value: d.failedCount },
{ color: 'gray', value: d.otherCount },
],
}))}
height={180}
/>
<div className="flex gap-4 mt-3 text-xs text-wing-muted justify-end">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-emerald-500" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-red-500" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-gray-400" />
</span>
</div>
</section>
)}
</div>
);
}