diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java b/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java
index d085aaa..8d93190 100644
--- a/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java
+++ b/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.util.SecurityUtil;
+import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -31,6 +32,7 @@ public class ApiController {
private final ApiService apiService;
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
+ private final ApiStatusCatalogService apiStatusCatalogService;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -86,6 +88,8 @@ public class ApiController {
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
+ // 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
+ model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
return "apps/apis/mainApiList";
}
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/controller/ApiStatusController.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/controller/ApiStatusController.java
index 0238dd1..2dcaa50 100644
--- a/src/main/java/com/eactive/apim/portal/djb/apistatus/controller/ApiStatusController.java
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/controller/ApiStatusController.java
@@ -2,12 +2,14 @@ package com.eactive.apim.portal.djb.apistatus.controller;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
+import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
+import com.eactive.apim.portal.djb.apistatus.service.ApiCurrentStatusService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusIssueHistoryService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusQueryService;
@@ -50,12 +52,15 @@ public class ApiStatusController {
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
private static final int MAX_PAGE_SIZE = 50;
+ /** 현재 상태 일괄 조회 시 한 번에 물어볼 수 있는 API 수 */
+ private static final int MAX_STATUS_BATCH = 100;
private final ApiStatusQueryService apiStatusQueryService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusIssueHistoryService issueHistoryService;
private final MyApiStatusQueryService myApiStatusQueryService;
private final ApiStatusCatalogService catalogService;
+ private final ApiCurrentStatusService apiCurrentStatusService;
/** P1 - API Status 메인 */
@GetMapping
@@ -176,4 +181,31 @@ public class ApiStatusController {
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
return issueHistoryService.getIssues(date, apiId, kind, PageRequest.of(safePage, safeSize));
}
+
+ /**
+ * P11 - API 한 건의 현재 상태 (정상/점검/지연/장애). API 상세 화면이 사용한다.
+ *
+ *
게시된 장애·점검만 반영하므로 비로그인도 조회 가능하다.
+ */
+ @GetMapping("/current.json")
+ @ResponseBody
+ public ResponseEntity currentStatus(@RequestParam("apiId") String apiId) {
+ return apiCurrentStatusService.getStatus(apiId)
+ .map(ResponseEntity::ok)
+ .orElseGet(() -> ResponseEntity.badRequest().build());
+ }
+
+ /**
+ * P11 - 여러 API 의 현재 상태. {@code ?apiId=A&apiId=B} 로 반복 지정한다.
+ */
+ @GetMapping("/current-list.json")
+ @ResponseBody
+ public ResponseEntity> currentStatuses(
+ @RequestParam(value = "apiId", required = false) List apiIds) {
+
+ if (apiIds != null && apiIds.size() > MAX_STATUS_BATCH) {
+ return ResponseEntity.badRequest().build();
+ }
+ return ResponseEntity.ok(apiCurrentStatusService.getStatuses(apiIds));
+ }
}
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiCurrentStatusDTO.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiCurrentStatusDTO.java
new file mode 100644
index 0000000..1036d59
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiCurrentStatusDTO.java
@@ -0,0 +1,53 @@
+package com.eactive.apim.portal.djb.apistatus.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * API 한 건의 현재 상태 (정상/점검/지연/장애).
+ *
+ * 게시된 장애·점검 공지(PTL_NOTICE + DJB_APISTATUS_INCIDENT)만 근거로 한다.
+ * 미게시(초안/USE_YN='N') 건은 다른 API Status 화면과 동일하게 보이지 않는다.
+ */
+@Data
+public class ApiCurrentStatusDTO {
+
+ private String apiId;
+
+ /** 공개 API 목록(PTL_API_SPEC_INFO, DISPLAY_YN='Y')에 없으면 null */
+ private String apiName;
+
+ /** NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
+ private String currentStatus;
+
+ /** 정상 / 지연 / 장애 / 점검 */
+ private String currentStatusLabel;
+
+ /** 현재 상태의 근거가 된 이슈. 정상이면 null */
+ private Long activeIncidentId;
+
+ /** INCIDENT / MAINTENANCE */
+ private String activeIncidentKind;
+
+ private String activeIncidentTitle;
+
+ /** 현재 상태가 시작된 시각 (근거 이슈의 시작 시각) */
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime statusSince;
+
+ /** 점검 종료 예정 시각. 장애이거나 미정이면 null */
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime expectedEndAt;
+
+ /** 조회 기간 내 마지막 장애 발생 시각 */
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime lastIncidentAt;
+
+ /** 조회 기간 가동률 (0.0 ~ 1.0). 상태만 계산하는 경로에서는 채우지 않는다 */
+ private Double uptimeRatio;
+
+ /** 가동률·최근 장애 집계 기간(일) */
+ private int windowDays;
+}
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/DailyStatDTO.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/DailyStatDTO.java
index c2759a9..e78790b 100644
--- a/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/DailyStatDTO.java
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/DailyStatDTO.java
@@ -19,8 +19,12 @@ public class DailyStatDTO {
/** 0.0000 ~ 1.0000 */
private double uptimeRatio;
+ /** 장애 + 지연 합계 (가동률 차감분). 지연도 서비스 저하이므로 계속 차감한다 */
private long incidentMinutes;
+ /** 그 중 지연(자동 탐지) 분 */
+ private long delayMinutes;
+
private long maintenanceMinutes;
/** 막대 색상 구분: NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/IssueDateEntryDTO.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/IssueDateEntryDTO.java
index 6fbc0fe..bf584f6 100644
--- a/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/IssueDateEntryDTO.java
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/IssueDateEntryDTO.java
@@ -16,9 +16,14 @@ public class IssueDateEntryDTO {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate date;
- /** INCIDENT / MAINTENANCE */
+ /** INCIDENT / DELAY / MAINTENANCE - 유형 필터와 같은 값 */
private List kinds = new ArrayList<>();
+ /** 장애 건수 (자동 탐지 지연 제외) */
private int incCount;
+
+ /** 지연 건수 (자동 탐지 지연 - INCIDENT 중 INTERFACE_ID 가 DELAY_START: 인 건) */
+ private int dlyCount;
+
private int mntCount;
}
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java
new file mode 100644
index 0000000..bc69dc2
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java
@@ -0,0 +1,273 @@
+package com.eactive.apim.portal.djb.apistatus.service;
+
+import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
+import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
+import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
+import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
+import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
+import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
+import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
+import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
+import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * API 별 현재 상태(정상/점검/지연/장애) 조회.
+ *
+ * "내 API 현황"(P4)과 API 상세 화면이 이 서비스를 공유해 판정 기준을 하나로 유지한다.
+ * 근거는 게시된 장애·점검 공지뿐이며(PTL_NOTICE.USE_YN='Y' + DRAFT_YN='N'),
+ * 미게시 건은 다른 API Status 화면과 마찬가지로 반영하지 않는다.
+ *
+ * 판정 규칙 (심각한 쪽 우선)
+ *
+ * - 진행 중 장애 → 장애(OUTAGE). 단 모니터링 상태이거나 자동 탐지 지연 건이면 지연(DEGRADED)
+ * - 이미 시작된 점검 → 점검(MAINTENANCE)
+ * - 해당 없음 → 정상(NORMAL)
+ *
+ */
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ApiCurrentStatusService {
+
+ private final ApiStatusIncidentQueryRepository incidentQueryRepository;
+ private final DjbApistatusIncidentApiRepository incidentApiRepository;
+ private final ApiSpecInfoRepository apiSpecInfoRepository;
+ private final ApiStatusUptimeService uptimeService;
+ private final ApiStatusCatalogService catalogService;
+
+ /** API 한 건의 현재 상태. apiId 가 비어 있으면 empty */
+ public Optional getStatus(String apiId) {
+ if (StringUtils.isBlank(apiId)) {
+ return Optional.empty();
+ }
+ return getStatuses(Collections.singletonList(apiId)).stream().findFirst();
+ }
+
+ /**
+ * 여러 API 의 현재 상태 (API 명·가동률 포함). 요청 순서를 유지하며 중복 apiId 는 한 번만 반환한다.
+ */
+ public List getStatuses(Collection apiIds) {
+ Set ids = normalize(apiIds);
+ if (ids.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ Map statuses = resolveStatuses(ids);
+ Map names = resolveApiNames(ids);
+ Map uptimes = uptimeService.getApiUptimeRatios(ids, catalogService.getWindowDays());
+
+ List result = new ArrayList<>(ids.size());
+ for (String apiId : ids) {
+ ApiCurrentStatusDTO dto = statuses.get(apiId);
+ dto.setApiName(names.get(apiId));
+ dto.setUptimeRatio(uptimes.getOrDefault(apiId, 1.0d));
+ result.add(dto);
+ }
+ return result;
+ }
+
+ /**
+ * 상태만 계산한다 (API 명·가동률 제외). 대상 API 를 이미 알고 있고 이름·가동률을
+ * 따로 채우는 호출자(P4 내 API 현황)를 위한 경량 경로다.
+ */
+ public Map resolveStatuses(Set apiIds) {
+ if (apiIds == null || apiIds.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ LocalDateTime now = ApiStatusSupport.now();
+ int windowDays = catalogService.getWindowDays();
+
+ Map openByApi = mapOpenIncidents(apiIds);
+ Map maintenanceByApi = mapStartedMaintenance(apiIds, now);
+ Map lastIncidentAt = collectLastIncidentAt(apiIds, now, windowDays);
+
+ Map result = new LinkedHashMap<>();
+ for (String apiId : apiIds) {
+ DjbApistatusIncident open = openByApi.get(apiId);
+ DjbApistatusIncident maintenance = maintenanceByApi.get(apiId);
+
+ ApiCurrentStatusDTO dto = new ApiCurrentStatusDTO();
+ dto.setApiId(apiId);
+ dto.setCurrentStatus(resolveStatus(open, maintenance != null));
+ dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
+ dto.setLastIncidentAt(lastIncidentAt.get(apiId));
+ dto.setWindowDays(windowDays);
+
+ // 장애가 점검보다 심각하므로 근거 이슈도 장애를 우선한다
+ DjbApistatusIncident active = open != null ? open : maintenance;
+ if (active != null) {
+ dto.setActiveIncidentId(active.getIncidentId());
+ dto.setActiveIncidentKind(active.getKind() == null ? null : active.getKind().name());
+ dto.setActiveIncidentTitle(active.getTitle());
+ dto.setStatusSince(active.getStartedAt());
+ dto.setExpectedEndAt(active.getEndAt());
+ }
+ result.put(apiId, dto);
+ }
+ return result;
+ }
+
+ /** 빈 값·중복 제거. 요청 순서는 유지한다 */
+ private Set normalize(Collection apiIds) {
+ if (apiIds == null || apiIds.isEmpty()) {
+ return Collections.emptySet();
+ }
+ return apiIds.stream()
+ .filter(StringUtils::isNotBlank)
+ .map(StringUtils::trim)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ /**
+ * API 명은 공개 목록(DISPLAY_YN='Y')에서만 가져온다. 비공개 API 의 이름이 새어나가지 않게 한다.
+ */
+ private Map resolveApiNames(Set apiIds) {
+ List specs =
+ apiSpecInfoRepository.findAllByDisplayYnAndApiIdIn("Y", new ArrayList<>(apiIds));
+ Map names = new HashMap<>();
+ for (ApiSpecInfo spec : specs) {
+ names.put(spec.getApiId(), StringUtils.defaultIfBlank(spec.getApiName(), spec.getApiId()));
+ }
+ return names;
+ }
+
+ /** 진행 중(미종결) 장애 중 API 별로 가장 심각한 한 건 */
+ private Map mapOpenIncidents(Set apiIds) {
+ List openIncidents = incidentQueryRepository
+ .findVisibleOpenIncidents(IncidentKind.INCIDENT, ApiStatusSupport.CLOSED_STATES);
+ if (openIncidents.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Map byId = openIncidents.stream()
+ .collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
+
+ Map byApi = new HashMap<>();
+ for (DjbApistatusIncidentApi api :
+ incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
+ if (!apiIds.contains(api.getApiId()) || api.getRecoveredAt() != null) {
+ continue;
+ }
+ DjbApistatusIncident incident = byId.get(api.getIncidentId());
+ DjbApistatusIncident previous = byApi.get(api.getApiId());
+ // 같은 API 에 여러 장애가 열려 있으면 더 심각한(장애 > 지연, 조사중 > 모니터링) 쪽을 우선한다
+ if (previous == null || severity(incident) < severity(previous)) {
+ byApi.put(api.getApiId(), incident);
+ }
+ }
+ return byApi;
+ }
+
+ /** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
+ private Map mapStartedMaintenance(Set apiIds, LocalDateTime now) {
+ List maintenances = incidentQueryRepository
+ .findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
+ .filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
+ .collect(Collectors.toList());
+ if (maintenances.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Map byId = maintenances.stream()
+ .collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
+
+ Map byApi = new HashMap<>();
+ for (DjbApistatusIncidentApi api :
+ incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
+ if (!apiIds.contains(api.getApiId())) {
+ continue;
+ }
+ DjbApistatusIncident incident = byId.get(api.getIncidentId());
+ DjbApistatusIncident previous = byApi.get(api.getApiId());
+ if (previous == null
+ || (incident.getStartedAt() != null && previous.getStartedAt() != null
+ && incident.getStartedAt().isBefore(previous.getStartedAt()))) {
+ byApi.put(api.getApiId(), incident);
+ }
+ }
+ return byApi;
+ }
+
+ /** 조회 기간 내 마지막 장애 발생 시각 */
+ private Map collectLastIncidentAt(Set apiIds, LocalDateTime now, int windowDays) {
+ LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
+ List incidents = incidentQueryRepository
+ .findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
+ .filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
+ .collect(Collectors.toList());
+ if (incidents.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Map byId = incidents.stream()
+ .collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
+
+ Map result = new HashMap<>();
+ for (DjbApistatusIncidentApi api :
+ incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
+ if (!apiIds.contains(api.getApiId())) {
+ continue;
+ }
+ LocalDateTime startedAt = byId.get(api.getIncidentId()).getStartedAt();
+ LocalDateTime previous = result.get(api.getApiId());
+ if (startedAt != null && (previous == null || startedAt.isAfter(previous))) {
+ result.put(api.getApiId(), startedAt);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * 진행 중 장애 + 점검 여부로 현재 상태를 정한다.
+ *
+ * 자동 탐지 지연(INTERFACE_ID {@code DELAY_START:}) 과 모니터링 단계는 장애가 아닌 지연으로 본다.
+ * 이슈 이력 화면의 "지연/장애" 필터와 같은 기준이다.
+ */
+ private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) {
+ if (open != null) {
+ boolean degraded = open.getState() == IncidentState.MONITORING || ApiStatusSupport.isDelay(open);
+ return degraded ? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE;
+ }
+ if (underMaintenance) {
+ return ApiStatusSupport.STATUS_MAINTENANCE;
+ }
+ return ApiStatusSupport.STATUS_NORMAL;
+ }
+
+ /**
+ * 같은 API 에 열린 장애가 여럿일 때의 우선순위 (낮을수록 심각).
+ * 지연으로 판정되는 건은 장애보다 뒤로 민다.
+ */
+ private int severity(DjbApistatusIncident incident) {
+ if (incident == null) {
+ return 99;
+ }
+ int base = ApiStatusSupport.isDelay(incident) ? 10 : 0;
+ IncidentState state = incident.getState();
+ if (state == null) {
+ return base + 9;
+ }
+ switch (state) {
+ case INVESTIGATING: return base;
+ case IDENTIFIED: return base + 1;
+ // 모니터링 단계는 지연으로 표기되므로 같은 장애라도 뒤로 민다
+ case MONITORING: return base + 12;
+ default: return base + 8;
+ }
+ }
+}
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java
index 6c021aa..450c83b 100644
--- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java
@@ -41,6 +41,11 @@ public class ApiStatusCatalogService {
/** 상태/이력 조회 기간(일). 가동률 바·이슈 인덱스·date picker 범위가 모두 이 값을 따른다 */
public static final String KEY_WINDOW_DAYS = "djb.apistatus.window-days";
+ /** OPEN API 목록 카드의 현재 상태 태그 노출 여부 (true/false) */
+ public static final String KEY_API_LIST_STATUS_BADGE = "djb.apistatus.api-list-status-badge";
+
+ private static final boolean DEFAULT_API_LIST_STATUS_BADGE = true;
+
private static final int MIN_WINDOW_DAYS = 1;
private static final int MAX_WINDOW_DAYS = 365;
@@ -132,6 +137,33 @@ public class ApiStatusCatalogService {
}
}
+ /**
+ * OPEN API 목록(/apis) 카드에 현재 상태 태그를 노출할지 여부.
+ * PTL_PROPERTY({@code Portal} / {@code djb.apistatus.api-list-status-badge}) 로 켜고 끈다.
+ *
+ * {@code true/Y/1} 이면 노출, {@code false/N/0} 이면 숨김. 값이 비었거나 이상하면 기본 노출.
+ */
+ @Transactional
+ public boolean isApiListStatusBadgeEnabled() {
+ try {
+ String value = portalPropertyService.getOrCreateProperty(
+ PROPERTY_GROUP, KEY_API_LIST_STATUS_BADGE,
+ String.valueOf(DEFAULT_API_LIST_STATUS_BADGE),
+ "OPEN API 목록 카드에 현재 상태(정상/점검/지연/장애) 태그 노출 여부 (true/false)");
+ String normalized = StringUtils.trimToEmpty(value);
+ if (StringUtils.equalsAnyIgnoreCase(normalized, "false", "n", "0")) {
+ return false;
+ }
+ if (StringUtils.equalsAnyIgnoreCase(normalized, "true", "y", "1")) {
+ return true;
+ }
+ return DEFAULT_API_LIST_STATUS_BADGE;
+ } catch (Exception e) {
+ log.warn("API 목록 상태 태그 노출 프로퍼티 조회 실패 - 기본값 {} 사용", DEFAULT_API_LIST_STATUS_BADGE, e);
+ return DEFAULT_API_LIST_STATUS_BADGE;
+ }
+ }
+
/**
* API 상태가 마지막으로 변경된 시각 (AGWAPP.API_STATUS 최근 upsert).
* 실행 시각(fire)과 달리 상태 변화가 있을 때만 갱신된다.
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java
index 843ba3c..4094ddd 100644
--- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java
@@ -30,6 +30,9 @@ import java.util.stream.Collectors;
@Transactional(readOnly = true)
public class ApiStatusIssueHistoryService {
+ /** 지연은 IncidentKind 가 아니라 INCIDENT 의 하위 구분이므로 필터 값만 따로 둔다 */
+ private static final String KIND_DELAY = "DELAY";
+
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
@@ -69,17 +72,7 @@ public class ApiStatusIssueHistoryService {
while (!cursor.isAfter(last)) {
IssueDateEntryDTO entry = byDate.get(cursor);
if (entry != null) {
- if (incident.getKind() == IncidentKind.MAINTENANCE) {
- entry.setMntCount(entry.getMntCount() + 1);
- if (!entry.getKinds().contains(IncidentKind.MAINTENANCE.name())) {
- entry.getKinds().add(IncidentKind.MAINTENANCE.name());
- }
- } else {
- entry.setIncCount(entry.getIncCount() + 1);
- if (!entry.getKinds().contains(IncidentKind.INCIDENT.name())) {
- entry.getKinds().add(IncidentKind.INCIDENT.name());
- }
- }
+ tally(entry, incident);
}
cursor = cursor.plusDays(1);
}
@@ -88,6 +81,26 @@ public class ApiStatusIssueHistoryService {
return new ArrayList<>(byDate.values());
}
+ /**
+ * 인덱스바 1칸에 이슈 1건을 집계한다. 유형은 이슈 목록 필터와 같은 세 축(장애/지연/점검)으로 가른다.
+ */
+ private void tally(IssueDateEntryDTO entry, DjbApistatusIncident incident) {
+ String kind;
+ if (incident.getKind() == IncidentKind.MAINTENANCE) {
+ entry.setMntCount(entry.getMntCount() + 1);
+ kind = IncidentKind.MAINTENANCE.name();
+ } else if (ApiStatusSupport.isDelay(incident)) {
+ entry.setDlyCount(entry.getDlyCount() + 1);
+ kind = KIND_DELAY;
+ } else {
+ entry.setIncCount(entry.getIncCount() + 1);
+ kind = IncidentKind.INCIDENT.name();
+ }
+ if (!entry.getKinds().contains(kind)) {
+ entry.getKinds().add(kind);
+ }
+ }
+
/**
* P10 - 날짜/API/유형 필터 이슈 목록. 날짜 미지정 시 90일 전체.
*/
@@ -120,9 +133,6 @@ 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 findOverlapping(LocalDateTime from, LocalDateTime to,
String apiId, String kind) {
List incidents = StringUtils.isBlank(apiId)
@@ -147,11 +157,12 @@ public class ApiStatusIssueHistoryService {
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
return null;
}
- if ("DELAY".equalsIgnoreCase(kind)) {
- return incident -> incident.getKind() == IncidentKind.INCIDENT && isDelay(incident);
+ if (KIND_DELAY.equalsIgnoreCase(kind)) {
+ return ApiStatusSupport::isDelay;
}
if ("INCIDENT".equalsIgnoreCase(kind)) {
- return incident -> incident.getKind() == IncidentKind.INCIDENT && !isDelay(incident);
+ return incident -> incident.getKind() == IncidentKind.INCIDENT
+ && !ApiStatusSupport.isDelay(incident);
}
try {
IncidentKind selected = IncidentKind.valueOf(kind.toUpperCase());
@@ -160,9 +171,4 @@ public class ApiStatusIssueHistoryService {
return null;
}
}
-
- private boolean isDelay(DjbApistatusIncident incident) {
- return incident.getInterfaceId() != null
- && incident.getInterfaceId().startsWith(DELAY_INTERFACE_PREFIX);
- }
}
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusSupport.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusSupport.java
index d9fb3aa..a2f1bc1 100644
--- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusSupport.java
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusSupport.java
@@ -1,5 +1,7 @@
package com.eactive.apim.portal.djb.apistatus.service;
+import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
+import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import java.time.Duration;
@@ -29,6 +31,13 @@ public final class ApiStatusSupport {
public static final String STATUS_OUTAGE = "OUTAGE";
public static final String STATUS_MAINTENANCE = "MAINTENANCE";
+ /**
+ * 자동 탐지 지연 건의 INTERFACE_ID prefix (admin ApiStatusDetectionService 가 event 명으로 생성).
+ *
+ * 지연(DELAY)은 별도 KIND 가 아니라 INCIDENT 로 흡수되므로(ADR-F12) 이 prefix 로 가른다.
+ */
+ public static final String DELAY_INTERFACE_PREFIX = "DELAY_START:";
+
private ApiStatusSupport() {
}
@@ -36,6 +45,29 @@ public final class ApiStatusSupport {
return LocalDateTime.now(ZONE);
}
+ /** 자동 탐지된 지연 건인지 여부 (장애와 구분) */
+ public static boolean isDelay(DjbApistatusIncident incident) {
+ return incident != null
+ && incident.getKind() == IncidentKind.INCIDENT
+ && incident.getInterfaceId() != null
+ && incident.getInterfaceId().startsWith(DELAY_INTERFACE_PREFIX);
+ }
+
+ /**
+ * 상태 심각도 순위 (낮을수록 심각). 정렬·상태 병합에 함께 쓴다.
+ */
+ public static int statusRank(String status) {
+ if (status == null) {
+ return 9;
+ }
+ switch (status) {
+ case STATUS_OUTAGE: return 0;
+ case STATUS_MAINTENANCE: return 1;
+ case STATUS_DEGRADED: return 2;
+ default: return 3;
+ }
+ }
+
public static String stateLabel(IncidentState state) {
if (state == null) {
return null;
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java
index c4f6cde..541242b 100644
--- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java
@@ -116,7 +116,11 @@ public class ApiStatusUptimeService {
return dto;
}
+ // 가동률은 장애+지연을 합쳐 차감하므로 합집합 계산용 리스트를 따로 둔다.
+ // (장애와 지연을 나눠 union 한 뒤 더하면 겹치는 구간이 이중 차감된다)
List incidentIntervals = new ArrayList<>();
+ List outageIntervals = new ArrayList<>();
+ List delayIntervals = new ArrayList<>();
List maintenanceIntervals = new ArrayList<>();
Set seen = new HashSet<>();
@@ -131,6 +135,11 @@ public class ApiStatusUptimeService {
maintenanceIntervals.add(interval);
} else {
incidentIntervals.add(interval);
+ if (ApiStatusSupport.isDelay(incident)) {
+ delayIntervals.add(interval);
+ } else {
+ outageIntervals.add(interval);
+ }
}
if (seen.add(incident.getIncidentId())) {
dto.getIssues().add(new DailyStatDTO.IssueRefDTO(incident.getIncidentId(),
@@ -141,18 +150,24 @@ public class ApiStatusUptimeService {
long dayMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(dayStart, dayEnd));
long incidentMinutes = unionMinutes(incidentIntervals);
+ long outageMinutes = unionMinutes(outageIntervals);
+ long delayMinutes = unionMinutes(delayIntervals);
long maintenanceMinutes = unionMinutes(maintenanceIntervals);
dto.setIncidentMinutes(incidentMinutes);
+ dto.setDelayMinutes(delayMinutes);
dto.setMaintenanceMinutes(maintenanceMinutes);
long downMinutes = Math.min(dayMinutes, incidentMinutes + maintenanceMinutes);
dto.setUptimeRatio(round4((double) (dayMinutes - downMinutes) / dayMinutes));
- if (incidentMinutes > 0) {
+ // 색상은 심각한 순으로 하나만 고른다 (장애 > 점검 > 지연)
+ if (outageMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_OUTAGE);
} else if (maintenanceMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_MAINTENANCE);
+ } else if (delayMinutes > 0) {
+ dto.setStatus(ApiStatusSupport.STATUS_DEGRADED);
} else {
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
}
diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/MyApiStatusQueryService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/MyApiStatusQueryService.java
index 8e0d78b..7fb3660 100644
--- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/MyApiStatusQueryService.java
+++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/MyApiStatusQueryService.java
@@ -5,34 +5,25 @@ import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
+import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
-import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
-import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
-import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
-import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
-import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
-import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
-import java.util.HashMap;
-import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import java.util.TreeMap;
-import java.util.stream.Collectors;
/**
* 로그인 사용자가 이용 중인 API 의 현재 상태 (P4).
*
- * 대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.
+ * 대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.
+ * 상태 판정은 {@link ApiCurrentStatusService} 에 위임해 API 상세 화면과 기준을 맞춘다.
*/
@Service
@RequiredArgsConstructor
@@ -40,8 +31,7 @@ import java.util.stream.Collectors;
public class MyApiStatusQueryService {
private final CredentialRepository credentialRepository;
- private final ApiStatusIncidentQueryRepository incidentQueryRepository;
- private final DjbApistatusIncidentApiRepository incidentApiRepository;
+ private final ApiCurrentStatusService apiCurrentStatusService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusCatalogService catalogService;
@@ -56,30 +46,29 @@ public class MyApiStatusQueryService {
return Collections.emptyList();
}
- LocalDateTime now = ApiStatusSupport.now();
- Map openByApi = mapOpenIncidents(myApis.keySet());
- Set underMaintenance = collectApisUnderMaintenance(myApis.keySet(), now);
- int windowDays = catalogService.getWindowDays();
- Map lastIncidentAt = collectLastIncidentAt(myApis.keySet(), now, windowDays);
- Map uptimes = uptimeService.getApiUptimeRatios(myApis.keySet(), windowDays);
+ Map statuses = apiCurrentStatusService.resolveStatuses(myApis.keySet());
+ Map uptimes =
+ uptimeService.getApiUptimeRatios(myApis.keySet(), catalogService.getWindowDays());
List result = new ArrayList<>();
for (Map.Entry entry : myApis.entrySet()) {
String apiId = entry.getKey();
- DjbApistatusIncident open = openByApi.get(apiId);
+ ApiCurrentStatusDTO status = statuses.get(apiId);
MyApiStatusDTO dto = new MyApiStatusDTO();
dto.setApiId(apiId);
+ // 기관이 발급받은 앱 기준 목록이므로 API 명은 credential 쪽 값을 그대로 쓴다
dto.setApiName(entry.getValue());
- dto.setCurrentStatus(resolveStatus(open, underMaintenance.contains(apiId)));
+ dto.setCurrentStatus(status == null ? ApiStatusSupport.STATUS_NORMAL : status.getCurrentStatus());
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
- dto.setActiveIncidentId(open == null ? null : open.getIncidentId());
- dto.setLastIncidentAt(lastIncidentAt.get(apiId));
+ dto.setActiveIncidentId(status == null ? null : status.getActiveIncidentId());
+ dto.setLastIncidentAt(status == null ? null : status.getLastIncidentAt());
dto.setUptime90d(uptimes.getOrDefault(apiId, 1.0d));
result.add(dto);
}
- result.sort(Comparator.comparingInt((MyApiStatusDTO dto) -> statusRank(dto.getCurrentStatus()))
+ result.sort(Comparator
+ .comparingInt((MyApiStatusDTO dto) -> ApiStatusSupport.statusRank(dto.getCurrentStatus()))
.thenComparing(MyApiStatusDTO::getApiName, Comparator.nullsLast(Comparator.naturalOrder())));
return result;
}
@@ -102,113 +91,4 @@ public class MyApiStatusQueryService {
}
return myApis;
}
-
- private Map mapOpenIncidents(Set apiIds) {
- List openIncidents = incidentQueryRepository
- .findVisibleOpenIncidents(IncidentKind.INCIDENT, ApiStatusSupport.CLOSED_STATES);
- if (openIncidents.isEmpty()) {
- return Collections.emptyMap();
- }
- Map byId = openIncidents.stream()
- .collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
-
- Map byApi = new HashMap<>();
- for (DjbApistatusIncidentApi api :
- incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
- if (!apiIds.contains(api.getApiId()) || api.getRecoveredAt() != null) {
- continue;
- }
- DjbApistatusIncident incident = byId.get(api.getIncidentId());
- DjbApistatusIncident previous = byApi.get(api.getApiId());
- // 같은 API 에 여러 장애가 열려 있으면 더 심각한(조사중) 쪽을 우선한다
- if (previous == null || statePriority(incident.getState()) < statePriority(previous.getState())) {
- byApi.put(api.getApiId(), incident);
- }
- }
- return byApi;
- }
-
- private Set collectApisUnderMaintenance(Set apiIds, LocalDateTime now) {
- List maintenances = incidentQueryRepository
- .findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
- .filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
- .collect(Collectors.toList());
- if (maintenances.isEmpty()) {
- return Collections.emptySet();
- }
- List ids = maintenances.stream()
- .map(DjbApistatusIncident::getIncidentId)
- .collect(Collectors.toList());
-
- Set result = new HashSet<>();
- for (DjbApistatusIncidentApi api : incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(ids)) {
- if (apiIds.contains(api.getApiId())) {
- result.add(api.getApiId());
- }
- }
- return result;
- }
-
- private Map collectLastIncidentAt(Set apiIds, LocalDateTime now, int windowDays) {
- LocalDateTime windowStart = now.toLocalDate()
- .minusDays(windowDays - 1L).atStartOfDay();
- List incidents = incidentQueryRepository
- .findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
- .filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
- .collect(Collectors.toList());
- if (incidents.isEmpty()) {
- return Collections.emptyMap();
- }
- Map byId = incidents.stream()
- .collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
-
- Map result = new HashMap<>();
- for (DjbApistatusIncidentApi api :
- incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
- if (!apiIds.contains(api.getApiId())) {
- continue;
- }
- LocalDateTime startedAt = byId.get(api.getIncidentId()).getStartedAt();
- LocalDateTime previous = result.get(api.getApiId());
- if (startedAt != null && (previous == null || startedAt.isAfter(previous))) {
- result.put(api.getApiId(), startedAt);
- }
- }
- return result;
- }
-
- private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) {
- if (open != null) {
- return open.getState() == IncidentState.MONITORING
- ? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE;
- }
- if (underMaintenance) {
- return ApiStatusSupport.STATUS_MAINTENANCE;
- }
- return ApiStatusSupport.STATUS_NORMAL;
- }
-
- private int statePriority(IncidentState state) {
- if (state == null) {
- return 9;
- }
- switch (state) {
- case INVESTIGATING: return 0;
- case IDENTIFIED: return 1;
- case MONITORING: return 2;
- default: return 8;
- }
- }
-
- private int statusRank(String status) {
- if (status == null) {
- return 9;
- }
- switch (status) {
- case ApiStatusSupport.STATUS_OUTAGE: return 0;
- case ApiStatusSupport.STATUS_MAINTENANCE: return 1;
- case ApiStatusSupport.STATUS_DEGRADED: return 2;
- default: return 3;
- }
- }
}
diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css
index ec6431d..52dc713 100644
--- a/src/main/resources/static/css/main.css
+++ b/src/main/resources/static/css/main.css
@@ -11163,16 +11163,28 @@ body.index-page-body {
.api-card:active .api-card-desc {
color: #ffffff;
}
-.api-card-badge {
+.api-card {
+ /* 그룹 배지 + 현재 상태 태그를 한 줄에 둔다.
+ 기존 그룹 배지가 갖고 있던 절대 위치(top 39 / left 32)를 이 래퍼가 이어받는다 */
+}
+.api-card-badges {
position: absolute;
top: 39px;
left: 32px;
+ right: 32px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+.api-card-badge {
background-color: #f2f2f3;
border-radius: 10px;
padding: 2px 20px;
display: inline-flex;
align-items: center;
justify-content: center;
+ min-width: 0;
}
.api-card-badge span {
font-size: 14px;
@@ -11180,6 +11192,44 @@ body.index-page-body {
font-weight: 500;
color: #1b4ab7;
white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.api-card {
+ /* 현재 상태 태그. 색 규격은 API Status 화면(.as-badge)과 동일.
+ 상태를 못 받았을 때(빈 값)는 자리만 차지하지 않도록 숨긴다 */
+}
+.api-card-status {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 12px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-family: "Spoqa Han Sans Neo", sans-serif;
+ font-weight: 600;
+ white-space: nowrap;
+ background: #eef1f5;
+ color: #4b5563;
+}
+.api-card-status:empty {
+ display: none;
+}
+.api-card-status.is-outage {
+ background: #feeae7;
+ color: #c8362f;
+}
+.api-card-status.is-maintenance {
+ background: #e7f0fb;
+ color: #0a66c2;
+}
+.api-card-status.is-degraded {
+ background: #fef3c7;
+ color: #c77800;
+}
+.api-card-status.is-normal {
+ background: #e7f6ec;
+ color: #1b8c4a;
}
.api-card-title {
position: absolute;
@@ -11499,7 +11549,7 @@ body.index-page-body {
}
}
-/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
+/* "기본 정보" 헤더: 좌측 제목 + 우측 현재 상태/상태 이력 */
.api-basic-info-header {
display: flex;
align-items: center;
@@ -11508,6 +11558,55 @@ body.index-page-body {
flex-wrap: wrap;
}
+/* 현재 상태 배지 + "상태 이력 확인" 버튼.
+ 색 규격은 API Status 화면(.as-badge)과 동일하게 맞춘다 */
+.api-status-inline {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.api-status-inline .api-status-inline__label {
+ font-size: 13px;
+ font-weight: 500;
+ color: #4b5563;
+}
+.api-status-inline .api-status-inline__badge {
+ display: inline-flex;
+ align-items: center;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 5px 12px;
+ border-radius: 999px;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+ background: #eef1f5;
+ color: #4b5563;
+}
+.api-status-inline .api-status-inline__badge.is-outage {
+ background: #feeae7;
+ color: #c8362f;
+}
+.api-status-inline .api-status-inline__badge.is-maintenance {
+ background: #e7f0fb;
+ color: #0a66c2;
+}
+.api-status-inline .api-status-inline__badge.is-degraded {
+ background: #fef3c7;
+ color: #c77800;
+}
+.api-status-inline .api-status-inline__badge.is-normal {
+ background: #e7f6ec;
+ color: #1b8c4a;
+}
+.api-status-inline .api-status-inline__badge.is-loading {
+ color: #6b7280;
+}
+.api-status-inline .api-status-inline__badge.is-muted {
+ background: #eef1f5;
+ color: #6b7280;
+}
+
/* 하단 중앙 "API 사용 신청" 버튼 */
.api-apply-footer {
display: flex;
@@ -27003,7 +27102,7 @@ input[type=checkbox]:checked + .custom-checkbox {
.api-status .as-legend .as-sw.is-none {
background: var(--as-none-bg);
}
-.api-status .as-legend .as-sw.is-both {
+.api-status .as-legend .as-sw.is-mixed, .api-status .as-legend .as-sw.is-both {
background: var(--as-both);
}
.api-status .as-my-apis table {
@@ -27248,10 +27347,13 @@ input[type=checkbox]:checked + .custom-checkbox {
.api-status .as-index-cell.has-incident {
background: var(--as-err);
}
+.api-status .as-index-cell.has-degraded {
+ background: var(--as-warn);
+}
.api-status .as-index-cell.has-maintenance {
background: var(--as-info);
}
-.api-status .as-index-cell.has-both {
+.api-status .as-index-cell.has-mixed, .api-status .as-index-cell.has-both {
background: var(--as-both);
}
.api-status .as-index-cell.is-selected {
@@ -28512,5 +28614,3 @@ input[type=checkbox]:checked + .custom-checkbox {
white-space: nowrap;
border: 0;
}
-
-/*# sourceMappingURL=main.css.map */
diff --git a/src/main/resources/static/js/djb/api-status-issues.js b/src/main/resources/static/js/djb/api-status-issues.js
index 2706ff4..2e16545 100644
--- a/src/main/resources/static/js/djb/api-status-issues.js
+++ b/src/main/resources/static/js/djb/api-status-issues.js
@@ -128,15 +128,21 @@
indexBar.innerHTML = entries.map(function (entry) {
const hasIncident = entry.incCount > 0;
+ const hasDelay = entry.dlyCount > 0;
const hasMaintenance = entry.mntCount > 0;
+
+ // 유형이 2종 이상 섞인 날은 개별 색 대신 복합 색으로 표시한다
+ const kindCount = (hasIncident ? 1 : 0) + (hasDelay ? 1 : 0) + (hasMaintenance ? 1 : 0);
let cls = '';
- if (hasIncident && hasMaintenance) cls = ' has-both';
+ if (kindCount > 1) cls = ' has-mixed';
else if (hasIncident) cls = ' has-incident';
+ else if (hasDelay) cls = ' has-degraded';
else if (hasMaintenance) cls = ' has-maintenance';
if (state.date && state.date === entry.date) cls += ' is-selected';
const parts = [];
if (hasIncident) parts.push('장애 ' + entry.incCount + '건');
+ if (hasDelay) parts.push('지연 ' + entry.dlyCount + '건');
if (hasMaintenance) parts.push('점검 ' + entry.mntCount + '건');
const tooltip = entry.date + (parts.length ? ' · ' + parts.join(' · ') : ' · 이슈 없음');
@@ -382,7 +388,20 @@
applyFilters(true);
});
- apiInput.addEventListener('focus', function () { openApiList(''); });
+ // readonly 자동완성 차단 트릭: 브라우저 autofill 은 readonly 입력을 후보에서 제외한다.
+ // 사용자가 실제로 만지는 순간에만 readonly 를 풀어 입력을 허용한다 (모바일은 touchstart 가 먼저 온다).
+ function unlockApiInput() {
+ if (apiInput.hasAttribute('readonly')) {
+ apiInput.removeAttribute('readonly');
+ }
+ }
+ apiInput.addEventListener('touchstart', unlockApiInput, { passive: true });
+ apiInput.addEventListener('mousedown', unlockApiInput);
+
+ apiInput.addEventListener('focus', function () {
+ unlockApiInput();
+ openApiList('');
+ });
apiInput.addEventListener('input', function () { openApiList(apiInput.value); });
diff --git a/src/main/resources/static/js/popup/custom-popups.js b/src/main/resources/static/js/popup/custom-popups.js
index e4e90ba..303d125 100644
--- a/src/main/resources/static/js/popup/custom-popups.js
+++ b/src/main/resources/static/js/popup/custom-popups.js
@@ -142,7 +142,11 @@ const customPopups = {
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
- $('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
+ // type 은 열려 있는 동안만 password 로 전환한다.
+ // 상주 DOM 에 password 입력이 있으면 브라우저가 페이지의 다른 텍스트 입력을
+ // 아이디 칸으로 오인해 계정 자동완성을 띄우기 때문 (닫을 때 text 로 복원).
+ $('#passwordPopupInput').val('').removeClass('error')
+ .attr('type', 'password').attr('placeholder', placeholder);
$('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
@@ -219,8 +223,8 @@ const customPopups = {
// Body 스크롤 복원
$('body').css('overflow', '');
- // 입력 필드 초기화
- $('#passwordPopupInput').val('').removeClass('error');
+ // 입력 필드 초기화 (type 도 text 로 복원 — 상주 password 로 남기지 않는다)
+ $('#passwordPopupInput').val('').removeClass('error').attr('type', 'text');
$('#passwordPopupError').removeClass('show').text('');
// 이벤트 리스너 제거
diff --git a/src/main/resources/static/sass/pages/_api-market.scss b/src/main/resources/static/sass/pages/_api-market.scss
index 65ed190..24ca2db 100644
--- a/src/main/resources/static/sass/pages/_api-market.scss
+++ b/src/main/resources/static/sass/pages/_api-market.scss
@@ -467,16 +467,27 @@
}
}
- &-badge {
+ /* 그룹 배지 + 현재 상태 태그를 한 줄에 둔다.
+ 기존 그룹 배지가 갖고 있던 절대 위치(top 39 / left 32)를 이 래퍼가 이어받는다 */
+ &-badges {
position: absolute;
top: 39px;
left: 32px;
+ right: 32px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ &-badge {
background-color: #f2f2f3;
border-radius: 10px;
padding: 2px 20px;
display: inline-flex;
align-items: center;
justify-content: center;
+ min-width: 0;
span {
font-size: 14px;
@@ -484,9 +495,34 @@
font-weight: 500;
color: #1b4ab7;
white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
}
+ /* 현재 상태 태그. 색 규격은 API Status 화면(.as-badge)과 동일.
+ 상태를 못 받았을 때(빈 값)는 자리만 차지하지 않도록 숨긴다 */
+ &-status {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 12px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-family: 'Spoqa Han Sans Neo', sans-serif;
+ font-weight: 600;
+ white-space: nowrap;
+ background: #eef1f5;
+ color: #4b5563;
+
+ &:empty { display: none; }
+
+ &.is-outage { background: #feeae7; color: #c8362f; }
+ &.is-maintenance { background: #e7f0fb; color: #0a66c2; }
+ &.is-degraded { background: #fef3c7; color: #c77800; }
+ &.is-normal { background: #e7f6ec; color: #1b8c4a; }
+ }
+
&-title {
position: absolute;
top: 86px;
@@ -846,7 +882,7 @@
}
// API Overview Card (Flat Style)
-/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
+/* "기본 정보" 헤더: 좌측 제목 + 우측 현재 상태/상태 이력 */
.api-basic-info-header {
display: flex;
align-items: center;
@@ -855,6 +891,41 @@
flex-wrap: wrap;
}
+/* 현재 상태 배지 + "상태 이력 확인" 버튼.
+ 색 규격은 API Status 화면(.as-badge)과 동일하게 맞춘다 */
+.api-status-inline {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+
+ .api-status-inline__label {
+ font-size: 13px;
+ font-weight: 500;
+ color: #4b5563;
+ }
+
+ .api-status-inline__badge {
+ display: inline-flex;
+ align-items: center;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 5px 12px;
+ border-radius: 999px;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+ background: #eef1f5;
+ color: #4b5563;
+
+ &.is-outage { background: #feeae7; color: #c8362f; }
+ &.is-maintenance { background: #e7f0fb; color: #0a66c2; }
+ &.is-degraded { background: #fef3c7; color: #c77800; }
+ &.is-normal { background: #e7f6ec; color: #1b8c4a; }
+ &.is-loading { color: #6b7280; }
+ &.is-muted { background: #eef1f5; color: #6b7280; }
+ }
+}
+
/* 하단 중앙 "API 사용 신청" 버튼 */
.api-apply-footer {
display: flex;
diff --git a/src/main/resources/static/sass/pages/_api-status.scss b/src/main/resources/static/sass/pages/_api-status.scss
index c7fef70..abfb01d 100644
--- a/src/main/resources/static/sass/pages/_api-status.scss
+++ b/src/main/resources/static/sass/pages/_api-status.scss
@@ -384,6 +384,8 @@
&.is-maintenance { background: var(--as-info); }
&.is-degraded { background: var(--as-warn); }
&.is-none { background: var(--as-none-bg); }
+ // 장애·지연·점검 중 2종 이상이 겹친 날
+ &.is-mixed,
&.is-both { background: var(--as-both); }
}
}
@@ -601,7 +603,10 @@
transition: opacity 0.15s;
&.has-incident { background: var(--as-err); }
+ &.has-degraded { background: var(--as-warn); }
&.has-maintenance { background: var(--as-info); }
+ // 유형이 2종 이상 섞인 날
+ &.has-mixed,
&.has-both { background: var(--as-both); }
&.is-selected { outline: 2px solid var(--as-text-2); outline-offset: 1px; }
&:hover { opacity: 0.75; }
diff --git a/src/main/resources/templates/views/apps/apis/mainApiDetail.html b/src/main/resources/templates/views/apps/apis/mainApiDetail.html
index 3709559..2301000 100644
--- a/src/main/resources/templates/views/apps/apis/mainApiDetail.html
+++ b/src/main/resources/templates/views/apps/apis/mainApiDetail.html
@@ -69,9 +69,18 @@
+
-
+
-
-
-
그룹 이름
+
+
@@ -152,6 +159,62 @@
});
});
+ // ===== 카드 현재 상태 태그 =====
+ // 게시된 장애·점검 공지 기준(/apistatus/current-list.json). 태그 자체는 PTL_PROPERTY 로 노출 제어된다.
+ (function loadApiCardStatuses() {
+ const grid = document.querySelector('.api-card-grid');
+ if (!grid) return;
+
+ const statusUrl = grid.getAttribute('data-status-url');
+ const slots = Array.prototype.slice.call(grid.querySelectorAll('.api-card-status[data-api-id]'));
+ if (!statusUrl || !slots.length) return;
+
+ const BADGE_CLASS = {
+ OUTAGE: 'is-outage',
+ DEGRADED: 'is-degraded',
+ MAINTENANCE: 'is-maintenance',
+ NORMAL: 'is-normal'
+ };
+ const CHUNK = 50; // 서버 일괄 조회 상한(100) 이내로 나눠 요청
+
+ // 같은 API 가 여러 카드에 나올 수 있어 apiId → 슬롯 목록으로 묶는다
+ const slotsByApiId = {};
+ slots.forEach(function (el) {
+ const apiId = el.getAttribute('data-api-id');
+ if (!apiId) return;
+ (slotsByApiId[apiId] = slotsByApiId[apiId] || []).push(el);
+ });
+ const apiIds = Object.keys(slotsByApiId);
+
+ function paint(list) {
+ (list || []).forEach(function (status) {
+ const targets = slotsByApiId[status.apiId];
+ const cls = BADGE_CLASS[status.currentStatus];
+ if (!targets || !cls) return;
+ targets.forEach(function (el) {
+ el.className = 'api-card-status ' + cls;
+ el.textContent = status.currentStatusLabel || '';
+ if (status.activeIncidentTitle) el.setAttribute('title', status.activeIncidentTitle);
+ });
+ });
+ }
+
+ for (let index = 0; index < apiIds.length; index += CHUNK) {
+ const query = apiIds.slice(index, index + CHUNK).map(function (apiId) {
+ return 'apiId=' + encodeURIComponent(apiId);
+ }).join('&');
+
+ fetch(statusUrl + '?' + query, { credentials: 'same-origin' })
+ .then(function (r) {
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ })
+ .then(paint)
+ // 상태 조회 실패는 목록 자체를 막지 않는다 (태그는 비어 있는 채로 숨겨진다)
+ .catch(function (e) { console.error('API 현재 상태 조회 실패', e); });
+ }
+ })();
+
// Category/Service Selection
const menuTitles = document.querySelectorAll('.js-menu-trigger');
menuTitles.forEach(function (title) {
diff --git a/src/main/resources/templates/views/djb/apistatus/index.html b/src/main/resources/templates/views/djb/apistatus/index.html
index 1d86d29..7521ca4 100644
--- a/src/main/resources/templates/views/djb/apistatus/index.html
+++ b/src/main/resources/templates/views/djb/apistatus/index.html
@@ -36,7 +36,7 @@
최근 90일 서비스 상태
-
막대 1개 = 하루 · 장애나 점검이 있던 날만 색으로 표시됩니다.
+
막대 1개 = 하루 · 장애·지연·점검이 있던 날만 색으로 표시됩니다.
막대를 클릭하면 그 날짜의 이슈 이력으로 이동합니다.
@@ -50,6 +50,7 @@
정상
장애
+ 지연
점검
diff --git a/src/main/resources/templates/views/djb/apistatus/issues.html b/src/main/resources/templates/views/djb/apistatus/issues.html
index 8d63e40..a166240 100644
--- a/src/main/resources/templates/views/djb/apistatus/issues.html
+++ b/src/main/resources/templates/views/djb/apistatus/issues.html
@@ -47,8 +47,9 @@
정상 (이슈 없음)
장애만
+ 지연만
점검만
- 장애 + 점검
+ 복합 (2종 이상)
@@ -67,10 +68,11 @@