feat(monitoring): 마지막 수집 완료일시 모니터링 기능 추가

- GET /api/batch/last-collections API 엔드포인트 추가
- BatchLastExecution 엔티티에 apiDesc 컬럼 추가
- 재수집 이력 페이지에 마지막 수집 완료일시 토글 패널 추가
  - 상태 요약 바 (정상/주의/경고 건수)
  - API별 테이블 (성공일시, 경과시간, 상태 뱃지)
- /monitoring SPA 라우트 추가

Closes #17

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-03-04 10:47:01 +09:00
부모 b6d3a769e3
커밋 7b2537e85d
7개의 변경된 파일257개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -324,6 +324,16 @@ export interface CollectionPeriodDto {
rangeToDate: string | null; rangeToDate: string | null;
} }
// ── Last Collection Status ───────────────────────────────────
export interface LastCollectionStatusDto {
apiKey: string;
apiDesc: string | null;
lastSuccessDate: string | null;
updatedAt: string | null;
elapsedMinutes: number;
}
// ── API Functions ──────────────────────────────────────────── // ── API Functions ────────────────────────────────────────────
export const batchApi = { export const batchApi = {
@ -475,4 +485,8 @@ export const batchApi = {
updateCollectionPeriod: (apiKey: string, body: { rangeFromDate: string; rangeToDate: string }) => updateCollectionPeriod: (apiKey: string, body: { rangeFromDate: string; rangeToDate: string }) =>
postJson<{ success: boolean; message: string }>(`${BASE}/collection-periods/${apiKey}/update`, body), postJson<{ success: boolean; message: string }>(`${BASE}/collection-periods/${apiKey}/update`, body),
// Last Collection Status
getLastCollectionStatuses: () =>
fetchJson<LastCollectionStatusDto[]>(`${BASE}/last-collections`),
}; };

파일 보기

@ -5,6 +5,7 @@ import {
type RecollectionHistoryDto, type RecollectionHistoryDto,
type RecollectionSearchResponse, type RecollectionSearchResponse,
type CollectionPeriodDto, type CollectionPeriodDto,
type LastCollectionStatusDto,
} from '../api/batchApi'; } from '../api/batchApi';
import { formatDateTime, formatDuration } from '../utils/formatters'; import { formatDateTime, formatDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller'; import { usePoller } from '../hooks/usePoller';
@ -51,6 +52,47 @@ interface PeriodEdit {
toTime: string; 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 = [ const DURATION_PRESETS = [
{ label: '6시간', hours: 6 }, { label: '6시간', hours: 6 },
@ -79,6 +121,9 @@ function addHoursToDateTime(
export default function Recollects() { export default function Recollects() {
const navigate = useNavigate(); const navigate = useNavigate();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [lastCollectionStatuses, setLastCollectionStatuses] = useState<LastCollectionStatusDto[]>([]);
const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false);
const [periods, setPeriods] = useState<CollectionPeriodDto[]>([]); const [periods, setPeriods] = useState<CollectionPeriodDto[]>([]);
const [histories, setHistories] = useState<RecollectionHistoryDto[]>([]); const [histories, setHistories] = useState<RecollectionHistoryDto[]>([]);
const [selectedApiKey, setSelectedApiKey] = 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 () => { const loadPeriods = useCallback(async () => {
try { try {
const data = await batchApi.getCollectionPeriods(); const data = await batchApi.getCollectionPeriods();
@ -270,9 +324,21 @@ export default function Recollects() {
} }
}, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page]); }, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page]);
usePoller(loadLastCollectionStatuses, 60_000, []);
usePoller(loadPeriods, 60_000, []); usePoller(loadPeriods, 60_000, []);
usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]); 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(() => { const filteredHistories = useMemo(() => {
if (useSearch) return histories; if (useSearch) return histories;
if (statusFilter === 'ALL') return histories; if (statusFilter === 'ALL') return histories;
@ -316,6 +382,115 @@ export default function Recollects() {
</p> </p>
</div> </div>
{/* 마지막 수집 성공일시 패널 */}
<div className="bg-wing-surface rounded-xl shadow-md">
<button
onClick={() => setLastCollectionPanelOpen((v) => !v)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-wing-hover transition-colors rounded-xl"
>
<div className="flex items-center gap-2">
<svg
className={`w-4 h-4 text-wing-muted transition-transform ${lastCollectionPanelOpen ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-sm font-semibold text-wing-text"> </span>
<span className="text-xs text-wing-muted">({collectionStatusSummary.total})</span>
{collectionStatusSummary.danger > 0 && (
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400">
{collectionStatusSummary.danger}
</span>
)}
{collectionStatusSummary.warn > 0 && (
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
{collectionStatusSummary.warn}
</span>
)}
</div>
<span className="text-xs text-wing-muted">
{lastCollectionPanelOpen ? '접기' : '펼치기'}
</span>
</button>
{lastCollectionPanelOpen && (
<div className="border-t border-wing-border/50 px-6 py-4 space-y-4">
{lastCollectionStatuses.length === 0 ? (
<div className="py-4 text-center text-sm text-wing-muted">
.
</div>
) : (
<>
{/* 요약 바 */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500" />
<span className="text-wing-muted"></span>
<span className="font-semibold text-wing-text">{collectionStatusSummary.normal}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-yellow-500" />
<span className="text-wing-muted"></span>
<span className="font-semibold text-wing-text">{collectionStatusSummary.warn}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-wing-muted"></span>
<span className="font-semibold text-wing-text">{collectionStatusSummary.danger}</span>
</div>
<span className="text-xs text-wing-muted ml-auto">
기준: 정상 24h · 24~48h · 48h
</span>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border/50">
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted">API명</th>
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted"> </th>
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted"></th>
<th className="text-center py-2 px-3 text-xs font-medium text-wing-muted"></th>
</tr>
</thead>
<tbody>
{lastCollectionStatuses.map((s) => {
const level = getStatusLevel(s.elapsedMinutes);
return (
<tr key={s.apiKey} className="border-b border-wing-border/30 hover:bg-wing-hover/50 transition-colors">
<td className="py-2.5 px-3">
<div className="text-wing-text font-medium">{s.apiDesc || s.apiKey}</div>
{s.apiDesc && (
<div className="text-xs text-wing-muted font-mono">{s.apiKey}</div>
)}
</td>
<td className="py-2.5 px-3 font-mono text-xs text-wing-muted">
{formatDateTime(s.lastSuccessDate)}
</td>
<td className={`py-2.5 px-3 text-xs font-medium ${getStatusColor(level)}`}>
{formatElapsed(s.elapsedMinutes)}
</td>
<td className="py-2.5 px-3 text-center">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${getStatusBgColor(level)}`}>
{level === 'normal' && '●'}
{level === 'warn' && '▲'}
{level === 'danger' && '■'}
{' '}{getStatusLabel(level)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
)}
</div>
{/* 수집 기간 관리 패널 */} {/* 수집 기간 관리 패널 */}
<div className="bg-wing-surface rounded-xl shadow-md"> <div className="bg-wing-surface rounded-xl shadow-md">
<button <button

파일 보기

@ -538,6 +538,17 @@ public class BatchController {
return ResponseEntity.ok(stats); return ResponseEntity.ok(stats);
} }
// 마지막 수집 성공일시 모니터링 API
@Operation(summary = "마지막 수집 성공일시 목록 조회",
description = "모든 API의 마지막 수집 성공 일시를 조회합니다. 오래된 순으로 정렬됩니다.")
@GetMapping("/last-collections")
public ResponseEntity<List<LastCollectionStatusResponse>> getLastCollectionStatuses() {
log.debug("Received request to get last collection statuses");
List<LastCollectionStatusResponse> statuses = batchService.getLastCollectionStatuses();
return ResponseEntity.ok(statuses);
}
// 수집 기간 관리 API // 수집 기간 관리 API
@Operation(summary = "수집 기간 목록 조회", description = "모든 API의 수집 기간 설정을 조회합니다") @Operation(summary = "수집 기간 목록 조회", description = "모든 API의 수집 기간 설정을 조회합니다")

파일 보기

@ -14,7 +14,7 @@ public class WebViewController {
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}", @GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
"/recollects", "/recollects/{id:\\d+}", "/recollects", "/recollects/{id:\\d+}",
"/schedules", "/schedule-timeline"}) "/schedules", "/schedule-timeline", "/monitoring"})
public String forward() { public String forward() {
return "forward:/index.html"; return "forward:/index.html";
} }

파일 보기

@ -0,0 +1,21 @@
package com.snp.batch.global.dto;
import java.time.LocalDateTime;
/**
* 마지막 수집 성공일시 모니터링 응답 DTO
*
* @param apiKey API 식별 (: "EVENT_IMPORT_API")
* @param apiDesc API 설명 (사용자 표시용, : "해양사건 수집")
* @param lastSuccessDate 마지막 수집 성공 일시
* @param updatedAt 레코드 최종 수정 일시
* @param elapsedMinutes 현재 시각 기준 경과 시간()
*/
public record LastCollectionStatusResponse(
String apiKey,
String apiDesc,
LocalDateTime lastSuccessDate,
LocalDateTime updatedAt,
long elapsedMinutes
) {
}

파일 보기

@ -22,6 +22,9 @@ public class BatchLastExecution {
@Column(name = "API_KEY", length = 50) @Column(name = "API_KEY", length = 50)
private String apiKey; private String apiKey;
@Column(name = "API_DESC", length = 100)
private String apiDesc;
@Column(name = "LAST_SUCCESS_DATE", nullable = false) @Column(name = "LAST_SUCCESS_DATE", nullable = false)
private LocalDateTime lastSuccessDate; private LocalDateTime lastSuccessDate;

파일 보기

@ -4,8 +4,10 @@ import com.snp.batch.common.batch.listener.RecollectionJobExecutionListener;
import com.snp.batch.global.dto.*; import com.snp.batch.global.dto.*;
import com.snp.batch.global.model.BatchApiLog; import com.snp.batch.global.model.BatchApiLog;
import com.snp.batch.global.model.BatchFailedRecord; import com.snp.batch.global.model.BatchFailedRecord;
import com.snp.batch.global.model.BatchLastExecution;
import com.snp.batch.global.repository.BatchApiLogRepository; import com.snp.batch.global.repository.BatchApiLogRepository;
import com.snp.batch.global.repository.BatchFailedRecordRepository; import com.snp.batch.global.repository.BatchFailedRecordRepository;
import com.snp.batch.global.repository.BatchLastExecutionRepository;
import com.snp.batch.global.repository.TimelineRepository; import com.snp.batch.global.repository.TimelineRepository;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -27,6 +29,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -43,6 +46,7 @@ public class BatchService {
private final RecollectionJobExecutionListener recollectionJobExecutionListener; private final RecollectionJobExecutionListener recollectionJobExecutionListener;
private final BatchApiLogRepository apiLogRepository; private final BatchApiLogRepository apiLogRepository;
private final BatchFailedRecordRepository failedRecordRepository; private final BatchFailedRecordRepository failedRecordRepository;
private final BatchLastExecutionRepository batchLastExecutionRepository;
@Autowired @Autowired
public BatchService(JobLauncher jobLauncher, public BatchService(JobLauncher jobLauncher,
@ -53,7 +57,8 @@ public class BatchService {
TimelineRepository timelineRepository, TimelineRepository timelineRepository,
RecollectionJobExecutionListener recollectionJobExecutionListener, RecollectionJobExecutionListener recollectionJobExecutionListener,
BatchApiLogRepository apiLogRepository, BatchApiLogRepository apiLogRepository,
BatchFailedRecordRepository failedRecordRepository) { BatchFailedRecordRepository failedRecordRepository,
BatchLastExecutionRepository batchLastExecutionRepository) {
this.jobLauncher = jobLauncher; this.jobLauncher = jobLauncher;
this.jobExplorer = jobExplorer; this.jobExplorer = jobExplorer;
this.jobOperator = jobOperator; this.jobOperator = jobOperator;
@ -63,6 +68,7 @@ public class BatchService {
this.recollectionJobExecutionListener = recollectionJobExecutionListener; this.recollectionJobExecutionListener = recollectionJobExecutionListener;
this.apiLogRepository = apiLogRepository; this.apiLogRepository = apiLogRepository;
this.failedRecordRepository = failedRecordRepository; this.failedRecordRepository = failedRecordRepository;
this.batchLastExecutionRepository = batchLastExecutionRepository;
} }
/** /**
@ -717,6 +723,31 @@ public class BatchService {
.build(); .build();
} }
// 마지막 수집 성공일시 모니터링
/**
* 전체 API의 마지막 수집 성공일시를 조회합니다.
* lastSuccessDate 오름차순 정렬 (오래된 것이 위로 모니터링 편의)
*/
@Transactional(readOnly = true)
public List<LastCollectionStatusResponse> getLastCollectionStatuses() {
LocalDateTime now = LocalDateTime.now();
return batchLastExecutionRepository.findAll().stream()
.sorted(Comparator.comparing(
BatchLastExecution::getLastSuccessDate,
Comparator.nullsFirst(Comparator.naturalOrder())))
.map(entity -> new LastCollectionStatusResponse(
entity.getApiKey(),
entity.getApiDesc(),
entity.getLastSuccessDate(),
entity.getUpdatedAt(),
entity.getLastSuccessDate() != null
? ChronoUnit.MINUTES.between(entity.getLastSuccessDate(), now)
: -1
))
.toList();
}
// F1: 강제 종료(Abandon) 관련 // F1: 강제 종료(Abandon) 관련
public List<JobExecutionDto> getStaleExecutions(int thresholdMinutes) { public List<JobExecutionDto> getStaleExecutions(int thresholdMinutes) {