## 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>
238 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|