feat(global): Job 한글 표시명 DB 관리 및 전체 화면 통합 (#45) #47
@ -29,6 +29,7 @@
|
||||
- 재시도 초과 레코드 초기화 API/UI 추가
|
||||
- IMO 기반 Risk 상세 조회 bypass API 추가 (#39)
|
||||
- 배치 작업 목록 한글 표시명 추가 (#40)
|
||||
- Job 한글 표시명 DB 관리 및 전체 화면 통합 (#45)
|
||||
|
||||
### 수정
|
||||
- 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결)
|
||||
|
||||
@ -336,6 +336,15 @@ export interface LastCollectionStatusDto {
|
||||
elapsedMinutes: number;
|
||||
}
|
||||
|
||||
// ── Job Display Name ──────────────────────────────────────────
|
||||
|
||||
export interface JobDisplayName {
|
||||
id: number;
|
||||
jobName: string;
|
||||
displayName: string;
|
||||
apiKey: string | null;
|
||||
}
|
||||
|
||||
// ── API Functions ────────────────────────────────────────────
|
||||
|
||||
export const batchApi = {
|
||||
@ -495,6 +504,10 @@ export const batchApi = {
|
||||
getLastCollectionStatuses: () =>
|
||||
fetchJson<LastCollectionStatusDto[]>(`${BASE}/last-collections`),
|
||||
|
||||
// Display Names
|
||||
getDisplayNames: () =>
|
||||
fetchJson<JobDisplayName[]>(`${BASE}/display-names`),
|
||||
|
||||
resolveFailedRecords: (ids: number[]) =>
|
||||
postJson<{ success: boolean; message: string; resolvedCount?: number }>(
|
||||
`${BASE}/failed-records/resolve`, { ids }),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
batchApi,
|
||||
@ -6,6 +6,7 @@ import {
|
||||
type DashboardStats,
|
||||
type ExecutionStatisticsDto,
|
||||
type RecollectionStatsResponse,
|
||||
type JobDisplayName,
|
||||
} from '../api/batchApi';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
@ -49,6 +50,7 @@ export default function Dashboard() {
|
||||
const [abandoning, setAbandoning] = useState(false);
|
||||
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
|
||||
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null);
|
||||
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
|
||||
|
||||
const loadStatistics = useCallback(async () => {
|
||||
try {
|
||||
@ -76,6 +78,19 @@ export default function Dashboard() {
|
||||
loadRecollectionStats();
|
||||
}, [loadRecollectionStats]);
|
||||
|
||||
useEffect(() => {
|
||||
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const dn of displayNames) {
|
||||
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
|
||||
map[dn.jobName] = dn.displayName;
|
||||
}
|
||||
return map;
|
||||
}, [displayNames]);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
try {
|
||||
const data = await batchApi.getDashboard();
|
||||
@ -235,7 +250,7 @@ export default function Dashboard() {
|
||||
<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 font-medium text-wing-text">{displayNameMap[job.jobName] || 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">
|
||||
@ -290,7 +305,7 @@ export default function Dashboard() {
|
||||
#{exec.executionId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 text-wing-text">{exec.jobName}</td>
|
||||
<td className="py-3 text-wing-text">{displayNameMap[exec.jobName] || 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">
|
||||
@ -337,7 +352,7 @@ export default function Dashboard() {
|
||||
#{fail.executionId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 text-wing-text">{fail.jobName}</td>
|
||||
<td className="py-3 text-wing-text">{displayNameMap[fail.jobName] || 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
|
||||
@ -406,7 +421,7 @@ export default function Dashboard() {
|
||||
#{h.historyId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 text-wing-text">{h.apiKeyName || h.jobName}</td>
|
||||
<td className="py-3 text-wing-text">{displayNameMap[h.apiKey] || 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">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse } from '../api/batchApi';
|
||||
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse, type JobDisplayName } from '../api/batchApi';
|
||||
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
@ -31,6 +31,7 @@ export default function Executions() {
|
||||
const jobFromQuery = searchParams.get('job') || '';
|
||||
|
||||
const [jobs, setJobs] = useState<string[]>([]);
|
||||
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
|
||||
const [executions, setExecutions] = useState<JobExecutionDto[]>([]);
|
||||
const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []);
|
||||
const [jobDropdownOpen, setJobDropdownOpen] = useState(false);
|
||||
@ -54,6 +55,18 @@ export default function Executions() {
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
useEffect(() => {
|
||||
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const dn of displayNames) {
|
||||
map[dn.jobName] = dn.displayName;
|
||||
}
|
||||
return map;
|
||||
}, [displayNames]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
const data = await batchApi.getJobs();
|
||||
@ -267,7 +280,7 @@ export default function Executions() {
|
||||
onChange={() => toggleJob(job)}
|
||||
className="rounded border-wing-border text-wing-accent focus:ring-wing-accent"
|
||||
/>
|
||||
<span className="text-wing-text truncate">{job}</span>
|
||||
<span className="text-wing-text truncate">{displayNameMap[job] || job}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@ -291,7 +304,7 @@ export default function Executions() {
|
||||
key={job}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full"
|
||||
>
|
||||
{job}
|
||||
{displayNameMap[job] || job}
|
||||
<button
|
||||
onClick={() => toggleJob(job)}
|
||||
className="hover:text-wing-text transition-colors"
|
||||
@ -407,7 +420,7 @@ export default function Executions() {
|
||||
#{exec.executionId}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-wing-text">
|
||||
{exec.jobName}
|
||||
{displayNameMap[exec.jobName] || exec.jobName}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
batchApi,
|
||||
type RecollectionDetailResponse,
|
||||
type StepExecutionDto,
|
||||
type FailedRecordDto,
|
||||
type JobDisplayName,
|
||||
} from '../api/batchApi';
|
||||
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
@ -136,6 +137,20 @@ export default function RecollectDetail() {
|
||||
const [data, setData] = useState<RecollectionDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const dn of displayNames) {
|
||||
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
|
||||
map[dn.jobName] = dn.displayName;
|
||||
}
|
||||
return map;
|
||||
}, [displayNames]);
|
||||
|
||||
const isRunning = data
|
||||
? data.history.executionStatus === 'STARTED'
|
||||
@ -203,7 +218,7 @@ export default function RecollectDetail() {
|
||||
재수집 #{history.historyId}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-wing-muted">
|
||||
{history.apiKeyName || history.apiKey} · {history.jobName}
|
||||
{displayNameMap[history.apiKey] || history.apiKeyName || history.apiKey} · {history.jobName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -352,7 +367,7 @@ export default function RecollectDetail() {
|
||||
#{oh.historyId}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-wing-text">
|
||||
{oh.apiKeyName || oh.apiKey}
|
||||
{displayNameMap[oh.apiKey] || oh.apiKeyName || oh.apiKey}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-wing-muted text-xs">
|
||||
{formatDateTime(oh.rangeFromDate)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
batchApi,
|
||||
@ -6,6 +6,7 @@ import {
|
||||
type RecollectionSearchResponse,
|
||||
type CollectionPeriodDto,
|
||||
type LastCollectionStatusDto,
|
||||
type JobDisplayName,
|
||||
} from '../api/batchApi';
|
||||
import { formatDateTime, formatDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
@ -121,6 +122,7 @@ function addHoursToDateTime(
|
||||
export default function Recollects() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToastContext();
|
||||
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
|
||||
const [lastCollectionStatuses, setLastCollectionStatuses] = useState<LastCollectionStatusDto[]>([]);
|
||||
const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false);
|
||||
|
||||
@ -205,7 +207,7 @@ export default function Recollects() {
|
||||
setSavingApiKey(p.apiKey);
|
||||
try {
|
||||
await batchApi.resetCollectionPeriod(p.apiKey);
|
||||
showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success');
|
||||
showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success');
|
||||
setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
|
||||
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
|
||||
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
|
||||
@ -241,7 +243,7 @@ export default function Recollects() {
|
||||
setSavingApiKey(p.apiKey);
|
||||
try {
|
||||
await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to });
|
||||
showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success');
|
||||
showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success');
|
||||
setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
|
||||
await loadPeriods();
|
||||
} catch (err) {
|
||||
@ -264,7 +266,7 @@ export default function Recollects() {
|
||||
executor: 'MANUAL',
|
||||
reason: '수집 기간 관리 화면에서 수동 실행',
|
||||
});
|
||||
showToast(result.message || `${p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success');
|
||||
showToast(result.message || `${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success');
|
||||
setLoading(true);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error');
|
||||
@ -379,7 +381,22 @@ export default function Recollects() {
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
batchApi.getDisplayNames()
|
||||
.then(setDisplayNames)
|
||||
.catch(() => { /* displayName 로드 실패 무시 */ });
|
||||
}, []);
|
||||
|
||||
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const dn of displayNames) {
|
||||
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
|
||||
}
|
||||
return map;
|
||||
}, [displayNames]);
|
||||
|
||||
const getApiLabel = (apiKey: string) => {
|
||||
if (displayNameMap[apiKey]) return displayNameMap[apiKey];
|
||||
const p = periods.find((p) => p.apiKey === apiKey);
|
||||
return p?.apiKeyName || apiKey;
|
||||
};
|
||||
@ -472,10 +489,7 @@ export default function Recollects() {
|
||||
return (
|
||||
<tr key={s.apiKey} className="border-b border-wing-border/30 hover:bg-wing-hover/50 transition-colors">
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="text-wing-text font-medium">{s.apiDesc || s.apiKey}</div>
|
||||
{s.apiDesc && (
|
||||
<div className="text-xs text-wing-muted font-mono">{s.apiKey}</div>
|
||||
)}
|
||||
<div className="text-wing-text font-medium">{displayNameMap[s.apiKey] || s.apiDesc || s.apiKey}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 font-mono text-xs text-wing-muted">
|
||||
{formatDateTime(s.lastSuccessDate)}
|
||||
@ -544,7 +558,7 @@ export default function Recollects() {
|
||||
>
|
||||
<span className={selectedPeriodKey ? 'text-wing-text' : 'text-wing-muted'}>
|
||||
{selectedPeriodKey
|
||||
? (periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
|
||||
? (displayNameMap[selectedPeriodKey] || periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
|
||||
: '작업을 선택하세요'}
|
||||
</span>
|
||||
<svg className={`w-4 h-4 text-wing-muted transition-transform ${periodDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@ -568,10 +582,7 @@ export default function Recollects() {
|
||||
: 'text-wing-text'
|
||||
}`}
|
||||
>
|
||||
<div>{p.apiKeyName || p.apiKey}</div>
|
||||
{p.jobName && (
|
||||
<div className="text-xs text-wing-muted font-mono">{p.jobName}</div>
|
||||
)}
|
||||
<div>{displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -757,7 +768,7 @@ export default function Recollects() {
|
||||
selectedApiKey === p.apiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
|
||||
}`}
|
||||
>
|
||||
{p.apiKeyName || p.apiKey}
|
||||
{displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -900,8 +911,8 @@ export default function Recollects() {
|
||||
#{hist.historyId}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-wing-text">
|
||||
<div className="max-w-[120px] truncate" title={hist.apiKeyName || hist.apiKey}>
|
||||
{hist.apiKeyName || hist.apiKey}
|
||||
<div className="max-w-[120px] truncate" title={displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}>
|
||||
{displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { batchApi, type ScheduleResponse } from '../api/batchApi';
|
||||
import { batchApi, type ScheduleResponse, type JobDisplayName } from '../api/batchApi';
|
||||
import { formatDateTime } from '../utils/formatters';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
import ConfirmModal from '../components/ConfirmModal';
|
||||
@ -93,6 +93,7 @@ export default function Schedules() {
|
||||
// Schedule list state
|
||||
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
|
||||
|
||||
// Confirm modal state
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
@ -125,8 +126,17 @@ export default function Schedules() {
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
loadSchedules();
|
||||
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
|
||||
}, [loadJobs, loadSchedules]);
|
||||
|
||||
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const dn of displayNames) {
|
||||
map[dn.jobName] = dn.displayName;
|
||||
}
|
||||
return map;
|
||||
}, [displayNames]);
|
||||
|
||||
const handleJobSelect = async (jobName: string) => {
|
||||
setSelectedJob(jobName);
|
||||
setCronExpression('');
|
||||
@ -250,7 +260,7 @@ export default function Schedules() {
|
||||
<option value="">-- 작업을 선택하세요 --</option>
|
||||
{jobs.map((job) => (
|
||||
<option key={job} value={job}>
|
||||
{job}
|
||||
{displayNameMap[job] || job}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -372,8 +382,8 @@ export default function Schedules() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-sm font-bold text-wing-text truncate">
|
||||
{schedule.jobName}
|
||||
<h3 className="text-sm font-bold text-wing-text truncate" title={schedule.jobName}>
|
||||
{displayNameMap[schedule.jobName] || schedule.jobName}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi';
|
||||
import { batchApi, type ExecutionInfo, type JobDisplayName, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi';
|
||||
import { formatDateTime, calculateDuration } from '../utils/formatters';
|
||||
import { usePoller } from '../hooks/usePoller';
|
||||
import { useToastContext } from '../contexts/ToastContext';
|
||||
@ -80,6 +80,20 @@ export default function Timeline() {
|
||||
const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const displayNameMap = useMemo<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const dn of displayNames) {
|
||||
map[dn.jobName] = dn.displayName;
|
||||
}
|
||||
return map;
|
||||
}, [displayNames]);
|
||||
|
||||
// Tooltip
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@ -288,9 +302,9 @@ export default function Timeline() {
|
||||
<div
|
||||
key={`name-${schedule.jobName}`}
|
||||
className="sticky left-0 z-10 bg-wing-surface border-b border-r border-wing-border px-3 py-2 text-xs font-medium text-wing-text truncate flex items-center"
|
||||
title={schedule.jobName}
|
||||
title={displayNameMap[schedule.jobName] || schedule.jobName}
|
||||
>
|
||||
{schedule.jobName}
|
||||
{displayNameMap[schedule.jobName] || schedule.jobName}
|
||||
</div>
|
||||
|
||||
{/* Execution Cells */}
|
||||
@ -345,7 +359,7 @@ export default function Timeline() {
|
||||
}}
|
||||
>
|
||||
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
|
||||
<div className="font-semibold mb-1">{tooltip.jobName}</div>
|
||||
<div className="font-semibold mb-1">{displayNameMap[tooltip.jobName] || tooltip.jobName}</div>
|
||||
<div className="space-y-0.5 text-gray-300">
|
||||
<div>기간: {tooltip.period.label}</div>
|
||||
<div>
|
||||
@ -379,7 +393,7 @@ export default function Timeline() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-wing-text">
|
||||
{selectedCell.jobName}
|
||||
{displayNameMap[selectedCell.jobName] || selectedCell.jobName}
|
||||
</h3>
|
||||
<p className="text-xs text-wing-muted mt-0.5">
|
||||
구간: {selectedCell.periodLabel}
|
||||
|
||||
@ -3,6 +3,8 @@ package com.snp.batch.global.controller;
|
||||
import com.snp.batch.global.dto.*;
|
||||
import com.snp.batch.global.model.BatchCollectionPeriod;
|
||||
import com.snp.batch.global.model.BatchRecollectionHistory;
|
||||
import com.snp.batch.global.model.JobDisplayNameEntity;
|
||||
import com.snp.batch.global.repository.JobDisplayNameRepository;
|
||||
import com.snp.batch.service.BatchFailedRecordService;
|
||||
import com.snp.batch.service.BatchService;
|
||||
import com.snp.batch.service.RecollectionHistoryService;
|
||||
@ -46,6 +48,7 @@ public class BatchController {
|
||||
private final ScheduleService scheduleService;
|
||||
private final RecollectionHistoryService recollectionHistoryService;
|
||||
private final BatchFailedRecordService batchFailedRecordService;
|
||||
private final JobDisplayNameRepository jobDisplayNameRepository;
|
||||
|
||||
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
|
||||
@ApiResponses(value = {
|
||||
@ -743,4 +746,46 @@ public class BatchController {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Job 한글 표시명 관리 API ──────────────────────────────────
|
||||
|
||||
@Operation(summary = "Job 표시명 전체 조회", description = "등록된 모든 Job의 한글 표시명을 조회합니다")
|
||||
@GetMapping("/display-names")
|
||||
public ResponseEntity<List<JobDisplayNameEntity>> getDisplayNames() {
|
||||
log.debug("Received request to get all display names");
|
||||
return ResponseEntity.ok(jobDisplayNameRepository.findAll());
|
||||
}
|
||||
|
||||
@Operation(summary = "Job 표시명 수정", description = "특정 Job의 한글 표시명을 수정합니다")
|
||||
@PutMapping("/display-names/{jobName}")
|
||||
public ResponseEntity<Map<String, Object>> updateDisplayName(
|
||||
@Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName,
|
||||
@RequestBody Map<String, String> request) {
|
||||
log.info("Update display name: jobName={}", jobName);
|
||||
try {
|
||||
String displayName = request.get("displayName");
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false,
|
||||
"message", "displayName은 필수입니다"));
|
||||
}
|
||||
|
||||
JobDisplayNameEntity entity = jobDisplayNameRepository.findByJobName(jobName)
|
||||
.orElseGet(() -> JobDisplayNameEntity.builder().jobName(jobName).build());
|
||||
entity.setDisplayName(displayName);
|
||||
jobDisplayNameRepository.save(entity);
|
||||
|
||||
batchService.refreshDisplayNameCache();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "표시명이 수정되었습니다",
|
||||
"data", Map.of("jobName", jobName, "displayName", displayName)));
|
||||
} catch (Exception e) {
|
||||
log.error("Error updating display name: jobName={}", jobName, e);
|
||||
return ResponseEntity.internalServerError().body(Map.of(
|
||||
"success", false,
|
||||
"message", "표시명 수정 실패: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
package com.snp.batch.global.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 배치 작업 한글 표시명을 관리하는 엔티티
|
||||
* 모든 화면(작업 목록, 재수집 이력 등)에서 공통으로 사용
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "job_display_name", indexes = {
|
||||
@Index(name = "idx_job_display_name_job_name", columnList = "job_name", unique = true),
|
||||
@Index(name = "idx_job_display_name_api_key", columnList = "api_key", unique = true)
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobDisplayNameEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 배치 작업 Bean 이름
|
||||
*/
|
||||
@Column(name = "job_name", unique = true, nullable = false, length = 100)
|
||||
private String jobName;
|
||||
|
||||
/**
|
||||
* 한글 표시명
|
||||
*/
|
||||
@Column(name = "display_name", nullable = false, length = 200)
|
||||
private String displayName;
|
||||
|
||||
/**
|
||||
* 외부 API 키 (batch_collection_period.api_key 연동용, nullable)
|
||||
*/
|
||||
@Column(name = "api_key", unique = true, length = 50)
|
||||
private String apiKey;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package com.snp.batch.global.repository;
|
||||
|
||||
import com.snp.batch.global.model.JobDisplayNameEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface JobDisplayNameRepository extends JpaRepository<JobDisplayNameEntity, Long> {
|
||||
|
||||
Optional<JobDisplayNameEntity> findByJobName(String jobName);
|
||||
|
||||
Optional<JobDisplayNameEntity> findByApiKey(String apiKey);
|
||||
}
|
||||
@ -5,7 +5,9 @@ import com.snp.batch.global.dto.*;
|
||||
import com.snp.batch.global.model.BatchApiLog;
|
||||
import com.snp.batch.global.model.BatchFailedRecord;
|
||||
import com.snp.batch.global.model.BatchLastExecution;
|
||||
import com.snp.batch.global.model.JobDisplayNameEntity;
|
||||
import com.snp.batch.global.repository.BatchApiLogRepository;
|
||||
import com.snp.batch.global.repository.JobDisplayNameRepository;
|
||||
import com.snp.batch.global.repository.BatchFailedRecordRepository;
|
||||
import com.snp.batch.global.repository.BatchLastExecutionRepository;
|
||||
import com.snp.batch.global.repository.TimelineRepository;
|
||||
@ -37,39 +39,11 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class BatchService {
|
||||
|
||||
/** Job Bean 이름 → 한글 표시명 매핑 */
|
||||
private static final Map<String, String> JOB_DISPLAY_NAMES = Map.ofEntries(
|
||||
// AIS
|
||||
Map.entry("aisTargetImportJob", "AIS 선박위치 수집(1분 주기)"),
|
||||
Map.entry("aisTargetDbSyncJob", "AIS 선박위치 DB 적재(15분 주기)"),
|
||||
// 공통코드
|
||||
Map.entry("FlagCodeImportJob", "선박 국가코드 수집"),
|
||||
Map.entry("Stat5CodeImportJob", "선박 유형코드 수집"),
|
||||
// Compliance
|
||||
Map.entry("ComplianceImportRangeJob", "선박 제재 정보 수집"),
|
||||
Map.entry("CompanyComplianceImportRangeJob", "회사 제재 정보 수집"),
|
||||
// Event
|
||||
Map.entry("EventImportJob", "해양 사건/사고 수집"),
|
||||
// Port
|
||||
Map.entry("PortImportJob", "항구 시설 수집"),
|
||||
// Movement
|
||||
Map.entry("AnchorageCallsRangeImportJob", "정박지 기항 이력 수집"),
|
||||
Map.entry("BerthCallsRangeImportJob", "선석 기항 이력 수집"),
|
||||
Map.entry("CurrentlyAtRangeImportJob", "현재 위치 이력 수집"),
|
||||
Map.entry("DestinationsRangeImportJob", "목적지 이력 수집"),
|
||||
Map.entry("PortCallsRangeImportJob", "항구 기항 이력 수집"),
|
||||
Map.entry("STSOperationRangeImportJob", "STS 작업 이력 수집"),
|
||||
Map.entry("TerminalCallsRangeImportJob", "터미널 기항 이력 수집"),
|
||||
Map.entry("TransitsRangeImportJob", "항해 이력 수집"),
|
||||
// PSC
|
||||
Map.entry("PSCDetailImportJob", "PSC 선박 검사 수집"),
|
||||
// Risk
|
||||
Map.entry("RiskRangeImportJob", "선박 위험지표 수집"),
|
||||
// Ship
|
||||
Map.entry("ShipDetailUpdateJob", "선박 제원정보 수집"),
|
||||
// Infra
|
||||
Map.entry("partitionManagerJob", "AIS 파티션 테이블 생성/관리")
|
||||
);
|
||||
/** DB에서 로드한 Job 한글 표시명 캐시 */
|
||||
private Map<String, String> jobDisplayNameCache = Map.of();
|
||||
|
||||
/** DB에서 로드한 apiKey → 한글 표시명 캐시 */
|
||||
private Map<String, String> apiKeyDisplayNameCache = Map.of();
|
||||
|
||||
private final JobLauncher jobLauncher;
|
||||
private final JobExplorer jobExplorer;
|
||||
@ -81,6 +55,7 @@ public class BatchService {
|
||||
private final BatchApiLogRepository apiLogRepository;
|
||||
private final BatchFailedRecordRepository failedRecordRepository;
|
||||
private final BatchLastExecutionRepository batchLastExecutionRepository;
|
||||
private final JobDisplayNameRepository jobDisplayNameRepository;
|
||||
|
||||
@Autowired
|
||||
public BatchService(JobLauncher jobLauncher,
|
||||
@ -92,7 +67,8 @@ public class BatchService {
|
||||
RecollectionJobExecutionListener recollectionJobExecutionListener,
|
||||
BatchApiLogRepository apiLogRepository,
|
||||
BatchFailedRecordRepository failedRecordRepository,
|
||||
BatchLastExecutionRepository batchLastExecutionRepository) {
|
||||
BatchLastExecutionRepository batchLastExecutionRepository,
|
||||
JobDisplayNameRepository jobDisplayNameRepository) {
|
||||
this.jobLauncher = jobLauncher;
|
||||
this.jobExplorer = jobExplorer;
|
||||
this.jobOperator = jobOperator;
|
||||
@ -103,6 +79,7 @@ public class BatchService {
|
||||
this.apiLogRepository = apiLogRepository;
|
||||
this.failedRecordRepository = failedRecordRepository;
|
||||
this.batchLastExecutionRepository = batchLastExecutionRepository;
|
||||
this.jobDisplayNameRepository = jobDisplayNameRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -110,13 +87,96 @@ public class BatchService {
|
||||
* 리스너 내부에서 executionMode 체크하므로 정상 실행에는 영향 없음
|
||||
*/
|
||||
@PostConstruct
|
||||
public void registerGlobalListeners() {
|
||||
public void init() {
|
||||
// 리스너 등록
|
||||
jobMap.values().forEach(job -> {
|
||||
if (job instanceof AbstractJob abstractJob) {
|
||||
abstractJob.registerJobExecutionListener(recollectionJobExecutionListener);
|
||||
}
|
||||
});
|
||||
log.info("[BatchService] RecollectionJobExecutionListener를 {}개 Job에 등록", jobMap.size());
|
||||
|
||||
// Job 한글 표시명 초기 데이터 시드 (테이블이 비어있을 때만)
|
||||
seedDisplayNamesIfEmpty();
|
||||
|
||||
// Job 한글 표시명 캐시 로드
|
||||
refreshDisplayNameCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* job_display_name 테이블이 비어있으면 기본 표시명을 시드합니다.
|
||||
*/
|
||||
private void seedDisplayNamesIfEmpty() {
|
||||
if (jobDisplayNameRepository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
List<JobDisplayNameEntity> entities = List.of(
|
||||
buildDisplayName("aisTargetImportJob", "AIS 선박위치 수집(1분 주기)", null),
|
||||
buildDisplayName("aisTargetDbSyncJob", "AIS 선박위치 DB 적재(15분 주기)", null),
|
||||
buildDisplayName("FlagCodeImportJob", "선박 국가코드 수집", null),
|
||||
buildDisplayName("Stat5CodeImportJob", "선박 유형코드 수집", null),
|
||||
buildDisplayName("ComplianceImportRangeJob", "선박 제재 정보 수집", "COMPLIANCE_IMPORT_API"),
|
||||
buildDisplayName("CompanyComplianceImportRangeJob", "회사 제재 정보 수집", "COMPANY_COMPLIANCE_IMPORT_API"),
|
||||
buildDisplayName("EventImportJob", "해양 사건/사고 수집", "EVENT_IMPORT_API"),
|
||||
buildDisplayName("PortImportJob", "항구 시설 수집", null),
|
||||
buildDisplayName("AnchorageCallsRangeImportJob", "정박지 기항 이력 수집", "ANCHORAGE_CALLS_IMPORT_API"),
|
||||
buildDisplayName("BerthCallsRangeImportJob", "선석 기항 이력 수집", "BERTH_CALLS_IMPORT_API"),
|
||||
buildDisplayName("CurrentlyAtRangeImportJob", "현재 위치 이력 수집", "CURRENTLY_AT_IMPORT_API"),
|
||||
buildDisplayName("DestinationsRangeImportJob", "목적지 이력 수집", "DESTINATIONS_IMPORT_API"),
|
||||
buildDisplayName("PortCallsRangeImportJob", "항구 기항 이력 수집", "PORT_CALLS_IMPORT_API"),
|
||||
buildDisplayName("STSOperationRangeImportJob", "STS 작업 이력 수집", "STS_OPERATION_IMPORT_API"),
|
||||
buildDisplayName("TerminalCallsRangeImportJob", "터미널 기항 이력 수집", "TERMINAL_CALLS_IMPORT_API"),
|
||||
buildDisplayName("TransitsRangeImportJob", "항해 이력 수집", "TRANSITS_IMPORT_API"),
|
||||
buildDisplayName("PSCDetailImportJob", "PSC 선박 검사 수집", "PSC_IMPORT_API"),
|
||||
buildDisplayName("RiskRangeImportJob", "선박 위험지표 수집", "RISK_IMPORT_API"),
|
||||
buildDisplayName("ShipDetailUpdateJob", "선박 제원정보 수집", "SHIP_DETAIL_UPDATE_API"),
|
||||
buildDisplayName("partitionManagerJob", "AIS 파티션 테이블 생성/관리", null)
|
||||
);
|
||||
jobDisplayNameRepository.saveAll(entities);
|
||||
log.info("[BatchService] Job 표시명 초기 데이터 시드: {} 건", entities.size());
|
||||
}
|
||||
|
||||
private JobDisplayNameEntity buildDisplayName(String jobName, String displayName, String apiKey) {
|
||||
return JobDisplayNameEntity.builder()
|
||||
.jobName(jobName)
|
||||
.displayName(displayName)
|
||||
.apiKey(apiKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* DB에서 Job 한글 표시명을 로드하여 캐시를 갱신합니다.
|
||||
*/
|
||||
public void refreshDisplayNameCache() {
|
||||
List<JobDisplayNameEntity> all = jobDisplayNameRepository.findAll();
|
||||
jobDisplayNameCache = all.stream()
|
||||
.collect(Collectors.toMap(
|
||||
JobDisplayNameEntity::getJobName,
|
||||
JobDisplayNameEntity::getDisplayName,
|
||||
(a, b) -> a
|
||||
));
|
||||
apiKeyDisplayNameCache = all.stream()
|
||||
.filter(e -> e.getApiKey() != null)
|
||||
.collect(Collectors.toMap(
|
||||
JobDisplayNameEntity::getApiKey,
|
||||
JobDisplayNameEntity::getDisplayName,
|
||||
(a, b) -> a
|
||||
));
|
||||
log.info("[BatchService] Job 표시명 캐시 로드: {} 건 (apiKey: {} 건)", jobDisplayNameCache.size(), apiKeyDisplayNameCache.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Job의 한글 표시명을 반환합니다. DB에 없으면 null.
|
||||
*/
|
||||
public String getDisplayName(String jobName) {
|
||||
return jobDisplayNameCache.get(jobName);
|
||||
}
|
||||
|
||||
/**
|
||||
* apiKey로 한글 표시명을 반환합니다. DB에 없으면 null.
|
||||
*/
|
||||
public String getDisplayNameByApiKey(String apiKey) {
|
||||
return apiKeyDisplayNameCache.get(apiKey);
|
||||
}
|
||||
|
||||
public Long executeJob(String jobName) throws Exception {
|
||||
@ -886,7 +946,7 @@ public class BatchService {
|
||||
|
||||
return JobDetailDto.builder()
|
||||
.jobName(jobName)
|
||||
.displayName(JOB_DISPLAY_NAMES.get(jobName))
|
||||
.displayName(jobDisplayNameCache.get(jobName))
|
||||
.lastExecution(lastExec)
|
||||
.scheduleCron(cronMap.get(jobName))
|
||||
.build();
|
||||
|
||||
@ -5,11 +5,13 @@ import com.snp.batch.global.model.BatchCollectionPeriod;
|
||||
import com.snp.batch.global.model.BatchLastExecution;
|
||||
import com.snp.batch.global.model.BatchRecollectionHistory;
|
||||
import com.snp.batch.global.model.BatchFailedRecord;
|
||||
import com.snp.batch.global.model.JobDisplayNameEntity;
|
||||
import com.snp.batch.global.repository.BatchApiLogRepository;
|
||||
import com.snp.batch.global.repository.BatchCollectionPeriodRepository;
|
||||
import com.snp.batch.global.repository.BatchFailedRecordRepository;
|
||||
import com.snp.batch.global.repository.BatchLastExecutionRepository;
|
||||
import com.snp.batch.global.repository.BatchRecollectionHistoryRepository;
|
||||
import com.snp.batch.global.repository.JobDisplayNameRepository;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -41,6 +43,7 @@ public class RecollectionHistoryService {
|
||||
private final BatchApiLogRepository apiLogRepository;
|
||||
private final BatchFailedRecordRepository failedRecordRepository;
|
||||
private final JobExplorer jobExplorer;
|
||||
private final JobDisplayNameRepository jobDisplayNameRepository;
|
||||
|
||||
/**
|
||||
* 재수집 실행 시작 기록
|
||||
@ -70,9 +73,11 @@ public class RecollectionHistoryService {
|
||||
if (isRetryByRecordKeys) {
|
||||
// 실패 건 재수집 (자동/수동): 날짜 범위가 아닌 실패 레코드 키 기반이므로 날짜 없이 이력 생성
|
||||
if (apiKey != null) {
|
||||
apiKeyName = periodRepository.findById(apiKey)
|
||||
.map(BatchCollectionPeriod::getApiKeyName)
|
||||
.orElse(null);
|
||||
apiKeyName = jobDisplayNameRepository.findByApiKey(apiKey)
|
||||
.map(JobDisplayNameEntity::getDisplayName)
|
||||
.orElseGet(() -> periodRepository.findById(apiKey)
|
||||
.map(BatchCollectionPeriod::getApiKeyName)
|
||||
.orElse(null));
|
||||
}
|
||||
log.info("[RecollectionHistory] 실패 건 재수집 이력 생성 (날짜 범위 없음): executor={}, apiKey={}, apiKeyName={}", executor, apiKey, apiKeyName);
|
||||
} else {
|
||||
@ -86,7 +91,9 @@ public class RecollectionHistoryService {
|
||||
BatchCollectionPeriod cp = period.get();
|
||||
rangeFrom = cp.getRangeFromDate();
|
||||
rangeTo = cp.getRangeToDate();
|
||||
apiKeyName = cp.getApiKeyName();
|
||||
apiKeyName = jobDisplayNameRepository.findByApiKey(apiKey)
|
||||
.map(JobDisplayNameEntity::getDisplayName)
|
||||
.orElseGet(cp::getApiKeyName);
|
||||
|
||||
// 기간 중복 검출
|
||||
List<BatchRecollectionHistory> overlaps = historyRepository
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user