This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git

commit e0a9547339869b653cf4d2b86a6be3a86668b9cb
Author: dark <[email protected]>
AuthorDate: Sat Aug 29 23:30:16 2026 +0800

    fix(hubble): align distributed health signals
    
    - derive PD health from PD nodes only
    - reconcile Store direct probes with topology
    - keep source summaries, facts, and cards consistent
---
 .../hugegraph/service/HugeClientPoolService.java   |  10 +
 .../service/op/LiveOperationsCollector.java        | 315 +++++++++++++++++----
 .../service/op/LiveOperationsCollectorTest.java    | 124 +++++++-
 .../src/i18n/resources/en-US/modules/pages.json    |   4 +-
 .../src/i18n/resources/zh-CN/modules/pages.json    |   4 +-
 .../src/pages/Operations/Overview.test.js          |   3 +-
 .../hubble-fe/src/pages/Operations/components.js   |  12 +-
 .../src/pages/Operations/components.test.js        |  11 +-
 8 files changed, 403 insertions(+), 80 deletions(-)

diff --git 
a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java
 
b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java
index bb92abc6e..b627b2d32 100644
--- 
a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java
+++ 
b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java
@@ -117,6 +117,16 @@ public final class HugeClientPoolService {
         return getOrCreate(null, graphSpace, graph, token);
     }
 
+    public List<String> discoveredServerURLs() {
+        return new ArrayList<>(this.allAvailableURLs(null, null));
+    }
+
+    public HugeClient createDiscoveredServerClient(String url,
+                                                   String authContext,
+                                                   int timeout) {
+        return this.create(url, null, null, authContext, null, null, timeout);
+    }
+
     public HugeClient getOrCreate(String url, String graphSpace, String graph,
             String token) {
         // 去掉缓存,固定每个 request 分配一个 client
diff --git 
a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java
 
b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java
index c21f1be62..5d2dd9dc4 100644
--- 
a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java
+++ 
b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java
@@ -48,6 +48,7 @@ import org.springframework.stereotype.Service;
 import org.apache.hugegraph.config.HugeConfig;
 import org.apache.hugegraph.driver.HugeClient;
 import org.apache.hugegraph.options.HubbleOptions;
+import org.apache.hugegraph.service.HugeClientPoolService;
 import org.apache.hugegraph.service.op.OperationsModels.Node;
 import org.apache.hugegraph.service.op.OperationsModels.MetricStatus;
 import org.apache.hugegraph.service.op.OperationsModels.Snapshot;
@@ -78,9 +79,11 @@ public class LiveOperationsCollector implements 
OperationsCollector {
     private final ExecutorService storeExecutor;
     private final int storeDeadlineMillis;
     private final Set<String> storeAllowedTargets;
+    private final ServerClientProvider serverClients;
 
     @Autowired
-    public LiveOperationsCollector(HugeConfig config, ObjectMapper mapper) {
+    public LiveOperationsCollector(HugeConfig config, ObjectMapper mapper,
+                                   HugeClientPoolService clientPool) {
         this(config.get(HubbleOptions.PD_ENABLED),
              pdBase(config.get(HubbleOptions.SERVER_PROTOCOL),
                     config.get(HubbleOptions.PD_SERVER)),
@@ -98,7 +101,8 @@ public class LiveOperationsCollector implements 
OperationsCollector {
              config.get(HubbleOptions.OPERATIONS_STORE_THREADS),
              config.get(HubbleOptions.OPERATIONS_STORE_DEADLINE),
              new java.util.LinkedHashSet<>(config.get(
-                     HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS)));
+                     HubbleOptions.OPERATIONS_STORE_ALLOWED_TARGETS)),
+             serverClients(clientPool));
     }
 
     LiveOperationsCollector(boolean pdEnabled, String pdBase,
@@ -108,7 +112,7 @@ public class LiveOperationsCollector implements 
OperationsCollector {
                             OperationsPayloadParser parser, Clock clock) {
         this(pdEnabled, pdBase, pdUsername, pdPassword, storeUsername,
              storePassword, serverIdentity, http, parser, clock, 16, 5000,
-             defaultStoreAllowedTargets());
+             defaultStoreAllowedTargets(), null);
     }
 
     LiveOperationsCollector(boolean pdEnabled, String pdBase,
@@ -119,7 +123,7 @@ public class LiveOperationsCollector implements 
OperationsCollector {
                             int storeThreads, int storeDeadlineMillis) {
         this(pdEnabled, pdBase, pdUsername, pdPassword, storeUsername,
              storePassword, serverIdentity, http, parser, clock, storeThreads,
-             storeDeadlineMillis, defaultStoreAllowedTargets());
+             storeDeadlineMillis, defaultStoreAllowedTargets(), null);
     }
 
     LiveOperationsCollector(boolean pdEnabled, String pdBase,
@@ -129,6 +133,19 @@ public class LiveOperationsCollector implements 
OperationsCollector {
                             OperationsPayloadParser parser, Clock clock,
                             int storeThreads, int storeDeadlineMillis,
                             Set<String> storeAllowedTargets) {
+        this(pdEnabled, pdBase, pdUsername, pdPassword, storeUsername,
+             storePassword, serverIdentity, http, parser, clock, storeThreads,
+             storeDeadlineMillis, storeAllowedTargets, null);
+    }
+
+    LiveOperationsCollector(boolean pdEnabled, String pdBase,
+                            String pdUsername, String pdPassword,
+                            String storeUsername, String storePassword,
+                            String serverIdentity, OperationsHttpClient http,
+                            OperationsPayloadParser parser, Clock clock,
+                            int storeThreads, int storeDeadlineMillis,
+                            Set<String> storeAllowedTargets,
+                            ServerClientProvider serverClients) {
         if (storeThreads <= 0 || storeDeadlineMillis <= 0) {
             throw new IllegalArgumentException(
                       "Store metric collection limits must be positive");
@@ -140,6 +157,7 @@ public class LiveOperationsCollector implements 
OperationsCollector {
         this.storeUsername = storeUsername;
         this.storePassword = storePassword;
         this.serverIdentity = serverIdentity;
+        this.serverClients = serverClients;
         this.http = http;
         this.parser = parser;
         this.clock = clock;
@@ -192,45 +210,178 @@ public class LiveOperationsCollector implements 
OperationsCollector {
     private void collectServer(HugeClient client, boolean includeMetrics,
                                long now, Map<String, SourceStatus> sources,
                                List<Node> nodes) {
+        List<String> urls = this.discoveredServerURLs();
+        if (urls.isEmpty()) {
+            this.collectSingleServer(client, this.serverIdentity,
+                                     "HugeGraph Server", includeMetrics, now,
+                                     sources, nodes);
+            return;
+        }
+        int up = 0;
+        boolean partial = false;
+        String reason = null;
+        String authContext = client.getAuthContext();
+        List<Future<ServerResult>> futures;
         try {
-            String version = client.versionManager().getCoreVersion();
-            Map<String, Object> metrics = Collections.emptyMap();
-            Map<String, MetricStatus> metricStatuses = Collections.emptyMap();
-            String availability = "AVAILABLE";
-            String reason = null;
-            if (includeMetrics) {
-                metrics = new LinkedHashMap<>();
-                metricStatuses = new LinkedHashMap<>();
-                try {
-                    Map<String, Object> system = this.safeSystemMetrics(
-                                                 client.metrics().system());
-                    metrics.put("system", system);
-                    metricStatuses.put("system", availableMetric(now));
-                } catch (RuntimeException e) {
-                    availability = "PARTIAL";
-                    reason = metricReason(e);
-                    metricStatuses.put("system", metricStatus(e, now, false));
-                }
-                try {
-                    metrics.put("backend", this.safeBackendMetrics(
-                                client.metrics().backend()));
-                    metricStatuses.put("backend", availableMetric(now));
-                } catch (RuntimeException e) {
-                    availability = "PARTIAL";
-                    reason = mergeReason(reason, metricReason(e));
-                    metricStatuses.put("backend", metricStatus(e, now, false));
-                }
+            futures = this.storeExecutor.invokeAll(
+                    urls.stream().map(url ->
+                            (java.util.concurrent.Callable<ServerResult>) () ->
+                            this.collectDiscoveredServer(
+                                 url, authContext, includeMetrics, now))
+                        .collect(Collectors.toList()),
+                    this.storeDeadlineMillis, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            sources.put("server", unavailable("upstream_interrupted", now));
+            return;
+        }
+        for (int i = 0; i < urls.size(); i++) {
+            ServerResult result;
+            String url = urls.get(i);
+            try {
+                result = futures.get(i).get();
+            } catch (CancellationException e) {
+                result = ServerResult.failure(url, now, "upstream_deadline");
+            } catch (ExecutionException e) {
+                result = ServerResult.failure(url, now,
+                                              "upstream_unavailable");
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                result = ServerResult.failure(url, now,
+                                              "upstream_interrupted");
+            }
+            nodes.add(result.getNode());
+            if ("UP".equals(result.getNode().getStatus())) {
+                up++;
+            }
+            if (result.isPartial()) {
+                partial = true;
+                reason = mergeServerReason(reason, result.getReason());
             }
-            String id = stableId("server", this.serverIdentity);
-            nodes.add(new Node(id, "SERVER", "HugeGraph Server", null,
-                               version, "UP", now, metrics, metricStatuses));
-            sources.put("server", new SourceStatus(availability, "UP", now,
-                                                    now, true, false, reason));
+        }
+        String status = up == urls.size() ? "UP" :
+                        up == 0 ? "DOWN" : "DEGRADED";
+        String availability = up == 0 ? "UNAVAILABLE" :
+                              partial ? "PARTIAL" : "AVAILABLE";
+        sources.put("server", new SourceStatus(
+                    availability, status, now, up > 0 ? now : null,
+                    up > 0 && !partial, false, reason));
+    }
+
+    private ServerResult collectDiscoveredServer(String url,
+                                                  String authContext,
+                                                  boolean includeMetrics,
+                                                  long now) {
+        HugeClient server = null;
+        try {
+            server = this.serverClients.create(
+                     url, authContext,
+                     Math.max(1, this.storeDeadlineMillis / 1000));
+            return this.serverResult(
+                   server, url, serverName(url), includeMetrics, now);
+        } catch (RuntimeException e) {
+            return ServerResult.failure(url, now, metricReason(e));
+        } finally {
+            if (server != null) {
+                server.close();
+            }
+        }
+    }
+
+    private List<String> discoveredServerURLs() {
+        if (!this.pdEnabled || this.serverClients == null) {
+            return Collections.emptyList();
+        }
+        return this.serverClients.urls().stream()
+                .filter(url -> url != null && !url.trim().isEmpty())
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    private static ServerClientProvider serverClients(
+            HugeClientPoolService clientPool) {
+        return new ServerClientProvider() {
+            @Override
+            public List<String> urls() {
+                return clientPool.discoveredServerURLs();
+            }
+
+            @Override
+            public HugeClient create(String url, String authContext,
+                                     int timeout) {
+                return clientPool.createDiscoveredServerClient(
+                       url, authContext, timeout);
+            }
+        };
+    }
+
+    private void collectSingleServer(HugeClient client, String identity,
+                                     String name, boolean includeMetrics,
+                                     long now,
+                                     Map<String, SourceStatus> sources,
+                                     List<Node> nodes) {
+        try {
+            ServerResult result = this.serverResult(client, identity, name,
+                                                    includeMetrics, now);
+            nodes.add(result.getNode());
+            sources.put("server", new SourceStatus(
+                        result.isPartial() ? "PARTIAL" : "AVAILABLE",
+                        "UP", now, now, !result.isPartial(), false,
+                        result.getReason()));
         } catch (RuntimeException e) {
             sources.put("server", unavailable("upstream_unavailable", now));
         }
     }
 
+    private ServerResult serverResult(HugeClient client, String identity,
+                                      String name, boolean includeMetrics,
+                                      long now) {
+        String version = client.versionManager().getCoreVersion();
+        Map<String, Object> metrics = Collections.emptyMap();
+        Map<String, MetricStatus> metricStatuses = Collections.emptyMap();
+        boolean partial = false;
+        String reason = null;
+        if (includeMetrics) {
+            metrics = new LinkedHashMap<>();
+            metricStatuses = new LinkedHashMap<>();
+            try {
+                Map<String, Object> system = this.safeSystemMetrics(
+                                             client.metrics().system());
+                metrics.put("system", system);
+                metricStatuses.put("system", availableMetric(now));
+            } catch (RuntimeException e) {
+                partial = true;
+                reason = metricReason(e);
+                metricStatuses.put("system", metricStatus(e, now, false));
+            }
+            try {
+                metrics.put("backend", this.safeBackendMetrics(
+                            client.metrics().backend()));
+                metricStatuses.put("backend", availableMetric(now));
+            } catch (RuntimeException e) {
+                partial = true;
+                reason = mergeServerReason(reason, metricReason(e));
+                metricStatuses.put("backend", metricStatus(e, now, false));
+            }
+        }
+        Node node = new Node(stableId("server", identity), "SERVER", name,
+                             null, version, "UP", now, metrics,
+                             metricStatuses);
+        return new ServerResult(node, partial, reason);
+    }
+
+    private static String mergeServerReason(String current, String addition) {
+        if (current == null || current.equals(addition)) {
+            return addition;
+        }
+        return "server_metrics_partial";
+    }
+
+    private static String serverName(String url) {
+        return "HugeGraph Server " +
+               stableId("server", url).substring("server-".length());
+    }
+
     private void collectPd(boolean includeMetrics, long now,
                            Map<String, SourceStatus> sources,
                            List<Node> nodes, Map<String, Long> facts) {
@@ -259,11 +410,8 @@ public class LiveOperationsCollector implements 
OperationsCollector {
                                                               now);
                 this.mergeNodes(nodes, topology.getNodes());
                 facts.putAll(topology.getFacts());
-                pdStatus = available(this.moreSevereStatus(
-                                     topology.getStatus(),
-                                     this.nodeStatus(topology.getNodes(), 
"PD")),
-                                     now,
-                                     topology.getReason());
+                pdStatus = available(this.nodeStatus(topology.getNodes(), 
"PD"),
+                                     now);
                 clusterParsed = true;
             } catch (MalformedUpstreamException e) {
                 pdStatus = malformed(now);
@@ -289,6 +437,9 @@ public class LiveOperationsCollector implements 
OperationsCollector {
         if (includeMetrics && storesParsed) {
             storesStatus = this.collectStoreMetrics(stores, now, storesStatus,
                                                      nodes);
+            storesStatus = withStatus(storesStatus,
+                                      this.nodeStatus(nodes, "STORE"));
+            this.reconcileStoreFacts(nodes, facts);
         } else if (includeMetrics) {
             this.applyStoreMetricStatus(nodes, this.metricStatus(storesStatus,
                                                                  now));
@@ -321,19 +472,6 @@ public class LiveOperationsCollector implements 
OperationsCollector {
         return "DEGRADED";
     }
 
-    private String moreSevereStatus(String first, String second) {
-        if ("DOWN".equals(first) || "DOWN".equals(second)) {
-            return "DOWN";
-        }
-        if ("DEGRADED".equals(first) || "DEGRADED".equals(second)) {
-            return "DEGRADED";
-        }
-        if ("UNKNOWN".equals(first) || "UNKNOWN".equals(second)) {
-            return "UNKNOWN";
-        }
-        return "UP";
-    }
-
     private void reconcileStoreFacts(List<Node> nodes,
                                      Map<String, Long> facts) {
         long stores = nodes.stream()
@@ -576,8 +714,17 @@ public class LiveOperationsCollector implements 
OperationsCollector {
                 statuses.put(group, metricStatus(e, now, true));
             }
         }
-        return new StoreMetricResult(copyNode(job.getNode(), metrics, 
statuses),
-                                     successfulGroups, failureReason);
+        Node node = copyNode(job.getNode(), metrics, statuses);
+        if (successfulGroups == 0 && directStoreFailure(failureReason)) {
+            node = copyNodeWithStatus(node, "DOWN");
+        }
+        return new StoreMetricResult(node, successfulGroups, failureReason);
+    }
+
+    private static boolean directStoreFailure(String reason) {
+        return "upstream_unavailable".equals(reason) ||
+               "upstream_timeout".equals(reason) ||
+               "upstream_deadline".equals(reason);
     }
 
     private static StoreTarget storeTarget(String nodeId,
@@ -695,6 +842,21 @@ public class LiveOperationsCollector implements 
OperationsCollector {
                         node.getObservedAt(), metrics, statuses);
     }
 
+    private static Node copyNodeWithStatus(Node node, String status) {
+        return new Node(node.getId(), node.getType(), node.getName(),
+                        node.getRole(), node.getVersion(), status,
+                        node.getObservedAt(), node.getMetrics(),
+                        node.getMetricStatuses());
+    }
+
+    private static SourceStatus withStatus(SourceStatus source,
+                                           String status) {
+        return new SourceStatus(source.getAvailability(), status,
+                                source.getObservedAt(),
+                                source.getLastSuccessAt(), source.isFresh(),
+                                source.isStale(), source.getReason());
+    }
+
     private String get(String path) {
         URI target = URI.create(this.pdBase + path);
         return this.http.get(target, this.pdUsername, this.pdPassword);
@@ -835,6 +997,47 @@ public class LiveOperationsCollector implements 
OperationsCollector {
         return "upstream_unavailable";
     }
 
+    private static final class ServerResult {
+
+        private final Node node;
+        private final boolean partial;
+        private final String reason;
+
+        private ServerResult(Node node, boolean partial, String reason) {
+            this.node = node;
+            this.partial = partial;
+            this.reason = reason;
+        }
+
+        private Node getNode() {
+            return this.node;
+        }
+
+        private boolean isPartial() {
+            return this.partial;
+        }
+
+        private String getReason() {
+            return this.reason;
+        }
+
+        private static ServerResult failure(String url, long now,
+                                            String reason) {
+            Node node = new Node(stableId("server", url), "SERVER",
+                                 serverName(url), null, null, "DOWN", now,
+                                 Collections.emptyMap(),
+                                 Collections.emptyMap());
+            return new ServerResult(node, true, reason);
+        }
+    }
+
+    interface ServerClientProvider {
+
+        List<String> urls();
+
+        HugeClient create(String url, String authContext, int timeout);
+    }
+
     private static final class StoreTarget {
 
         private final URI uri;
diff --git 
a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java
 
b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java
index 91160cbc9..9869b963e 100644
--- 
a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java
+++ 
b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java
@@ -24,6 +24,7 @@ import java.nio.charset.StandardCharsets;
 import java.time.Clock;
 import java.time.Instant;
 import java.time.ZoneOffset;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -81,6 +82,46 @@ public class LiveOperationsCollectorTest {
         Mockito.verify(client, Mockito.never()).metrics();
     }
 
+    @Test
+    public void testPdModeCollectsEveryDiscoveredServer()
+           throws IOException {
+        HttpServer pd = pdServer(200, cluster(), 200, stores());
+        LiveOperationsCollector.ServerClientProvider servers =
+                new LiveOperationsCollector.ServerClientProvider() {
+            @Override
+            public java.util.List<String> urls() {
+                return Arrays.asList("http://server-a:8080";,
+                                     "http://server-b:8080";);
+            }
+
+            @Override
+            public HugeClient create(String url, String authContext,
+                                     int timeout) {
+                return serverClient();
+            }
+        };
+        HugeClient requestClient = serverClient();
+        Mockito.when(requestClient.getAuthContext()).thenReturn("Bearer 
token");
+        LiveOperationsCollector collector = collector(true, pd, servers);
+
+        Snapshot snapshot;
+        try {
+            snapshot = collector.collect(requestClient, false);
+        } finally {
+            collector.close();
+            pd.stop(0);
+        }
+
+        Assert.assertEquals(2L, snapshot.getNodes().stream()
+                                .filter(node -> 
"SERVER".equals(node.getType()))
+                                .count());
+        Assert.assertEquals("UP", snapshot.getSources().get("server")
+                                          .getStatus());
+        Assert.assertEquals("AVAILABLE",
+                            snapshot.getSources().get("server")
+                                    .getAvailability());
+    }
+
     @Test
     public void testNonPdModeIsDownWhenOnlySupportedSourceFails() {
         Snapshot snapshot = collector(false, null).collect(
@@ -162,7 +203,8 @@ public class LiveOperationsCollectorTest {
     }
 
     @Test
-    public void testPdDegradedStatusMakesOverallSnapshotDegraded() throws 
IOException {
+    public void testClusterWarningDoesNotOverrideHealthyPdNodes()
+           throws IOException {
         String degraded = cluster().replace("Cluster_OK", "Cluster_Warn");
         HttpServer pd = pdServer(200, degraded, 200, stores());
         Snapshot snapshot;
@@ -172,12 +214,13 @@ public class LiveOperationsCollectorTest {
             pd.stop(0);
         }
 
-        Assert.assertEquals("DEGRADED", snapshot.getStatus());
-        Assert.assertEquals("DEGRADED", 
snapshot.getSources().get("pd").getStatus());
+        Assert.assertEquals("UP", snapshot.getStatus());
+        Assert.assertEquals("UP", snapshot.getSources().get("pd").getStatus());
     }
 
     @Test
-    public void testPdNotReadyKeepsPdNodesUpAndOverallDegraded() throws 
IOException {
+    public void testPdNotReadyKeepsPdSourceAlignedWithNodes()
+           throws IOException {
         String notReady = cluster().replace("Cluster_OK", "Cluster_Not_Ready");
         HttpServer pd = pdServer(200, notReady, 200, stores());
         Snapshot snapshot;
@@ -190,9 +233,9 @@ public class LiveOperationsCollectorTest {
         OperationsModels.SourceStatus pdSource = 
snapshot.getSources().get("pd");
         Assert.assertEquals("AVAILABLE", pdSource.getAvailability());
         Assert.assertTrue(pdSource.isFresh());
-        Assert.assertEquals("DEGRADED", pdSource.getStatus());
-        Assert.assertEquals("cluster_not_ready", pdSource.getReason());
-        Assert.assertEquals("DEGRADED", snapshot.getStatus());
+        Assert.assertEquals("UP", pdSource.getStatus());
+        Assert.assertNull(pdSource.getReason());
+        Assert.assertEquals("UP", snapshot.getStatus());
         long pdCount = snapshot.getNodes().stream()
                                .filter(node -> "PD".equals(node.getType()))
                                .count();
@@ -260,7 +303,8 @@ public class LiveOperationsCollectorTest {
     }
 
     @Test
-    public void testPdNotReadyReasonSurvivesMetricsFailure() throws 
IOException {
+    public void testPdMetricsFailureDoesNotChangeHealthyNodeStatus()
+           throws IOException {
         String notReady = cluster().replace("Cluster_OK", "Cluster_Not_Ready");
         HttpServer pd = HttpServer.create(new InetSocketAddress(0), 0);
         String storePayload = stores().replace(
@@ -279,12 +323,13 @@ public class LiveOperationsCollectorTest {
         OperationsModels.SourceStatus source =
                 snapshot.getSources().get("pd");
         Assert.assertEquals("PARTIAL", source.getAvailability());
-        Assert.assertEquals("DEGRADED", source.getStatus());
-        Assert.assertEquals("cluster_not_ready", source.getReason());
+        Assert.assertEquals("UP", source.getStatus());
+        Assert.assertEquals("upstream_rejected", source.getReason());
     }
 
     @Test
-    public void testPdUnknownStatusMakesOverallSnapshotDegraded() throws 
IOException {
+    public void testUnknownClusterStateDoesNotOverrideHealthyPdNodes()
+           throws IOException {
         String unknown = cluster().replace("Cluster_OK", "Cluster_Starting");
         HttpServer pd = pdServer(200, unknown, 200, stores());
         Snapshot snapshot;
@@ -294,8 +339,8 @@ public class LiveOperationsCollectorTest {
             pd.stop(0);
         }
 
-        Assert.assertEquals("DEGRADED", snapshot.getStatus());
-        Assert.assertEquals("UNKNOWN", 
snapshot.getSources().get("pd").getStatus());
+        Assert.assertEquals("UP", snapshot.getStatus());
+        Assert.assertEquals("UP", snapshot.getSources().get("pd").getStatus());
     }
 
     @Test
@@ -305,7 +350,7 @@ public class LiveOperationsCollectorTest {
 
         Snapshot snapshot = collector.collect(serverClient(), false);
 
-        Assert.assertEquals("DEGRADED", 
snapshot.getSources().get("pd").getStatus());
+        Assert.assertEquals("UP", snapshot.getSources().get("pd").getStatus());
         Assert.assertEquals(0, http.leaderRequests());
     }
 
@@ -354,6 +399,34 @@ public class LiveOperationsCollectorTest {
         Assert.assertEquals(Long.valueOf(2000L), pdNode.getObservedAt());
     }
 
+    @Test
+    public void testDirectMetricFailureOverridesStaleStoreRegistration() {
+        RecordingHttpClient http = new RecordingHttpClient(
+                storesWithDifferentRestAddresses(),
+                targets("127.0.0.1:8520", "127.0.0.1:9520"));
+        http.unavailableAuthority("127.0.0.1:9520");
+        LiveOperationsCollector collector = collector(http, 4, 1000);
+        Snapshot snapshot;
+        try {
+            snapshot = collector.collect(serverClient(), true);
+        } finally {
+            collector.close();
+        }
+
+        Assert.assertEquals("UP",
+                            snapshot.getSources().get("pd").getStatus());
+        Assert.assertEquals("DEGRADED",
+                            snapshot.getSources().get("stores").getStatus());
+        Assert.assertEquals(Long.valueOf(2L),
+                            snapshot.getFacts().get("stores"));
+        Assert.assertEquals(Long.valueOf(1L),
+                            snapshot.getFacts().get("stores_up"));
+        Assert.assertEquals(1L, snapshot.getNodes().stream()
+                .filter(node -> "STORE".equals(node.getType()))
+                .filter(node -> "DOWN".equals(node.getStatus()))
+                .count());
+    }
+
     @Test
     public void testMalformedStoresKeepValidPdTopologyAndFacts()
            throws IOException {
@@ -818,6 +891,18 @@ public class LiveOperationsCollectorTest {
                         "http://127.0.0.1:"; + pd.getAddress().getPort()));
     }
 
+    private static LiveOperationsCollector collector(
+            boolean pdEnabled, HttpServer pd,
+            LiveOperationsCollector.ServerClientProvider servers) {
+        String pdBase = "http://127.0.0.1:"; + pd.getAddress().getPort();
+        return new LiveOperationsCollector(
+                pdEnabled, pdBase, "hubble", "secret", "store-hubble",
+                "store-secret", "server-under-test",
+                new OperationsHttpClient(1000, 1000, 8192),
+                new OperationsPayloadParser(new ObjectMapper()), CLOCK,
+                16, 5000, Collections.singleton(pdBase), servers);
+    }
+
     private static LiveOperationsCollector collector(RecordingHttpClient http,
                                                       int threads,
                                                       int deadlineMillis) {
@@ -992,6 +1077,7 @@ public class LiveOperationsCollectorTest {
         private final Set<String> metricAuthorities;
         private volatile long delayMillis;
         private volatile String delayAuthority;
+        private volatile String unavailableAuthority;
 
         private RecordingHttpClient(String stores, String targets) {
             super(1000, 1000, 8192);
@@ -1036,7 +1122,11 @@ public class LiveOperationsCollectorTest {
                 return "process_uptime_seconds{hg=\"pd\"} 12\n";
             }
             this.metrics.incrementAndGet();
-            this.metricAuthorities.add(OperationsHttpClient.authority(target));
+            String authority = OperationsHttpClient.authority(target);
+            this.metricAuthorities.add(authority);
+            if (authority.equals(this.unavailableAuthority)) {
+                throw new UpstreamRequestException("upstream_unavailable");
+            }
             int current = this.active.incrementAndGet();
             this.maximum.accumulateAndGet(current, Math::max);
             try {
@@ -1069,6 +1159,10 @@ public class LiveOperationsCollectorTest {
             this.delayMillis = delayMillis;
         }
 
+        private void unavailableAuthority(String authority) {
+            this.unavailableAuthority = authority;
+        }
+
         private int metricRequests() {
             return this.metrics.get();
         }
diff --git 
a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json 
b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json
index 4fd812f84..bee87bfe0 100644
--- a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json
+++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json
@@ -1291,12 +1291,12 @@
     "fresh": "Fresh",
     "observed_at": "Observed",
     "sources": "Source freshness",
-    "topology": "Service topology",
+    "topology": "HugeGraph Cluster Topology",
     "topology_view": "Topology",
     "node_list_view": "Node list",
     "cluster_nodes": "Cluster nodes",
     "logical_relationship": "Logical service relationship, not network 
traffic",
-    "topology_label": "Server, PD, and Store service topology",
+    "topology_label": "HugeGraph cluster topology",
     "attention_nodes": "Nodes needing attention",
     "attention_items": "Items needing attention",
     "all_nodes_healthy": "All discovered nodes are healthy",
diff --git 
a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json 
b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json
index e6ea0732b..855b6adfd 100644
--- a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json
+++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json
@@ -1291,12 +1291,12 @@
     "fresh": "数据新鲜",
     "observed_at": "观测时间",
     "sources": "来源新鲜度",
-    "topology": "服务拓扑图",
+    "topology": "HugeGraph 集群拓扑",
     "topology_view": "拓扑图",
     "node_list_view": "节点列表",
     "cluster_nodes": "集群节点",
     "logical_relationship": "表示逻辑服务关系,不表示网络流量",
-    "topology_label": "Server、PD 与 Store 服务拓扑",
+    "topology_label": "HugeGraph 集群拓扑",
     "attention_nodes": "需关注的节点",
     "attention_items": "需关注项",
     "all_nodes_healthy": "已发现的节点均健康",
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js 
b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js
index c3fe5fa9b..54f777df9 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js
@@ -355,7 +355,8 @@ test('uses explicit Chinese topology labels and a compact 
monitoring tool status
     renderOverview();
 
     expect(await screen.findByRole('radio', {name: 
'拓扑图'})).toBeInTheDocument();
-    expect(screen.getByRole('heading', {name: '服务拓扑图'})).toBeInTheDocument();
+    expect(screen.getByRole('heading', {name: 'HugeGraph 集群拓扑'}))
+        .toBeInTheDocument();
     expect(screen.getByRole('link', {name: 'Server 层'})).toBeInTheDocument();
     const tools = document.querySelector('.operations-header-tools');
     expect(tools).not.toHaveTextContent('Dashboard 不可用');
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js 
b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js
index 036d78954..2bd2c70b7 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js
@@ -61,6 +61,13 @@ const NODE_TYPE_LABELS = {
 
 const displayNodeType = type => NODE_TYPE_LABELS[type] ?? type ?? '—';
 
+const displayTopologyNodeName = node => {
+    if (node?.type !== 'SERVER') {
+        return node?.name;
+    }
+    return node.name?.replace(/^HugeGraph Server\b/, 'Server');
+};
+
 const displayHealthStatus = (status, t) => (
     status === 'DEGRADED' ? t('operations.status_degraded') : status
 );
@@ -269,6 +276,7 @@ const nodeRoleLabel = (node, t) => {
 
 const TierNode = ({node, returnState}) => {
     const {t} = useTranslation();
+    const name = displayTopologyNodeName(node);
     return (
         <Link
             className={[
@@ -279,12 +287,12 @@ const TierNode = ({node, returnState}) => {
             ].filter(Boolean).join(' ')}
             to={`/operations/nodes/${node.id}`}
             state={returnState}
-            aria-label={`${node.type} ${node.name} ${nodeRoleLabel(node, t)} ${
+            aria-label={`${node.type} ${name} ${nodeRoleLabel(node, t)} ${
                 displayHealthStatus(node.status, t)}`}
         >
             <TierIcon type={node.type} />
             <span className='operations-node-copy'>
-                <strong>{node.name}</strong>
+                <strong>{name}</strong>
                 <span>
                     {node.type === 'PD' && node.role === 'LEADER' && (
                         <CrownOutlined
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js 
b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js
index e3152cf89..562647b91 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js
@@ -32,7 +32,7 @@ test('localizes the topology accessible name', async () => {
         </MemoryRouter>
     );
 
-    expect(screen.getByLabelText('Server、PD 与 Store 
服务拓扑')).toBeInTheDocument();
+    expect(screen.getByLabelText('HugeGraph 集群拓扑')).toBeInTheDocument();
     expect(screen.queryByLabelText('Server PD Store 
topology')).not.toBeInTheDocument();
 });
 
@@ -73,7 +73,12 @@ test('uses semantic tier icons and keeps the PD leader on 
the visual axis', () =
     render(
         <MemoryRouter future={{v7_startTransition: true, v7_relativeSplatPath: 
true}}>
             <ClusterTopology nodes={[
-                {id: 'server-1', name: 'server-1', type: 'SERVER', status: 
'UP'},
+                {
+                    id: 'server-1',
+                    name: 'HugeGraph Server 123abc',
+                    type: 'SERVER',
+                    status: 'UP',
+                },
                 {id: 'pd-2', name: 'pd-2', type: 'PD', status: 'UP', role: 
'FOLLOWER'},
                 {id: 'pd-1', name: 'pd-1', type: 'PD', status: 'UP', role: 
'LEADER'},
                 {id: 'store-1', name: 'store-1', type: 'STORE', status: 'UP'},
@@ -83,6 +88,8 @@ test('uses semantic tier icons and keeps the PD leader on the 
visual axis', () =
     );
 
     expect(screen.getByLabelText('SERVER icon')).toBeInTheDocument();
+    expect(screen.getByText('Server 123abc')).toBeInTheDocument();
+    expect(screen.queryByText('HugeGraph Server 
123abc')).not.toBeInTheDocument();
     expect(screen.getAllByLabelText('PD icon')).toHaveLength(2);
     expect(screen.getByLabelText('STORE icon')).toBeInTheDocument();
     expect(screen.getByText('pd-1').closest('a')).toHaveClass('is-axis-node');

Reply via email to