signal-batch/frontend/src/pages/JobMonitor.tsx
htlee dd694bdcbb feat: React 19 SPA Dashboard Phase 1 + 안전 배포 시스템
## React SPA Dashboard
- React 19 + Vite 7 + Tailwind CSS 4 + Recharts 2 SPA 구축
- Dashboard (배치현황/시스템메트릭/캐시/처리량) + JobMonitor (이력조회/Step상세)
- i18n 다국어(ko/en) 시스템, Light/Dark 테마 CSS 토큰 전환
- frontend-maven-plugin 1.15.1 (mvn package 시 자동 빌드)
- WebViewController SPA forward + context-path /signal-batch
- 레거시 HTML 48개 파일 전체 삭제

## 안전 배포
- VesselBatchScheduler @PreDestroy: 신규 Job 차단 + 실행 중 Job 완료 대기
- server.shutdown=graceful, timeout-per-shutdown-phase=3m
- deploy.yml: 활성 Job 3초 연속 확인 후 stop → 교체 → start
- signal-batch.service TimeoutStopSec 60→180
- scripts/deploy.sh: 수동 배포용 안전 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:05:38 +09:00

238 lines
7.8 KiB
TypeScript

import { useState, useCallback, useMemo } from 'react'
import { usePoller } from '../hooks/usePoller.ts'
import { useI18n } from '../hooks/useI18n.ts'
import { batchApi } from '../api/batchApi.ts'
import type { JobExecution, StepExecution } from '../api/types.ts'
import DataTable, { type Column } from '../components/common/DataTable.tsx'
import StatusBadge from '../components/common/StatusBadge.tsx'
import { formatDuration, formatNumber, formatDateTime } from '../utils/formatters.ts'
import type { TranslationKey } from '../i18n/I18nContext.tsx'
const POLL_INTERVAL = 30_000
const JOB_FILTERS: { labelKey: TranslationKey; value: string }[] = [
{ labelKey: 'jobs.all', value: '' },
{ labelKey: 'jobs.track5min', value: 'vesselTrackAggregationJob' },
{ labelKey: 'jobs.hourly', value: 'hourlyAggregationJob' },
{ labelKey: 'jobs.daily', value: 'dailyAggregationJob' },
]
const STATUS_FILTERS = ['ALL', 'COMPLETED', 'FAILED', 'RUNNING', 'STOPPED']
export default function JobMonitor() {
const { t } = useI18n()
const [jobs, setJobs] = useState<JobExecution[]>([])
const [jobFilter, setJobFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('ALL')
const [selectedJob, setSelectedJob] = useState<JobExecution | null>(null)
const [steps, setSteps] = useState<StepExecution[]>([])
const [stepsLoading, setStepsLoading] = useState(false)
usePoller(async () => {
const data = await batchApi.getJobHistory(jobFilter || undefined, 100)
setJobs(data)
}, POLL_INTERVAL, [jobFilter])
const filtered = statusFilter === 'ALL'
? jobs
: jobs.filter(j => j.status === statusFilter)
const handleRowClick = useCallback(async (job: JobExecution) => {
setSelectedJob(job)
setStepsLoading(true)
try {
const data = await batchApi.getStepDetails(job.executionId)
setSteps(data)
} catch {
setSteps([])
} finally {
setStepsLoading(false)
}
}, [])
const jobColumns: Column<JobExecution>[] = useMemo(() => [
{
key: 'status',
label: t('jobs.status'),
render: (row) => <StatusBadge status={row.status} />,
sortable: true,
},
{ key: 'jobName', label: t('jobs.job'), sortable: true },
{ key: 'executionId', label: t('jobs.id'), sortable: true, align: 'center' as const },
{
key: 'startTime',
label: t('jobs.start'),
render: (row) => <span className="font-mono text-xs">{formatDateTime(row.startTime)}</span>,
sortable: true,
},
{
key: 'durationSeconds',
label: t('jobs.duration'),
render: (row) => formatDuration(row.durationSeconds),
sortable: true,
align: 'right' as const,
},
{
key: 'totalRead',
label: t('jobs.read'),
render: (row) => formatNumber(row.totalRead),
sortable: true,
align: 'right' as const,
},
{
key: 'totalWrite',
label: t('jobs.write'),
render: (row) => formatNumber(row.totalWrite),
sortable: true,
align: 'right' as const,
},
{
key: 'totalSkip',
label: t('jobs.skip'),
render: (row) => row.totalSkip > 0 ? <span className="text-warning">{formatNumber(row.totalSkip)}</span> : '0',
sortable: true,
align: 'right' as const,
},
], [t])
const stepColumns: Column<StepExecution>[] = useMemo(() => [
{
key: 'status',
label: t('jobs.status'),
render: (row) => <StatusBadge status={row.status} />,
},
{ key: 'stepName', label: t('jobs.step') },
{
key: 'startTime',
label: t('jobs.start'),
render: (row) => <span className="font-mono text-xs">{formatDateTime(row.startTime)}</span>,
},
{
key: 'durationSeconds',
label: t('jobs.duration'),
render: (row) => formatDuration(row.durationSeconds),
align: 'right' as const,
},
{
key: 'readCount',
label: t('jobs.read'),
render: (row) => formatNumber(row.readCount),
align: 'right' as const,
},
{
key: 'writeCount',
label: t('jobs.write'),
render: (row) => formatNumber(row.writeCount),
align: 'right' as const,
},
{
key: 'commitCount',
label: t('jobs.commits'),
render: (row) => formatNumber(row.commitCount),
align: 'right' as const,
},
{
key: 'errors',
label: t('jobs.errors'),
render: (row) => row.errors?.length > 0
? <span className="text-danger">{row.errors.length}</span>
: <span className="text-muted">-</span>,
align: 'center' as const,
},
], [t])
return (
<div className="space-y-6 fade-in">
<h1 className="text-2xl font-bold">{t('jobs.title')}</h1>
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex gap-1">
{JOB_FILTERS.map(j => (
<button
key={j.value}
onClick={() => setJobFilter(j.value)}
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
jobFilter === j.value ? 'bg-primary text-white' : 'text-muted hover:bg-surface-hover'
}`}
>
{t(j.labelKey)}
</button>
))}
</div>
<div className="h-5 w-px bg-border" />
<div className="flex gap-1">
{STATUS_FILTERS.map(s => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
statusFilter === s ? 'bg-primary text-white' : 'text-muted hover:bg-surface-hover'
}`}
>
{s}
</button>
))}
</div>
<span className="ml-auto text-sm text-muted">{filtered.length}{t('common.items')}</span>
</div>
{/* Job Table */}
<DataTable
columns={jobColumns}
data={filtered}
keyExtractor={(row) => row.executionId}
onRowClick={handleRowClick}
/>
{/* Step Detail */}
{selectedJob && (
<div className="sb-card">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="sb-card-header">{t('jobs.stepDetails')}</div>
<div className="text-sm">
<span className="font-medium">{selectedJob.jobName}</span>
<span className="text-muted"> #{selectedJob.executionId}</span>
</div>
</div>
<button
onClick={() => setSelectedJob(null)}
className="rounded-md border border-border px-3 py-1 text-sm text-muted hover:text-foreground"
>
{t('common.close')}
</button>
</div>
{stepsLoading ? (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
) : (
<DataTable
columns={stepColumns}
data={steps}
keyExtractor={(row) => row.stepName}
pageSize={50}
/>
)}
{steps.some(s => s.errors?.length > 0) && (
<div className="mt-4 rounded-lg border border-danger bg-danger/5 p-3">
<div className="mb-2 text-sm font-semibold text-danger">{t('jobs.errors')}</div>
{steps
.filter(s => s.errors?.length > 0)
.map(s => (
<div key={s.stepName} className="mb-2">
<div className="text-xs font-medium text-muted">{s.stepName}</div>
{s.errors.map((err, i) => (
<pre key={i} className="mt-1 overflow-x-auto rounded bg-surface-hover p-2 font-mono text-xs">
{err}
</pre>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
)
}