Compare commits

...

2 커밋

작성자 SHA1 메시지 날짜
6a3e8d66bf Merge pull request 'feat(monitoring): 마지막 수집 완료일시 모니터링 기능 추가' (#19) from feature/ISSUE-17-last-collection-monitoring into develop 2026-03-04 14:36:00 +09:00
7b2537e85d 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>
2026-03-04 10:47:01 +09:00
7개의 변경된 파일257개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -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<LastCollectionStatusDto[]>(`${BASE}/last-collections`),
};

파일 보기

@ -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<LastCollectionStatusDto[]>([]);
const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false);
const [periods, setPeriods] = useState<CollectionPeriodDto[]>([]);
const [histories, setHistories] = useState<RecollectionHistoryDto[]>([]);
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() {
</p>
</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">
<button

파일 보기

@ -538,6 +538,17 @@ public class BatchController {
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
@Operation(summary = "수집 기간 목록 조회", description = "모든 API의 수집 기간 설정을 조회합니다")

파일 보기

@ -14,7 +14,7 @@ public class WebViewController {
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
"/recollects", "/recollects/{id:\\d+}",
"/schedules", "/schedule-timeline"})
"/schedules", "/schedule-timeline", "/monitoring"})
public String forward() {
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)
private String apiKey;
@Column(name = "API_DESC", length = 100)
private String apiDesc;
@Column(name = "LAST_SUCCESS_DATE", nullable = false)
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.model.BatchApiLog;
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.BatchFailedRecordRepository;
import com.snp.batch.global.repository.BatchLastExecutionRepository;
import com.snp.batch.global.repository.TimelineRepository;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
@ -27,6 +29,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@ -43,6 +46,7 @@ public class BatchService {
private final RecollectionJobExecutionListener recollectionJobExecutionListener;
private final BatchApiLogRepository apiLogRepository;
private final BatchFailedRecordRepository failedRecordRepository;
private final BatchLastExecutionRepository batchLastExecutionRepository;
@Autowired
public BatchService(JobLauncher jobLauncher,
@ -53,7 +57,8 @@ public class BatchService {
TimelineRepository timelineRepository,
RecollectionJobExecutionListener recollectionJobExecutionListener,
BatchApiLogRepository apiLogRepository,
BatchFailedRecordRepository failedRecordRepository) {
BatchFailedRecordRepository failedRecordRepository,
BatchLastExecutionRepository batchLastExecutionRepository) {
this.jobLauncher = jobLauncher;
this.jobExplorer = jobExplorer;
this.jobOperator = jobOperator;
@ -63,6 +68,7 @@ public class BatchService {
this.recollectionJobExecutionListener = recollectionJobExecutionListener;
this.apiLogRepository = apiLogRepository;
this.failedRecordRepository = failedRecordRepository;
this.batchLastExecutionRepository = batchLastExecutionRepository;
}
/**
@ -717,6 +723,31 @@ public class BatchService {
.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) 관련
public List<JobExecutionDto> getStaleExecutions(int thresholdMinutes) {