diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/StatusPageServiceImpl.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/StatusPageServiceImpl.java index 0fa0ef9e69b..22946902d83 100644 --- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/StatusPageServiceImpl.java +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/StatusPageServiceImpl.java @@ -18,9 +18,8 @@ package org.apache.hertzbeat.manager.service.impl; import java.time.Instant; -import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; @@ -131,45 +130,70 @@ public List queryComponentsStatus() { componentStatus.setInfo(component); List histories = new LinkedList<>(); // query today status - LocalDateTime nowTime = LocalDateTime.now(); - LocalDateTime todayStartTime = nowTime.withHour(0).withMinute(0).withSecond(0).withNano(0); - ZoneOffset zoneOffset = ZoneId.systemDefault().getRules().getOffset(Instant.now()); - long nowTimestamp = nowTime.toInstant(zoneOffset).toEpochMilli(); - long todayStartTimestamp = todayStartTime.toInstant(zoneOffset).toEpochMilli(); + ZoneId zoneId = ZoneId.systemDefault(); + + Instant now = Instant.now(); + long nowTimestamp = now.toEpochMilli(); + + long todayStartTimestamp = now + .atZone(zoneId) + .toLocalDate() + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli(); + List todayStatusPageHistoryList = statusPageHistoryDao .findStatusPageHistoriesByComponentIdAndTimestampBetween(component.getId(), todayStartTimestamp, nowTimestamp); StatusPageHistory todayStatus = combineOneDayStatusPageHistory(todayStatusPageHistoryList, component, nowTimestamp); histories.add(todayStatus); // query 30d component status history - LocalDateTime preTime = todayStartTime.minusDays(HISTORY_SPAN_DAYS); - long preTimestamp = preTime.toInstant(zoneOffset).toEpochMilli(); + long preTimestamp = now + .atZone(zoneId) + .toLocalDate() + .minusDays(HISTORY_SPAN_DAYS) + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli(); + List history = statusPageHistoryDao .findStatusPageHistoriesByComponentIdAndTimestampBetween(component.getId(), preTimestamp, todayStartTimestamp); LinkedList historyList = new LinkedList<>(history); - historyList.sort((o1, o2) -> (int) (o1.getTimestamp() - o2.getTimestamp())); - LocalDateTime endTime = todayStartTime.minusSeconds(1); - LocalDateTime startTime = endTime.withHour(0).withMinute(0).withSecond(0).withNano(0); - for (int index = 0; index < HISTORY_SPAN_DAYS; index++) { - long startTimestamp = startTime.toInstant(zoneOffset).toEpochMilli(); - long endTimestamp = endTime.toInstant(zoneOffset).toEpochMilli(); - List thisDayHistory = historyList.stream().filter(item -> - item.getTimestamp() >= startTimestamp && item.getTimestamp() <= endTimestamp) - .collect(Collectors.toList()); + historyList.sort((o1, o2) -> Long.compare(o1.getTimestamp(), o2.getTimestamp())); + ZonedDateTime end = Instant.ofEpochMilli(todayStartTimestamp) + .atZone(zoneId) + .minusSeconds(1); // yesterday 23:59:59 local time + + for (int i = 0; i < HISTORY_SPAN_DAYS; i++) { + long endTimestamp = end.toInstant().toEpochMilli(); + + long startTimestamp = end.toLocalDate() + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli(); + + List thisDayHistory = historyList.stream() + .filter(h -> h.getTimestamp() >= startTimestamp && h.getTimestamp() <= endTimestamp) + .collect(Collectors.toList()); + if (thisDayHistory.isEmpty()) { - StatusPageHistory statusPageHistory = StatusPageHistory.builder().timestamp(endTimestamp) - .componentId(component.getId()).state(CommonConstants.STATUS_PAGE_COMPONENT_STATE_UNKNOWN).build(); - histories.add(statusPageHistory); + histories.add(StatusPageHistory.builder() + .timestamp(endTimestamp) + .componentId(component.getId()) + .state(CommonConstants.STATUS_PAGE_COMPONENT_STATE_UNKNOWN) + .build()); } else if (thisDayHistory.size() == 1) { histories.add(thisDayHistory.get(0)); } else { - StatusPageHistory statusPageHistory = combineOneDayStatusPageHistory(thisDayHistory, component, endTimestamp); - histories.add(statusPageHistory); + StatusPageHistory merged = + combineOneDayStatusPageHistory(thisDayHistory, component, endTimestamp); + histories.add(merged); statusPageHistoryDao.deleteAll(thisDayHistory); - statusPageHistoryDao.save(statusPageHistory); + statusPageHistoryDao.save(merged); } - startTime = startTime.minusDays(1); - endTime = endTime.minusDays(1); + + end = end.minusDays(1); } + componentStatus.setHistory(histories); componentStatusList.add(componentStatus); } @@ -215,50 +239,86 @@ private StatusPageHistory combineOneDayStatusPageHistory(List @Override public ComponentStatus queryComponentStatus(long id) { - StatusPageComponent component = statusPageComponentDao.findById(id).orElseThrow(() -> new IllegalArgumentException("component not found")); + StatusPageComponent component = statusPageComponentDao.findById(id) + .orElseThrow(() -> new IllegalArgumentException("component not found")); + ComponentStatus componentStatus = new ComponentStatus(); componentStatus.setInfo(component); List histories = new LinkedList<>(); - // query today status - LocalDateTime nowTime = LocalDateTime.now(); - LocalDateTime todayStartTime = nowTime.withHour(0).withMinute(0).withSecond(0).withNano(0); - ZoneOffset zoneOffset = ZoneId.systemDefault().getRules().getOffset(Instant.now()); - long nowTimestamp = nowTime.toInstant(zoneOffset).toEpochMilli(); - long todayStartTimestamp = todayStartTime.toInstant(zoneOffset).toEpochMilli(); - List todayStatusPageHistoryList = statusPageHistoryDao - .findStatusPageHistoriesByComponentIdAndTimestampBetween(component.getId(), todayStartTimestamp, nowTimestamp); - StatusPageHistory todayStatus = combineOneDayStatusPageHistory(todayStatusPageHistoryList, component, nowTimestamp); + + ZoneId zoneId = ZoneId.systemDefault(); + + Instant now = Instant.now(); + long nowTimestamp = now.toEpochMilli(); + + long todayStartTimestamp = now + .atZone(zoneId) + .toLocalDate() + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli(); + + // Today + List todayStatusPageHistoryList = + statusPageHistoryDao.findStatusPageHistoriesByComponentIdAndTimestampBetween( + component.getId(), todayStartTimestamp, nowTimestamp); + + StatusPageHistory todayStatus = + combineOneDayStatusPageHistory(todayStatusPageHistoryList, component, nowTimestamp); + histories.add(todayStatus); - // query 30d component status history - LocalDateTime preTime = todayStartTime.minusDays(HISTORY_SPAN_DAYS); - long preTimestamp = preTime.toInstant(zoneOffset).toEpochMilli(); - List history = statusPageHistoryDao - .findStatusPageHistoriesByComponentIdAndTimestampBetween(component.getId(), preTimestamp, todayStartTimestamp); + + // Previous HISTORY_SPAN_DAYS days (excluding today) + long preTimestamp = now + .atZone(zoneId) + .toLocalDate() + .minusDays(HISTORY_SPAN_DAYS) + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli(); + + List history = + statusPageHistoryDao.findStatusPageHistoriesByComponentIdAndTimestampBetween( + component.getId(), preTimestamp, todayStartTimestamp); + LinkedList historyList = new LinkedList<>(history); - historyList.sort((o1, o2) -> (int) (o1.getTimestamp() - o2.getTimestamp())); - LocalDateTime endTime = todayStartTime.minusSeconds(1); - LocalDateTime startTime = endTime.withHour(0).withMinute(0).withSecond(0).withNano(0); - for (int index = 0; index < HISTORY_SPAN_DAYS; index++) { - long startTimestamp = startTime.toInstant(zoneOffset).toEpochMilli(); - long endTimestamp = endTime.toInstant(zoneOffset).toEpochMilli(); - List thisDayHistory = historyList.stream().filter(item -> - item.getTimestamp() >= startTimestamp && item.getTimestamp() <= endTimestamp) - .collect(Collectors.toList()); + historyList.sort((o1, o2) -> Long.compare(o1.getTimestamp(), o2.getTimestamp())); + + ZonedDateTime end = Instant.ofEpochMilli(todayStartTimestamp) + .atZone(zoneId) + .minusSeconds(1); // yesterday 23:59:59 local time + + for (int i = 0; i < HISTORY_SPAN_DAYS; i++) { + long endTimestamp = end.toInstant().toEpochMilli(); + + long startTimestamp = end.toLocalDate() + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli(); + + List thisDayHistory = historyList.stream() + .filter(h -> h.getTimestamp() >= startTimestamp && h.getTimestamp() <= endTimestamp) + .collect(Collectors.toList()); + if (thisDayHistory.isEmpty()) { - StatusPageHistory statusPageHistory = StatusPageHistory.builder().timestamp(endTimestamp) - .componentId(component.getId()).state(CommonConstants.STATUS_PAGE_COMPONENT_STATE_UNKNOWN).build(); - histories.add(statusPageHistory); + histories.add(StatusPageHistory.builder() + .timestamp(endTimestamp) + .componentId(component.getId()) + .state(CommonConstants.STATUS_PAGE_COMPONENT_STATE_UNKNOWN) + .build()); } else if (thisDayHistory.size() == 1) { histories.add(thisDayHistory.get(0)); } else { - StatusPageHistory statusPageHistory = combineOneDayStatusPageHistory(thisDayHistory, component, endTimestamp); - histories.add(statusPageHistory); + StatusPageHistory merged = + combineOneDayStatusPageHistory(thisDayHistory, component, endTimestamp); + histories.add(merged); statusPageHistoryDao.deleteAll(thisDayHistory); - statusPageHistoryDao.save(statusPageHistory); + statusPageHistoryDao.save(merged); } - startTime = startTime.minusDays(1); - endTime = endTime.minusDays(1); + + end = end.minusDays(1); } + componentStatus.setHistory(histories); return componentStatus; } diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/StatusPageServiceImplTimeTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/StatusPageServiceImplTimeTest.java new file mode 100644 index 00000000000..8ad87c638eb --- /dev/null +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/StatusPageServiceImplTimeTest.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.manager.StatusPageComponent; +import org.apache.hertzbeat.common.entity.manager.StatusPageHistory; +import org.apache.hertzbeat.manager.component.status.CalculateStatus; +import org.apache.hertzbeat.manager.dao.StatusPageComponentDao; +import org.apache.hertzbeat.manager.dao.StatusPageHistoryDao; +import org.apache.hertzbeat.manager.dao.StatusPageIncidentComponentBindDao; +import org.apache.hertzbeat.manager.dao.StatusPageIncidentDao; +import org.apache.hertzbeat.manager.dao.StatusPageOrgDao; +import org.apache.hertzbeat.manager.pojo.dto.ComponentStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class StatusPageServiceImplTimeTest { + + @Mock private StatusPageHistoryDao historyDao; + @Mock private StatusPageComponentDao componentDao; + @Mock private StatusPageOrgDao orgDao; + @Mock private StatusPageIncidentDao incidentDao; + @Mock private StatusPageIncidentComponentBindDao bindDao; + @Mock private CalculateStatus calculateStatus; + + private StatusPageServiceImpl service; + private StatusPageComponent component; + + @BeforeEach + void setup() throws Exception { + MockitoAnnotations.openMocks(this); + + // Create service using its real constructor + service = new StatusPageServiceImpl(bindDao); + + // Inject all @Autowired fields manually + inject("statusPageHistoryDao", historyDao); + inject("statusPageComponentDao", componentDao); + inject("statusPageOrgDao", orgDao); + inject("statusPageIncidentDao", incidentDao); + inject("calculateStatus", calculateStatus); + + component = new StatusPageComponent(); + component.setId(1L); + component.setState((byte) CommonConstants.STATUS_PAGE_COMPONENT_STATE_NORMAL); + + when(componentDao.findAll()).thenReturn(List.of(component)); + when(calculateStatus.getCalculateStatusIntervals()).thenReturn(300); + when(bindDao.countByComponentId(anyLong())).thenReturn(0L); + } + + private void inject(String field, Object value) throws Exception { + Field f = StatusPageServiceImpl.class.getDeclaredField(field); + f.setAccessible(true); + f.set(service, value); + } + + @Test + void testMidnightBoundary() { + Instant midnight = LocalDate.of(2026, 1, 14) + .atStartOfDay(ZoneId.of("UTC")) + .toInstant(); + + StatusPageHistory before = history(midnight.minusSeconds(1), + CommonConstants.STATUS_PAGE_COMPONENT_STATE_ABNORMAL); + StatusPageHistory after = history(midnight.plusSeconds(1), + CommonConstants.STATUS_PAGE_COMPONENT_STATE_NORMAL); + + when(historyDao.findStatusPageHistoriesByComponentIdAndTimestampBetween(anyLong(), anyLong(), anyLong())) + .thenReturn(List.of(before, after)); + + List result = service.queryComponentsStatus(); + assertEquals(30, result.get(0).getHistory().size()); + } + + @Test + void testDstDay() { + ZoneId zone = ZoneId.of("America/New_York"); + Instant dstDay = ZonedDateTime.of(2026, 3, 8, 12, 0, 0, 0, zone).toInstant(); + + StatusPageHistory history = history(dstDay, + CommonConstants.STATUS_PAGE_COMPONENT_STATE_ABNORMAL); + + when(historyDao.findStatusPageHistoriesByComponentIdAndTimestampBetween(anyLong(), anyLong(), anyLong())) + .thenReturn(List.of(history)); + + List result = service.queryComponentsStatus(); + assertEquals(30, result.get(0).getHistory().size()); + } + + @Test + void testHistoryWindowSize() { + Instant tenDaysAgo = Instant.now().minus(Duration.ofDays(10)); + + StatusPageHistory history = history(tenDaysAgo, + CommonConstants.STATUS_PAGE_COMPONENT_STATE_NORMAL); + + when(historyDao.findStatusPageHistoriesByComponentIdAndTimestampBetween(anyLong(), anyLong(), anyLong())) + .thenReturn(List.of(history)); + + List result = service.queryComponentsStatus(); + assertEquals(30, result.get(0).getHistory().size()); + } + + private StatusPageHistory history(Instant time, int state) { + return StatusPageHistory.builder() + .timestamp(time.toEpochMilli()) + .state((byte) state) + .componentId(1L) + .build(); + } +}