API Status 페이지 기능 및 UI 업데이트:
- 상태 모니터링 시각 및 세부 정보 노출 관련 로직 추가 - 지연 필터 및 상대 시간 표기 기능 구현 - 점검/장애 필터 옵션 및 탐지 메타 정보 조회 로직 보완
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
+1
-1
@@ -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();
|
||||||
|
|||||||
+6
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-6
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-7
@@ -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) + "일 전";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user