This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 4378c83039f64be35c2728b7dc4ec9c430633aee Author: Logic <[email protected]> AuthorDate: Wed Aug 26 09:50:58 2026 +0800 feat: preserve monitor investigation workflow --- .../manager/controller/MonitorController.java | 14 ++ .../pojo/dto/MonitorInvestigationBindingInfo.java | 36 +++++ .../MonitorInvestigationReadModelService.java | 117 +++++++++++++++ .../manager/controller/MonitorControllerTest.java | 44 ++++++ .../MonitorInvestigationReadModelServiceTest.java | 160 +++++++++++++++++++++ .../controller/MetricsDataController.java | 36 +++++ .../controller/MetricsDataControllerTest.java | 34 +++++ .../monitor-navigation-resource-loader.test.tsx | 17 +++ .../refine/monitor-navigation-resource-loader.tsx | 6 +- .../explore/controller/explore-query-keys.test.ts | 2 +- .../explore/model/explore-context-model.ts | 4 + .../features/explore/model/explore-model.test.ts | 22 +++ .../src/features/explore/model/explore-query.ts | 12 ++ .../features/explore/model/explore-url-model.ts | 25 +++- .../src/features/monitor/api/monitor-api.test.ts | 2 +- web-app/src/features/monitor/api/monitor-api.ts | 8 ++ .../monitor/api/monitor-history-path.test.ts | 41 ++++++ .../monitor/api/monitor-investigation-api.test.ts | 62 ++++++++ .../monitor/api/monitor-investigation-schema.ts | 47 ++++++ .../src/features/monitor/api/monitor-metric-api.ts | 11 +- .../monitor/api/monitor-metric-layout-api.test.ts | 101 +++++++++++++ .../monitor/api/monitor-metric-layout-api.ts | 17 ++- .../monitor/components/monitor-list-results.tsx | 6 +- .../monitor/components/monitor-list-view.test.tsx | 15 ++ .../components/monitor-metric-workbench.test.tsx | 48 +++++++ .../monitor-realtime-layout-grid.test.tsx | 76 +++++++--- .../monitor-selected-history-controls.tsx | 38 ++++- .../components/monitor-selected-history-tray.tsx | 2 +- .../controller/monitor-metric-workbench-actions.ts | 3 + .../controller/monitor-metric-workbench-result.ts | 5 + .../monitor/controller/monitor-query-keys.ts | 1 + .../controller/use-monitor-investigation.ts | 58 ++++++++ .../use-monitor-metric-layout-command-owner.ts | 103 +++++++++++++ .../use-monitor-metric-layout-controller.test.tsx | 148 ++++++++++++++++++- .../use-monitor-metric-layout-controller.ts | 69 ++++----- ...se-monitor-metric-workbench-controller.test.tsx | 48 +++++++ .../use-monitor-metric-workbench-controller.ts | 8 +- .../features/monitor/model/monitor-detail-model.ts | 3 + .../model/monitor-investigation-model.test.ts | 112 +++++++++++++++ .../monitor/model/monitor-investigation-model.ts | 118 +++++++++++++++ .../query-context/query-context-model.test.ts | 4 +- .../shared/query-context/query-context-model.ts | 36 +++++ 42 files changed, 1637 insertions(+), 82 deletions(-) diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/MonitorController.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/MonitorController.java index 0ae90f76f2..54e8b6dd37 100644 --- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/MonitorController.java +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/MonitorController.java @@ -24,7 +24,9 @@ import jakarta.validation.Valid; import org.apache.hertzbeat.common.entity.dto.Message; import org.apache.hertzbeat.common.entity.manager.Monitor; import org.apache.hertzbeat.manager.pojo.dto.MonitorDto; +import org.apache.hertzbeat.manager.pojo.dto.MonitorInvestigationBindingInfo; import org.apache.hertzbeat.manager.service.MonitorService; +import org.apache.hertzbeat.manager.service.entity.MonitorInvestigationReadModelService; import org.apache.hertzbeat.manager.support.exception.MonitorCopySourceNotFoundException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; @@ -56,6 +58,9 @@ public class MonitorController { @Autowired private MonitorService monitorService; + @Autowired + private MonitorInvestigationReadModelService monitorInvestigationReadModelService; + @PostMapping @Operation(summary = "Add a monitoring application", description = "Add a monitoring application") public ResponseEntity<Message<Void>> addNewMonitor(@Valid @RequestBody MonitorDto monitorDto) { @@ -122,6 +127,15 @@ public class MonitorController { } } + @GetMapping(path = "/{id}/investigation") + @Operation(summary = "Get the exact Entity investigation binding for a Monitor", + description = "Get the workspace-visible Entity and observed signals bound to one Monitor") + public ResponseEntity<Message<MonitorInvestigationBindingInfo>> getMonitorInvestigation( + @Parameter(description = "Monitoring task ID", example = "6565463543") + @PathVariable("id") final long id) { + return ResponseEntity.ok(Message.success(monitorInvestigationReadModelService.resolve(id).orElse(null))); + } + @DeleteMapping(path = "/{id}") @Operation(summary = "Delete monitoring application based on monitoring ID", description = "Delete monitoring application based on monitoring ID") public ResponseEntity<Message<Void>> deleteMonitor( diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/pojo/dto/MonitorInvestigationBindingInfo.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/pojo/dto/MonitorInvestigationBindingInfo.java new file mode 100644 index 0000000000..4ca46ccbfb --- /dev/null +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/pojo/dto/MonitorInvestigationBindingInfo.java @@ -0,0 +1,36 @@ +/* + * 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.pojo.dto; + +import java.util.List; + +/** + * Exact entity and signal identity available for one monitor investigation. + */ +public record MonitorInvestigationBindingInfo(Long monitorId, + Long entityId, + String entityType, + String serviceName, + String serviceNamespace, + String environment, + List<String> signals) { + + public MonitorInvestigationBindingInfo { + signals = signals == null ? List.of() : List.copyOf(signals); + } +} diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/MonitorInvestigationReadModelService.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/MonitorInvestigationReadModelService.java new file mode 100644 index 0000000000..9aa5fee05e --- /dev/null +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/entity/MonitorInvestigationReadModelService.java @@ -0,0 +1,117 @@ +/* + * 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.entity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind; +import org.apache.hertzbeat.common.entity.manager.ObserveEntity; +import org.apache.hertzbeat.manager.pojo.dto.EntityDetailDto; +import org.apache.hertzbeat.manager.pojo.dto.MonitorInvestigationBindingInfo; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * Resolves the exact persisted Entity binding for a Monitor investigation. + */ +@Service +public class MonitorInvestigationReadModelService { + + private static final String ACTIVE_BIND = "active"; + private static final String SERVICE_ENTITY = "service"; + + private final EntityMonitorBindQueryService entityMonitorBindQueryService; + private final EntityDetailObservabilityReadModelService entityDetailObservabilityReadModelService; + + public MonitorInvestigationReadModelService(EntityMonitorBindQueryService entityMonitorBindQueryService, + EntityDetailObservabilityReadModelService + entityDetailObservabilityReadModelService) { + this.entityMonitorBindQueryService = entityMonitorBindQueryService; + this.entityDetailObservabilityReadModelService = entityDetailObservabilityReadModelService; + } + + public Optional<MonitorInvestigationBindingInfo> resolve(Long monitorId) { + if (monitorId == null || monitorId <= 0) { + return Optional.empty(); + } + List<EntityMonitorBind> activeBinds = entityMonitorBindQueryService.findMonitorBindsByMonitorId(monitorId) + .stream() + .filter(bind -> isExactActiveBind(bind, monitorId)) + .toList(); + if (activeBinds.size() != 1) { + return Optional.empty(); + } + EntityMonitorBind bind = activeBinds.getFirst(); + EntityDetailDto detail = entityDetailObservabilityReadModelService.buildEntityDetail(bind.getEntityId()); + return toBinding(monitorId, bind.getEntityId(), detail); + } + + private Optional<MonitorInvestigationBindingInfo> toBinding(Long monitorId, + Long entityId, + EntityDetailDto detail) { + ObserveEntity entity = detail == null || detail.getEntity() == null ? null : detail.getEntity().getEntity(); + if (!validServiceEntity(entityId, entity) || !containsMonitor(detail, monitorId)) { + return Optional.empty(); + } + List<String> signals = new ArrayList<>(); + signals.add("metrics"); + if (!CollectionUtils.isEmpty(detail.getLogEvidence())) { + signals.add("logs"); + } + if (!CollectionUtils.isEmpty(detail.getTraceEvidence())) { + signals.add("traces"); + } + return Optional.of(new MonitorInvestigationBindingInfo( + monitorId, + entityId, + SERVICE_ENTITY, + entity.getName().trim(), + normalize(entity.getNamespace()), + normalize(entity.getEnvironment()), + signals + )); + } + + private boolean isExactActiveBind(EntityMonitorBind bind, Long monitorId) { + return bind != null + && Objects.equals(monitorId, bind.getMonitorId()) + && bind.getEntityId() != null + && bind.getEntityId() > 0 + && ACTIVE_BIND.equals(bind.getStatus()); + } + + private boolean validServiceEntity(Long entityId, ObserveEntity entity) { + return entity != null + && Objects.equals(entityId, entity.getId()) + && SERVICE_ENTITY.equalsIgnoreCase(entity.getType()) + && StringUtils.hasText(entity.getName()); + } + + private boolean containsMonitor(EntityDetailDto detail, Long monitorId) { + return !CollectionUtils.isEmpty(detail.getBoundMonitors()) + && detail.getBoundMonitors().stream() + .anyMatch(monitor -> monitor != null && Objects.equals(monitorId, monitor.getId())); + } + + private String normalize(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } +} diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/MonitorControllerTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/MonitorControllerTest.java index 4438ab35dd..a73d01217d 100644 --- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/MonitorControllerTest.java +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/MonitorControllerTest.java @@ -24,11 +24,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import org.apache.hertzbeat.common.constants.CommonConstants; import org.apache.hertzbeat.common.entity.manager.Monitor; import org.apache.hertzbeat.common.entity.manager.Param; import org.apache.hertzbeat.common.util.JsonUtil; import org.apache.hertzbeat.manager.pojo.dto.MonitorDto; +import org.apache.hertzbeat.manager.pojo.dto.MonitorInvestigationBindingInfo; +import org.apache.hertzbeat.manager.service.entity.MonitorInvestigationReadModelService; import org.apache.hertzbeat.manager.service.impl.MonitorServiceImpl; import org.apache.hertzbeat.manager.support.exception.MonitorCopySourceNotFoundException; import org.junit.jupiter.api.BeforeEach; @@ -54,6 +57,9 @@ class MonitorControllerTest { @Mock private MonitorServiceImpl monitorService; + @Mock + private MonitorInvestigationReadModelService monitorInvestigationReadModelService; + @InjectMocks private MonitorController monitorController; @@ -197,6 +203,44 @@ class MonitorControllerTest { Mockito.verify(monitorService).getMonitorDto(6565463543L); } + @Test + void getMonitorInvestigationReturnsOnlyTheResolvedExactBinding() throws Exception { + Mockito.when(monitorInvestigationReadModelService.resolve(6565463543L)) + .thenReturn(Optional.of(new MonitorInvestigationBindingInfo( + 6565463543L, + 7L, + "service", + "checkout", + "commerce", + "production", + List.of("metrics", "logs")))); + + this.mockMvc.perform(MockMvcRequestBuilders.get("/api/monitor/{id}/investigation", 6565463543L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.data.monitorId").value(6565463543L)) + .andExpect(jsonPath("$.data.entityId").value(7L)) + .andExpect(jsonPath("$.data.entityType").value("service")) + .andExpect(jsonPath("$.data.serviceName").value("checkout")) + .andExpect(jsonPath("$.data.signals[0]").value("metrics")) + .andExpect(jsonPath("$.data.signals[1]").value("logs")); + + Mockito.verify(monitorInvestigationReadModelService).resolve(6565463543L); + } + + @Test + void getMonitorInvestigationDoesNotFabricateMissingBinding() throws Exception { + Mockito.when(monitorInvestigationReadModelService.resolve(6565463543L)) + .thenReturn(Optional.empty()); + + this.mockMvc.perform(MockMvcRequestBuilders.get("/api/monitor/{id}/investigation", 6565463543L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.data").doesNotExist()); + + Mockito.verify(monitorInvestigationReadModelService).resolve(6565463543L); + } + @Test void deleteMonitor() throws Exception { diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/MonitorInvestigationReadModelServiceTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/MonitorInvestigationReadModelServiceTest.java new file mode 100644 index 0000000000..e537e327a6 --- /dev/null +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/entity/MonitorInvestigationReadModelServiceTest.java @@ -0,0 +1,160 @@ +/* + * 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.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.apache.hertzbeat.common.entity.manager.EntityMonitorBind; +import org.apache.hertzbeat.common.entity.manager.ObserveEntity; +import org.apache.hertzbeat.common.observability.dto.entity.MonitorInfo; +import org.apache.hertzbeat.common.observability.dto.evidence.LogEvidence; +import org.apache.hertzbeat.common.observability.dto.evidence.TraceEvidence; +import org.apache.hertzbeat.manager.pojo.dto.EntityDetailDto; +import org.apache.hertzbeat.manager.pojo.dto.EntityDto; +import org.apache.hertzbeat.manager.pojo.dto.MonitorInvestigationBindingInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Exact Monitor-to-Entity investigation binding contract. + */ +@ExtendWith(MockitoExtension.class) +class MonitorInvestigationReadModelServiceTest { + + @InjectMocks + private MonitorInvestigationReadModelService readModelService; + + @Mock + private EntityMonitorBindQueryService entityMonitorBindQueryService; + + @Mock + private EntityDetailObservabilityReadModelService entityDetailObservabilityReadModelService; + + @Test + void resolvesOneActiveWorkspaceVisibleServiceBindingAndOnlyObservedSignals() { + when(entityMonitorBindQueryService.findMonitorBindsByMonitorId(42L)) + .thenReturn(List.of(activeBind(42L, 7L))); + when(entityDetailObservabilityReadModelService.buildEntityDetail(7L)) + .thenReturn(detail(42L, "service", "checkout", true, true)); + + Optional<MonitorInvestigationBindingInfo> result = readModelService.resolve(42L); + + assertEquals(Optional.of(new MonitorInvestigationBindingInfo( + 42L, 7L, "service", "checkout", "commerce", "production", + List.of("metrics", "logs", "traces"))), result); + verify(entityDetailObservabilityReadModelService).buildEntityDetail(7L); + } + + @Test + void keepsUnobservedSignalsOutOfTheBinding() { + when(entityMonitorBindQueryService.findMonitorBindsByMonitorId(42L)) + .thenReturn(List.of(activeBind(42L, 7L))); + when(entityDetailObservabilityReadModelService.buildEntityDetail(7L)) + .thenReturn(detail(42L, "service", "checkout", false, false)); + + assertEquals(List.of("metrics"), readModelService.resolve(42L).orElseThrow().signals()); + } + + @ParameterizedTest + @MethodSource("ambiguousOrInactiveBindings") + void rejectsMissingAmbiguousOrInactivePersistedBindings(List<EntityMonitorBind> bindings) { + when(entityMonitorBindQueryService.findMonitorBindsByMonitorId(42L)).thenReturn(bindings); + + assertTrue(readModelService.resolve(42L).isEmpty()); + verifyNoInteractions(entityDetailObservabilityReadModelService); + } + + @Test + void rejectsInaccessibleNonServiceOrStaleEntityDetails() { + when(entityMonitorBindQueryService.findMonitorBindsByMonitorId(42L)) + .thenReturn(List.of(activeBind(42L, 7L))); + when(entityDetailObservabilityReadModelService.buildEntityDetail(7L)) + .thenReturn(null, detail(42L, "host", "checkout", true, true), + detail(41L, "service", "checkout", true, true)); + + assertTrue(readModelService.resolve(42L).isEmpty()); + assertTrue(readModelService.resolve(42L).isEmpty()); + assertTrue(readModelService.resolve(42L).isEmpty()); + } + + @Test + void rejectsInvalidMonitorIdentityWithoutReadingPersistedState() { + assertTrue(readModelService.resolve(null).isEmpty()); + assertTrue(readModelService.resolve(0L).isEmpty()); + verify(entityMonitorBindQueryService, never()).findMonitorBindsByMonitorId(org.mockito.ArgumentMatchers.any()); + verifyNoInteractions(entityDetailObservabilityReadModelService); + } + + private static Stream<List<EntityMonitorBind>> ambiguousOrInactiveBindings() { + return Stream.of( + List.of(), + List.of(EntityMonitorBind.builder() + .monitorId(42L) + .entityId(7L) + .status("inactive") + .build()), + List.of(activeBind(42L, 7L), activeBind(42L, 8L)), + List.of(activeBind(41L, 7L)) + ); + } + + private static EntityMonitorBind activeBind(long monitorId, long entityId) { + return EntityMonitorBind.builder() + .monitorId(monitorId) + .entityId(entityId) + .status("active") + .build(); + } + + private static EntityDetailDto detail(long monitorId, + String entityType, + String serviceName, + boolean logs, + boolean traces) { + ObserveEntity entity = new ObserveEntity(); + entity.setId(7L); + entity.setType(entityType); + entity.setName(serviceName); + entity.setNamespace("commerce"); + entity.setEnvironment("production"); + EntityDto entityDto = new EntityDto(); + entityDto.setEntity(entity); + + EntityDetailDto detail = new EntityDetailDto(); + detail.setEntity(entityDto); + MonitorInfo monitor = new MonitorInfo(); + monitor.setId(monitorId); + detail.setBoundMonitors(List.of(monitor)); + detail.setLogEvidence(logs ? List.of(new LogEvidence()) : List.of()); + detail.setTraceEvidence(traces ? List.of(new TraceEvidence()) : List.of()); + return detail; + } +} diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/controller/MetricsDataController.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/controller/MetricsDataController.java index b7611254c7..1151e74af4 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/controller/MetricsDataController.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/controller/MetricsDataController.java @@ -103,6 +103,42 @@ public class MetricsDataController { String app = names[0]; String metrics = names[1]; String metric = names[2]; + return queryMetricHistoryData(instance, app, metrics, metric, history, interval, start, end, step); + } + + @GetMapping("/api/monitor/{instance}/metric") + @Operation(summary = "Query metric history by structured identity", + description = "Queries historical data without combining application, group, and field names") + public ResponseEntity<Message<MetricsHistoryData>> getMetricHistoryDataByIdentity( + @Parameter(description = "monitor instance", example = "127.0.0.1:8080") + @PathVariable String instance, + @Parameter(description = "monitor application", example = "linux") + @RequestParam String app, + @Parameter(description = "metric group", example = "cpu") + @RequestParam String metrics, + @Parameter(description = "metric field", example = "usage") + @RequestParam String metric, + @RequestParam(required = false) String history, + @RequestParam(required = false) Boolean interval, + @RequestParam(required = false) Long start, + @RequestParam(required = false) Long end, + @RequestParam(required = false) String step) { + if (!metricsDataService.getWarehouseStorageServerStatus()) { + return ResponseEntity.ok(Message.fail(FAIL_CODE, "time series database not available")); + } + return queryMetricHistoryData(instance, app, metrics, metric, history, interval, start, end, step); + } + + private ResponseEntity<Message<MetricsHistoryData>> queryMetricHistoryData( + String instance, + String app, + String metrics, + String metric, + String history, + Boolean interval, + Long start, + Long end, + String step) { MetricsHistoryData historyData = metricsDataService.getMetricHistoryData(instance, app, metrics, metric, history, interval, start, end, step); return ResponseEntity.ok(Message.success(historyData)); diff --git a/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/controller/MetricsDataControllerTest.java b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/controller/MetricsDataControllerTest.java index 18e0207d93..89fd164fe5 100644 --- a/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/controller/MetricsDataControllerTest.java +++ b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/controller/MetricsDataControllerTest.java @@ -178,4 +178,38 @@ class MetricsDataControllerTest { verify(metricsDataService).getMetricHistoryData(eq(instance), eq(app), eq(metrics), eq(metric), eq(history), eq(interval), eq(start), eq(end), eq(step)); } + + @Test + void getMetricHistoryDataWithStructuredIdentityPreservesDottedApplication() throws Exception { + final String instance = "127.0.0.1:9090"; + final String app = "_prometheus_node.prod.example"; + final String metrics = "system"; + final String metric = "cpu_usage"; + final String history = "30m"; + final Boolean interval = false; + MetricsHistoryData historyData = MetricsHistoryData.builder() + .instance(instance) + .metrics(metrics) + .field(Field.builder().name(metric).type(CommonConstants.TYPE_NUMBER).build()) + .build(); + + when(metricsDataService.getWarehouseStorageServerStatus()).thenReturn(true); + when(metricsDataService.getMetricHistoryData(instance, app, metrics, metric, history, interval, + null, null, null)).thenReturn(historyData); + + this.mockMvc.perform(MockMvcRequestBuilders.get("/api/monitor/{instance}/metric", instance) + .param("app", app) + .param("metrics", metrics) + .param("metric", metric) + .param("history", history) + .param("interval", String.valueOf(interval))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.data.instance").value(instance)) + .andExpect(jsonPath("$.data.metrics").value(metrics)) + .andExpect(jsonPath("$.data.field.name").value(metric)); + + verify(metricsDataService).getMetricHistoryData(instance, app, metrics, metric, history, interval, + null, null, null); + } } diff --git a/web-app/src/app/refine/monitor-navigation-resource-loader.test.tsx b/web-app/src/app/refine/monitor-navigation-resource-loader.test.tsx index de53492019..d429d754f3 100644 --- a/web-app/src/app/refine/monitor-navigation-resource-loader.test.tsx +++ b/web-app/src/app/refine/monitor-navigation-resource-loader.test.tsx @@ -84,6 +84,23 @@ describe('MonitorNavigationResourceLoader', () => { ]) ); }); + + it('replays a cached authenticated catalog without clearing resources during a remount', async () => { + runtime.session = { loading: false, session: { authenticated: true } }; + const apps = [{ category: 'db', value: 'mysql', label: 'MySQL', hide: false }]; + runtime.load.mockResolvedValue(apps); + const onChange = vi.fn(); + const view = renderLoader(onChange); + + await waitFor(() => expect(onChange).toHaveBeenLastCalledWith(apps)); + onChange.mockClear(); + + view.unmount(); + render(loader(onChange, view.client)); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith(apps)); + expect(onChange).not.toHaveBeenCalledWith([]); + }); }); function renderLoader(onChange: (apps: readonly MonitorApp[]) => void) { diff --git a/web-app/src/app/refine/monitor-navigation-resource-loader.tsx b/web-app/src/app/refine/monitor-navigation-resource-loader.tsx index 92213234fb..72178aeeba 100644 --- a/web-app/src/app/refine/monitor-navigation-resource-loader.tsx +++ b/web-app/src/app/refine/monitor-navigation-resource-loader.tsx @@ -27,11 +27,7 @@ export function MonitorNavigationResourceLoader({ onChange }: { onChange: (apps: }); useEffect(() => { - onChange(emptyMonitorApps); - }, [enabled, locale, onChange]); - - useEffect(() => { - if (enabled && query.isSuccess) onChange(query.data); + onChange(enabled && query.isSuccess ? query.data : emptyMonitorApps); }, [enabled, onChange, query.data, query.isSuccess]); return null; diff --git a/web-app/src/features/explore/controller/explore-query-keys.test.ts b/web-app/src/features/explore/controller/explore-query-keys.test.ts index e57898ffc9..81214a6e54 100644 --- a/web-app/src/features/explore/controller/explore-query-keys.test.ts +++ b/web-app/src/features/explore/controller/explore-query-keys.test.ts @@ -62,7 +62,7 @@ describe('Explore Query Key factory', () => { expect(exploreQueryKeys.history(metricQuery, window, 3)).toEqual([ 'explore-history', { - context: '["","collector-east","checkout","commerce","prod","checkout-1","/checkout"]', + context: '["","","","collector-east","checkout","commerce","prod","checkout-1","/checkout"]', window: '1000:2000', refreshRevision: 3 }, diff --git a/web-app/src/features/explore/model/explore-context-model.ts b/web-app/src/features/explore/model/explore-context-model.ts index 12c4d0c69d..f3da966586 100644 --- a/web-app/src/features/explore/model/explore-context-model.ts +++ b/web-app/src/features/explore/model/explore-context-model.ts @@ -28,6 +28,8 @@ const exploreContextFields = Object.values(QUERY_CONTEXT_FIELDS); export function exploreQueryContext(query: ExploreQuery): QueryContext { return { + entityId: query.entityId, + monitorId: query.monitorId, intakeProfileId: query.intakeProfileId, collectorId: query.collectorId, serviceName: query.serviceName, @@ -51,6 +53,8 @@ export function mergeExploreContextChanges(context: QueryContext, changes: Explo operationName: undefined, pageIndex: undefined, ...changes, + entityId: next.entityId, + monitorId: next.monitorId, intakeProfileId: next.intakeProfileId, collectorId: next.collectorId, serviceName: next.serviceName, diff --git a/web-app/src/features/explore/model/explore-model.test.ts b/web-app/src/features/explore/model/explore-model.test.ts index 374f10e593..ddba4b34d4 100644 --- a/web-app/src/features/explore/model/explore-model.test.ts +++ b/web-app/src/features/explore/model/explore-model.test.ts @@ -38,6 +38,28 @@ describe('explore query state', () => { expect(parseExploreQuery(new URLSearchParams())).toMatchObject({ signal: 'metrics', timeRange: 'last-30m' }); }); + it('round-trips authoritative investigation identity and timezone without changing the exact epoch window', () => { + const query = parseExploreQuery( + new URLSearchParams( + 'signal=logs&timeRange=last-30m&entityId=7&monitorId=42&serviceName=checkout' + + '&start=1723454400000&end=1723456200000&timeZone=Asia%2FShanghai' + ) + ); + + expect(query).toMatchObject({ + entityId: '7', + monitorId: '42', + serviceName: 'checkout', + start: 1_723_454_400_000, + end: 1_723_456_200_000, + timeZone: 'Asia/Shanghai' + }); + expect(buildExplorePath(query)).toBe( + '/explore?signal=logs&timeRange=last-30m&start=1723454400000&end=1723456200000' + + '&timeZone=Asia%2FShanghai&entityId=7&monitorId=42&serviceName=checkout' + ); + }); + it('keeps only supported values and trims empty context', () => { const query = parseExploreQuery( new URLSearchParams('signal=logs&timeRange=last-1h&serviceName=%20checkout%20&query=timeout&errorOnly=true') diff --git a/web-app/src/features/explore/model/explore-query.ts b/web-app/src/features/explore/model/explore-query.ts index cbcdfccbe5..8651805ed4 100644 --- a/web-app/src/features/explore/model/explore-query.ts +++ b/web-app/src/features/explore/model/explore-query.ts @@ -24,6 +24,8 @@ export type TraceSpanScope = 'root' | 'entrypoint'; type SharedExploreQuery = { timeRange: ExploreTimeRange; + entityId?: string | undefined; + monitorId?: string | undefined; serviceName?: string | undefined; serviceNamespace?: string | undefined; environment?: string | undefined; @@ -36,6 +38,7 @@ type SharedExploreQuery = { autoRefreshMs?: number | undefined; start?: number | undefined; end?: number | undefined; + timeZone?: string | undefined; }; export type MetricExploreQuery = SharedExploreQuery & { @@ -80,6 +83,8 @@ export type ExploreQuery = MetricExploreQuery | LogExploreQuery | TraceExploreQu export type ExploreQueryPatch = { signal?: ExploreSignal | undefined; timeRange?: ExploreTimeRange | undefined; + entityId?: string | undefined; + monitorId?: string | undefined; serviceName?: string | undefined; serviceNamespace?: string | undefined; environment?: string | undefined; @@ -92,6 +97,7 @@ export type ExploreQueryPatch = { autoRefreshMs?: number | undefined; start?: number | undefined; end?: number | undefined; + timeZone?: string | undefined; traceId?: string | undefined; errorOnly?: boolean | undefined; live?: boolean | undefined; @@ -125,6 +131,12 @@ export function timeRangeMilliseconds(timeRange: ExploreTimeRange) { } export function exploreHandoffState(query: ExploreQuery): 'none' | 'scoped' | 'invalid' { + if ([query.entityId, query.monitorId, query.timeZone].some(isPresent)) { + return [query.entityId, query.monitorId, query.serviceName, query.timeZone].every(isPresent) && + validExactWindow(query.start, query.end) + ? 'scoped' + : 'invalid'; + } // Ordinary Explore filters can include a namespace. Only onboarding-owned identity/window markers activate // the stricter handoff contract. if (![query.intakeProfileId, query.collectorId, query.windowMode].some(isPresent)) { diff --git a/web-app/src/features/explore/model/explore-url-model.ts b/web-app/src/features/explore/model/explore-url-model.ts index a6ef519125..17327ab4f6 100644 --- a/web-app/src/features/explore/model/explore-url-model.ts +++ b/web-app/src/features/explore/model/explore-url-model.ts @@ -16,7 +16,7 @@ */ import { applicationRoutePaths } from '@/shared/navigation/app-paths'; -import { parseQueryContext, writeQueryContext } from '@/shared/query-context'; +import { normalizeInvestigationTimeZone, parseQueryContext, writeQueryContext } from '@/shared/query-context'; import { parseExploreFilterParams } from './explore-field-contract'; import { enabledFilterValue, temporalAggregationValue, traceSpanScopeValue } from './explore-parity-filter-model'; @@ -44,6 +44,7 @@ export function parseExploreQuery(params: URLSearchParams): ExploreQuery { autoRefreshMs: readAutoRefresh(params.get('autoRefresh')), start: time.start, end: time.end, + timeZone: normalizeInvestigationTimeZone(params.get('timeZone')), live: readLiveMode(params), severityText: readValue(params.get('severityText')), spanId: readValue(params.get('spanId')), @@ -68,17 +69,17 @@ export function buildExplorePath(query: ExploreQuery) { if (normalized.autoRefreshMs) params.set('autoRefresh', String(normalized.autoRefreshMs)); if (normalized.start) params.set('start', String(normalized.start)); if (normalized.end) params.set('end', String(normalized.end)); + if (normalized.timeZone) params.set('timeZone', normalized.timeZone); return `${applicationRoutePaths.explore}?${writeQueryContext(params, normalized).toString()}`; } export function normalizeExploreQuery( query: ExploreQueryPatch & { signal: ExploreSignal; timeRange: ExploreTimeRange } ): ExploreQuery { - const exactWindow = query.start != null && query.end != null && query.start < query.end; - // A preset with residual timestamps is invalid handoff evidence. Preserve it so URL repair cannot widen scope. - const invalidPresetEvidence = query.windowMode === 'preset' && (query.start != null || query.end != null); const shared = { timeRange: query.timeRange, + entityId: query.entityId, + monitorId: query.monitorId, intakeProfileId: query.intakeProfileId, serviceName: query.serviceName, serviceNamespace: query.serviceNamespace, @@ -88,9 +89,7 @@ export function normalizeExploreQuery( endpoint: query.endpoint, query: query.query, windowMode: query.windowMode, - autoRefreshMs: exactWindow ? undefined : query.autoRefreshMs, - start: exactWindow || invalidPresetEvidence ? query.start : undefined, - end: exactWindow || invalidPresetEvidence ? query.end : undefined + ...normalizeExploreTimeEvidence(query) }; if (query.signal === 'metrics') return { @@ -132,6 +131,18 @@ export function normalizeExploreQuery( }; } +function normalizeExploreTimeEvidence(query: ExploreQueryPatch) { + const exactWindow = query.start != null && query.end != null && query.start < query.end; + // A preset with residual timestamps is invalid handoff evidence. Preserve it so URL repair cannot widen scope. + const invalidPresetEvidence = query.windowMode === 'preset' && (query.start != null || query.end != null); + return { + autoRefreshMs: exactWindow ? undefined : query.autoRefreshMs, + start: exactWindow || invalidPresetEvidence ? query.start : undefined, + end: exactWindow || invalidPresetEvidence ? query.end : undefined, + timeZone: exactWindow ? normalizeInvestigationTimeZone(query.timeZone) : undefined + }; +} + function parseAliasedContext(params: URLSearchParams) { return { ...parseQueryContext(params), diff --git a/web-app/src/features/monitor/api/monitor-api.test.ts b/web-app/src/features/monitor/api/monitor-api.test.ts index 660e4cf7a1..abf693184c 100644 --- a/web-app/src/features/monitor/api/monitor-api.test.ts +++ b/web-app/src/features/monitor/api/monitor-api.test.ts @@ -361,7 +361,7 @@ describe('monitor metric API paths', () => { const metric = { key: 'summary.responseTime', group: 'summary', field: 'responseTime', unit: 'ms' }; expect(buildHistoryMetricPath(monitor, metric, history, interval)).toBe( - `/api/monitor/example.com%3A443/metric/website.summary.responseTime?history=${history}&interval=${interval}` + `/api/monitor/example.com%3A443/metric?app=website&metrics=summary&metric=responseTime&history=${history}&interval=${interval}` ); }); }); diff --git a/web-app/src/features/monitor/api/monitor-api.ts b/web-app/src/features/monitor/api/monitor-api.ts index 3bf0925efc..2f0735c8fe 100644 --- a/web-app/src/features/monitor/api/monitor-api.ts +++ b/web-app/src/features/monitor/api/monitor-api.ts @@ -21,6 +21,7 @@ import { MonitorContractError, type MonitorAction, type MonitorQuery } from '../ import { writeMonitorQuery } from '../model/monitor-query'; import { parseMonitorApps, parseMonitorNavigationApps } from './monitor-apps-schema'; import { parseMonitorDetail } from './monitor-detail-schema'; +import { parseMonitorInvestigationBinding } from './monitor-investigation-schema'; import { parseMonitorPage } from './monitor-page-schema'; export { detectMonitor, loadMonitorCollectors, loadMonitorParamDefines, saveMonitor } from './monitor-editor-api'; @@ -131,6 +132,13 @@ export async function loadMonitorDetail(id: string | number, signal?: AbortSigna return parseMonitorDetail(value, requestedId); } +export async function loadMonitorInvestigationBinding(id: number, signal?: AbortSignal) { + if (!Number.isSafeInteger(id) || id <= 0) throw new MonitorContractError(); + const path = `/api/monitor/${id}/investigation`; + const value = signal ? await apiMessageGet(path, { signal }) : await apiMessageGet(path); + return parseMonitorInvestigationBinding(value, id); +} + export function mutateMonitors(action: MonitorAction, ids: number[], signal?: AbortSignal) { const path = buildMonitorActionPath(action, ids); const options = signal ? { signal } : undefined; diff --git a/web-app/src/features/monitor/api/monitor-history-path.test.ts b/web-app/src/features/monitor/api/monitor-history-path.test.ts new file mode 100644 index 0000000000..5d67d34ec0 --- /dev/null +++ b/web-app/src/features/monitor/api/monitor-history-path.test.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +import { describe, expect, it } from 'vitest'; +import { buildHistoryMetricPath } from './monitor-metric-api'; + +describe('monitor history path', () => { + it('keeps a dotted Prometheus monitor name in one structured application parameter', () => { + const monitor = { + id: 7, + app: 'prometheus', + name: 'node.prod.example', + instance: 'example.com:9090', + status: 1 + }; + const metric = { + key: 'system.cpu_usage', + group: 'system', + field: 'cpu_usage', + unit: '%' + }; + + expect(buildHistoryMetricPath(monitor, metric, '30m', false)).toBe( + '/api/monitor/example.com%3A9090/metric?app=_prometheus_node.prod.example&metrics=system&metric=cpu_usage&history=30m&interval=false' + ); + }); +}); diff --git a/web-app/src/features/monitor/api/monitor-investigation-api.test.ts b/web-app/src/features/monitor/api/monitor-investigation-api.test.ts new file mode 100644 index 0000000000..80828b000a --- /dev/null +++ b/web-app/src/features/monitor/api/monitor-investigation-api.test.ts @@ -0,0 +1,62 @@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const http = vi.hoisted(() => ({ apiMessageGet: vi.fn() })); +vi.mock('@/core/http/api-message', async importOriginal => ({ + ...(await importOriginal<typeof import('@/core/http/api-message')>()), + ...http +})); + +import { loadMonitorInvestigationBinding } from './monitor-api'; +import { MonitorContractError } from '../model/monitor-contract'; + +describe('monitor investigation API', () => { + beforeEach(() => http.apiMessageGet.mockReset()); + + it('loads one exact persisted Monitor-to-Entity binding and forwards cancellation', async () => { + const signal = new AbortController().signal; + http.apiMessageGet.mockResolvedValue({ + monitorId: 42, + entityId: 7, + entityType: 'service', + serviceName: 'checkout', + serviceNamespace: 'commerce', + environment: 'production', + signals: ['metrics', 'logs', 'traces'] + }); + + await expect(loadMonitorInvestigationBinding(42, signal)).resolves.toEqual({ + monitorId: 42, + entityId: 7, + entityType: 'service', + serviceName: 'checkout', + serviceNamespace: 'commerce', + environment: 'production', + signals: ['metrics', 'logs', 'traces'] + }); + expect(http.apiMessageGet).toHaveBeenCalledWith('/api/monitor/42/investigation', { signal }); + }); + + it('keeps a missing authoritative binding absent instead of fabricating a route', async () => { + http.apiMessageGet.mockResolvedValue(null); + + await expect(loadMonitorInvestigationBinding(42)).resolves.toBeUndefined(); + }); + + it.each([ + { monitorId: 41, entityId: 7, entityType: 'service', serviceName: 'checkout', signals: ['metrics'] }, + { monitorId: 42, entityId: 7, entityType: 'host', serviceName: 'checkout', signals: ['metrics'] }, + { monitorId: 42, entityId: 7, entityType: 'service', serviceName: 'checkout', signals: ['metrics', 'profiles'] }, + { monitorId: 42, entityId: 7, entityType: 'service', serviceName: 'checkout', signals: ['metrics', 'metrics'] } + ])('rejects malformed or mismatched binding evidence %#', async value => { + http.apiMessageGet.mockResolvedValue(value); + + await expect(loadMonitorInvestigationBinding(42)).rejects.toBeInstanceOf(MonitorContractError); + }); + + it.each([0, -1, Number.MAX_SAFE_INTEGER + 1])('rejects invalid Monitor identity %s before I/O', async monitorId => { + await expect(loadMonitorInvestigationBinding(monitorId)).rejects.toBeInstanceOf(MonitorContractError); + expect(http.apiMessageGet).not.toHaveBeenCalled(); + }); +}); diff --git a/web-app/src/features/monitor/api/monitor-investigation-schema.ts b/web-app/src/features/monitor/api/monitor-investigation-schema.ts new file mode 100644 index 0000000000..7c6cee7666 --- /dev/null +++ b/web-app/src/features/monitor/api/monitor-investigation-schema.ts @@ -0,0 +1,47 @@ +/* + * 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. + */ + +import { z } from 'zod'; + +import { MonitorContractError } from '../model/monitor-contract'; +import type { MonitorInvestigationBinding } from '../model/monitor-investigation-model'; +import { nonEmptyStringSchema, positiveIntegerSchema } from './monitor-read-schema-primitives'; + +const signalSchema = z.enum(['metrics', 'logs', 'traces']); +const optionalIdentitySchema = z + .string() + .nullable() + .transform(value => value?.trim() || undefined); +const bindingSchema = z + .object({ + monitorId: positiveIntegerSchema, + entityId: positiveIntegerSchema, + entityType: z.literal('service'), + serviceName: nonEmptyStringSchema, + serviceNamespace: optionalIdentitySchema, + environment: optionalIdentitySchema, + signals: z.array(signalSchema) + }) + .strict() + .superRefine((binding, context) => { + if (new Set(binding.signals).size !== binding.signals.length) { + context.addIssue({ code: 'custom', message: 'Signal identities must be unique' }); + } + }); + +export function parseMonitorInvestigationBinding( + value: unknown, + requestedMonitorId: number +): MonitorInvestigationBinding | undefined { + if (value === null || value === undefined) return undefined; + const result = bindingSchema.safeParse(value); + if (!result.success || result.data.monitorId !== requestedMonitorId) throw new MonitorContractError(); + return { + ...result.data, + serviceName: result.data.serviceName.trim() + }; +} diff --git a/web-app/src/features/monitor/api/monitor-metric-api.ts b/web-app/src/features/monitor/api/monitor-metric-api.ts index 35684900c6..66bc50c6ce 100644 --- a/web-app/src/features/monitor/api/monitor-metric-api.ts +++ b/web-app/src/features/monitor/api/monitor-metric-api.ts @@ -52,9 +52,14 @@ export function buildHistoryMetricPath( ) { const sourceApp = monitor.scrape && monitor.scrape !== 'static' ? monitor.scrape : monitor.app; const app = sourceApp === 'prometheus' ? `_prometheus_${monitor.name}` : sourceApp; - const fullMetric = `${app}.${metric.group}.${metric.field}`; - const params = new URLSearchParams({ history, interval: String(interval) }); - return `/api/monitor/${encodeURIComponent(monitor.instance)}/metric/${fullMetric}?${params.toString()}`; + const params = new URLSearchParams({ + app, + metrics: metric.group, + metric: metric.field, + history, + interval: String(interval) + }); + return `/api/monitor/${encodeURIComponent(monitor.instance)}/metric?${params.toString()}`; } export async function loadFavoriteMetrics(monitorId: number, signal?: AbortSignal) { diff --git a/web-app/src/features/monitor/api/monitor-metric-layout-api.test.ts b/web-app/src/features/monitor/api/monitor-metric-layout-api.test.ts new file mode 100644 index 0000000000..0af4f39a49 --- /dev/null +++ b/web-app/src/features/monitor/api/monitor-metric-layout-api.test.ts @@ -0,0 +1,101 @@ +/* + * 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. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const transport = vi.hoisted(() => ({ + apiMessageDelete: vi.fn(), + apiMessageGet: vi.fn(), + apiMessagePut: vi.fn() +})); + +vi.mock('@/core/http/api-message', async importOriginal => ({ + ...(await importOriginal<typeof import('@/core/http/api-message')>()), + ...transport +})); + +import { + loadMonitorMetricLayout, + resetMonitorMetricLayout, + saveMonitorMetricLayout +} from './monitor-metric-layout-api'; +import { MonitorMetricLayoutContractError } from './monitor-metric-layout-schema'; + +describe('monitor metric layout API', () => { + beforeEach(() => vi.clearAllMocks()); + + it('uses the encoded application path and forwards the read signal', async () => { + const signal = new AbortController().signal; + transport.apiMessageGet.mockResolvedValue(layout('mysql:custom')); + + await expect(loadMonitorMetricLayout('mysql:custom', signal)).resolves.toEqual(layout('mysql:custom')); + + expect(transport.apiMessageGet).toHaveBeenCalledWith('/api/metrics/layout/mysql%3Acustom', { signal }); + }); + + it('rejects a layout returned for a different application identity', async () => { + transport.apiMessageGet.mockResolvedValue(layout('redis')); + + await expect(loadMonitorMetricLayout('mysql')).rejects.toBeInstanceOf(MonitorMetricLayoutContractError); + }); + + it('puts the complete canonical layout document with its expected revision', async () => { + const payload = { + schemaVersion: 1 as const, + mode: 'custom' as const, + columns: 12 as const, + items: layout().items, + historyDock: { collapsed: false, height: 18 }, + expectedRevision: 'layout-r1' + }; + transport.apiMessagePut.mockResolvedValue({ ...layout(), revision: 'layout-r2', historyDock: payload.historyDock }); + + await expect(saveMonitorMetricLayout('mysql', payload)).resolves.toMatchObject({ + revision: 'layout-r2', + historyDock: payload.historyDock + }); + + expect(transport.apiMessagePut).toHaveBeenCalledWith('/api/metrics/layout/mysql', payload); + }); + + it.each([null, layout('redis')])('rejects a missing or mismatched save confirmation', async response => { + transport.apiMessagePut.mockResolvedValue(response); + + await expect( + saveMonitorMetricLayout('mysql', { + schemaVersion: 1, + mode: 'custom', + columns: 12, + items: layout().items, + historyDock: layout().historyDock, + expectedRevision: 'layout-r1' + }) + ).rejects.toBeInstanceOf(MonitorMetricLayoutContractError); + }); + + it('encodes the expected revision on reset', async () => { + transport.apiMessageDelete.mockResolvedValue(undefined); + + await resetMonitorMetricLayout('mysql/custom', 'layout r1/+'); + + expect(transport.apiMessageDelete).toHaveBeenCalledWith( + '/api/metrics/layout/mysql%2Fcustom?expectedRevision=layout+r1%2F%2B' + ); + }); +}); + +function layout(application = 'mysql') { + return { + application, + revision: 'layout-r1', + schemaVersion: 1 as const, + mode: 'custom' as const, + columns: 12 as const, + items: [{ group: 'basic', x: 0, y: 0, w: 12 as const, h: 12, collapsed: false, order: 0 }], + historyDock: { collapsed: false, height: 12 } + }; +} diff --git a/web-app/src/features/monitor/api/monitor-metric-layout-api.ts b/web-app/src/features/monitor/api/monitor-metric-layout-api.ts index ea72daa232..461619c5ad 100644 --- a/web-app/src/features/monitor/api/monitor-metric-layout-api.ts +++ b/web-app/src/features/monitor/api/monitor-metric-layout-api.ts @@ -8,7 +8,7 @@ import { apiMessageDelete, apiMessageGet, apiMessagePut } from '@/core/http/api-message'; import type { MonitorMetricLayoutDocument } from '../model/monitor-metric-layout-model'; -import { parseMonitorMetricLayout } from './monitor-metric-layout-schema'; +import { MonitorMetricLayoutContractError, parseMonitorMetricLayout } from './monitor-metric-layout-schema'; export type MonitorMetricLayoutSavePayload = MonitorMetricLayoutDocument & { expectedRevision: string }; @@ -18,14 +18,25 @@ function buildMonitorMetricLayoutPath(application: string) { export async function loadMonitorMetricLayout(application: string, signal?: AbortSignal) { const value = await apiMessageGet(buildMonitorMetricLayoutPath(application), signal ? { signal } : undefined); - return parseMonitorMetricLayout(value); + return parseMatchingMonitorMetricLayout(value, application); } export async function saveMonitorMetricLayout(application: string, payload: MonitorMetricLayoutSavePayload) { - return parseMonitorMetricLayout(await apiMessagePut(buildMonitorMetricLayoutPath(application), payload)); + const layout = parseMatchingMonitorMetricLayout( + await apiMessagePut(buildMonitorMetricLayoutPath(application), payload), + application + ); + if (!layout) throw new MonitorMetricLayoutContractError(); + return layout; } export async function resetMonitorMetricLayout(application: string, expectedRevision: string) { const params = new URLSearchParams({ expectedRevision }); await apiMessageDelete(`${buildMonitorMetricLayoutPath(application)}?${params.toString()}`); } + +function parseMatchingMonitorMetricLayout(value: unknown, application: string) { + const layout = parseMonitorMetricLayout(value); + if (layout && layout.application !== application) throw new MonitorMetricLayoutContractError(); + return layout; +} diff --git a/web-app/src/features/monitor/components/monitor-list-results.tsx b/web-app/src/features/monitor/components/monitor-list-results.tsx index 89aebcfa3b..ec2847fc6a 100644 --- a/web-app/src/features/monitor/components/monitor-list-results.tsx +++ b/web-app/src/features/monitor/components/monitor-list-results.tsx @@ -80,7 +80,11 @@ export function MonitorListResults({ return <OperationalStatePanel kind="error" title={t('common.routeError.description')} />; const rowSelection: TableRowSelection<MonitorListRow> = { selectedRowKeys: selectedIds, - getCheckboxProps: row => ({ disabled: operating || isMonitorRowDisappeared(row) }), + getTitleCheckboxProps: () => ({ 'aria-label': t('monitorActions.selectAll') }), + getCheckboxProps: row => ({ + 'aria-label': t('monitorActions.selectOne', { name: row.name }), + disabled: operating || isMonitorRowDisappeared(row) + }), onChange: keys => actions.selectIds(keys.flatMap(key => (typeof key === 'number' ? [key] : []))) }; const selectionProps = canSelect ? { rowSelection } : {}; diff --git a/web-app/src/features/monitor/components/monitor-list-view.test.tsx b/web-app/src/features/monitor/components/monitor-list-view.test.tsx index 27d79bbe53..64b97f9a3e 100644 --- a/web-app/src/features/monitor/components/monitor-list-view.test.tsx +++ b/web-app/src/features/monitor/components/monitor-list-view.test.tsx @@ -244,6 +244,21 @@ describe('MonitorListView evidence states', () => { expect(actions.selectIds).not.toHaveBeenCalled(); }); + it('labels bulk and row selection controls with their exact scope', () => { + renderView({ + monitors: { + kind: 'ready', + records: [{ id: 7, name: 'checkout-api', app: 'website', instance: 'prod', status: 1 }], + total: 1 + } + }); + + expect(screen.getByRole('checkbox', { name: i18n.t('monitorActions.selectAll') })).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: i18n.t('monitorActions.selectOne', { name: 'checkout-api' }) }) + ).toBeInTheDocument(); + }); + it('preserves endpoint copy, application drilldown, and discovery target semantics', () => { const copyInstance = vi.fn(); const changeApp = vi.fn(); diff --git a/web-app/src/features/monitor/components/monitor-metric-workbench.test.tsx b/web-app/src/features/monitor/components/monitor-metric-workbench.test.tsx index e34d90ac24..b2ceda7e94 100644 --- a/web-app/src/features/monitor/components/monitor-metric-workbench.test.tsx +++ b/web-app/src/features/monitor/components/monitor-metric-workbench.test.tsx @@ -317,6 +317,26 @@ describe('MonitorMetricWorkbench', () => { expect(screen.queryByRole('button', { name: i18n.t('monitorMetrics.expandHistory') })).not.toBeInTheDocument(); }); + it('offers one compact cross-signal menu containing only authoritative signal bindings', async () => { + const value = controller({ + selectedHistoryChart: historyChart('summary.value', { kind: 'loading', rows: [] }), + investigationSignals: ['metrics', 'logs'] + }); + renderWorkbench(value); + + fireEvent.click(screen.getByRole('button', { name: i18n.t('monitorMetrics.investigation.open') })); + fireEvent.click(await screen.findByRole('menuitem', { name: i18n.t('explore.signals.logs') })); + + expect(value.actions.openInvestigationSignal).toHaveBeenCalledWith('logs'); + expect(screen.queryByRole('menuitem', { name: i18n.t('explore.signals.traces') })).not.toBeInTheDocument(); + }); + + it('does not render an investigation affordance without an authoritative binding', () => { + renderWorkbench(controller({ selectedHistoryChart: historyChart('summary.value', { kind: 'loading', rows: [] }) })); + + expect(screen.queryByRole('button', { name: i18n.t('monitorMetrics.investigation.open') })).not.toBeInTheDocument(); + }); + it('integrates layout editing into the dashboard command bar and places history before realtime panels', () => { renderWorkbench(controller({ selectedHistoryChart: historyChart('summary.value', { kind: 'loading', rows: [] }) })); @@ -329,6 +349,32 @@ describe('MonitorMetricWorkbench', () => { expect(screen.queryByRole('button', { name: 'summary' })).not.toBeInTheDocument(); }); + it.each([ + ['ArrowUp', 8, 8], + ['ArrowDown', 20, 20], + ['ArrowUp', 12, 11], + ['ArrowDown', 12, 13] + ] as const)('resizes the editable history dock with %s and clamps it to the saved range', (key, height, expected) => { + const value = controller({ + selectedHistoryChart: historyChart('summary.value', { kind: 'loading', rows: [] }), + layout: { + ...controller().state.layout, + editing: true, + layout: { + ...controller().state.layout.layout, + historyDock: { collapsed: false, height } + } + } + }); + renderWorkbench(value); + + fireEvent.keyDown(screen.getByRole('separator', { name: i18n.t('monitorMetrics.layout.resizeHistory') }), { + key + }); + + expect(value.actions.layout.changeHistoryDock).toHaveBeenCalledWith({ collapsed: false, height: expected }); + }); + it('pauses and resumes shared live refresh without hiding current values', () => { const value = controller(); const rendered = renderWorkbench(value); @@ -369,6 +415,7 @@ function controller( hasMoreRealtimeGroups: false, historyAvailability: { kind: 'available' }, historyCharts: [], + investigationSignals: [], hasMoreHistoryCharts: false, realtime: { kind: 'ready', rows: [metricRow('value', '12')] }, historical: { kind: 'empty', rows: [] }, @@ -400,6 +447,7 @@ function controller( setHistoryChartRange: vi.fn(), setHistoryChartMode: vi.fn(), refreshHistoryChart: vi.fn(), + openInvestigationSignal: vi.fn(), loadMoreHistoryCharts: vi.fn(), refresh: vi.fn(), layout: { diff --git a/web-app/src/features/monitor/components/monitor-realtime-layout-grid.test.tsx b/web-app/src/features/monitor/components/monitor-realtime-layout-grid.test.tsx index f2e0431b88..70234bfe48 100644 --- a/web-app/src/features/monitor/components/monitor-realtime-layout-grid.test.tsx +++ b/web-app/src/features/monitor/components/monitor-realtime-layout-grid.test.tsx @@ -5,7 +5,7 @@ * The ASF licenses this file to You under the Apache License, Version 2.0. */ -import { cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -13,7 +13,13 @@ import type { MonitorMetricWorkbenchController } from '../model/monitor-detail-m import type { MonitorMetricLayoutActions, MonitorMetricLayoutState } from '../model/monitor-metric-layout-model'; import { MonitorRealtimeLayoutGrid } from './monitor-realtime-layout-grid'; -const gridHarness = vi.hoisted(() => ({ width: 876 })); +const gridHarness = vi.hoisted(() => ({ + width: 876, + onDragStop: undefined as + undefined | ((layout: Array<{ i: string; x: number; y: number; w: number; h: number }>) => void), + onResizeStop: undefined as + undefined | ((layout: Array<{ i: string; x: number; y: number; w: number; h: number }>) => void) +})); vi.mock('react-grid-layout', () => ({ default: ({ @@ -21,33 +27,45 @@ vi.mock('react-grid-layout', () => ({ dragConfig, gridConfig, layout, + onDragStop, + onResizeStop, resizeConfig }: { children: ReactNode; dragConfig: { enabled: boolean; handle?: string; cancel?: string }; gridConfig: { cols: number }; - layout: Array<{ i: string; x: number; w: number }>; + layout: Array<{ i: string; x: number; y: number; w: number; h: number }>; + onDragStop: (layout: Array<{ i: string; x: number; y: number; w: number; h: number }>) => void; + onResizeStop: (layout: Array<{ i: string; x: number; y: number; w: number; h: number }>) => void; resizeConfig: { enabled: boolean }; - }) => ( - <div - data-testid="grid-layout" - data-cols={gridConfig.cols} - data-drag-enabled={dragConfig.enabled} - data-drag-handle={dragConfig.handle} - data-drag-cancel={dragConfig.cancel} - data-resize-enabled={resizeConfig.enabled} - data-layout={JSON.stringify(layout)} - > - {children} - </div> - ), + }) => { + gridHarness.onDragStop = onDragStop; + gridHarness.onResizeStop = onResizeStop; + return ( + <div + data-testid="grid-layout" + data-cols={gridConfig.cols} + data-drag-enabled={dragConfig.enabled} + data-drag-handle={dragConfig.handle} + data-drag-cancel={dragConfig.cancel} + data-resize-enabled={resizeConfig.enabled} + data-layout={JSON.stringify(layout)} + > + {children} + </div> + ); + }, useContainerWidth: () => ({ width: gridHarness.width, containerRef: { current: null }, mounted: true }), verticalCompactor: {} })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + gridHarness.onDragStop = undefined; + gridHarness.onResizeStop = undefined; +}); describe('MonitorRealtimeLayoutGrid', () => { it('opens a canonical desktop editing canvas at an ordinary tablet-width work surface', () => { @@ -89,9 +107,29 @@ describe('MonitorRealtimeLayoutGrid', () => { expect.objectContaining({ i: 'cache', x: 3, w: 3 }) ]); }); + + it.each(['onDragStop', 'onResizeStop'] as const)( + 'forwards %s geometry through the canonical snap boundary', + callback => { + const actions = layoutActions(); + renderGrid(layoutState({ editing: true }), actions); + + act(() => + gridHarness[callback]?.([ + { i: 'basic', x: 4, y: 0, w: 5, h: 13 }, + { i: 'cache', x: 0, y: 0, w: 4, h: 10 } + ]) + ); + + expect(actions.changeItems).toHaveBeenCalledWith([ + { group: 'cache', x: 0, y: 0, w: 4, h: 10, collapsed: false, order: 0 }, + { group: 'basic', x: 4, y: 0, w: 6, h: 13, collapsed: false, order: 1 } + ]); + } + ); }); -function renderGrid(state: MonitorMetricLayoutState) { +function renderGrid(state: MonitorMetricLayoutState, actions = layoutActions()) { const groups = [ { group: 'basic' }, { group: 'cache' } @@ -99,7 +137,7 @@ function renderGrid(state: MonitorMetricLayoutState) { render( <MonitorRealtimeLayoutGrid state={state} - actions={layoutActions()} + actions={actions} groups={groups} renderGroup={group => <article>{group.group}</article>} /> diff --git a/web-app/src/features/monitor/components/monitor-selected-history-controls.tsx b/web-app/src/features/monitor/components/monitor-selected-history-controls.tsx index 4e6486de96..7ab7d9f8a5 100644 --- a/web-app/src/features/monitor/components/monitor-selected-history-controls.tsx +++ b/web-app/src/features/monitor/components/monitor-selected-history-controls.tsx @@ -5,8 +5,8 @@ * The ASF licenses this file to You under the Apache License, Version 2.0. */ -import { ReloadOutlined } from '@ant-design/icons'; -import { Button, Select } from 'antd'; +import { DownOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Select } from 'antd'; import { useTranslation } from 'react-i18next'; import { @@ -19,10 +19,12 @@ import { MonitorSegmentedSwitch } from './monitor-segmented-switch'; export function SelectedHistoryControls({ chart, - actions + actions, + investigationSignals }: { chart: MonitorHistoryChart; actions: MonitorMetricWorkbenchController['actions']; + investigationSignals: MonitorMetricWorkbenchController['state']['investigationSignals']; }) { const { t } = useTranslation(); const aggregated = chart.interval; @@ -52,6 +54,36 @@ export function SelectedHistoryControls({ aria-label={t('monitorMetrics.refreshHistory')} onClick={() => actions.refreshHistoryChart(chart.metric.key)} /> + <InvestigationMenu signals={investigationSignals} actions={actions} /> </div> ); } + +function InvestigationMenu({ + signals, + actions +}: { + signals: MonitorMetricWorkbenchController['state']['investigationSignals']; + actions: MonitorMetricWorkbenchController['actions']; +}) { + const { t } = useTranslation(); + if (signals.length === 0) return null; + return ( + <Dropdown + trigger={['click']} + menu={{ + items: signals.map(signal => ({ key: signal, label: t(`explore.signals.${signal}`) })), + onClick: item => actions.openInvestigationSignal(item.key as (typeof signals)[number]) + }} + > + <Button + size="small" + icon={<DownOutlined />} + iconPosition="end" + aria-label={t('monitorMetrics.investigation.open')} + > + {t('monitorMetrics.investigation.open')} + </Button> + </Dropdown> + ); +} diff --git a/web-app/src/features/monitor/components/monitor-selected-history-tray.tsx b/web-app/src/features/monitor/components/monitor-selected-history-tray.tsx index 9fa326ef3b..6578234720 100644 --- a/web-app/src/features/monitor/components/monitor-selected-history-tray.tsx +++ b/web-app/src/features/monitor/components/monitor-selected-history-tray.tsx @@ -88,7 +88,7 @@ function SelectedHistoryHeader({ </span> <time>{formatMetricTime(current.time)}</time> </div> - <SelectedHistoryControls chart={chart} actions={actions} /> + <SelectedHistoryControls chart={chart} actions={actions} investigationSignals={state.investigationSignals} /> </header> ); } diff --git a/web-app/src/features/monitor/controller/monitor-metric-workbench-actions.ts b/web-app/src/features/monitor/controller/monitor-metric-workbench-actions.ts index 646f81b40b..87d5ca13e0 100644 --- a/web-app/src/features/monitor/controller/monitor-metric-workbench-actions.ts +++ b/web-app/src/features/monitor/controller/monitor-metric-workbench-actions.ts @@ -8,6 +8,7 @@ import type { Monitor } from '../model/monitor-contract'; import type { MonitorMetricWorkbenchController } from '../model/monitor-detail-model'; import type { useMonitorFavoriteMutation } from './use-monitor-favorite-mutation'; +import type { useMonitorInvestigation } from './use-monitor-investigation'; import type { useMonitorMetricData } from './use-monitor-metric-data'; import type { useMonitorMetricSources } from './use-monitor-metric-sources'; @@ -21,6 +22,7 @@ export function buildWorkbenchActions(input: { realtimeSelection: ReturnType<typeof useMonitorMetricSources>['realtimeSelection']; historySelection: ReturnType<typeof useMonitorMetricSources>['historySelection']; refreshDetail: () => void; + investigation: ReturnType<typeof useMonitorInvestigation>; }) { const selectedGroup = input.metric?.group ?? ''; return { @@ -42,6 +44,7 @@ export function buildWorkbenchActions(input: { loadMoreHistoryCharts: input.historySelection.loadMore, refresh: () => { input.refreshDetail(); + void input.investigation.refetch(); refreshMonitorMetricQueries( input.queries, Boolean(input.monitor && (input.metric || input.realtimeGroups.length > 0)), diff --git a/web-app/src/features/monitor/controller/monitor-metric-workbench-result.ts b/web-app/src/features/monitor/controller/monitor-metric-workbench-result.ts index e9f8c93d2e..96d23b44da 100644 --- a/web-app/src/features/monitor/controller/monitor-metric-workbench-result.ts +++ b/web-app/src/features/monitor/controller/monitor-metric-workbench-result.ts @@ -10,6 +10,7 @@ import type { MonitorMetricHistory, MonitorMetricWorkbenchController } from '../model/monitor-detail-model'; +import type { SignalKind } from '@/shared/query-context'; type MetricUrlActions = Pick<MonitorMetricWorkbenchController['actions'], 'setMetric' | 'setHistory'>; @@ -27,6 +28,7 @@ type MonitorMetricWorkbenchResultInput = { historyAvailability: MonitorMetricWorkbenchController['state']['historyAvailability']; historyCharts: MonitorMetricWorkbenchController['state']['historyCharts']; selectedHistoryChart?: MonitorMetricWorkbenchController['state']['selectedHistoryChart']; + investigationSignals: SignalKind[]; hasMoreHistoryCharts: boolean; realtime: MonitorMetricWorkbenchController['state']['realtime']; historical: MonitorMetricWorkbenchController['state']['historical']; @@ -43,6 +45,7 @@ type MonitorMetricWorkbenchResultInput = { setHistoryChartMode: MonitorMetricWorkbenchController['actions']['setHistoryChartMode']; refreshHistoryChart: MonitorMetricWorkbenchController['actions']['refreshHistoryChart']; loadMoreHistoryCharts: MonitorMetricWorkbenchController['actions']['loadMoreHistoryCharts']; + openInvestigationSignal: MonitorMetricWorkbenchController['actions']['openInvestigationSignal']; refresh: () => void; }; @@ -68,6 +71,7 @@ function buildWorkbenchState(input: MonitorMetricWorkbenchResultInput): MonitorM historyAvailability: input.historyAvailability, historyCharts: input.historyCharts, selectedHistoryChart: input.selectedHistoryChart, + investigationSignals: input.investigationSignals, hasMoreHistoryCharts: input.hasMoreHistoryCharts, realtime: input.realtime, historical: input.historical, @@ -88,6 +92,7 @@ function buildWorkbenchActions(input: MonitorMetricWorkbenchResultInput): Monito setHistoryChartMode: input.setHistoryChartMode, refreshHistoryChart: input.refreshHistoryChart, loadMoreHistoryCharts: input.loadMoreHistoryCharts, + openInvestigationSignal: input.openInvestigationSignal, refresh: input.refresh, layout: input.layoutActions }; diff --git a/web-app/src/features/monitor/controller/monitor-query-keys.ts b/web-app/src/features/monitor/controller/monitor-query-keys.ts index c437242732..6c1cdfd312 100644 --- a/web-app/src/features/monitor/controller/monitor-query-keys.ts +++ b/web-app/src/features/monitor/controller/monitor-query-keys.ts @@ -39,6 +39,7 @@ export const monitorQueryKeys = { ] as const, apps: (locale: string) => [...rootKey, 'apps', locale] as const, detail: (id: number | undefined) => [...rootKey, 'detail', id] as const, + investigation: (id: number | undefined) => [...rootKey, 'investigation', id] as const, importTasks: () => [...rootKey, 'import-tasks'] as const, importTask: (taskId: string | null) => [...rootKey, 'import-tasks', 'detail', taskId] as const, collectors: () => [...rootKey, 'collectors'] as const, diff --git a/web-app/src/features/monitor/controller/use-monitor-investigation.ts b/web-app/src/features/monitor/controller/use-monitor-investigation.ts new file mode 100644 index 0000000000..8bb2204ad0 --- /dev/null +++ b/web-app/src/features/monitor/controller/use-monitor-investigation.ts @@ -0,0 +1,58 @@ +/* + * 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. + */ + +import { skipToken, useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import type { SignalKind } from '@/shared/query-context'; +import { loadMonitorInvestigationBinding } from '../api/monitor-api'; +import type { Monitor } from '../model/monitor-contract'; +import { + monitorDetailRefreshInterval, + type MonitorDetailRefreshSeconds, + type MonitorHistoryChart +} from '../model/monitor-detail-model'; +import { + buildMonitorInvestigationSignalPath, + createMonitorInvestigation, + monitorInvestigationWindow +} from '../model/monitor-investigation-model'; +import { monitorQueryKeys } from './monitor-query-keys'; + +export function useMonitorInvestigation( + monitor: Monitor | undefined, + chart: MonitorHistoryChart | undefined, + refreshSeconds: MonitorDetailRefreshSeconds +) { + const navigate = useNavigate(); + const query = useQuery({ + queryKey: monitorQueryKeys.investigation(monitor?.id), + queryFn: monitor ? ({ signal }) => loadMonitorInvestigationBinding(monitor.id, signal) : skipToken, + refetchInterval: monitor ? monitorDetailRefreshInterval(refreshSeconds) : false, + retry: false + }); + const binding = query.data?.monitorId === monitor?.id ? query.data : undefined; + return { + signals: binding ? [...binding.signals] : [], + open: (signal: SignalKind) => { + if (!monitor || !binding || !chart || !binding.signals.includes(signal)) return; + try { + const window = monitorInvestigationWindow( + chart.history, + Date.now(), + Intl.DateTimeFormat().resolvedOptions().timeZone + ); + const investigation = createMonitorInvestigation(monitor, binding, window); + const path = buildMonitorInvestigationSignalPath(investigation, signal); + if (path) void navigate(path); + } catch { + // Invalid local time or stale identity must not create a partial handoff. + } + }, + refetch: query.refetch + }; +} diff --git a/web-app/src/features/monitor/controller/use-monitor-metric-layout-command-owner.ts b/web-app/src/features/monitor/controller/use-monitor-metric-layout-command-owner.ts new file mode 100644 index 0000000000..6daedf7342 --- /dev/null +++ b/web-app/src/features/monitor/controller/use-monitor-metric-layout-command-owner.ts @@ -0,0 +1,103 @@ +/* + * 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. + */ + +import { useLayoutEffect, useRef, useState, type RefObject } from 'react'; + +export type MonitorMetricLayoutCommandOwner = Readonly<{ + application: string; + epoch: number; +}>; + +export function useMonitorMetricLayoutCommandOwner(application: string | undefined) { + const [source, setSource] = useState({ application, epoch: 0 }); + let currentSource = source; + if (source.application !== application) { + currentSource = { application, epoch: source.epoch + 1 }; + setSource(currentSource); + } + const currentOwner = application ? { application, epoch: currentSource.epoch } : null; + const activeOwnerRef = useRef<MonitorMetricLayoutCommandOwner | null>(null); + const currentOwnerRef = useRef<MonitorMetricLayoutCommandOwner | null>(currentOwner); + const [activeOwner, setActiveOwner] = useState<MonitorMetricLayoutCommandOwner | null>(null); + const commands = useOwnerCommands(currentOwnerRef); + + useLayoutEffect(() => { + currentOwnerRef.current = currentOwner; + if (activeOwnerRef.current && !sameOwner(activeOwnerRef.current, currentOwner)) { + activeOwnerRef.current = null; + } + }); + + const isCurrent = (owner: MonitorMetricLayoutCommandOwner) => + sameOwner(owner, currentOwnerRef.current) && sameOwner(owner, activeOwnerRef.current); + const beginEdit = () => { + if (!currentOwner) return false; + activeOwnerRef.current = currentOwner; + setActiveOwner(currentOwner); + return true; + }; + const cancelEdit = () => { + activeOwnerRef.current = null; + setActiveOwner(null); + }; + const clearEdit = (owner: MonitorMetricLayoutCommandOwner) => { + if (!isCurrent(owner)) return false; + cancelEdit(); + return true; + }; + return { + activeOwner, + currentOwner, + editing: sameOwner(activeOwner, currentOwner), + saving: currentOwner ? commands.pendingKeys.has(ownerKey(currentOwner)) : false, + beginEdit, + cancelEdit, + clearEdit, + isCurrent, + beginCommand: commands.beginCommand, + endCommand: commands.endCommand + }; +} + +function useOwnerCommands(currentOwnerRef: RefObject<MonitorMetricLayoutCommandOwner | null>) { + const pendingKeysRef = useRef<Set<string>>(new Set()); + const [pendingKeys, setPendingKeys] = useState<ReadonlySet<string>>(new Set()); + const beginCommand = (owner: MonitorMetricLayoutCommandOwner) => { + const key = ownerKey(owner); + if (!sameOwner(owner, currentOwnerRef.current) || pendingKeysRef.current.has(key)) return false; + pendingKeysRef.current = new Set(pendingKeysRef.current).add(key); + setPendingKeys(pendingKeysRef.current); + return true; + }; + const endCommand = (owner: MonitorMetricLayoutCommandOwner) => { + const key = ownerKey(owner); + if (!pendingKeysRef.current.has(key)) return; + const next = new Set(pendingKeysRef.current); + next.delete(key); + pendingKeysRef.current = next; + setPendingKeys(next); + }; + return { pendingKeys, beginCommand, endCommand }; +} + +function ownerKey(owner: MonitorMetricLayoutCommandOwner) { + return `${owner.epoch}:${owner.application}`; +} + +function sameOwner(left: MonitorMetricLayoutCommandOwner | null, right: MonitorMetricLayoutCommandOwner | null) { + return Boolean(left && right && left.application === right.application && left.epoch === right.epoch); +} diff --git a/web-app/src/features/monitor/controller/use-monitor-metric-layout-controller.test.tsx b/web-app/src/features/monitor/controller/use-monitor-metric-layout-controller.test.tsx index 8750b0260e..7ba22a8e2a 100644 --- a/web-app/src/features/monitor/controller/use-monitor-metric-layout-controller.test.tsx +++ b/web-app/src/features/monitor/controller/use-monitor-metric-layout-controller.test.tsx @@ -81,6 +81,44 @@ describe('useMonitorMetricLayoutController', () => { expect(result.current.state.revision).toBe('layout-r2'); }); + it('persists changed panel geometry and history dock through the confirmed readback', async () => { + const changedItems = [ + { group: 'basic', x: 0, y: 0, w: 8, h: 14, collapsed: false, order: 0 }, + { group: 'status', x: 8, y: 0, w: 4, h: 12, collapsed: false, order: 1 } + ]; + const savedReadback = { + ...saved(), + revision: 'layout-r2', + items: changedItems, + historyDock: { collapsed: false, height: 18 } + }; + api.loadMonitorMetricLayout.mockResolvedValue(saved()); + api.saveMonitorMetricLayout.mockResolvedValue(savedReadback); + const { result } = renderHook(() => useMonitorMetricLayoutController('mysql', ['basic', 'status']), { + wrapper: wrapper() + }); + await waitFor(() => expect(result.current.state.readState).toBe('ready')); + + act(() => result.current.actions.beginEdit()); + act(() => { + result.current.actions.changeItems(changedItems); + result.current.actions.changeHistoryDock({ collapsed: false, height: 18 }); + }); + await act(async () => result.current.actions.save()); + + expect(api.saveMonitorMetricLayout).toHaveBeenCalledWith('mysql', { + schemaVersion: 1, + mode: 'custom', + columns: 12, + items: changedItems, + historyDock: { collapsed: false, height: 18 }, + expectedRevision: 'layout-r1' + }); + await waitFor(() => expect(result.current.state.editing).toBe(false)); + expect(result.current.state.layout.items).toEqual(changedItems); + expect(result.current.state.layout.historyDock).toEqual({ collapsed: false, height: 18 }); + }); + it('keeps the draft open and reloads canonical state after a cross-tab conflict', async () => { api.loadMonitorMetricLayout.mockResolvedValue(saved()); api.saveMonitorMetricLayout.mockRejectedValue(new ApiMessageError('redacted', { status: 409 })); @@ -96,12 +134,108 @@ describe('useMonitorMetricLayoutController', () => { expect(notifications.warning).toHaveBeenCalledWith('monitorMetrics.layout.conflict'); expect(api.loadMonitorMetricLayout).toHaveBeenCalledTimes(2); }); + + it('does not let an earlier application save clear a later application draft', async () => { + const save = deferred<ReturnType<typeof saved>>(); + api.loadMonitorMetricLayout.mockImplementation((application: string) => Promise.resolve(saved(application))); + api.saveMonitorMetricLayout.mockReturnValue(save.promise); + const { result, rerender } = renderHook( + ({ application }) => useMonitorMetricLayoutController(application, ['basic', 'status']), + { initialProps: { application: 'mysql' }, wrapper: wrapper() } + ); + await waitFor(() => expect(result.current.state.readState).toBe('ready')); + + act(() => result.current.actions.beginEdit()); + let pendingSave!: Promise<void>; + act(() => { + pendingSave = result.current.actions.save(); + }); + await waitFor(() => expect(result.current.state.saving).toBe(true)); + + rerender({ application: 'redis' }); + await waitFor(() => expect(result.current.state.revision).toBe('layout-r1-redis')); + act(() => result.current.actions.beginEdit()); + act(() => + result.current.actions.changeItems([ + { group: 'basic', x: 0, y: 0, w: 12, h: 16, collapsed: false, order: 0 }, + { group: 'status', x: 0, y: 16, w: 12, h: 10, collapsed: false, order: 1 } + ]) + ); + + save.resolve({ ...saved('mysql'), revision: 'layout-r2-mysql' }); + await act(async () => pendingSave); + + expect(result.current.state.editing).toBe(true); + expect(result.current.state.layout.items[0]).toMatchObject({ group: 'basic', w: 12, h: 16 }); + expect(notifications.success).not.toHaveBeenCalled(); + }); + + it('does not let an earlier save clear a new draft after an application ABA transition', async () => { + const save = deferred<ReturnType<typeof saved>>(); + api.loadMonitorMetricLayout.mockImplementation((application: string) => Promise.resolve(saved(application))); + api.saveMonitorMetricLayout.mockReturnValue(save.promise); + const { result, rerender } = renderHook( + ({ application }) => useMonitorMetricLayoutController(application, ['basic', 'status']), + { initialProps: { application: 'mysql' }, wrapper: wrapper() } + ); + await waitFor(() => expect(result.current.state.readState).toBe('ready')); + + act(() => result.current.actions.beginEdit()); + let pendingSave!: Promise<void>; + act(() => { + pendingSave = result.current.actions.save(); + }); + rerender({ application: 'redis' }); + await waitFor(() => expect(result.current.state.revision).toBe('layout-r1-redis')); + rerender({ application: 'mysql' }); + await waitFor(() => expect(result.current.state.revision).toBe('layout-r1')); + act(() => result.current.actions.beginEdit()); + + save.resolve({ ...saved('mysql'), revision: 'layout-r2-mysql' }); + await act(async () => pendingSave); + + expect(result.current.state.editing).toBe(true); + expect(notifications.success).not.toHaveBeenCalled(); + }); + + it('does not let an earlier application reset clear a later application draft', async () => { + const reset = deferred<void>(); + api.loadMonitorMetricLayout.mockImplementation((application: string) => Promise.resolve(saved(application))); + api.resetMonitorMetricLayout.mockReturnValue(reset.promise); + const { result, rerender } = renderHook( + ({ application }) => useMonitorMetricLayoutController(application, ['basic', 'status']), + { initialProps: { application: 'mysql' }, wrapper: wrapper() } + ); + await waitFor(() => expect(result.current.state.readState).toBe('ready')); + + let pendingReset!: Promise<void>; + act(() => { + pendingReset = result.current.actions.reset(); + }); + await waitFor(() => expect(result.current.state.saving).toBe(true)); + rerender({ application: 'redis' }); + await waitFor(() => expect(result.current.state.revision).toBe('layout-r1-redis')); + act(() => result.current.actions.beginEdit()); + act(() => + result.current.actions.changeItems([ + { group: 'basic', x: 0, y: 0, w: 12, h: 16, collapsed: false, order: 0 }, + { group: 'status', x: 0, y: 16, w: 12, h: 10, collapsed: false, order: 1 } + ]) + ); + + reset.resolve(undefined); + await act(async () => pendingReset); + + expect(result.current.state.editing).toBe(true); + expect(result.current.state.layout.items[0]).toMatchObject({ group: 'basic', w: 12, h: 16 }); + expect(notifications.success).not.toHaveBeenCalled(); + }); }); -function saved() { +function saved(application = 'mysql') { return { - application: 'mysql', - revision: 'layout-r1', + application, + revision: application === 'mysql' ? 'layout-r1' : `layout-r1-${application}`, schemaVersion: 1 as const, mode: 'custom' as const, columns: 12 as const, @@ -113,6 +247,14 @@ function saved() { }; } +function deferred<T>() { + let resolve!: (value: T) => void; + const promise = new Promise<T>(value => { + resolve = value; + }); + return { promise, resolve }; +} + function wrapper() { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); return function Wrapper({ children }: PropsWithChildren) { diff --git a/web-app/src/features/monitor/controller/use-monitor-metric-layout-controller.ts b/web-app/src/features/monitor/controller/use-monitor-metric-layout-controller.ts index 2ed521d02b..552a236d90 100644 --- a/web-app/src/features/monitor/controller/use-monitor-metric-layout-controller.ts +++ b/web-app/src/features/monitor/controller/use-monitor-metric-layout-controller.ts @@ -27,35 +27,34 @@ import { type MonitorMetricLayoutItem } from '../model/monitor-metric-layout-model'; import { monitorQueryKeys } from './monitor-query-keys'; +import { useMonitorMetricLayoutCommandOwner } from './use-monitor-metric-layout-command-owner'; export function useMonitorMetricLayoutController(application: string | undefined, groups: string[]) { const resource = useLayoutResource(application); - const [editingApplication, setEditingApplication] = useState<string | null>(null); - const [saving, setSaving] = useState(false); const [draft, setDraft] = useState<MonitorMetricLayoutDocument | null>(null); - const editing = application !== undefined && editingApplication === application; + const ownership = useMonitorMetricLayoutCommandOwner(application); const canonical = useMemo(() => mergeMonitorMetricLayout(resource.data, groups), [groups, resource.data]); - const layout = editing && draft ? draft : canonical; - const commandInput = { application, resource, saving, setSaving, setEditingApplication, setDraft }; + const layout = ownership.editing && draft ? draft : canonical; + const commandInput = { resource, ownership, setDraft }; const save = useSaveLayoutCommand({ ...commandInput, draft }); const reset = useResetLayoutCommand(commandInput); return { state: { readState: readState(resource.error, resource.isPending), - editing, - saving, + editing: ownership.editing, + saving: ownership.saving, revision: resource.data?.revision ?? 'missing', hasSavedLayout: Boolean(resource.data), layout }, actions: { beginEdit: () => { + if (!ownership.beginEdit()) return; setDraft({ ...canonical, mode: 'custom', items: canonical.items.map(item => ({ ...item })) }); - setEditingApplication(application ?? null); }, cancelEdit: () => { - setEditingApplication(null); + ownership.cancelEdit(); setDraft(null); }, changeItems: (items: MonitorMetricLayoutItem[]) => @@ -79,11 +78,8 @@ function useLayoutResource(application: string | undefined) { } type LayoutCommandInput = { - application: string | undefined; resource: ReturnType<typeof useLayoutResource>; - saving: boolean; - setSaving: Dispatch<SetStateAction<boolean>>; - setEditingApplication: Dispatch<SetStateAction<string | null>>; + ownership: ReturnType<typeof useMonitorMetricLayoutCommandOwner>; setDraft: Dispatch<SetStateAction<MonitorMetricLayoutDocument | null>>; }; @@ -92,22 +88,28 @@ function useSaveLayoutCommand(input: LayoutCommandInput & { draft: MonitorMetric const { message } = App.useApp(); const queryClient = useQueryClient(); return async () => { - if (!input.application || !input.draft || input.saving) return; - input.setSaving(true); + const owner = input.ownership.activeOwner; + if (!owner || !input.draft || !input.ownership.isCurrent(owner) || !input.ownership.beginCommand(owner)) return; try { - const saved = await saveMonitorMetricLayout(input.application, { - ...input.draft, + const saved = await saveMonitorMetricLayout(owner.application, { + schemaVersion: input.draft.schemaVersion, mode: 'custom', + columns: input.draft.columns, + items: input.draft.items, + historyDock: input.draft.historyDock, expectedRevision: input.resource.data?.revision ?? 'missing' }); - queryClient.setQueryData(monitorQueryKeys.layout(input.application), saved); - input.setEditingApplication(null); - input.setDraft(null); - void message.success(t('monitorMetrics.layout.saved')); + queryClient.setQueryData(monitorQueryKeys.layout(owner.application), saved); + if (input.ownership.clearEdit(owner)) { + input.setDraft(null); + void message.success(t('monitorMetrics.layout.saved')); + } } catch (error) { - await reportLayoutCommandError(error, input.resource.refetch, message, t); + if (input.ownership.isCurrent(owner)) { + await reportLayoutCommandError(error, input.resource.refetch, message, t); + } } finally { - input.setSaving(false); + input.ownership.endCommand(owner); } }; } @@ -117,18 +119,21 @@ function useResetLayoutCommand(input: LayoutCommandInput) { const { message } = App.useApp(); const queryClient = useQueryClient(); return async () => { - if (!input.application || input.saving) return; - input.setSaving(true); + const owner = input.ownership.currentOwner; + if (!owner || !input.ownership.beginCommand(owner)) return; try { - if (input.resource.data) await resetMonitorMetricLayout(input.application, input.resource.data.revision); - queryClient.setQueryData(monitorQueryKeys.layout(input.application), null); - input.setEditingApplication(null); - input.setDraft(null); - void message.success(t('monitorMetrics.layout.resetDone')); + if (input.resource.data) await resetMonitorMetricLayout(owner.application, input.resource.data.revision); + queryClient.setQueryData(monitorQueryKeys.layout(owner.application), null); + if (input.ownership.isCurrent(owner)) { + if (input.ownership.clearEdit(owner)) input.setDraft(null); + void message.success(t('monitorMetrics.layout.resetDone')); + } } catch (error) { - await reportLayoutCommandError(error, input.resource.refetch, message, t); + if (input.ownership.isCurrent(owner)) { + await reportLayoutCommandError(error, input.resource.refetch, message, t); + } } finally { - input.setSaving(false); + input.ownership.endCommand(owner); } }; } diff --git a/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.test.tsx b/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.test.tsx index 901d1f02d3..6d2678b252 100644 --- a/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.test.tsx +++ b/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.test.tsx @@ -29,6 +29,7 @@ import { defaultMonitorDetailRefreshSeconds } from '../model/monitor-detail-mode const api = vi.hoisted(() => ({ loadFavoriteMetrics: vi.fn(), loadHistoryMetric: vi.fn(), + loadMonitorInvestigationBinding: vi.fn(), loadMonitorMetricCatalog: vi.fn(), loadRealtimeMetric: vi.fn(), updateFavoriteMetric: vi.fn() @@ -64,6 +65,7 @@ describe('useMonitorMetricWorkbenchController', () => { vi.clearAllMocks(); runtimeStatus.useRuntimeStatusController.mockReturnValue(runtimeStatusEvidence('available')); api.loadMonitorMetricCatalog.mockResolvedValue(catalog()); + api.loadMonitorInvestigationBinding.mockResolvedValue(undefined); api.loadFavoriteMetrics.mockResolvedValue([]); api.loadRealtimeMetric.mockResolvedValue({ fields: [], valueRows: [] }); api.loadHistoryMetric.mockResolvedValue({ values: {} }); @@ -116,6 +118,52 @@ describe('useMonitorMetricWorkbenchController', () => { ); }); + it('opens only an advertised signal with the selected exact history window and canonical identity', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000); + api.loadMonitorInvestigationBinding.mockResolvedValue({ + monitorId: 7, + entityId: 17, + entityType: 'service', + serviceName: 'checkout', + serviceNamespace: 'commerce', + environment: 'production', + signals: ['metrics', 'logs'] + }); + try { + const view = renderController(monitor(), [], '/monitors/7?metric=summary.value&history=1h'); + await waitFor(() => + expect(view.result.current.controller.state.investigationSignals).toEqual(['metrics', 'logs']) + ); + + act(() => view.result.current.controller.actions.openInvestigationSignal('logs')); + + const params = new URLSearchParams(view.result.current.location.search); + expect(view.result.current.location.pathname).toBe('/explore'); + expect(params.get('signal')).toBe('logs'); + expect(params.get('monitorId')).toBe('7'); + expect(params.get('entityId')).toBe('17'); + expect(params.get('serviceName')).toBe('checkout'); + expect(params.get('start')).toBe(String(1_800_000_000_000 - 60 * 60_000)); + expect(params.get('end')).toBe('1800000000000'); + expect(params.get('timeZone')).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); + + act(() => view.result.current.controller.actions.openInvestigationSignal('traces')); + expect(view.result.current.location.search).toBe(`?${params.toString()}`); + expect(api.loadMonitorInvestigationBinding).toHaveBeenCalledWith(7, expect.any(AbortSignal)); + } finally { + now.mockRestore(); + } + }); + + it('keeps the cross-signal handoff absent without an authoritative binding', async () => { + const view = renderController(monitor(), [], '/monitors/7'); + + await waitFor(() => expect(api.loadMonitorInvestigationBinding).toHaveBeenCalledOnce()); + expect(view.result.current.controller.state.investigationSignals).toEqual([]); + act(() => view.result.current.controller.actions.openInvestigationSignal('logs')); + expect(view.result.current.location.pathname).toBe('/monitors/7'); + }); + it('keeps embedded realtime groups available when the history catalog is unavailable', async () => { api.loadMonitorMetricCatalog.mockRejectedValue(new ApiMessageError('offline', { status: 503 })); api.loadFavoriteMetrics.mockRejectedValue(new ApiMessageError('offline', { status: 503 })); diff --git a/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.ts b/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.ts index 3ed6765562..bf0cbb4d66 100644 --- a/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.ts +++ b/web-app/src/features/monitor/controller/use-monitor-metric-workbench-controller.ts @@ -27,6 +27,7 @@ import { buildWorkbenchActions } from './monitor-metric-workbench-actions'; import { useMonitorMetricData } from './use-monitor-metric-data'; import { useMonitorMetricLayoutController } from './use-monitor-metric-layout-controller'; import { useMonitorMetricSources } from './use-monitor-metric-sources'; +import { useMonitorInvestigation } from './use-monitor-investigation'; type MonitorMetricWorkbenchOptions = { notifications?: Notifications; @@ -68,6 +69,7 @@ function useMonitorMetricContext( const layout = useMonitorMetricLayoutController(monitorLayoutApplication(monitor), realtimeSelection.names); const historyCharts = buildHistoryChartEvidence(historySelection, queries); const selectedHistoryChart = buildSelectedHistoryChart(metric, historySelection, queries); + const investigation = useMonitorInvestigation(monitor, selectedHistoryChart, options.refreshControl.refreshSeconds); const actions = buildWorkbenchActions({ monitor, metric, @@ -77,7 +79,8 @@ function useMonitorMetricContext( favoriteMutation, realtimeSelection, historySelection, - refreshDetail: options.refreshDetail + refreshDetail: options.refreshDetail, + investigation }); const sources = { catalog, history, metric, metricKey, urlActions, queries, realtimeSelection, historySelection }; const evidence = { favorite, favoriteCollection, historical, historySupported, realtime }; @@ -90,6 +93,7 @@ function useMonitorMetricContext( layout, historyCharts, selectedHistoryChart, + investigation, actions }; } @@ -112,6 +116,7 @@ function composeMonitorMetricWorkbench( historyAvailability: context.historySelection.availability, historyCharts: context.historyCharts, selectedHistoryChart: context.selectedHistoryChart, + investigationSignals: context.investigation.signals, hasMoreHistoryCharts: context.historySelection.hasMore, realtime: context.realtime, historical: context.historical, @@ -119,6 +124,7 @@ function composeMonitorMetricWorkbench( layoutActions: context.layout.actions, refreshControl, urlActions: context.urlActions, + openInvestigationSignal: context.investigation.open, ...context.actions }); } diff --git a/web-app/src/features/monitor/model/monitor-detail-model.ts b/web-app/src/features/monitor/model/monitor-detail-model.ts index 4e22be569a..5a2661bca0 100644 --- a/web-app/src/features/monitor/model/monitor-detail-model.ts +++ b/web-app/src/features/monitor/model/monitor-detail-model.ts @@ -16,6 +16,7 @@ */ import type { RemotePayloadState } from '@/shared/remote-state'; +import type { SignalKind } from '@/shared/query-context'; import type { MonitorDetail, @@ -152,6 +153,7 @@ export type MonitorMetricWorkbenchController = { historyAvailability: MonitorHistoryAvailability; historyCharts: MonitorHistoryChart[]; selectedHistoryChart?: MonitorHistoryChart | undefined; + investigationSignals: SignalKind[]; hasMoreHistoryCharts: boolean; realtime: MonitorMetricRowsEvidence<ReturnType<typeof monitorRealtimeRows>[number]>; historical: MonitorMetricRowsEvidence<ReturnType<typeof monitorHistoryRows>[number]>; @@ -169,6 +171,7 @@ export type MonitorMetricWorkbenchController = { setHistoryChartRange: (metricKey: string, value: MonitorMetricHistory) => void; setHistoryChartMode: (metricKey: string, interval: boolean) => void; refreshHistoryChart: (metricKey: string) => void; + openInvestigationSignal: (signal: SignalKind) => void; loadMoreHistoryCharts: () => void; refresh: () => void; layout: MonitorMetricLayoutActions; diff --git a/web-app/src/features/monitor/model/monitor-investigation-model.test.ts b/web-app/src/features/monitor/model/monitor-investigation-model.test.ts new file mode 100644 index 0000000000..839cbb024a --- /dev/null +++ b/web-app/src/features/monitor/model/monitor-investigation-model.test.ts @@ -0,0 +1,112 @@ +/* + * 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. + */ + +import { describe, expect, it } from 'vitest'; + +import type { Monitor } from './monitor-contract'; +import { + buildMonitorInvestigationSignalPath, + createMonitorInvestigation, + monitorInvestigationWindow, + type MonitorInvestigationBinding +} from './monitor-investigation-model'; + +const monitor: Monitor = { + id: 42, + name: 'Checkout MySQL', + app: 'mysql', + instance: '10.0.0.8:3306', + status: 1 +}; + +const binding: MonitorInvestigationBinding = { + monitorId: 42, + entityId: 7, + entityType: 'service', + serviceName: 'checkout', + serviceNamespace: 'commerce', + environment: 'production', + signals: ['metrics', 'logs', 'traces'] as const +}; + +describe('monitor investigation handoff', () => { + it('keeps authoritative Monitor and Entity identity with one exact timezone-bound window', () => { + const investigation = createMonitorInvestigation(monitor, binding, { + from: 1_723_454_400_000, + to: 1_723_456_200_000, + timeZone: 'Asia/Shanghai' + }); + + expect(investigation).toEqual({ + context: { + entityId: '7', + monitorId: '42', + serviceName: 'checkout', + serviceNamespace: 'commerce', + environment: 'production', + instance: '10.0.0.8:3306' + }, + window: { + from: 1_723_454_400_000, + to: 1_723_456_200_000, + timeZone: 'Asia/Shanghai' + }, + signals: ['metrics', 'logs', 'traces'] + }); + expect(buildMonitorInvestigationSignalPath(investigation, 'logs')).toBe( + '/explore?signal=logs&entityId=7&monitorId=42&serviceName=checkout' + + '&serviceNamespace=commerce&environment=production&instance=10.0.0.8%3A3306' + + '&start=1723454400000&end=1723456200000&timeZone=Asia%2FShanghai' + ); + }); + + it('does not invent a signal link that authoritative binding evidence did not advertise', () => { + const investigation = createMonitorInvestigation( + monitor, + { ...binding, signals: ['metrics'] }, + { from: 1_000, to: 2_000, timeZone: 'UTC' } + ); + + expect(buildMonitorInvestigationSignalPath(investigation, 'metrics')).toContain('signal=metrics'); + expect(buildMonitorInvestigationSignalPath(investigation, 'logs')).toBeUndefined(); + }); + + it.each([ + ['30m', 30 * 60_000], + ['1h', 60 * 60_000], + ['6h', 6 * 60 * 60_000], + ['24h', 24 * 60 * 60_000], + ['1W', 7 * 24 * 60 * 60_000], + ['4W', 28 * 24 * 60 * 60_000], + ['12W', 84 * 24 * 60 * 60_000] + ] as const)('turns the visible %s history range into one immutable exact window', (history, duration) => { + expect(monitorInvestigationWindow(history, 1_800_000_000_000, 'Asia/Shanghai')).toEqual({ + from: 1_800_000_000_000 - duration, + to: 1_800_000_000_000, + timeZone: 'Asia/Shanghai' + }); + }); + + it.each([ + [ + { ...binding, monitorId: 41 }, + { from: 1_000, to: 2_000, timeZone: 'UTC' } + ], + [ + { ...binding, entityId: 0 }, + { from: 1_000, to: 2_000, timeZone: 'UTC' } + ], + [ + { ...binding, serviceName: ' ' }, + { from: 1_000, to: 2_000, timeZone: 'UTC' } + ], + [binding, { from: 2_000, to: 1_000, timeZone: 'UTC' }], + [binding, { from: 1_000, to: 2_000, timeZone: 'not/a-zone' }] + ])('fails closed on mismatched identity or invalid time evidence %#', (candidate, window) => { + expect(() => createMonitorInvestigation(monitor, candidate, window)).toThrow(); + }); +}); diff --git a/web-app/src/features/monitor/model/monitor-investigation-model.ts b/web-app/src/features/monitor/model/monitor-investigation-model.ts new file mode 100644 index 0000000000..8655a2b3e8 --- /dev/null +++ b/web-app/src/features/monitor/model/monitor-investigation-model.ts @@ -0,0 +1,118 @@ +/* + * 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. + */ + +import { + buildInvestigationSignalHandoffPath, + normalizeInvestigationTimeZone, + type InvestigationTimeWindow, + type QueryContext, + type SignalKind +} from '@/shared/query-context'; + +import type { Monitor } from './monitor-contract'; +import type { MonitorMetricHistory } from './monitor-detail-model'; + +export type MonitorInvestigationBinding = { + monitorId: number; + entityId: number; + entityType: 'service'; + serviceName: string; + serviceNamespace?: string | undefined; + environment?: string | undefined; + signals: readonly SignalKind[]; +}; + +export type MonitorInvestigation = { + context: QueryContext & { entityId: string; monitorId: string; serviceName: string }; + window: InvestigationTimeWindow; + signals: SignalKind[]; +}; + +const signalOrder: SignalKind[] = ['metrics', 'logs', 'traces']; +const historyDuration: Record<MonitorMetricHistory, number> = { + '30m': 30 * 60_000, + '1h': 60 * 60_000, + '6h': 6 * 60 * 60_000, + '24h': 24 * 60 * 60_000, + '1W': 7 * 24 * 60 * 60_000, + '4W': 28 * 24 * 60 * 60_000, + '12W': 84 * 24 * 60 * 60_000 +}; + +export function createMonitorInvestigation( + monitor: Monitor, + binding: MonitorInvestigationBinding, + window: InvestigationTimeWindow +): MonitorInvestigation { + requireBinding(monitor, binding); + const timeZone = normalizeInvestigationTimeZone(window.timeZone); + if (!validWindow(window) || !timeZone) throw new Error('Monitor investigation time evidence is invalid'); + const signals = canonicalSignals(binding.signals); + return { + context: { + entityId: String(binding.entityId), + monitorId: String(monitor.id), + serviceName: binding.serviceName.trim(), + ...(hasContent(binding.serviceNamespace) ? { serviceNamespace: binding.serviceNamespace!.trim() } : {}), + ...(hasContent(binding.environment) ? { environment: binding.environment!.trim() } : {}), + instance: monitor.instance.trim() + }, + window: { from: window.from, to: window.to, timeZone }, + signals + }; +} + +export function buildMonitorInvestigationSignalPath(investigation: MonitorInvestigation, signal: SignalKind) { + if (!investigation.signals.includes(signal)) return undefined; + return buildInvestigationSignalHandoffPath(signal, investigation.context, investigation.window); +} + +export function monitorInvestigationWindow( + history: MonitorMetricHistory, + to: number, + timeZone: string +): InvestigationTimeWindow { + const from = to - historyDuration[history]; + const normalizedTimeZone = normalizeInvestigationTimeZone(timeZone); + if (!Number.isSafeInteger(to) || to <= 0 || !Number.isSafeInteger(from) || from <= 0 || !normalizedTimeZone) { + throw new Error('Monitor investigation time evidence is invalid'); + } + return { from, to, timeZone: normalizedTimeZone }; +} + +function requireBinding(monitor: Monitor, binding: MonitorInvestigationBinding) { + if ( + !Number.isSafeInteger(monitor.id) || + monitor.id <= 0 || + binding.monitorId !== monitor.id || + !Number.isSafeInteger(binding.entityId) || + binding.entityId <= 0 || + binding.entityType !== 'service' || + !hasContent(binding.serviceName) || + !hasContent(monitor.instance) + ) { + throw new Error('Monitor investigation identity evidence is invalid'); + } +} + +function canonicalSignals(signals: readonly SignalKind[]) { + const available = new Set(signals); + if (available.size !== signals.length || signals.some(signal => !signalOrder.includes(signal))) { + throw new Error('Monitor investigation signal evidence is invalid'); + } + return signalOrder.filter(signal => available.has(signal)); +} + +function validWindow(window: InvestigationTimeWindow) { + return ( + Number.isSafeInteger(window.from) && Number.isSafeInteger(window.to) && window.from > 0 && window.from < window.to + ); +} + +function hasContent(value: string | undefined) { + return Boolean(value?.trim()); +} diff --git a/web-app/src/shared/query-context/query-context-model.test.ts b/web-app/src/shared/query-context/query-context-model.test.ts index 288fb81a2b..93e810b8aa 100644 --- a/web-app/src/shared/query-context/query-context-model.test.ts +++ b/web-app/src/shared/query-context/query-context-model.test.ts @@ -84,7 +84,7 @@ describe('shared query context model', () => { expect(first).not.toEqual(switched); expect(first).not.toEqual(refreshed); expect(queryContextScopeKey(checkout)).toBe( - '["","collector-east","checkout","commerce","prod","checkout-7d9","/checkout"]' + '["","","","collector-east","checkout","commerce","prod","checkout-7d9","/checkout"]' ); }); @@ -96,7 +96,7 @@ describe('shared query context model', () => { it('keeps fixed field order and stable defaults independently of object key order', () => { expect(queryContextScopeKey({ serviceName: 'checkout', collectorId: 'collector-east' })).toBe( - '["","collector-east","checkout","","","",""]' + '["","","","collector-east","checkout","","","",""]' ); }); }); diff --git a/web-app/src/shared/query-context/query-context-model.ts b/web-app/src/shared/query-context/query-context-model.ts index c4a753afcf..2ef4e951b9 100644 --- a/web-app/src/shared/query-context/query-context-model.ts +++ b/web-app/src/shared/query-context/query-context-model.ts @@ -9,6 +9,8 @@ import { isSensitiveFieldName } from '@/core/security/sensitive-field'; import { applicationRoutePaths } from '@/shared/navigation/app-paths'; export type QueryContext = { + entityId?: string | undefined; + monitorId?: string | undefined; intakeProfileId?: string | undefined; collectorId?: string | undefined; serviceName?: string | undefined; @@ -21,8 +23,11 @@ export type QueryContext = { export type QueryContextField = keyof QueryContext; export type SignalKind = 'metrics' | 'logs' | 'traces'; export type ExactTimeWindow = { from: number; to: number }; +export type InvestigationTimeWindow = ExactTimeWindow & { timeZone: string }; export const QUERY_CONTEXT_FIELDS = { + entityId: 'entityId', + monitorId: 'monitorId', intakeProfileId: 'intakeProfileId', collectorId: 'collectorId', serviceName: 'serviceName', @@ -92,6 +97,8 @@ export function buildSignalHandoffPath(signal: SignalKind, context: QueryContext requireExactWindow(window); rejectSensitiveRecord(context); const params = new URLSearchParams({ signal }); + append(params, 'entityId', context.entityId); + append(params, 'monitorId', context.monitorId); append(params, 'serviceName', context.serviceName); append(params, 'serviceNamespace', context.serviceNamespace); append(params, 'environment', context.environment); @@ -104,6 +111,35 @@ export function buildSignalHandoffPath(signal: SignalKind, context: QueryContext return `${applicationRoutePaths.explore}?${params.toString()}`; } +export function buildInvestigationSignalHandoffPath( + signal: SignalKind, + context: QueryContext, + window: InvestigationTimeWindow +) { + const timeZone = normalizeInvestigationTimeZone(window.timeZone); + if (!timeZone) throw new Error('Cross-signal investigation requires an IANA time zone'); + const url = new URL(buildSignalHandoffPath(signal, context, window), 'https://hertzbeat.local'); + url.searchParams.set('timeZone', timeZone); + return `${url.pathname}?${url.searchParams.toString()}`; +} + +export function normalizeInvestigationTimeZone(value: unknown) { + if (typeof value !== 'string') return undefined; + const normalized = value.trim(); + if (!normalized || normalized.length > 128 || [...normalized].some(isControlCharacter)) return undefined; + try { + new Intl.DateTimeFormat('en-US', { timeZone: normalized }).format(0); + return normalized; + } catch { + return undefined; + } +} + +function isControlCharacter(value: string) { + const code = value.charCodeAt(0); + return code <= 31 || code === 127; +} + export function scopedQueryKey( prefix: readonly unknown[], context: QueryContext, --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
