diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java index f3e900f..bc47090 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java @@ -15,31 +15,52 @@ import java.util.List; import java.util.Optional; /** - * 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건(공지 게시 + 초안 아님)을 항상 적용한다. + * 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건을 항상 적용한다. * - *

PTL_NOTICE 와의 조인은 두 가지 역할을 한다. - * (1) 관리자가 미게시(USE_YN='N')한 장애/점검을 숨긴다. - * (2) 공지가 삭제된 고아 장애 행을 자연히 제외한다 (물리 FK 없음 - ADR-F10).

+ *

공개 조건({@link #VISIBLE})은 두 갈래다. + *

+ * + *

공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면 + * NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.

*/ public interface ApiStatusIncidentQueryRepository extends Repository { - String VISIBLE = " i.noticeId = n.id AND n.useYn = 'Y' AND i.draftYn = 'N' "; + String VISIBLE = " i.draftYn = 'N'" + + " AND (i.noticeId IS NULL" + + " OR EXISTS (SELECT 1 FROM PortalNotice n" + + " WHERE n.id = i.noticeId AND n.useYn = 'Y')) "; + + /** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */ + String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT"; + String KIND_DELAY = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.DELAY"; + String KIND_MAINTENANCE = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE"; + + /** 종결 조건 - 장애·지연은 STATE 로, 점검은 종료 시각 경과로 판정한다 */ + String CLOSED_CONDITION = " ((i.kind IN (" + KIND_INCIDENT + ", " + KIND_DELAY + ")" + + " AND i.state IN :closedStates)" + + " OR (i.kind = " + KIND_MAINTENANCE + + " AND i.endAt IS NOT NULL AND i.endAt < :now)) "; /** - * 진행 중 장애 (P3) + * 진행 중 이슈 (P3). 장애와 지연을 함께 본다. */ - @Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n" + @Query("SELECT i FROM DjbApistatusIncident i" + " WHERE " + VISIBLE - + " AND i.kind = :kind" + + " AND i.kind IN :kinds" + " AND i.state NOT IN :closedStates" + " ORDER BY i.startedAt DESC") - List findVisibleOpenIncidents(@Param("kind") IncidentKind kind, + List findVisibleOpenIncidents(@Param("kinds") Collection kinds, @Param("closedStates") Collection closedStates); /** * 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검. */ - @Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n" + @Query("SELECT i FROM DjbApistatusIncident i" + " WHERE " + VISIBLE + " AND i.kind = :kind" + " AND (i.endAt IS NULL OR i.endAt >= :now)" @@ -48,21 +69,15 @@ public interface ApiStatusIncidentQueryRepository extends Repository findVisibleClosedIssues(@Param("closedStates") Collection closedStates, @Param("now") LocalDateTime now, Pageable pageable); @@ -71,7 +86,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository= :from)" @@ -82,7 +97,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository findVisibleById(@Param("incidentId") Long incidentId); 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 index bc69dc2..6293cf0 100644 --- 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 @@ -149,10 +149,10 @@ public class ApiCurrentStatusService { return names; } - /** 진행 중(미종결) 장애 중 API 별로 가장 심각한 한 건 */ + /** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */ private Map mapOpenIncidents(Set apiIds) { List openIncidents = incidentQueryRepository - .findVisibleOpenIncidents(IncidentKind.INCIDENT, ApiStatusSupport.CLOSED_STATES); + .findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES); if (openIncidents.isEmpty()) { return Collections.emptyMap(); } @@ -204,12 +204,12 @@ public class ApiCurrentStatusService { 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) + .filter(incident -> incident.getKind() != null && incident.getKind().isDegrading()) .collect(Collectors.toList()); if (incidents.isEmpty()) { return Collections.emptyMap(); @@ -233,14 +233,15 @@ public class ApiCurrentStatusService { } /** - * 진행 중 장애 + 점검 여부로 현재 상태를 정한다. + * 진행 중 장애·지연 + 점검 여부로 현재 상태를 정한다. * - *

자동 탐지 지연(INTERFACE_ID {@code DELAY_START:}) 과 모니터링 단계는 장애가 아닌 지연으로 본다. - * 이슈 이력 화면의 "지연/장애" 필터와 같은 기준이다.

+ *

KIND 가 DELAY 면 지연이다. 장애(INCIDENT)라도 모니터링 단계면 이미 완화된 상태라 + * 지연으로 표기한다 - 이슈 이력의 유형 필터(KIND 기준)와는 이 지점만 다르다.

*/ private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) { if (open != null) { - boolean degraded = open.getState() == IncidentState.MONITORING || ApiStatusSupport.isDelay(open); + boolean degraded = open.getKind() == IncidentKind.DELAY + || open.getState() == IncidentState.MONITORING; return degraded ? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE; } if (underMaintenance) { @@ -250,14 +251,14 @@ public class ApiCurrentStatusService { } /** - * 같은 API 에 열린 장애가 여럿일 때의 우선순위 (낮을수록 심각). - * 지연으로 판정되는 건은 장애보다 뒤로 민다. + * 같은 API 에 열린 이슈가 여럿일 때의 우선순위 (낮을수록 심각). + * 지연은 장애보다 뒤로 민다. */ private int severity(DjbApistatusIncident incident) { if (incident == null) { return 99; } - int base = ApiStatusSupport.isDelay(incident) ? 10 : 0; + int base = incident.getKind() == IncidentKind.DELAY ? 10 : 0; IncidentState state = incident.getState(); if (state == null) { return base + 9; diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusAssembler.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusAssembler.java index 8ca2abc..896ae0d 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusAssembler.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusAssembler.java @@ -125,7 +125,7 @@ public class ApiStatusAssembler { } /** - * 지난 이슈 카드. 장애는 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15). + * 지난 이슈 카드. 장애·지연은 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15). */ public List toPastIssueCards(List incidents) { if (incidents.isEmpty()) { @@ -135,7 +135,7 @@ public class ApiStatusAssembler { Map> apis = loadApis(ids); List incidentKindIds = incidents.stream() - .filter(incident -> incident.getKind() == IncidentKind.INCIDENT) + .filter(incident -> incident.getKind() != null && incident.getKind().isDegrading()) .map(DjbApistatusIncident::getIncidentId) .collect(Collectors.toList()); Map> timelines = loadTimelines(incidentKindIds); 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 4094ddd..3c5eb8e 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,9 +30,6 @@ 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; @@ -85,19 +82,17 @@ public class ApiStatusIssueHistoryService { * 인덱스바 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(); + IncidentKind kind = incident.getKind(); + if (kind == null) { + return; } - if (!entry.getKinds().contains(kind)) { - entry.getKinds().add(kind); + switch (kind) { + case MAINTENANCE: entry.setMntCount(entry.getMntCount() + 1); break; + case DELAY: entry.setDlyCount(entry.getDlyCount() + 1); break; + default: entry.setIncCount(entry.getIncCount() + 1); break; + } + if (!entry.getKinds().contains(kind.name())) { + entry.getKinds().add(kind.name()); } } @@ -148,24 +143,14 @@ public class ApiStatusIssueHistoryService { /** * 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다. - * - *

지연(DELAY)은 별도 KIND 가 아니라 자동 탐지 시 INCIDENT 로 흡수된다(ADR-F12). - * 자동 탐지 지연 건은 INTERFACE_ID 가 {@code DELAY_START:} 로 시작하므로 이를 기준으로 가른다. - * "장애만" 은 지연을 제외한 INCIDENT 다.

+ * 필터 값은 {@link IncidentKind} 이름 그대로다 (INCIDENT / DELAY / MAINTENANCE). */ private java.util.function.Predicate kindFilter(String kind) { if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) { return null; } - if (KIND_DELAY.equalsIgnoreCase(kind)) { - return ApiStatusSupport::isDelay; - } - if ("INCIDENT".equalsIgnoreCase(kind)) { - return incident -> incident.getKind() == IncidentKind.INCIDENT - && !ApiStatusSupport.isDelay(incident); - } try { - IncidentKind selected = IncidentKind.valueOf(kind.toUpperCase()); + IncidentKind selected = IncidentKind.valueOf(kind.trim().toUpperCase()); return incident -> incident.getKind() == selected; } catch (IllegalArgumentException e) { return null; diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java index 7318d5a..7ac0314 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java @@ -26,11 +26,11 @@ public class ApiStatusQueryService { private final ApiStatusIncidentQueryRepository incidentQueryRepository; private final ApiStatusAssembler assembler; - /** P3 - 진행 중 장애 */ + /** P3 - 진행 중 장애·지연 */ public List getActiveIncidents() { LocalDateTime now = ApiStatusSupport.now(); List incidents = incidentQueryRepository - .findVisibleOpenIncidents(IncidentKind.INCIDENT, ApiStatusSupport.CLOSED_STATES); + .findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES); return assembler.toActiveIncidents(incidents, now); } 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 a2f1bc1..bd9282d 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 @@ -31,13 +31,6 @@ 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() { } @@ -45,12 +38,22 @@ 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); + return incident != null && incident.getKind() == IncidentKind.DELAY; + } + + /** 이슈 종류에 대응하는 현재 상태 코드 */ + public static String statusOf(IncidentKind kind) { + if (kind == null) { + return STATUS_NORMAL; + } + switch (kind) { + case INCIDENT: return STATUS_OUTAGE; + case DELAY: return STATUS_DEGRADED; + case MAINTENANCE: return STATUS_MAINTENANCE; + default: return STATUS_NORMAL; + } } /** 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 541242b..c51cf68 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 @@ -64,9 +64,10 @@ public class ApiStatusUptimeService { LocalDateTime now = ApiStatusSupport.now(); 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) + .filter(incident -> incident.getKind() != null && incident.getKind().isDegrading()) .collect(Collectors.toList()); Map> intervalsByApi = new HashMap<>(); @@ -135,7 +136,7 @@ public class ApiStatusUptimeService { maintenanceIntervals.add(interval); } else { incidentIntervals.add(interval); - if (ApiStatusSupport.isDelay(incident)) { + if (incident.getKind() == IncidentKind.DELAY) { delayIntervals.add(interval); } else { outageIntervals.add(interval); diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 52dc713..55f9f7d 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -27221,6 +27221,9 @@ input[type=checkbox]:checked + .custom-checkbox { .api-status .as-issue-card.is-incident { border-left: 4px solid var(--as-err); } +.api-status .as-issue-card.is-degraded { + border-left: 4px solid var(--as-warn); +} .api-status .as-issue-card.is-maintenance { border-left: 4px solid var(--as-info); } @@ -27243,6 +27246,9 @@ input[type=checkbox]:checked + .custom-checkbox { .api-status .as-issue-card.is-incident .as-issue-title { color: var(--as-err); } +.api-status .as-issue-card.is-degraded .as-issue-title { + color: var(--as-warn); +} .api-status .as-issue-card.is-maintenance .as-issue-title { color: var(--as-info); } 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 2e16545..5b5d1dd 100644 --- a/src/main/resources/static/js/djb/api-status-issues.js +++ b/src/main/resources/static/js/djb/api-status-issues.js @@ -11,7 +11,8 @@ /** 오픈 API 목록에 노출되는 API (링크 가능 대상) */ const linkableApiIds = new Set(); - const KIND_LABEL = { INCIDENT: '장애', MAINTENANCE: '점검' }; + const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' }; + const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' }; const PAGE_SIZE = 10; const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한 @@ -191,7 +192,7 @@ } function issueCardHtml(issue) { - const kindClass = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident'; + const kindClass = KIND_CLASS[issue.kind] || 'is-incident'; let inner; if (issue.kind === 'MAINTENANCE') { diff --git a/src/main/resources/static/js/djb/api-status.js b/src/main/resources/static/js/djb/api-status.js index ea8db63..33de027 100644 --- a/src/main/resources/static/js/djb/api-status.js +++ b/src/main/resources/static/js/djb/api-status.js @@ -12,7 +12,8 @@ /** 오픈 API 목록에 노출되는 API (링크 가능 대상) */ const linkableApiIds = new Set(); - const KIND_LABEL = { INCIDENT: '장애', MAINTENANCE: '점검' }; + const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' }; + const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' }; /** 진행 중 장애 카드 노출 상한. 초과분은 전체 이력 링크로 넘긴다. */ const ACTIVE_INCIDENT_LIMIT = 1; @@ -262,8 +263,8 @@ // ---------------- ❺ 지난 이슈 사항 ---------------- function issueCardHtml(issue) { - const kindClass = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident'; - const kindBadge = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident'; + const kindClass = KIND_CLASS[issue.kind] || 'is-incident'; + const kindBadge = kindClass; let inner; if (issue.kind === 'MAINTENANCE') { diff --git a/src/main/resources/static/sass/pages/_api-status.scss b/src/main/resources/static/sass/pages/_api-status.scss index abfb01d..603638a 100644 --- a/src/main/resources/static/sass/pages/_api-status.scss +++ b/src/main/resources/static/sass/pages/_api-status.scss @@ -495,6 +495,7 @@ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); &.is-incident { border-left: 4px solid var(--as-err); } + &.is-degraded { border-left: 4px solid var(--as-warn); } &.is-maintenance { border-left: 4px solid var(--as-info); } .as-issue-meta-row { @@ -516,6 +517,7 @@ } &.is-incident .as-issue-title { color: var(--as-err); } + &.is-degraded .as-issue-title { color: var(--as-warn); } &.is-maintenance .as-issue-title { color: var(--as-info); } }