From 4916bdf4af604c4bd48900031db6b86b67825e8e Mon Sep 17 00:00:00 2001 From: Rinjae Date: Mon, 3 Aug 2026 15:33:21 +0900 Subject: [PATCH] =?UTF-8?q?API=20=EC=83=81=ED=83=9C=20=EC=A7=80=EC=97=B0?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=20=EB=B0=8F=20UI/=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0:=20-=20=EC=9E=A5=EC=95=A0=C2=B7=EC=A7=80?= =?UTF-8?q?=EC=97=B0=20=EA=B5=AC=EB=B6=84=20=EB=B0=8F=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=8C=80=EC=9D=91?= =?UTF-8?q?=20-=20=EC=A7=80=EC=97=B0=20=EC=9C=A0=ED=98=95=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EC=83=89=EC=83=81/=ED=83=9C=EA=B7=B8/=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=B8=94/UI=20=EA=B2=BD=EB=A1=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/apis/controller/ApiController.java | 4 + .../controller/ApiStatusController.java | 32 ++ .../apistatus/dto/ApiCurrentStatusDTO.java | 53 ++++ .../djb/apistatus/dto/DailyStatDTO.java | 4 + .../djb/apistatus/dto/IssueDateEntryDTO.java | 7 +- .../service/ApiCurrentStatusService.java | 273 ++++++++++++++++++ .../service/ApiStatusCatalogService.java | 32 ++ .../service/ApiStatusIssueHistoryService.java | 50 ++-- .../apistatus/service/ApiStatusSupport.java | 32 ++ .../service/ApiStatusUptimeService.java | 17 +- .../service/MyApiStatusQueryService.java | 148 +--------- src/main/resources/static/css/main.css | 112 ++++++- .../static/js/djb/api-status-issues.js | 23 +- .../static/js/popup/custom-popups.js | 10 +- .../static/sass/pages/_api-market.scss | 75 ++++- .../static/sass/pages/_api-status.scss | 5 + .../views/apps/apis/mainApiDetail.html | 56 +++- .../views/apps/apis/mainApiList.html | 71 ++++- .../templates/views/djb/apistatus/index.html | 3 +- .../templates/views/djb/apistatus/issues.html | 12 +- .../fragment/popup/passwordInputPopup.html | 6 +- 21 files changed, 842 insertions(+), 183 deletions(-) create mode 100644 src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiCurrentStatusDTO.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java 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 @@
+

기본 정보

- +
+ 현재 상태 + 조회 중 + + 상태 이력 확인 +
GET
@@ -475,6 +484,51 @@ btn.addEventListener('click', requestApiUse); }); + // ===== 기본 정보: 현재 상태 ===== + // 게시된 장애·점검 공지 기준(/apistatus/current.json). 상태 이력은 /apistatus/issues?apiId= 로 이어진다. + (function loadApiCurrentStatus() { + const box = document.getElementById('apiStatusInline'); + const badge = document.getElementById('apiStatusBadge'); + if (!box || !badge) return; + + const apiId = box.getAttribute('data-api-id'); + const statusUrl = box.getAttribute('data-status-url'); + if (!apiId || !statusUrl) return; + + const BADGE_CLASS = { + OUTAGE: 'is-outage', + DEGRADED: 'is-degraded', + MAINTENANCE: 'is-maintenance', + NORMAL: 'is-normal' + }; + + function paint(cls, text, tooltip) { + badge.className = 'api-status-inline__badge ' + cls; + badge.textContent = text; + if (tooltip) badge.setAttribute('title', tooltip); + else badge.removeAttribute('title'); + } + + fetch(statusUrl + '?apiId=' + encodeURIComponent(apiId), { credentials: 'same-origin' }) + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(function (status) { + const tooltip = [ + status.activeIncidentTitle, + status.statusSince ? '시작 ' + status.statusSince : null, + status.expectedEndAt ? '종료 예정 ' + status.expectedEndAt : null + ].filter(Boolean).join('\n'); + paint(BADGE_CLASS[status.currentStatus] || 'is-muted', + status.currentStatusLabel || '정상', tooltip); + }) + .catch(function (e) { + console.error('API 현재 상태 조회 실패', e); + paint('is-muted', '상태 확인 불가'); + }); + })(); + // ===== DJPGPT0001: 앱 인증 정보 자동 주입 ===== const DEFAULT_TOKEN_API_ID = 'default-token-api-spec'; const appsSelect = document.getElementById('apps'); diff --git a/src/main/resources/templates/views/apps/apis/mainApiList.html b/src/main/resources/templates/views/apps/apis/mainApiList.html index ebd3a00..dd357ba 100644 --- a/src/main/resources/templates/views/apps/apis/mainApiList.html +++ b/src/main/resources/templates/views/apps/apis/mainApiList.html @@ -75,13 +75,20 @@
-
+
- -
- 그룹 이름 + +
+
+ 그룹 이름 +
+ +
@@ -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 @@
- - + diff --git a/src/main/resources/templates/views/fragment/popup/passwordInputPopup.html b/src/main/resources/templates/views/fragment/popup/passwordInputPopup.html index 875129b..0ab2e53 100644 --- a/src/main/resources/templates/views/fragment/popup/passwordInputPopup.html +++ b/src/main/resources/templates/views/fragment/popup/passwordInputPopup.html @@ -25,10 +25,14 @@

+
-