feat(동기화현황): 동기화 현황 메뉴 추가 (#1)

Backend:
- SyncStatusService: batch_flag 기반 테이블별 N/P/S 집계 (병렬 조회)
- SyncDataPreviewResponse/SyncStatusResponse DTO 추가
- BatchController: sync-status 조회/미리보기/P상태조회/리셋 API 4개
- BatchTableProperties: application.yml 테이블 매핑 ConfigurationProperties
- WebViewController: /sync-status SPA 라우트 추가

Frontend:
- SyncStatus 페이지: 도메인 탭 + 테이블 아코디언 + 인라인 데이터 테이블
- SyncDataPreviewModal/StuckRecordsModal 컴포넌트
- batchApi.ts: SyncStatus 관련 타입 및 API 함수 추가
- App.tsx 라우트 + Navbar 메뉴 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-03-23 17:12:20 +09:00
부모 75478266a6
커밋 924135e360
12개의 변경된 파일1190개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -12,6 +12,7 @@ const Executions = lazy(() => import('./pages/Executions'));
const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail')); const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail'));
const Schedules = lazy(() => import('./pages/Schedules')); const Schedules = lazy(() => import('./pages/Schedules'));
const Timeline = lazy(() => import('./pages/Timeline')); const Timeline = lazy(() => import('./pages/Timeline'));
const SyncStatus = lazy(() => import('./pages/SyncStatus'));
function AppLayout() { function AppLayout() {
const { toasts, removeToast } = useToastContext(); const { toasts, removeToast } = useToastContext();
@ -28,6 +29,7 @@ function AppLayout() {
<Route path="/executions/:id" element={<ExecutionDetail />} /> <Route path="/executions/:id" element={<ExecutionDetail />} />
<Route path="/schedules" element={<Schedules />} /> <Route path="/schedules" element={<Schedules />} />
<Route path="/schedule-timeline" element={<Timeline />} /> <Route path="/schedule-timeline" element={<Timeline />} />
<Route path="/sync-status" element={<SyncStatus />} />
</Routes> </Routes>
</Suspense> </Suspense>
</div> </div>

파일 보기

@ -273,6 +273,48 @@ export interface ExecutionStatisticsDto {
avgDurationMs: number; avgDurationMs: number;
} }
// ── Sync Status ─────────────────────────────────────────────
export interface SyncStatusSummary {
totalTables: number;
pendingCount: number;
processingCount: number;
completedCount: number;
stuckTables: number;
}
export interface SyncTableStatus {
tableKey: string;
sourceTable: string;
targetTable: string;
domain: string;
pendingCount: number;
processingCount: number;
completedCount: number;
lastSyncTime: string | null;
stuck: boolean;
}
export interface SyncDomainGroup {
domain: string;
domainLabel: string;
tables: SyncTableStatus[];
}
export interface SyncStatusResponse {
summary: SyncStatusSummary;
domains: SyncDomainGroup[];
}
export interface SyncDataPreviewResponse {
tableKey: string;
targetTable: string;
targetSchema: string;
columns: string[];
rows: Record<string, unknown>[];
totalCount: number;
}
// ── API Functions ──────────────────────────────────────────── // ── API Functions ────────────────────────────────────────────
export const batchApi = { export const batchApi = {
@ -399,4 +441,18 @@ export const batchApi = {
resetRetryCount: (ids: number[]) => resetRetryCount: (ids: number[]) =>
postJson<{ success: boolean; message: string; resetCount?: number }>( postJson<{ success: boolean; message: string; resetCount?: number }>(
`${BASE}/failed-records/reset-retry`, { ids }), `${BASE}/failed-records/reset-retry`, { ids }),
// Sync Status
getSyncStatus: () =>
fetchJson<SyncStatusResponse>(`${BASE}/sync-status`),
getSyncDataPreview: (tableKey: string, limit = 10) =>
fetchJson<SyncDataPreviewResponse>(`${BASE}/sync-status/${tableKey}/preview?limit=${limit}`),
getStuckRecords: (tableKey: string, limit = 50) =>
fetchJson<SyncDataPreviewResponse>(`${BASE}/sync-status/${tableKey}/stuck?limit=${limit}`),
resetStuckRecords: (tableKey: string) =>
postJson<{ success: boolean; message: string; resetCount?: number }>(
`${BASE}/sync-status/${tableKey}/reset`),
}; };

파일 보기

@ -7,6 +7,7 @@ const navItems = [
{ path: '/jobs', label: '작업', icon: '⚙️' }, { path: '/jobs', label: '작업', icon: '⚙️' },
{ path: '/schedules', label: '스케줄', icon: '🕐' }, { path: '/schedules', label: '스케줄', icon: '🕐' },
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' }, { path: '/schedule-timeline', label: '타임라인', icon: '📅' },
{ path: '/sync-status', label: '동기화 현황', icon: '🔄' },
]; ];
export default function Navbar() { export default function Navbar() {

파일 보기

@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import { batchApi, type SyncDataPreviewResponse } from '../api/batchApi';
import LoadingSpinner from './LoadingSpinner';
interface Props {
open: boolean;
tableKey: string;
tableName: string;
onClose: () => void;
onReset: () => void;
}
export default function StuckRecordsModal({ open, tableKey, tableName, onClose, onReset }: Props) {
const [data, setData] = useState<SyncDataPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !tableKey) return;
setLoading(true);
setError(null);
batchApi.getStuckRecords(tableKey, 50)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [open, tableKey]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
<div
className="bg-wing-surface rounded-xl shadow-2xl max-w-5xl w-full mx-4 max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-wing-border">
<div>
<div className="flex items-center gap-2">
<span className="text-red-500"></span>
<h3 className="text-lg font-semibold text-wing-text">P </h3>
</div>
<p className="text-xs text-wing-muted mt-0.5">
{tableName}
{data ? ` | ${data.targetSchema}.${data.targetTable} | 총 ${data.totalCount.toLocaleString()}건 고착` : ''}
</p>
</div>
<div className="flex items-center gap-2">
{data && data.totalCount > 0 && (
<button
onClick={onReset}
className="px-3 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
>
PN
</button>
)}
<button
onClick={onClose}
className="px-3 py-1.5 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
{loading && <LoadingSpinner />}
{error && (
<div className="text-center py-8 text-red-400"> : {error}</div>
)}
{!loading && !error && data && data.rows.length === 0 && (
<div className="text-center py-8 text-wing-muted">P </div>
)}
{!loading && !error && data && data.rows.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-wing-border">
{data.columns.map((col) => (
<th
key={col}
className={`px-3 py-2 text-left font-medium whitespace-nowrap bg-wing-card
${col === 'batch_flag' ? 'text-red-500' : 'text-wing-muted'}`}
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, idx) => (
<tr key={idx} className="border-b border-wing-border/50 hover:bg-wing-hover">
{data.columns.map((col) => (
<td
key={col}
className={`px-3 py-1.5 whitespace-nowrap max-w-[200px] truncate
${col === 'batch_flag' ? 'text-red-500 font-bold' : 'text-wing-text'}`}
>
{formatCellValue(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Footer */}
{data && data.rows.length > 0 && (
<div className="px-6 py-3 border-t border-wing-border text-xs text-wing-muted flex items-center justify-between">
<span>{data.rows.length} ( {data.totalCount.toLocaleString()})</span>
<span className="text-red-400"> batch_flag가 PN으로 </span>
</div>
)}
</div>
</div>
);
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}

파일 보기

@ -0,0 +1,108 @@
import { useState, useEffect } from 'react';
import { batchApi, type SyncDataPreviewResponse } from '../api/batchApi';
import LoadingSpinner from './LoadingSpinner';
interface Props {
open: boolean;
tableKey: string;
tableName: string;
onClose: () => void;
}
export default function SyncDataPreviewModal({ open, tableKey, tableName, onClose }: Props) {
const [data, setData] = useState<SyncDataPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !tableKey) return;
setLoading(true);
setError(null);
batchApi.getSyncDataPreview(tableKey, 10)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [open, tableKey]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
<div
className="bg-wing-surface rounded-xl shadow-2xl max-w-5xl w-full mx-4 max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-wing-border">
<div>
<h3 className="text-lg font-semibold text-wing-text">{tableName}</h3>
<p className="text-xs text-wing-muted mt-0.5">
{data ? `${data.targetSchema}.${data.targetTable} | 총 ${data.totalCount.toLocaleString()}` : ''}
</p>
</div>
<button
onClick={onClose}
className="px-3 py-1.5 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
{loading && <LoadingSpinner />}
{error && (
<div className="text-center py-8 text-wing-muted">
<p className="text-red-400"> : {error}</p>
</div>
)}
{!loading && !error && data && data.rows.length === 0 && (
<div className="text-center py-8 text-wing-muted"> </div>
)}
{!loading && !error && data && data.rows.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-wing-border">
{data.columns.map((col) => (
<th
key={col}
className="px-3 py-2 text-left font-medium text-wing-muted whitespace-nowrap bg-wing-card"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, idx) => (
<tr key={idx} className="border-b border-wing-border/50 hover:bg-wing-hover">
{data.columns.map((col) => (
<td key={col} className="px-3 py-1.5 text-wing-text whitespace-nowrap max-w-[200px] truncate">
{formatCellValue(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Footer */}
{data && data.rows.length > 0 && (
<div className="px-6 py-3 border-t border-wing-border text-xs text-wing-muted">
{data.rows.length} ( {data.totalCount.toLocaleString()})
</div>
)}
</div>
</div>
);
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}

파일 보기

@ -0,0 +1,393 @@
import { useState, useCallback, useEffect } from 'react';
import {
batchApi,
type SyncStatusResponse,
type SyncTableStatus,
type SyncDataPreviewResponse,
} from '../api/batchApi';
import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
import LoadingSpinner from '../components/LoadingSpinner';
import EmptyState from '../components/EmptyState';
import ConfirmModal from '../components/ConfirmModal';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL = 30000;
const DOMAIN_ICONS: Record<string, string> = {
ship: '🚢',
company: '🏢',
event: '⚠️',
facility: '🏭',
psc: '🔍',
movements: '📍',
code: '🏷️',
'risk-compliance': '🛡️',
};
const GUIDE_ITEMS = [
{
title: '도메인 탭',
content: 'Ship, PSC 등 도메인별로 테이블을 그룹핑하여 조회합니다.\nP 고착 테이블이 있는 도메인에는 경고 뱃지가 표시됩니다.',
},
{
title: '테이블 아코디언',
content: '각 테이블을 펼치면 대기(N)/진행(P)/완료(S) 건수와 상세 데이터를 확인할 수 있습니다.\n⚠ 표시는 P 상태에 고착된 레코드가 있음을 의미합니다.',
},
{
title: '동기화 데이터 / P 상태 레코드',
content: '동기화 데이터 탭: 타겟 스키마(std_snp_svc)의 최근 동기화 데이터를 보여줍니다.\nP 상태 레코드 탭: Writer 실패로 P 상태에 멈춘 레코드를 확인하고 리셋할 수 있습니다.',
},
];
export default function SyncStatus() {
const { showToast } = useToastContext();
const [data, setData] = useState<SyncStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [guideOpen, setGuideOpen] = useState(false);
// Tab & accordion state
const [activeDomain, setActiveDomain] = useState<string>('ship');
const [expandedTable, setExpandedTable] = useState<string>('ship-001');
const [detailTabs, setDetailTabs] = useState<Record<string, 'preview' | 'stuck'>>({});
// Reset confirm
const [resetTableKey, setResetTableKey] = useState('');
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const [resetting, setResetting] = useState(false);
const loadData = useCallback(async () => {
try {
const result = await batchApi.getSyncStatus();
setData(result);
} catch {
if (loading) showToast('동기화 현황 조회 실패', 'error');
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
usePoller(loadData, POLLING_INTERVAL);
const toggleAccordion = (tableKey: string) => {
setExpandedTable((prev) => (prev === tableKey ? '' : tableKey));
};
const getDetailTab = (tableKey: string) => detailTabs[tableKey] || 'preview';
const setDetailTab = (tableKey: string, tab: 'preview' | 'stuck') => {
setDetailTabs((prev) => ({ ...prev, [tableKey]: tab }));
};
const handleReset = async () => {
setResetting(true);
try {
const result = await batchApi.resetStuckRecords(resetTableKey);
const allTables = data?.domains.flatMap((d) => d.tables) ?? [];
const table = allTables.find((t) => t.tableKey === resetTableKey);
showToast(`${table?.sourceTable ?? resetTableKey}: ${result.resetCount}건 리셋 완료`, 'success');
setResetConfirmOpen(false);
loadData();
} catch {
showToast('리셋 실패', 'error');
} finally {
setResetting(false);
}
};
const activeDomainGroup = data?.domains.find((d) => d.domain === activeDomain);
const resetTable = data?.domains.flatMap((d) => d.tables).find((t) => t.tableKey === resetTableKey);
if (loading) return <LoadingSpinner />;
if (!data) return <EmptyState message="데이터를 불러올 수 없습니다" />;
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
{/* ── Domain Tabs ── */}
<div className="flex gap-0 overflow-x-auto border-b border-wing-border mb-4">
{data.domains.map((d) => {
const stuckCount = d.tables.filter((t) => t.stuck).length;
return (
<button
key={d.domain}
onClick={() => setActiveDomain(d.domain)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
${activeDomain === d.domain
? 'border-wing-accent text-wing-accent'
: 'border-transparent text-wing-muted hover:text-wing-text hover:border-wing-border'
}`}
>
<span className="mr-1">{DOMAIN_ICONS[d.domain] || ''}</span>
{d.domainLabel}
<span className="ml-1 text-xs opacity-60">({d.tables.length})</span>
{stuckCount > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-5 h-5 text-[10px] font-bold text-white bg-red-500 rounded-full">
{stuckCount}
</span>
)}
</button>
);
})}
</div>
{/* ── Table Accordions ── */}
{activeDomainGroup && (
<div className="space-y-2">
{activeDomainGroup.tables.map((table) => (
<TableAccordion
key={table.tableKey}
table={table}
expanded={expandedTable === table.tableKey}
detailTab={getDetailTab(table.tableKey)}
onToggle={() => toggleAccordion(table.tableKey)}
onDetailTabChange={(tab) => setDetailTab(table.tableKey, tab)}
onReset={() => { setResetTableKey(table.tableKey); setResetConfirmOpen(true); }}
/>
))}
</div>
)}
{/* Reset Confirm Modal */}
<ConfirmModal
open={resetConfirmOpen}
title="P→N 리셋 확인"
message={`${resetTable?.sourceTable ?? ''}의 P 상태 레코드를 모두 N(대기)으로 리셋하시겠습니까?\n리셋된 레코드는 다음 동기화 실행 시 재처리됩니다.`}
confirmLabel={resetting ? '리셋 중...' : '리셋'}
onConfirm={handleReset}
onCancel={() => setResetConfirmOpen(false)}
/>
<GuideModal
open={guideOpen}
pageTitle="동기화 현황"
sections={GUIDE_ITEMS}
onClose={() => setGuideOpen(false)}
/>
</div>
);
}
// ── Sub Components ────────────────────────────────────────────
interface TableAccordionProps {
table: SyncTableStatus;
expanded: boolean;
detailTab: 'preview' | 'stuck';
onToggle: () => void;
onDetailTabChange: (tab: 'preview' | 'stuck') => void;
onReset: () => void;
}
function TableAccordion({ table, expanded, detailTab, onToggle, onDetailTabChange, onReset }: TableAccordionProps) {
return (
<div className={`bg-wing-card rounded-xl border overflow-hidden
${table.stuck ? 'border-amber-400 ring-1 ring-amber-100' : 'border-wing-border'}`}>
{/* Accordion header */}
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-5 py-3 hover:bg-wing-hover transition-colors text-left"
>
<div className="flex items-center gap-3">
{table.stuck && <span className="text-red-500"></span>}
<div>
<span className="text-wing-text font-semibold text-sm">{table.targetTable}</span>
<span className="text-xs text-wing-muted ml-2">{table.tableKey}</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full font-semibold tabular-nums bg-amber-100 text-amber-700">
{table.pendingCount.toLocaleString()}
</span>
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full font-semibold tabular-nums
${table.stuck ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}`}>
{table.processingCount.toLocaleString()}
</span>
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full font-semibold tabular-nums bg-emerald-100 text-emerald-700">
{table.completedCount.toLocaleString()}
</span>
<span className="text-wing-muted w-16 text-right ml-1">{formatRelativeTime(table.lastSyncTime)}</span>
<span className="text-wing-muted">{expanded ? '▲' : '▼'}</span>
</div>
</button>
{/* Accordion body */}
{expanded && (
<div className="border-t border-wing-border p-5">
{/* Stats row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<MiniStat label="대기 (N)" value={table.pendingCount} color="text-amber-600 dark:text-amber-400" />
<MiniStat label="진행 (P)" value={table.processingCount} color="text-blue-600 dark:text-blue-400"
warn={table.stuck} />
<MiniStat label="완료 (S)" value={table.completedCount} color="text-emerald-600 dark:text-emerald-400" />
<div className="bg-wing-bg rounded-lg p-3">
<p className="text-xs text-wing-muted"> </p>
<p className="text-sm font-medium text-wing-text mt-0.5">{formatRelativeTime(table.lastSyncTime)}</p>
</div>
</div>
{/* Detail sub-tabs */}
<div className="flex items-center justify-between mb-3">
<div className="flex gap-1">
<button
onClick={() => onDetailTabChange('preview')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
${detailTab === 'preview'
? 'bg-wing-accent text-white'
: 'bg-wing-bg text-wing-muted hover:bg-wing-hover'
}`}
>
</button>
<button
onClick={() => onDetailTabChange('stuck')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
${detailTab === 'stuck'
? 'bg-wing-accent text-white'
: 'bg-wing-bg text-wing-muted hover:bg-wing-hover'
}`}
>
P
{table.processingCount > 0 && (
<span className="ml-1 text-red-300">({table.processingCount.toLocaleString()})</span>
)}
</button>
</div>
{detailTab === 'stuck' && table.stuck && (
<button
onClick={onReset}
className="px-3 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
>
PN
</button>
)}
</div>
{/* Tab content */}
{detailTab === 'preview' && (
<InlineDataTable tableKey={table.tableKey} fetchFn={batchApi.getSyncDataPreview} />
)}
{detailTab === 'stuck' && (
<InlineDataTable tableKey={table.tableKey} fetchFn={batchApi.getStuckRecords} />
)}
</div>
)}
</div>
);
}
interface MiniStatProps {
label: string;
value: number;
color: string;
warn?: boolean;
}
function MiniStat({ label, value, color, warn }: MiniStatProps) {
return (
<div className={`bg-wing-bg rounded-lg p-3 ${warn ? 'ring-1 ring-red-400' : ''}`}>
<p className="text-xs text-wing-muted">{label}</p>
<p className={`text-lg font-bold mt-0.5 tabular-nums ${color}`}>
{value.toLocaleString()}
{warn && <span className="ml-1 text-xs text-red-500"></span>}
</p>
</div>
);
}
interface InlineDataTableProps {
tableKey: string;
fetchFn: (tableKey: string, limit: number) => Promise<SyncDataPreviewResponse>;
}
function InlineDataTable({ tableKey, fetchFn }: InlineDataTableProps) {
const [data, setData] = useState<SyncDataPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
setData(null);
fetchFn(tableKey, 20)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [tableKey, fetchFn]);
if (loading) return <div className="py-8"><LoadingSpinner /></div>;
if (error) return <div className="text-center py-8 text-red-400 text-sm"> : {error}</div>;
if (!data || data.rows.length === 0) {
return <div className="text-center py-8 text-wing-muted text-sm"> </div>;
}
return (
<div>
<div className="overflow-x-auto rounded-lg border border-wing-border">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-wing-border">
{data.columns.map((col) => (
<th
key={col}
className={`px-3 py-2 text-left font-medium whitespace-nowrap bg-wing-bg
${col === 'batch_flag' ? 'text-blue-500' : 'text-wing-muted'}`}
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, idx) => (
<tr key={idx} className="border-b border-wing-border/50 hover:bg-wing-hover">
{data.columns.map((col) => (
<td
key={col}
className={`px-3 py-1.5 whitespace-nowrap max-w-[200px] truncate
${col === 'batch_flag' ? 'font-bold text-blue-600 dark:text-blue-400' : 'text-wing-text'}`}
>
{formatCellValue(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-wing-muted mt-2">
{data.rows.length} ( {data.totalCount.toLocaleString()}) &middot; {data.targetSchema}.{data.targetTable}
</p>
</div>
);
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '-';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return '방금 전';
if (diffMin < 60) return `${diffMin}분 전`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour}시간 전`;
const diffDay = Math.floor(diffHour / 24);
return `${diffDay}일 전`;
} catch {
return '-';
}
}

파일 보기

@ -0,0 +1,26 @@
package com.snp.batch.global.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app.batch")
public class BatchTableProperties {
private SchemaConfig sourceSchema = new SchemaConfig();
private SchemaConfig targetSchema = new SchemaConfig();
@Getter
@Setter
public static class SchemaConfig {
private String name;
private Map<String, String> tables = new LinkedHashMap<>();
}
}

파일 보기

@ -3,8 +3,11 @@ package com.snp.batch.global.controller;
import com.snp.batch.global.dto.JobExecutionDto; import com.snp.batch.global.dto.JobExecutionDto;
import com.snp.batch.global.dto.ScheduleRequest; import com.snp.batch.global.dto.ScheduleRequest;
import com.snp.batch.global.dto.ScheduleResponse; import com.snp.batch.global.dto.ScheduleResponse;
import com.snp.batch.global.dto.SyncDataPreviewResponse;
import com.snp.batch.global.dto.SyncStatusResponse;
import com.snp.batch.service.BatchService; import com.snp.batch.service.BatchService;
import com.snp.batch.service.ScheduleService; import com.snp.batch.service.ScheduleService;
import com.snp.batch.service.SyncStatusService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
@ -27,6 +30,7 @@ public class BatchController {
private final BatchService batchService; private final BatchService batchService;
private final ScheduleService scheduleService; private final ScheduleService scheduleService;
private final SyncStatusService syncStatusService;
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
@ApiResponses(value = { @ApiResponses(value = {
@ -324,4 +328,84 @@ public class BatchController {
com.snp.batch.global.dto.ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days); com.snp.batch.global.dto.ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
return ResponseEntity.ok(stats); return ResponseEntity.ok(stats);
} }
// 동기화 현황 API
@Operation(summary = "동기화 현황 조회", description = "전체 테이블의 batch_flag 기반 동기화 현황을 조회합니다")
@GetMapping("/sync-status")
public ResponseEntity<SyncStatusResponse> getSyncStatus() {
log.info("Received request to get sync status");
try {
SyncStatusResponse status = syncStatusService.getSyncStatus();
return ResponseEntity.ok(status);
} catch (Exception e) {
log.error("Error getting sync status", e);
return ResponseEntity.internalServerError().build();
}
}
@Operation(summary = "동기화 데이터 미리보기", description = "특정 테이블의 최근 동기화 성공 데이터를 조회합니다")
@GetMapping("/sync-status/{tableKey}/preview")
public ResponseEntity<SyncDataPreviewResponse> getSyncDataPreview(
@Parameter(description = "테이블 키 (예: ship-001)", required = true)
@PathVariable String tableKey,
@Parameter(description = "조회 건수", example = "10")
@RequestParam(defaultValue = "10") int limit) {
log.info("Received request to preview sync data for: {}", tableKey);
try {
SyncDataPreviewResponse preview = syncStatusService.getDataPreview(tableKey, limit);
return ResponseEntity.ok(preview);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error getting sync data preview for: {}", tableKey, e);
return ResponseEntity.internalServerError().build();
}
}
@Operation(summary = "P 상태 고착 레코드 조회", description = "특정 테이블의 batch_flag='P' 고착 레코드를 조회합니다")
@GetMapping("/sync-status/{tableKey}/stuck")
public ResponseEntity<SyncDataPreviewResponse> getStuckRecords(
@Parameter(description = "테이블 키 (예: ship-001)", required = true)
@PathVariable String tableKey,
@Parameter(description = "조회 건수", example = "50")
@RequestParam(defaultValue = "50") int limit) {
log.info("Received request to get stuck records for: {}", tableKey);
try {
SyncDataPreviewResponse stuck = syncStatusService.getStuckRecords(tableKey, limit);
return ResponseEntity.ok(stuck);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error getting stuck records for: {}", tableKey, e);
return ResponseEntity.internalServerError().build();
}
}
@Operation(summary = "P 상태 고착 레코드 리셋", description = "특정 테이블의 batch_flag='P' 레코드를 'N'으로 리셋합니다")
@PostMapping("/sync-status/{tableKey}/reset")
public ResponseEntity<Map<String, Object>> resetStuckRecords(
@Parameter(description = "테이블 키 (예: ship-001)", required = true)
@PathVariable String tableKey) {
log.info("Received request to reset stuck records for: {}", tableKey);
try {
int resetCount = syncStatusService.resetStuckRecords(tableKey);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "P→N 리셋 완료",
"resetCount", resetCount
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
log.error("Error resetting stuck records for: {}", tableKey, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "리셋 실패: " + e.getMessage()
));
}
}
} }

파일 보기

@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.GetMapping;
public class WebViewController { public class WebViewController {
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}", @GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
"/schedules", "/schedule-timeline", "/schedules", "/schedule-timeline", "/sync-status",
"/jobs/**", "/executions/**", "/jobs/**", "/executions/**",
"/schedules/**", "/schedule-timeline/**"}) "/schedules/**", "/schedule-timeline/**", "/sync-status/**"})
public String forward() { public String forward() {
return "forward:/index.html"; return "forward:/index.html";
} }

파일 보기

@ -0,0 +1,26 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 동기화 성공 데이터 미리보기 응답
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SyncDataPreviewResponse {
private String tableKey;
private String targetTable;
private String targetSchema;
private List<String> columns;
private List<Map<String, Object>> rows;
private long totalCount;
}

파일 보기

@ -0,0 +1,59 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 동기화 현황 전체 응답
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SyncStatusResponse {
private SyncStatusSummary summary;
private List<SyncDomainGroup> domains;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SyncStatusSummary {
private int totalTables;
private long pendingCount;
private long processingCount;
private long completedCount;
private int stuckTables;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SyncDomainGroup {
private String domain;
private String domainLabel;
private List<SyncTableStatus> tables;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SyncTableStatus {
private String tableKey;
private String sourceTable;
private String targetTable;
private String domain;
private long pendingCount;
private long processingCount;
private long completedCount;
private String lastSyncTime;
private boolean stuck;
}
}

파일 보기

@ -0,0 +1,306 @@
package com.snp.batch.service;
import com.snp.batch.global.config.BatchTableProperties;
import com.snp.batch.global.dto.SyncDataPreviewResponse;
import com.snp.batch.global.dto.SyncStatusResponse;
import com.snp.batch.global.dto.SyncStatusResponse.SyncDomainGroup;
import com.snp.batch.global.dto.SyncStatusResponse.SyncStatusSummary;
import com.snp.batch.global.dto.SyncStatusResponse.SyncTableStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* 동기화 현황 조회 서비스
* - batch_flag 기반 테이블별 N/P/S 건수 집계
* - 타겟 스키마 데이터 미리보기
* - P 상태 고착 레코드 리셋
*/
@Slf4j
@Service
public class SyncStatusService {
private static final Map<String, String> DOMAIN_LABELS = Map.of(
"ship", "Ship (선박)",
"company", "Company (회사)",
"event", "Event (사건)",
"facility", "Facility (시설)",
"psc", "PSC (검사)",
"movements", "Movements (이동)",
"code", "Code (코드)",
"risk-compliance", "Risk & Compliance"
);
private static final List<String> DOMAIN_ORDER = List.of(
"ship", "company", "event", "facility", "psc",
"movements", "code", "risk-compliance"
);
private final JdbcTemplate businessJdbc;
private final BatchTableProperties tableProps;
private String sourceSchema;
private String targetSchema;
private Map<String, String> sourceTables;
private Map<String, String> targetTables;
public SyncStatusService(@Qualifier("businessDataSource") DataSource businessDataSource,
BatchTableProperties tableProps) {
this.businessJdbc = new JdbcTemplate(businessDataSource);
this.tableProps = tableProps;
this.sourceSchema = tableProps.getSourceSchema().getName();
this.targetSchema = tableProps.getTargetSchema().getName();
this.sourceTables = tableProps.getSourceSchema().getTables();
this.targetTables = tableProps.getTargetSchema().getTables();
}
/**
* 전체 동기화 현황 조회
*/
public SyncStatusResponse getSyncStatus() {
// 테이블을 병렬 조회 (HikariCP pool=10 기준 동시 10개)
ExecutorService executor = Executors.newFixedThreadPool(
Math.min(sourceTables.size(), 10));
List<CompletableFuture<SyncTableStatus>> futures = sourceTables.entrySet().stream()
.map(entry -> CompletableFuture.supplyAsync(() -> {
String tableKey = entry.getKey();
String sourceTable = entry.getValue();
String targetTable = targetTables.getOrDefault(tableKey, "");
try {
return queryTableStatus(tableKey, sourceTable, targetTable);
} catch (Exception e) {
log.warn("테이블 상태 조회 실패: {} ({})", tableKey, e.getMessage());
return SyncTableStatus.builder()
.tableKey(tableKey)
.sourceTable(sourceTable)
.targetTable(targetTable)
.domain(extractDomain(tableKey))
.pendingCount(0)
.processingCount(0)
.completedCount(0)
.stuck(false)
.build();
}
}, executor))
.collect(Collectors.toList());
List<SyncTableStatus> allStatuses = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
executor.shutdown();
// 도메인별 그룹핑
Map<String, List<SyncTableStatus>> grouped = allStatuses.stream()
.collect(Collectors.groupingBy(SyncTableStatus::getDomain));
List<SyncDomainGroup> domains = DOMAIN_ORDER.stream()
.filter(grouped::containsKey)
.map(domain -> SyncDomainGroup.builder()
.domain(domain)
.domainLabel(DOMAIN_LABELS.getOrDefault(domain, domain))
.tables(grouped.get(domain))
.build())
.collect(Collectors.toList());
// 요약
long totalPending = allStatuses.stream().mapToLong(SyncTableStatus::getPendingCount).sum();
long totalProcessing = allStatuses.stream().mapToLong(SyncTableStatus::getProcessingCount).sum();
long totalCompleted = allStatuses.stream().mapToLong(SyncTableStatus::getCompletedCount).sum();
int stuckTables = (int) allStatuses.stream().filter(SyncTableStatus::isStuck).count();
SyncStatusSummary summary = SyncStatusSummary.builder()
.totalTables(allStatuses.size())
.pendingCount(totalPending)
.processingCount(totalProcessing)
.completedCount(totalCompleted)
.stuckTables(stuckTables)
.build();
return SyncStatusResponse.builder()
.summary(summary)
.domains(domains)
.build();
}
/**
* 특정 테이블의 최근 동기화 데이터 미리보기
*/
public SyncDataPreviewResponse getDataPreview(String tableKey, int limit) {
String targetTable = targetTables.get(tableKey);
if (targetTable == null) {
throw new IllegalArgumentException("존재하지 않는 테이블 키: " + tableKey);
}
String countSql = "SELECT COUNT(*) FROM %s.%s".formatted(targetSchema, targetTable);
Long totalCount = businessJdbc.queryForObject(countSql, Long.class);
String sql = "SELECT * FROM %s.%s ORDER BY mdfcn_dt DESC NULLS LAST LIMIT %d"
.formatted(targetSchema, targetTable, limit);
List<Map<String, Object>> rows = businessJdbc.queryForList(sql);
List<String> columns = rows.isEmpty()
? getTableColumns(targetTable)
: new ArrayList<>(rows.get(0).keySet());
return SyncDataPreviewResponse.builder()
.tableKey(tableKey)
.targetTable(targetTable)
.targetSchema(targetSchema)
.columns(columns)
.rows(rows)
.totalCount(totalCount != null ? totalCount : 0)
.build();
}
/**
* P 상태 고착 레코드 조회
*/
public SyncDataPreviewResponse getStuckRecords(String tableKey, int limit) {
String sourceTable = sourceTables.get(tableKey);
if (sourceTable == null) {
throw new IllegalArgumentException("존재하지 않는 테이블 키: " + tableKey);
}
String countSql = """
SELECT COUNT(*)
FROM %s.%s a
INNER JOIN %s.batch_job_execution b
ON a.job_execution_id = b.job_execution_id
AND b.status = 'COMPLETED'
WHERE a.batch_flag = 'P'
""".formatted(sourceSchema, sourceTable, sourceSchema);
Long totalCount = businessJdbc.queryForObject(countSql, Long.class);
String sql = """
SELECT a.*
FROM %s.%s a
INNER JOIN %s.batch_job_execution b
ON a.job_execution_id = b.job_execution_id
AND b.status = 'COMPLETED'
WHERE a.batch_flag = 'P'
ORDER BY a.mdfcn_dt DESC NULLS LAST
LIMIT %d
""".formatted(sourceSchema, sourceTable, sourceSchema, limit);
List<Map<String, Object>> rows = businessJdbc.queryForList(sql);
List<String> columns = rows.isEmpty()
? getTableColumns(sourceTable)
: new ArrayList<>(rows.get(0).keySet());
return SyncDataPreviewResponse.builder()
.tableKey(tableKey)
.targetTable(sourceTable)
.targetSchema(sourceSchema)
.columns(columns)
.rows(rows)
.totalCount(totalCount != null ? totalCount : 0)
.build();
}
/**
* P 상태 고착 레코드를 N으로 리셋
*/
public int resetStuckRecords(String tableKey) {
String sourceTable = sourceTables.get(tableKey);
if (sourceTable == null) {
throw new IllegalArgumentException("존재하지 않는 테이블 키: " + tableKey);
}
String sql = """
UPDATE %s.%s
SET batch_flag = 'N'
, mdfcn_dt = CURRENT_TIMESTAMP
, mdfr_id = 'MANUAL_RESET'
WHERE batch_flag = 'P'
""".formatted(sourceSchema, sourceTable);
int updated = businessJdbc.update(sql);
log.info("P→N 리셋 완료: {} ({}) {}건", tableKey, sourceTable, updated);
return updated;
}
private SyncTableStatus queryTableStatus(String tableKey, String sourceTable, String targetTable) {
// batch_job_execution.status = 'COMPLETED' 데이터만 집계
// (수집/적재가 완전히 완료된 데이터만 동기화 대상)
String sql = """
SELECT a.batch_flag, COUNT(*) AS cnt
FROM %s.%s a
INNER JOIN %s.batch_job_execution b
ON a.job_execution_id = b.job_execution_id
AND b.status = 'COMPLETED'
WHERE a.batch_flag IN ('N', 'P', 'S')
GROUP BY a.batch_flag
""".formatted(sourceSchema, sourceTable, sourceSchema);
Map<String, Long> counts = new HashMap<>();
counts.put("N", 0L);
counts.put("P", 0L);
counts.put("S", 0L);
businessJdbc.query(sql, rs -> {
counts.put(rs.getString("batch_flag"), rs.getLong("cnt"));
});
// 최근 동기화 시간 (COMPLETED된 job의 batch_flag='S' 가장 최근 mdfcn_dt)
String lastSyncSql = """
SELECT MAX(a.mdfcn_dt)
FROM %s.%s a
INNER JOIN %s.batch_job_execution b
ON a.job_execution_id = b.job_execution_id
AND b.status = 'COMPLETED'
WHERE a.batch_flag = 'S'
""".formatted(sourceSchema, sourceTable, sourceSchema);
String lastSyncTime = null;
try {
lastSyncTime = businessJdbc.queryForObject(lastSyncSql, String.class);
} catch (Exception e) {
log.trace("최근 동기화 시간 조회 실패: {}", tableKey);
}
boolean stuck = counts.get("P") > 0;
return SyncTableStatus.builder()
.tableKey(tableKey)
.sourceTable(sourceTable)
.targetTable(targetTable)
.domain(extractDomain(tableKey))
.pendingCount(counts.get("N"))
.processingCount(counts.get("P"))
.completedCount(counts.get("S"))
.lastSyncTime(lastSyncTime)
.stuck(stuck)
.build();
}
private String extractDomain(String tableKey) {
int dashIndex = tableKey.indexOf('-');
if (dashIndex < 0) return tableKey;
String prefix = tableKey.substring(0, dashIndex);
// "risk" prefix "risk-compliance" domain
if ("risk".equals(prefix)) return "risk-compliance";
return prefix;
}
private List<String> getTableColumns(String tableName) {
String sql = """
SELECT column_name FROM information_schema.columns
WHERE table_schema = ? AND table_name = ?
ORDER BY ordinal_position
""";
return businessJdbc.queryForList(sql, String.class, targetSchema, tableName);
}
}