Compare commits
2 커밋
b6d3a769e3
...
6a3e8d66bf
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 6a3e8d66bf | |||
| 7b2537e85d |
@ -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) {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user