Thymeleaf → React 19 + Vite + Tailwind CSS 4 SPA 전환 - frontend-maven-plugin으로 단일 JAR 배포 유지 - 6개 페이지 lazy 로딩, 5초/30초 폴링 자동 갱신 10대 신규 기능: - F1: 강제 종료(Abandon) - stale 실행 단건/전체 강제 종료 - F2: Job 실행 날짜 파라미터 (startDate/stopDate) - F3: Step API 호출 정보 표시 (apiUrl, method, calls) - F4: 실행 이력 검색 (멀티 Job 필터, 날짜 범위, 페이지네이션) - F5: Cron 표현식 도우미 (프리셋 + 다음 5회 미리보기) - F6: 대시보드 실패 통계 (24h/7d, 최근 실패 목록, stale 경고) - F7: Job 상세 카드 (마지막 실행 상태/시간 + 스케줄 cron) - F8: 실행 통계 차트 (CSS-only 30일 일별 막대그래프) - F9: 실패 로그 뷰어 (exitCode/exitMessage 모달) - F10: 다크모드 (data-theme + CSS 변수 + Tailwind @theme) 추가 개선: - 실행 이력 멀티 Job 선택 (체크박스 드롭다운 + 칩) - 스케줄 카드 편집 버튼 (폼 자동 채움 + 수정 모드) - 검색 모드 폴링 비활성화 (1회 조회 후 수동 갱신) - pre-commit hook: 프론트엔드 빌드 스킵 플래그 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
508 lines
19 KiB
TypeScript
508 lines
19 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import {
|
|
batchApi,
|
|
type DashboardResponse,
|
|
type DashboardStats,
|
|
type ExecutionStatisticsDto,
|
|
} 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);
|
|
|
|
// 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 [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
|
|
|
const loadStatistics = useCallback(async () => {
|
|
try {
|
|
const data = await batchApi.getStatistics(30);
|
|
setStatistics(data);
|
|
} catch {
|
|
/* 통계 로드 실패는 무시 */
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadStatistics();
|
|
}, [loadStatistics]);
|
|
|
|
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 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 () => {
|
|
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>
|
|
<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>
|
|
|
|
{/* 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"
|
|
>
|
|
전체 보기 →
|
|
</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>
|
|
)}
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|