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).
*
* <p>eapim-admin 의 {@code ApiStatusMonitorJob}(Quartz, 매분)이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 갱신 시각" 표시에 사용한다.</p>
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
*/
@Entity
@Table(name = "API_STATUS")
@@ -10,7 +10,7 @@ import java.util.Optional;
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
/**
* 탐지 결과가 마지막으로 갱신된 시각. 데이터가 없으면 empty.
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
*/
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
Optional<LocalDateTime> findLastModifiedDate();
@@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@@ -62,7 +63,11 @@ public class ApiStatusController {
ModelAndView mav = new ModelAndView("djb/apistatus/index");
mav.addObject("windowDays", ApiStatusSupport.DEFAULT_WINDOW_DAYS);
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;
}
@@ -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.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Comparator;
@@ -18,7 +22,7 @@ import java.util.List;
import java.util.stream.Collectors;
/**
* API Status 화면의 부가 조회 - 필터용 API 목록, 탐지 결과 갱신 시각.
* API Status 화면의 부가 조회 - 필터용 API 목록, 상태 모니터링 최근 실행 시각.
*/
@Slf4j
@Service
@@ -26,9 +30,21 @@ import java.util.stream.Collectors;
@Transactional(readOnly = true)
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 PortalPropertyService portalPropertyService;
private final GwApiStatusRepository gwApiStatusRepository;
@PersistenceContext
private EntityManager entityManager;
/**
* 이슈 이력 필터용 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 {
return gwApiStatusRepository.findLastModifiedDate().orElse(null);
} catch (Exception e) {
// 게이트웨이 조회 실패가 화면 전체를 막지 않도록 한다
log.warn("API 상태 갱신 시각 조회 실패", e);
log.warn("API 상태 변경 시각 조회 실패", e);
return null;
}
}
@@ -119,32 +119,49 @@ public class ApiStatusIssueHistoryService {
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,
String apiId, String kind) {
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
? incidentQueryRepository.findVisibleOverlapping(from, to)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
IncidentKind selected = parseKind(kind);
if (selected == null) {
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (filter == null) {
return incidents;
}
return incidents.stream()
.filter(incident -> incident.getKind() == selected)
.collect(Collectors.toList());
return incidents.stream().filter(filter).collect(Collectors.toList());
}
/**
* 유형 필터. 미지정/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)) {
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 {
return IncidentKind.valueOf(kind.toUpperCase());
IncidentKind selected = IncidentKind.valueOf(kind.toUpperCase());
return incident -> incident.getKind() == selected;
} catch (IllegalArgumentException e) {
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();
}
/**
* "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-pill: 999px;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
padding: 0 0 70px;
}
@@ -27488,7 +27489,13 @@ input[type=checkbox]:checked + .custom-checkbox {
justify-content: space-between;
}
.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 {
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-inner">
<h1>API Status</h1>
<div class="as-title-meta" th:if="${lastStatusUpdatedAt != null}">
<p class="as-title-meta-main">
<div class="as-title-meta" th:if="${lastFireAt != null or lastStatusChangedAt != null}">
<p class="as-title-meta-main" th:if="${lastFireAt != null}">
<span class="as-title-dot"></span>
<span>마지막 상태 갱신 </span>
<strong th:text="${#temporals.format(lastStatusUpdatedAt, 'yyyy-MM-dd HH:mm')} + ' KST'">-</strong>
<span>마지막 점검 </span>
<strong th:text="${lastFireRelative}">-</strong>
</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>
@@ -83,8 +83,9 @@
<div class="as-filter-field">
<label class="as-filter-label" for="filterKindSelect">유형</label>
<select id="filterKindSelect" class="as-input">
<option value="">장애 + 점검</option>
<option value="">전체 유형</option>
<option value="INCIDENT">장애만</option>
<option value="DELAY">지연만</option>
<option value="MAINTENANCE">점검만</option>
</select>
</div>