feat(ui): 배치 모니터링 UI 최적화 (#46)

- 대시보드 퀵 네비게이션 제거
- 작업 목록 기본 뷰/정렬 변경, jobName 서브텍스트 제거
- 실행 이력 AIS 필터 프리셋 버튼 추가
- 스케줄 카드/테이블 뷰 토글, 등록/수정 폼 모달 전환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-03-13 15:23:35 +09:00
부모 54cb37ce0c
커밋 5e54c6c475
5개의 변경된 파일304개의 추가작업 그리고 173개의 파일을 삭제

파일 보기

@ -193,34 +193,6 @@ export default function Dashboard() {
/>
</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">

파일 보기

@ -67,6 +67,9 @@ export default function Executions() {
return map;
}, [displayNames]);
const aisJobs = useMemo(() => jobs.filter(j => j.toLowerCase().startsWith('ais')), [jobs]);
const nonAisJobs = useMemo(() => jobs.filter(j => !j.toLowerCase().startsWith('ais')), [jobs]);
const loadJobs = useCallback(async () => {
try {
const data = await batchApi.getJobs();
@ -287,6 +290,38 @@ export default function Executions() {
</>
)}
</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>
<button
onClick={() => setSelectedJobs(nonAisJobs)}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
selectedJobs.length > 0 && selectedJobs.length === nonAisJobs.length && selectedJobs.every(j => nonAisJobs.includes(j))
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
AIS
</button>
<button
onClick={() => setSelectedJobs([...aisJobs])}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
selectedJobs.length > 0 && selectedJobs.length === aisJobs.length && selectedJobs.every(j => aisJobs.includes(j))
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
AIS만
</button>
</div>
{selectedJobs.length > 0 && (
<button
onClick={clearSelectedJobs}

파일 보기

@ -53,8 +53,8 @@ export default function Jobs() {
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>('ALL');
const [sortKey, setSortKey] = useState<SortKey>('name');
const [viewMode, setViewMode] = useState<ViewMode>('card');
const [sortKey, setSortKey] = useState<SortKey>('recent');
const [viewMode, setViewMode] = useState<ViewMode>('table');
// Execute modal (individual card)
const [executeModalOpen, setExecuteModalOpen] = useState(false);
@ -243,10 +243,23 @@ export default function Jobs() {
{/* View mode toggle */}
<div className="flex rounded-lg border border-wing-border overflow-hidden">
<button
onClick={() => setViewMode('table')}
title="테이블 보기"
className={`px-3 py-2 transition-colors ${
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>
<button
onClick={() => setViewMode('card')}
title="카드 보기"
className={`px-3 py-2 transition-colors ${
className={`px-3 py-2 transition-colors border-l border-wing-border ${
viewMode === 'card'
? 'bg-wing-accent text-white'
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
@ -261,19 +274,6 @@ export default function Jobs() {
/>
</svg>
</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>
@ -316,9 +316,6 @@ export default function Jobs() {
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
{getJobLabel(job)}
</h3>
{job.displayName && (
<p className="text-xs text-wing-muted mt-0.5">{job.jobName}</p>
)}
</div>
<div className="flex items-center gap-2 ml-2 shrink-0">
{isRunning && (
@ -436,12 +433,7 @@ export default function Jobs() {
{isRunning && (
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
)}
<div>
<span>{getJobLabel(job)}</span>
{job.displayName && (
<p className="text-xs text-wing-muted font-normal">{job.jobName}</p>
)}
</div>
<span>{getJobLabel(job)}</span>
</div>
</td>
<td className="px-4 py-3">

파일 보기

@ -477,7 +477,7 @@ export default function Recollects() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border/50">
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">API</th>
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted"> </th>
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted"> </th>
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted"></th>
<th className="text-center py-2 px-3 text-xs font-medium text-wing-muted"></th>

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { batchApi, type ScheduleResponse, type JobDisplayName } from '../api/batchApi';
import { formatDateTime } from '../utils/formatters';
import { useToastContext } from '../contexts/ToastContext';
@ -8,6 +8,7 @@ import LoadingSpinner from '../components/LoadingSpinner';
import { getNextExecutions } from '../utils/cronPreview';
type ScheduleMode = 'new' | 'existing';
type ScheduleViewMode = 'card' | 'table';
interface ConfirmAction {
type: 'toggle' | 'delete';
@ -95,11 +96,14 @@ export default function Schedules() {
const [listLoading, setListLoading] = useState(true);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
// View mode state
const [viewMode, setViewMode] = useState<ScheduleViewMode>('table');
// Confirm modal state
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
// 폼 영역 ref (편집 버튼 클릭 시 스크롤)
const formRef = useRef<HTMLDivElement>(null);
// Form modal state
const [formOpen, setFormOpen] = useState(false);
const loadSchedules = useCallback(async () => {
try {
@ -186,8 +190,8 @@ export default function Schedules() {
showToast('스케줄이 등록되었습니다', 'success');
}
await loadSchedules();
// Reload schedule info for current job
await handleJobSelect(selectedJob);
setFormOpen(false);
resetForm();
} catch (err) {
const message = err instanceof Error ? err.message : '저장 실패';
showToast(message, 'error');
@ -216,12 +220,10 @@ export default function Schedules() {
await batchApi.deleteSchedule(schedule.jobName);
showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success');
await loadSchedules();
// Clear form if deleted schedule was selected
// Close form if deleted schedule was being edited
if (selectedJob === schedule.jobName) {
setSelectedJob('');
setCronExpression('');
setDescription('');
setScheduleMode('new');
resetForm();
setFormOpen(false);
}
} catch (err) {
const message = err instanceof Error ? err.message : '삭제 실패';
@ -230,124 +232,159 @@ export default function Schedules() {
setConfirmAction(null);
};
const resetForm = () => {
setSelectedJob('');
setCronExpression('');
setDescription('');
setScheduleMode('new');
};
const handleEditFromCard = (schedule: ScheduleResponse) => {
setSelectedJob(schedule.jobName);
setCronExpression(schedule.cronExpression);
setDescription(schedule.description ?? '');
setScheduleMode('existing');
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
setFormOpen(true);
};
const handleNewSchedule = () => {
resetForm();
setFormOpen(true);
};
return (
<div className="space-y-6">
{/* Form Section */}
<div ref={formRef} className="bg-wing-surface rounded-xl shadow-lg p-6">
<h2 className="text-lg font-bold text-wing-text mb-4"> / </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Job Select */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<div className="flex items-center gap-2">
<select
value={selectedJob}
onChange={(e) => handleJobSelect(e.target.value)}
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={formLoading}
{/* Form Modal */}
{formOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => setFormOpen(false)} />
<div className="relative bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-wing-text">
{scheduleMode === 'existing' ? '스케줄 수정' : '스케줄 등록'}
</h2>
<button
onClick={() => setFormOpen(false)}
className="p-1 text-wing-muted hover:text-wing-text transition-colors"
>
<option value="">-- --</option>
{jobs.map((job) => (
<option key={job} value={job}>
{displayNameMap[job] || job}
</option>
))}
</select>
{selectedJob && (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
scheduleMode === 'existing'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}`}
>
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
</span>
)}
{formLoading && (
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
)}
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Cron Expression */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
Cron
</label>
<input
type="text"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="0 0/15 * * * ?"
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={!selectedJob || formLoading}
/>
</div>
<div className="space-y-4">
{/* Job Select */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<div className="flex items-center gap-2">
<select
value={selectedJob}
onChange={(e) => handleJobSelect(e.target.value)}
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={formLoading || scheduleMode === 'existing'}
>
<option value="">-- --</option>
{jobs.map((job) => (
<option key={job} value={job}>
{displayNameMap[job] || job}
</option>
))}
</select>
{selectedJob && (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
scheduleMode === 'existing'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}`}
>
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
</span>
)}
{formLoading && (
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
)}
</div>
</div>
{/* Cron Presets */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<div className="flex flex-wrap gap-2">
{CRON_PRESETS.map(({ label, cron }) => (
<button
key={cron}
type="button"
onClick={() => setCronExpression(cron)}
{/* Cron Expression */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
Cron
</label>
<input
type="text"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="0 0/15 * * * ?"
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={!selectedJob || formLoading}
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
>
{label}
</button>
))}
/>
</div>
{/* Cron Presets */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<div className="flex flex-wrap gap-2">
{CRON_PRESETS.map(({ label, cron }) => (
<button
key={cron}
type="button"
onClick={() => setCronExpression(cron)}
disabled={!selectedJob || formLoading}
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
>
{label}
</button>
))}
</div>
</div>
{/* Cron Preview */}
{cronExpression.trim() && (
<CronPreview cron={cronExpression.trim()} />
)}
{/* Description */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="스케줄 설명 (선택)"
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={!selectedJob || formLoading}
/>
</div>
</div>
{/* Modal Footer */}
<div className="mt-6 flex justify-end gap-2">
<button
onClick={() => setFormOpen(false)}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
{/* Cron Preview */}
{cronExpression.trim() && (
<CronPreview cron={cronExpression.trim()} />
)}
{/* Description */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="스케줄 설명 (선택)"
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={!selectedJob || formLoading}
/>
</div>
</div>
{/* Save Button */}
<div className="mt-4 flex justify-end">
<button
onClick={handleSave}
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
)}
{/* Schedule List */}
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
@ -360,19 +397,47 @@ export default function Schedules() {
</span>
)}
</h2>
<button
onClick={loadSchedules}
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
<div className="flex items-center gap-2">
<button
onClick={handleNewSchedule}
className="px-3 py-1.5 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors"
>
+
</button>
<div className="flex rounded-lg border border-wing-border overflow-hidden">
<button
onClick={() => setViewMode('table')}
className={`p-1.5 ${viewMode === 'table' ? 'bg-wing-accent text-white' : 'bg-wing-surface text-wing-muted hover:bg-wing-hover'}`}
title="테이블 보기"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onClick={() => setViewMode('card')}
className={`p-1.5 ${viewMode === 'card' ? 'bg-wing-accent text-white' : 'bg-wing-surface text-wing-muted hover:bg-wing-hover'}`}
title="카드 보기"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</button>
</div>
<button
onClick={loadSchedules}
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</div>
</div>
{listLoading ? (
<LoadingSpinner />
) : schedules.length === 0 ? (
<EmptyState message="등록된 스케줄이 없습니다" sub="위 폼에서 새 스케줄을 등록하세요" />
) : (
<EmptyState message="등록된 스케줄이 없습니다" sub="'+ 새 스케줄' 버튼을 클릭하여 등록하세요" />
) : viewMode === 'card' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{schedules.map((schedule) => (
<div
@ -468,6 +533,73 @@ export default function Schedules() {
</div>
))}
</div>
) : (
<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="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium">Cron </th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"> </th>
<th className="px-4 py-3 font-medium"> </th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{schedules.map((schedule) => (
<tr key={schedule.id} className="hover:bg-wing-hover transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-wing-text" title={schedule.jobName}>
{displayNameMap[schedule.jobName] || schedule.jobName}
</div>
{schedule.description && (
<div className="text-xs text-wing-muted mt-0.5">{schedule.description}</div>
)}
</td>
<td className="px-4 py-3">
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">{schedule.cronExpression}</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
schedule.active ? 'bg-emerald-100 text-emerald-700' : 'bg-wing-card text-wing-muted'
}`}>
{schedule.active ? '활성' : '비활성'}
</span>
</td>
<td className="px-4 py-3 text-xs text-wing-muted">{formatDateTime(schedule.nextFireTime)}</td>
<td className="px-4 py-3 text-xs text-wing-muted">{schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<button
onClick={() => handleEditFromCard(schedule)}
className="px-2 py-1 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded hover:bg-wing-accent/20 transition-colors"
>
</button>
<button
onClick={() => setConfirmAction({ type: 'toggle', schedule })}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
schedule.active
? 'text-amber-600 bg-amber-50 hover:bg-amber-100'
: 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100'
}`}
>
{schedule.active ? '비활성화' : '활성화'}
</button>
<button
onClick={() => setConfirmAction({ type: 'delete', schedule })}
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded hover:bg-red-100 transition-colors"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>