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 초과
+
+
+
+ {/* 테이블 */}
+
+
+
+
+ | API명 |
+ 마지막 수집 완료일시 |
+ 경과시간 |
+ 상태 |
+
+
+
+ {lastCollectionStatuses.map((s) => {
+ const level = getStatusLevel(s.elapsedMinutes);
+ return (
+
+ |
+ {s.apiDesc || s.apiKey}
+ {s.apiDesc && (
+ {s.apiKey}
+ )}
+ |
+
+ {formatDateTime(s.lastSuccessDate)}
+ |
+
+ {formatElapsed(s.elapsedMinutes)}
+ |
+
+
+ {level === 'normal' && '●'}
+ {level === 'warn' && '▲'}
+ {level === 'danger' && '■'}
+ {' '}{getStatusLabel(level)}
+
+ |
+
+ );
+ })}
+
+
+
+ >
+ )}
+
+ )}
+
+
{/* 수집 기간 관리 패널 */}