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]

Reply via email to