API Status 페이지 기능 및 UI 업데이트:

- 상태 모니터링 시각 및 세부 정보 노출 관련 로직 추가
- 지연 필터 및 상대 시간 표기 기능 구현
- 점검/장애 필터 옵션 및 탐지 메타 정보 조회 로직 보완
This commit is contained in:
Rinjae
2026-07-31 14:42:09 +09:00
parent 5e8d08f1af
commit fd229fca43
10 changed files with 133 additions and 25 deletions
@@ -11,8 +11,8 @@ import java.time.LocalDateTime;
/** /**
* API 상태 모니터링 결과 (AGWAPP.API_STATUS). * API 상태 모니터링 결과 (AGWAPP.API_STATUS).
* *
* <p>eapim-admin 의 {@code ApiStatusMonitorJob}(Quartz, 매분)이 상태 변화가 있을 때만 upsert 한다. * <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 갱신 시각" 표시에 사용한다.</p> * 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
*/ */
@Entity @Entity
@Table(name = "API_STATUS") @Table(name = "API_STATUS")
@@ -10,7 +10,7 @@ import java.util.Optional;
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> { public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
/** /**
* 탐지 결과가 마지막으로 갱신된 시각. 데이터가 없으면 empty. * API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
*/ */
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s") @Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
Optional<LocalDateTime> findLastModifiedDate(); Optional<LocalDateTime> findLastModifiedDate();
@@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.ModelAndView;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -62,7 +63,11 @@ public class ApiStatusController {
ModelAndView mav = new ModelAndView("djb/apistatus/index"); ModelAndView mav = new ModelAndView("djb/apistatus/index");
mav.addObject("windowDays", ApiStatusSupport.DEFAULT_WINDOW_DAYS); mav.addObject("windowDays", ApiStatusSupport.DEFAULT_WINDOW_DAYS);
mav.addObject("authenticated", SecurityUtil.isAuthenticated()); mav.addObject("authenticated", SecurityUtil.isAuthenticated());
mav.addObject("lastStatusUpdatedAt", catalogService.getLastStatusUpdatedAt()); LocalDateTime lastFireAt = catalogService.getLastMonitorFireAt();
mav.addObject("lastFireAt", lastFireAt);
mav.addObject("lastFireRelative",
ApiStatusSupport.relativeTime(lastFireAt, ApiStatusSupport.now()));
mav.addObject("lastStatusChangedAt", catalogService.getLastStatusChangedAt());
return mav; return mav;
} }
@@ -5,12 +5,16 @@ import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade; import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch; import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO; import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
@@ -18,7 +22,7 @@ import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* API Status 화면의 부가 조회 - 필터용 API 목록, 탐지 결과 갱신 시각. * API Status 화면의 부가 조회 - 필터용 API 목록, 상태 모니터링 최근 실행 시각.
*/ */
@Slf4j @Slf4j
@Service @Service
@@ -26,9 +30,21 @@ import java.util.stream.Collectors;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class ApiStatusCatalogService { public class ApiStatusCatalogService {
public static final String PROPERTY_GROUP = "Portal";
/** API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME) */
public static final String KEY_MONITOR_JOB_NAME = "djb.apistatus.monitor.job-name";
/** 실서버(DAPM) QRTZ_JOB_DETAILS 실사값 (2026-07-31) */
private static final String DEFAULT_MONITOR_JOB_NAME = "ApiStatusMonitorJob";
private final ApiSearchFacade apiSearchFacade; private final ApiSearchFacade apiSearchFacade;
private final PortalPropertyService portalPropertyService;
private final GwApiStatusRepository gwApiStatusRepository; private final GwApiStatusRepository gwApiStatusRepository;
@PersistenceContext
private EntityManager entityManager;
/** /**
* 이슈 이력 필터용 API 목록. * 이슈 이력 필터용 API 목록.
* *
@@ -54,15 +70,52 @@ public class ApiStatusCatalogService {
} }
/** /**
* 탐지 결과(AGWAPP.API_STATUS)가 마지막으로 갱신된 시각. * API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
* 상태 변화가 있을 때만 갱신되므로 "마지막 상태 변경 시각" 이다. *
* <p>Job 이름은 PTL_PROPERTY({@code Portal} / {@code djb.apistatus.monitor.job-name})로
* 바꿀 수 있으며, 최초 접근 시 기본값으로 row 가 자동 생성된다.
* Quartz 메타(QRTZ_TRIGGERS)는 EMSAPP 스키마라 EMS EntityManager 로 직접 읽는다.</p>
*
* <p>readOnly 를 풀어야 최초 접근 시 프로퍼티 row 자동 생성(INSERT)이 가능하다.</p>
*/ */
public LocalDateTime getLastStatusUpdatedAt() { @Transactional
public LocalDateTime getLastMonitorFireAt() {
try {
String jobName = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_MONITOR_JOB_NAME, DEFAULT_MONITOR_JOB_NAME,
"API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME)");
if (StringUtils.isBlank(jobName)) {
jobName = DEFAULT_MONITOR_JOB_NAME;
}
Object fireTime = entityManager.createNativeQuery(
"SELECT MAX(PREV_FIRE_TIME) FROM QRTZ_TRIGGERS WHERE JOB_NAME = :jobName")
.setParameter("jobName", jobName)
.getSingleResult();
if (!(fireTime instanceof Number)) {
return null;
}
long epochMillis = ((Number) fireTime).longValue();
if (epochMillis <= 0) {
return null;
}
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ApiStatusSupport.ZONE);
} catch (Exception e) {
// 스케줄 메타 조회 실패가 화면 전체를 막지 않도록 한다
log.warn("API 상태 모니터링 실행 시각 조회 실패", e);
return null;
}
}
/**
* API 상태가 마지막으로 <b>변경</b>된 시각 (AGWAPP.API_STATUS 최근 upsert).
* 실행 시각(fire)과 달리 상태 변화가 있을 때만 갱신된다.
*/
public LocalDateTime getLastStatusChangedAt() {
try { try {
return gwApiStatusRepository.findLastModifiedDate().orElse(null); return gwApiStatusRepository.findLastModifiedDate().orElse(null);
} catch (Exception e) { } catch (Exception e) {
// 게이트웨이 조회 실패가 화면 전체를 막지 않도록 한다 log.warn("API 상태 변경 시각 조회 실패", e);
log.warn("API 상태 갱신 시각 조회 실패", e);
return null; return null;
} }
} }
@@ -119,32 +119,49 @@ public class ApiStatusIssueHistoryService {
return new PageImpl<>(content, pageable, incidents.size()); return new PageImpl<>(content, pageable, incidents.size());
} }
/** 자동 탐지 지연 건의 INTERFACE_ID prefix (admin ApiStatusDetectionService 가 event 명으로 생성) */
private static final String DELAY_INTERFACE_PREFIX = "DELAY_START:";
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to, private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
String apiId, String kind) { String apiId, String kind) {
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId) List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
? incidentQueryRepository.findVisibleOverlapping(from, to) ? incidentQueryRepository.findVisibleOverlapping(from, to)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId); : incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
IncidentKind selected = parseKind(kind); java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (selected == null) { if (filter == null) {
return incidents; return incidents;
} }
return incidents.stream() return incidents.stream().filter(filter).collect(Collectors.toList());
.filter(incident -> incident.getKind() == selected)
.collect(Collectors.toList());
} }
/** /**
* 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다. * 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다.
*
* <p>지연(DELAY)은 별도 KIND 가 아니라 자동 탐지 시 INCIDENT 로 흡수된다(ADR-F12).
* 자동 탐지 지연 건은 INTERFACE_ID 가 {@code DELAY_START:} 로 시작하므로 이를 기준으로 가른다.
* "장애만" 은 지연을 제외한 INCIDENT 다.</p>
*/ */
private IncidentKind parseKind(String kind) { private java.util.function.Predicate<DjbApistatusIncident> kindFilter(String kind) {
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) { if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
return null; return null;
} }
if ("DELAY".equalsIgnoreCase(kind)) {
return incident -> incident.getKind() == IncidentKind.INCIDENT && isDelay(incident);
}
if ("INCIDENT".equalsIgnoreCase(kind)) {
return incident -> incident.getKind() == IncidentKind.INCIDENT && !isDelay(incident);
}
try { try {
return IncidentKind.valueOf(kind.toUpperCase()); IncidentKind selected = IncidentKind.valueOf(kind.toUpperCase());
return incident -> incident.getKind() == selected;
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return null; return null;
} }
} }
private boolean isDelay(DjbApistatusIncident incident) {
return incident.getInterfaceId() != null
&& incident.getInterfaceId().startsWith(DELAY_INTERFACE_PREFIX);
}
} }
@@ -72,4 +72,26 @@ public final class ApiStatusSupport {
} }
return Duration.between(from, to).toMinutes(); return Duration.between(from, to).toMinutes();
} }
/**
* "N분 전" 형태의 상대 시간 표기.
*/
public static String relativeTime(LocalDateTime past, LocalDateTime now) {
if (past == null || now == null) {
return null;
}
long minutes = Duration.between(past, now).toMinutes();
if (minutes < 1) {
return "방금 전";
}
if (minutes < 60) {
return minutes + "분 전";
}
long hours = minutes / 60;
if (hours < 24) {
long restMinutes = minutes % 60;
return restMinutes == 0 ? hours + "시간 전" : hours + "시간 " + restMinutes + "분 전";
}
return (hours / 24) + "일 전";
}
} }
+8 -1
View File
@@ -26711,6 +26711,7 @@ input[type=checkbox]:checked + .custom-checkbox {
--as-radius: 12px; --as-radius: 12px;
--as-pill: 999px; --as-pill: 999px;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px; gap: 20px;
padding: 0 0 70px; padding: 0 0 70px;
} }
@@ -27488,7 +27489,13 @@ input[type=checkbox]:checked + .custom-checkbox {
justify-content: space-between; justify-content: space-between;
} }
.api-status .as-index-bar { .api-status .as-index-bar {
height: 36px; height: 40px;
}
.api-status .as-index-bar .as-index-cell {
flex: 0 0 12px;
}
.api-status .as-uptime .as-bar-chart .as-bar {
flex: 0 0 12px;
} }
.api-status .as-issue-card { .api-status .as-issue-card {
padding: 18px 16px; padding: 18px 16px;
File diff suppressed because one or more lines are too long
@@ -10,13 +10,16 @@
<div class="as-title-banner"> <div class="as-title-banner">
<div class="as-title-banner-inner"> <div class="as-title-banner-inner">
<h1>API Status</h1> <h1>API Status</h1>
<div class="as-title-meta" th:if="${lastStatusUpdatedAt != null}"> <div class="as-title-meta" th:if="${lastFireAt != null or lastStatusChangedAt != null}">
<p class="as-title-meta-main"> <p class="as-title-meta-main" th:if="${lastFireAt != null}">
<span class="as-title-dot"></span> <span class="as-title-dot"></span>
<span>마지막 상태 갱신 </span> <span>마지막 점검 </span>
<strong th:text="${#temporals.format(lastStatusUpdatedAt, 'yyyy-MM-dd HH:mm')} + ' KST'">-</strong> <strong th:text="${lastFireRelative}">-</strong>
</p> </p>
<p class="as-title-meta-sub">API 상태 모니터링 최근 반영 시각</p> <p class="as-title-meta-sub" th:if="${lastFireAt != null}"
th:text="${#temporals.format(lastFireAt, 'yyyy-MM-dd HH:mm')} + ' KST · API 상태 모니터링 실행 시각'">-</p>
<p class="as-title-meta-sub" th:if="${lastStatusChangedAt != null}"
th:text="'마지막 상태 변경 ' + ${#temporals.format(lastStatusChangedAt, 'yyyy-MM-dd HH:mm')} + ' KST'">-</p>
</div> </div>
</div> </div>
</div> </div>
@@ -83,8 +83,9 @@
<div class="as-filter-field"> <div class="as-filter-field">
<label class="as-filter-label" for="filterKindSelect">유형</label> <label class="as-filter-label" for="filterKindSelect">유형</label>
<select id="filterKindSelect" class="as-input"> <select id="filterKindSelect" class="as-input">
<option value="">장애 + 점검</option> <option value="">전체 유형</option>
<option value="INCIDENT">장애만</option> <option value="INCIDENT">장애만</option>
<option value="DELAY">지연만</option>
<option value="MAINTENANCE">점검만</option> <option value="MAINTENANCE">점검만</option>
</select> </select>
</div> </div>