From 7b2537e85d6270c228a13fbfdd624c87b12aeeb7 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 4 Mar 2026 10:47:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(monitoring):=20=EB=A7=88=EC=A7=80=EB=A7=89?= =?UTF-8?q?=20=EC=88=98=EC=A7=91=20=EC=99=84=EB=A3=8C=EC=9D=BC=EC=8B=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/batch/last-collections API 엔드포인트 추가 - BatchLastExecution 엔티티에 apiDesc 컬럼 추가 - 재수집 이력 페이지에 마지막 수집 완료일시 토글 패널 추가 - 상태 요약 바 (정상/주의/경고 건수) - API별 테이블 (성공일시, 경과시간, 상태 뱃지) - /monitoring SPA 라우트 추가 Closes #17 Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/batchApi.ts | 14 ++ frontend/src/pages/Recollects.tsx | 175 ++++++++++++++++++ .../global/controller/BatchController.java | 11 ++ .../global/controller/WebViewController.java | 2 +- .../dto/LastCollectionStatusResponse.java | 21 +++ .../global/model/BatchLastExecution.java | 3 + .../com/snp/batch/service/BatchService.java | 33 +++- 7 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/snp/batch/global/dto/LastCollectionStatusResponse.java diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts index 846b51f..9de8fa0 100644 --- a/frontend/src/api/batchApi.ts +++ b/frontend/src/api/batchApi.ts @@ -324,6 +324,16 @@ export interface CollectionPeriodDto { rangeToDate: string | null; } +// ── Last Collection Status ─────────────────────────────────── + +export interface LastCollectionStatusDto { + apiKey: string; + apiDesc: string | null; + lastSuccessDate: string | null; + updatedAt: string | null; + elapsedMinutes: number; +} + // ── API Functions ──────────────────────────────────────────── export const batchApi = { @@ -475,4 +485,8 @@ export const batchApi = { updateCollectionPeriod: (apiKey: string, body: { rangeFromDate: string; rangeToDate: string }) => postJson<{ success: boolean; message: string }>(`${BASE}/collection-periods/${apiKey}/update`, body), + + // Last Collection Status + getLastCollectionStatuses: () => + fetchJson(`${BASE}/last-collections`), }; diff --git a/frontend/src/pages/Recollects.tsx b/frontend/src/pages/Recollects.tsx index e6bbc09..5967299 100644 --- a/frontend/src/pages/Recollects.tsx +++ b/frontend/src/pages/Recollects.tsx @@ -5,6 +5,7 @@ import { type RecollectionHistoryDto, type RecollectionSearchResponse, type CollectionPeriodDto, + type LastCollectionStatusDto, } from '../api/batchApi'; import { formatDateTime, formatDuration } from '../utils/formatters'; import { usePoller } from '../hooks/usePoller'; @@ -51,6 +52,47 @@ interface PeriodEdit { toTime: string; } +// ── 수집 상태 임계값 (분 단위) ──────────────────────────────── +const THRESHOLD_WARN_MINUTES = 24 * 60; // 24시간 +const THRESHOLD_DANGER_MINUTES = 48 * 60; // 48시간 + +type CollectionStatusLevel = 'normal' | 'warn' | 'danger'; + +function getStatusLevel(elapsedMinutes: number): CollectionStatusLevel { + if (elapsedMinutes < 0) return 'danger'; + if (elapsedMinutes <= THRESHOLD_WARN_MINUTES) return 'normal'; + if (elapsedMinutes <= THRESHOLD_DANGER_MINUTES) return 'warn'; + return 'danger'; +} + +function getStatusLabel(level: CollectionStatusLevel): string { + if (level === 'normal') return '정상'; + if (level === 'warn') return '주의'; + return '경고'; +} + +function getStatusColor(level: CollectionStatusLevel): string { + if (level === 'normal') return 'text-green-500'; + if (level === 'warn') return 'text-yellow-500'; + return 'text-red-500'; +} + +function getStatusBgColor(level: CollectionStatusLevel): string { + if (level === 'normal') return 'bg-green-500/10 text-green-600 dark:text-green-400'; + if (level === 'warn') return 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400'; + return 'bg-red-500/10 text-red-600 dark:text-red-400'; +} + +function formatElapsed(minutes: number): string { + if (minutes < 0) return '-'; + if (minutes < 60) return `${minutes}분 전`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 ${minutes % 60}분 전`; + const days = Math.floor(hours / 24); + const remainHours = hours % 24; + return remainHours > 0 ? `${days}일 ${remainHours}시간 전` : `${days}일 전`; +} + /** 기간 프리셋 정의 (시간 단위) */ const DURATION_PRESETS = [ { label: '6시간', hours: 6 }, @@ -79,6 +121,9 @@ function addHoursToDateTime( export default function Recollects() { const navigate = useNavigate(); const { showToast } = useToastContext(); + const [lastCollectionStatuses, setLastCollectionStatuses] = useState([]); + const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false); + const [periods, setPeriods] = useState([]); const [histories, setHistories] = useState([]); const [selectedApiKey, setSelectedApiKey] = useState(''); @@ -228,6 +273,15 @@ export default function Recollects() { } }; + const loadLastCollectionStatuses = useCallback(async () => { + try { + const data = await batchApi.getLastCollectionStatuses(); + setLastCollectionStatuses(data); + } catch { + /* 수집 성공일시 로드 실패 무시 */ + } + }, []); + const loadPeriods = useCallback(async () => { try { const data = await batchApi.getCollectionPeriods(); @@ -270,9 +324,21 @@ export default function Recollects() { } }, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page]); + usePoller(loadLastCollectionStatuses, 60_000, []); usePoller(loadPeriods, 60_000, []); usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]); + const collectionStatusSummary = useMemo(() => { + let normal = 0, warn = 0, danger = 0; + for (const s of lastCollectionStatuses) { + const level = getStatusLevel(s.elapsedMinutes); + if (level === 'normal') normal++; + else if (level === 'warn') warn++; + else danger++; + } + return { normal, warn, danger, total: lastCollectionStatuses.length }; + }, [lastCollectionStatuses]); + const filteredHistories = useMemo(() => { if (useSearch) return histories; if (statusFilter === 'ALL') return histories; @@ -316,6 +382,115 @@ export default function Recollects() {

+ {/* 마지막 수집 성공일시 패널 */} +
+ + + {lastCollectionPanelOpen && ( +
+ {lastCollectionStatuses.length === 0 ? ( +
+ 수집 이력이 없습니다. +
+ ) : ( + <> + {/* 요약 바 */} +
+
+ + 정상 + {collectionStatusSummary.normal} +
+
+ + 주의 + {collectionStatusSummary.warn} +
+
+ + 경고 + {collectionStatusSummary.danger} +
+ + 기준: 정상 24h 이내 · 주의 24~48h · 경고 48h 초과 + +
+ + {/* 테이블 */} +
+ + + + + + + + + + + {lastCollectionStatuses.map((s) => { + const level = getStatusLevel(s.elapsedMinutes); + return ( + + + + + + + ); + })} + +
API명마지막 수집 완료일시경과시간상태
+
{s.apiDesc || s.apiKey}
+ {s.apiDesc && ( +
{s.apiKey}
+ )} +
+ {formatDateTime(s.lastSuccessDate)} + + {formatElapsed(s.elapsedMinutes)} + + + {level === 'normal' && '●'} + {level === 'warn' && '▲'} + {level === 'danger' && '■'} + {' '}{getStatusLabel(level)} + +
+
+ + )} +
+ )} +
+ {/* 수집 기간 관리 패널 */}