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:
부모
75478266a6
커밋
924135e360
@ -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() {
|
||||||
|
|||||||
127
frontend/src/components/StuckRecordsModal.tsx
Normal file
127
frontend/src/components/StuckRecordsModal.tsx
Normal file
@ -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"
|
||||||
|
>
|
||||||
|
전체 P→N 리셋
|
||||||
|
</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가 P→N으로 변경되어 다음 동기화에 재처리됩니다</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);
|
||||||
|
}
|
||||||
108
frontend/src/components/SyncDataPreviewModal.tsx
Normal file
108
frontend/src/components/SyncDataPreviewModal.tsx
Normal file
@ -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);
|
||||||
|
}
|
||||||
393
frontend/src/pages/SyncStatus.tsx
Normal file
393
frontend/src/pages/SyncStatus.tsx
Normal file
@ -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"
|
||||||
|
>
|
||||||
|
전체 P→N 리셋
|
||||||
|
</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()}건) · {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;
|
||||||
|
}
|
||||||
|
}
|
||||||
306
src/main/java/com/snp/batch/service/SyncStatusService.java
Normal file
306
src/main/java/com/snp/batch/service/SyncStatusService.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user