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:
부모
b6d3a769e3
커밋
7b2537e85d
@ -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) {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user