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 238c402e4d0a4118123cf91065d2d4b3e577908c Author: dark <[email protected]> AuthorDate: Sun Aug 30 11:46:39 2026 +0800 fix(hubble): complete final closeout - unify responsive operations, GraphSpace, and node-detail states - preserve independent Server, PD, and Store health ownership - close account, session, capability, and compatibility gaps - refresh final documentation and cluster screenshot --- .../docs/images/showcase/operations-overview.jpg | Bin 152506 -> 149091 bytes .../hugegraph/controller/ConfigController.java | 5 +- .../hugegraph/controller/auth/LoginController.java | 3 + .../hugegraph/service/auth/AuthContextService.java | 33 +++- .../apache/hugegraph/service/auth/UserService.java | 11 +- .../service/op/DefaultOperationsDataService.java | 4 +- .../service/op/LiveOperationsCollector.java | 24 ++- .../service/auth/AuthContextServiceTest.java | 32 +++- .../op/DefaultOperationsDataServiceTest.java | 28 +++ .../service/op/LiveOperationsCollectorTest.java | 46 ++++- .../apache/hugegraph/unit/AuthSecurityTest.java | 4 + .../hugegraph/unit/ConfigControllerTest.java | 6 +- .../unit/UserServiceCompatibilityTest.java | 25 +-- .../src/i18n/resources/en-US/modules/pages.json | 23 ++- .../src/i18n/resources/zh-CN/modules/pages.json | 21 +- .../hubble-fe/src/pages/Account/EditLayer.js | 12 +- .../hubble-fe/src/pages/Account/SpaceAccess.js | 41 ++-- .../src/pages/Account/SpaceAccess.test.js | 110 ++++++++++- .../pages/Account/account-edit-recovery.test.js | 50 +++-- .../hubble-fe/src/pages/GraphSpace/Card.js | 11 +- .../hubble-fe/src/pages/GraphSpace/Card.test.js | 24 +++ .../hubble-fe/src/pages/GraphSpace/index.js | 4 +- .../src/pages/GraphSpace/index.module.scss | 43 +++++ .../hubble-fe/src/pages/Operations/NodeDetail.js | 211 +++++++++++---------- .../src/pages/Operations/NodeDetail.test.js | 171 ++++++++++++++--- .../hubble-fe/src/pages/Operations/Overview.js | 42 +++- .../src/pages/Operations/Overview.test.js | 43 ++++- .../hubble-fe/src/pages/Operations/components.js | 28 ++- .../src/pages/Operations/components.test.js | 26 +++ .../hubble-fe/src/pages/Operations/operations.scss | 36 +++- 30 files changed, 852 insertions(+), 265 deletions(-) diff --git a/hugegraph-hubble/docs/images/showcase/operations-overview.jpg b/hugegraph-hubble/docs/images/showcase/operations-overview.jpg index 3c6ad722a..6501a11e8 100644 Binary files a/hugegraph-hubble/docs/images/showcase/operations-overview.jpg and b/hugegraph-hubble/docs/images/showcase/operations-overview.jpg differ diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java index 2793fbb6a..18cab88de 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java @@ -70,8 +70,9 @@ public class ConfigController { capabilities.put("auth_enabled", this.authModeService.update( client.isServerAuthEnabled())); - capabilities.put("graph_create_enabled", true); - capabilities.put("cypher_enabled", true); + capabilities.put("graph_create_enabled", + client.supportsGraphCreate()); + capabilities.put("cypher_enabled", client.supportsCypher()); capabilities.put("server_capabilities_verified", true); return capabilities; } catch (RuntimeException ignored) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java index 3cdcf81be..e5dcc97f8 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java @@ -120,6 +120,9 @@ public class LoginController extends BaseController { this.setSession(Constant.PASSWORD_EXPIRE_AT_KEY, System.currentTimeMillis() + TOKEN_EXPIRE_SECONDS * 1000L); + } else { + this.delSession(Constant.PASSWORD_KEY); + this.delSession(Constant.PASSWORD_EXPIRE_AT_KEY); } return user; } catch (Throwable e) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java index cc2d45589..3886fc9e5 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java @@ -42,6 +42,7 @@ import org.apache.hugegraph.exception.ForbiddenException; import org.apache.hugegraph.exception.InternalException; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.op.OperationsCapabilityService; +import org.apache.hugegraph.service.space.GraphSpaceService; @Service public class AuthContextService { @@ -85,13 +86,16 @@ public class AuthContextService { private final HugeConfig config; private final UserService users; private final AuthModeService authMode; + private final GraphSpaceService graphSpaces; @Autowired public AuthContextService(HugeConfig config, UserService users, - AuthModeService authMode) { + AuthModeService authMode, + GraphSpaceService graphSpaces) { this.config = config; this.users = users; this.authMode = authMode; + this.graphSpaces = graphSpaces; } public Map<String, Object> context(HugeClient client, String username) { @@ -123,8 +127,10 @@ public class AuthContextService { boolean profileUpdate = client.supportsPersonalProfileUpdate(); List<String> writeGraphSpaces = pdEnabled ? - writeGraphSpaces(user, - permissionPresets) : + writeGraphSpaces( + user, permissionPresets, + this.graphSpaces + .listAnonymous(client)) : Collections.emptyList(); Set<String> capabilities = this.capabilities(pdEnabled, role, permissionPresets); @@ -156,11 +162,15 @@ public class AuthContextService { !client.supportsDefaultRole()) { return; } + if (!this.graphSpaces.isAuth(client, graphSpace)) { + return; + } UserEntity user = this.users.getpersonal(client, username); if (user.isSuperadmin() || contains(user.getAdminSpaces(), graphSpace) || - writeGraphSpaces(user, true).contains(graphSpace)) { + writeGraphSpaces(user, true, + Collections.emptyList()).contains(graphSpace)) { return; } throw new ForbiddenException( @@ -265,15 +275,22 @@ public class AuthContextService { } private static List<String> writeGraphSpaces(UserEntity user, - boolean permissionPresets) { + boolean permissionPresets, + Collection<String> publicSpaces) { if (user.isSuperadmin()) { return Collections.emptyList(); } - if (!permissionPresets) { - return sorted(user.getResSpaces()); - } Set<String> values = new TreeSet<>(); + if (publicSpaces != null) { + values.addAll(publicSpaces); + } + if (!permissionPresets) { + if (user.getResSpaces() != null) { + values.addAll(user.getResSpaces()); + } + return Collections.unmodifiableList(new ArrayList<>(values)); + } if (user.getGraphspacePermissions() != null) { for (Map<String, String> permission : user.getGraphspacePermissions()) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java index 18676daff..d1c448439 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java @@ -631,17 +631,16 @@ public class UserService extends AuthService { String username = previous.name(); boolean previousSuperAdmin = client.auth().listSuperAdmin().contains(username); try { - if (previousSuperAdmin && !userEntity.isSuperadmin()) { - client.auth().delSuperAdmin(username); - } - if (!previousSuperAdmin && userEntity.isSuperadmin()) { - client.auth().addSuperAdmin(username); - } client.auth().updateUser(user); this.graphSpaceUserService.applyPermissionPresets( client, userEntity.getName(), userEntity.getGraphspacePermissions(), userEntity.getPermissionPreset()); + if (!previousSuperAdmin && userEntity.isSuperadmin()) { + client.auth().addSuperAdmin(username); + } else if (previousSuperAdmin && !userEntity.isSuperadmin()) { + client.auth().delSuperAdmin(username); + } } catch (RuntimeException error) { this.restoreAccountProfile(client, previous, error); this.restoreSuperAdmin(client, username, previousSuperAdmin, error); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java index 180394eb0..4818abc8c 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java @@ -278,8 +278,10 @@ public class DefaultOperationsDataService implements OperationsDataService { } String status = "DOWN".equals(current.getStatus()) ? "DOWN" : "DEGRADED"; + String reason = current.getReason() != null ? current.getReason() : + "partial_refresh_failed"; return new Snapshot(status, current.getObservedAt(), true, - "partial_refresh_failed", sources, nodes, facts); + reason, sources, nodes, facts); } private void mergeFailedFacts(Map<String, Long> current, 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 a633f9fe7..b2ccb7503 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 @@ -198,13 +198,19 @@ public class LiveOperationsCollector implements OperationsCollector { List<Node> nodes = new ArrayList<>(); Map<String, Long> facts = new LinkedHashMap<>(); this.collectServer(client, includeMetrics, now, sources, nodes); + String clusterReason = null; if (this.pdEnabled) { - this.collectPd(includeMetrics, now, sources, nodes, facts); + clusterReason = this.collectPd(includeMetrics, now, sources, nodes, + facts); } else { sources.put("pd", unsupported()); sources.put("stores", unsupported()); } - return new Snapshot(this.overallStatus(sources), now, false, null, + String status = this.overallStatus(sources); + if (clusterReason != null && "UP".equals(status)) { + status = "DEGRADED"; + } + return new Snapshot(status, now, false, clusterReason, sources, nodes, facts); } @@ -220,6 +226,11 @@ public class LiveOperationsCollector implements OperationsCollector { return; } if (urls.isEmpty()) { + if (this.pdEnabled && this.serverClients != null) { + sources.put("server", unavailable( + "topology_fields_unavailable", now)); + return; + } this.collectSingleServer(client, this.serverIdentity, "HugeGraph Server", includeMetrics, now, sources, nodes); @@ -409,11 +420,12 @@ public class LiveOperationsCollector implements OperationsCollector { stableId("server", url).substring("server-".length()); } - private void collectPd(boolean includeMetrics, long now, - Map<String, SourceStatus> sources, - List<Node> nodes, Map<String, Long> facts) { + private String collectPd(boolean includeMetrics, long now, + Map<String, SourceStatus> sources, + List<Node> nodes, Map<String, Long> facts) { String cluster = null; String stores = null; + String clusterReason = null; SourceStatus pdStatus; SourceStatus storesStatus; try { @@ -437,6 +449,7 @@ public class LiveOperationsCollector implements OperationsCollector { now); this.mergeNodes(nodes, topology.getNodes()); facts.putAll(topology.getFacts()); + clusterReason = topology.getReason(); pdStatus = available(this.nodeStatus(topology.getNodes(), "PD"), now); clusterParsed = true; @@ -473,6 +486,7 @@ public class LiveOperationsCollector implements OperationsCollector { } sources.put("pd", pdStatus); sources.put("stores", storesStatus); + return clusterReason; } private String nodeStatus(List<Node> nodes, String type) { diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java index a697a84bb..30a76fec5 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java @@ -36,6 +36,7 @@ import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.UserEntity; import org.apache.hugegraph.exception.ForbiddenException; import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.space.GraphSpaceService; import org.apache.hugegraph.testutil.Assert; public class AuthContextServiceTest { @@ -189,6 +190,27 @@ public class AuthContextServiceTest { fixture.client, "alice", "space-a")); } + @Test + public void testPdUserRetainsWriteAccessToPublicGraphSpace() { + Fixture fixture = new Fixture(true); + UserEntity user = user(false, Collections.emptyList()); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user); + Mockito.when(fixture.graphSpaces.listAnonymous(fixture.client)) + .thenReturn(Collections.singletonList("public")); + Mockito.when(fixture.graphSpaces.isAuth(fixture.client, "public")) + .thenReturn(false); + + Map<String, Object> context = fixture.service.context(fixture.client, + "alice"); + + Assert.assertEquals(Collections.singletonList("public"), + scopes(context).get("write_graphspaces")); + fixture.service.requireGraphSpaceWrite(fixture.client, "alice", + "public"); + } + @Test public void testPdReadWriteUserCanWriteGraphSpaceResources() { Fixture fixture = new Fixture(true); @@ -342,6 +364,8 @@ public class AuthContextServiceTest { private final UserService users = Mockito.mock(UserService.class); private final AuthModeService authMode = Mockito.mock(AuthModeService.class); + private final GraphSpaceService graphSpaces = + Mockito.mock(GraphSpaceService.class); private final AuthContextService service; private Fixture(boolean pdEnabled) { @@ -349,8 +373,14 @@ public class AuthContextServiceTest { .thenReturn(pdEnabled); Mockito.when(this.client.supportsPersonalProfileUpdate()) .thenReturn(true); + Mockito.when(this.graphSpaces.listAnonymous(this.client)) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpaces.isAuth( + Mockito.eq(this.client), Mockito.anyString())) + .thenReturn(true); this.service = new AuthContextService(this.config, this.users, - this.authMode); + this.authMode, + this.graphSpaces); } } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java index f20cbf613..a53aad512 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java @@ -248,6 +248,34 @@ public class DefaultOperationsDataServiceTest { Assert.assertTrue(backend.isStale()); } + @Test + public void testPartialRefreshPreservesCurrentClusterReason() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> { + if (calls.getAndIncrement() == 0) { + return fullSnapshot(); + } + Snapshot partial = partialSnapshot(); + return new Snapshot(partial.getStatus(), partial.getObservedAt(), + false, "cluster_not_ready", + partial.getSources(), partial.getNodes(), + partial.getFacts()); + }; + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + HugeClient client = client("token-a"); + Set<String> capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ); + + service.overview(client, capabilities, false); + Map<String, Object> result = service.overview(client, capabilities, + true); + + Assert.assertEquals("cluster_not_ready", result.get("reason")); + Assert.assertEquals(true, result.get("stale")); + } + @SuppressWarnings("unchecked") @Test public void testPdFailureKeepsFreshStoreCapacityFacts() { 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 b075a8507..c4e8347d1 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 @@ -122,6 +122,43 @@ public class LiveOperationsCollectorTest { .getAvailability()); } + @Test + public void testPdModeDoesNotInventServerWhenDiscoveryIsEmpty() + throws IOException { + HttpServer pd = pdServer(200, cluster(), 200, stores()); + LiveOperationsCollector.ServerClientProvider servers = + new LiveOperationsCollector.ServerClientProvider() { + @Override + public java.util.List<String> urls() { + return Collections.emptyList(); + } + + @Override + public HugeClient create(String url, String authContext, + int timeout) { + throw new AssertionError("Server probe must not start"); + } + }; + LiveOperationsCollector collector = collector(true, pd, servers); + + Snapshot snapshot; + try { + snapshot = collector.collect(serverClient(), false); + } finally { + collector.close(); + pd.stop(0); + } + + Assert.assertFalse(snapshot.getNodes().stream() + .anyMatch(node -> "SERVER".equals( + node.getType()))); + Assert.assertEquals("UNAVAILABLE", + snapshot.getSources().get("server") + .getAvailability()); + Assert.assertEquals("topology_fields_unavailable", + snapshot.getSources().get("server").getReason()); + } + @Test public void testPdServerDiscoveryUsesOperationsDeadline() throws IOException { @@ -257,7 +294,8 @@ public class LiveOperationsCollectorTest { pd.stop(0); } - Assert.assertEquals("UP", snapshot.getStatus()); + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("cluster_warn", snapshot.getReason()); Assert.assertEquals("UP", snapshot.getSources().get("pd").getStatus()); } @@ -278,7 +316,8 @@ public class LiveOperationsCollectorTest { Assert.assertTrue(pdSource.isFresh()); Assert.assertEquals("UP", pdSource.getStatus()); Assert.assertNull(pdSource.getReason()); - Assert.assertEquals("UP", snapshot.getStatus()); + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("cluster_not_ready", snapshot.getReason()); long pdCount = snapshot.getNodes().stream() .filter(node -> "PD".equals(node.getType())) .count(); @@ -382,7 +421,8 @@ public class LiveOperationsCollectorTest { pd.stop(0); } - Assert.assertEquals("UP", snapshot.getStatus()); + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("cluster_state_unknown", snapshot.getReason()); Assert.assertEquals("UP", snapshot.getSources().get("pd").getStatus()); } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java index da4cd1206..6f8c8e35b 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java @@ -850,6 +850,10 @@ public class AuthSecurityTest { @Test public void testModernLoginDoesNotRetainPassword() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession().setAttribute(Constant.PASSWORD_KEY, + "old-legacy-password"); + request.getSession().setAttribute(Constant.PASSWORD_EXPIRE_AT_KEY, + Long.MAX_VALUE); RequestContextHolder.setRequestAttributes( new ServletRequestAttributes(request)); TestLoginController controller = new TestLoginController(); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java index 3e0e235ad..32d45eb43 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java @@ -35,12 +35,14 @@ import org.apache.hugegraph.service.auth.AuthModeService; public class ConfigControllerTest { @Test - public void testPdConfigMarksSuccessfulAuthProbeVerified() { + public void testPdConfigUsesDiscoveredServerCapabilities() { HugeConfig config = Mockito.mock(HugeConfig.class); Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); HugeClient client = Mockito.mock(HugeClient.class); AuthModeService authMode = Mockito.mock(AuthModeService.class); Mockito.when(client.isServerAuthEnabled()).thenReturn(false); + Mockito.when(client.supportsGraphCreate()).thenReturn(false); + Mockito.when(client.supportsCypher()).thenReturn(true); Mockito.when(authMode.update(false)).thenReturn(false); ConfigController controller = new ConfigController() { @@ -59,7 +61,7 @@ public class ConfigControllerTest { Assert.assertEquals(Map.of("pd_enabled", true, "server_capabilities_verified", true, "auth_enabled", false, - "graph_create_enabled", true, + "graph_create_enabled", false, "cypher_enabled", true), result); Mockito.verify(client).close(); } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java index 600fe3728..aa7b78756 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java @@ -430,13 +430,14 @@ public class UserServiceCompatibilityTest { account.setGraphspacePermissions(Collections.singletonList( permission("team", "GS_READ_ONLY"))); + ParameterizedException error = null; try { this.service.update(this.client, account); - Assert.fail("Expected combined update to be rejected"); - } catch (ParameterizedException ignored) { - // Expected + } catch (ParameterizedException e) { + error = e; } + Assert.assertNotNull(error); Mockito.verify(this.auth, Mockito.never()) .updateUser(Mockito.any(User.class)); Mockito.verify(this.graphSpaceUsers, Mockito.never()) @@ -445,7 +446,7 @@ public class UserServiceCompatibilityTest { } @Test - public void testModernUserUpdateRollsBackProfileAndSuperAdmin() { + public void testModernUserUpdateKeepsSuperAdminWhenPresetFails() { Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); User previous = user("alice"); @@ -454,7 +455,7 @@ public class UserServiceCompatibilityTest { Mockito.when(this.auth.getUser("u-1")).thenReturn(previous); Mockito.when(this.auth.listSuperAdmin()) .thenReturn(Collections.singletonList("alice")) - .thenReturn(Collections.emptyList()); + .thenReturn(Collections.singletonList("alice")); UserEntity account = UserEntity.builder() .id("u-1") .name("alice") @@ -477,8 +478,8 @@ public class UserServiceCompatibilityTest { } Assert.assertSame(failure, error); - Mockito.verify(this.auth).delSuperAdmin("alice"); - Mockito.verify(this.auth).addSuperAdmin("alice"); + Mockito.verify(this.auth, Mockito.never()).delSuperAdmin("alice"); + Mockito.verify(this.auth, Mockito.never()).addSuperAdmin("alice"); ArgumentCaptor<User> updates = ArgumentCaptor.forClass(User.class); Mockito.verify(this.auth, Mockito.times(2)) .updateUser(updates.capture()); @@ -802,13 +803,15 @@ public class UserServiceCompatibilityTest { this.service.update(this.client, account); - Mockito.verify(this.auth).delSuperAdmin("alice"); Mockito.verify(this.graphSpaceUsers, Mockito.never()) .validatePermissionPresets(Mockito.any(), Mockito.any(), Mockito.any()); - Mockito.verify(this.graphSpaceUsers).applyPermissionPresets( - this.client, "alice", Collections.emptyList(), - "GS_READ_ONLY"); + org.mockito.InOrder order = Mockito.inOrder(this.auth, + this.graphSpaceUsers); + order.verify(this.auth).updateUser(Mockito.any(User.class)); + order.verify(this.graphSpaceUsers).applyPermissionPresets( + this.client, "alice", Collections.emptyList(), "GS_READ_ONLY"); + order.verify(this.auth).delSuperAdmin("alice"); } @Test 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 bee87bfe0..7b64c4d4c 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 @@ -468,6 +468,8 @@ "existing_account": "Existing Account ID", "account_not_found": "This account does not exist. Create it to continue assigning access.", "account_not_found_contact_admin": "This account does not exist. Ask a super administrator to create the user before assigning access.", + "missing_response_subject": "Account", + "missing_response_predicate": "does not exist", "create_account": "Create this account", "account_check_failed": "Could not verify this account. Check the connection and retry.", "batch_failed": "Assigned {{success}} of {{total}} GraphSpaces. Failed: {{spaces}}. Check these spaces and retry.", @@ -1286,6 +1288,7 @@ "status_degraded": "Attention", "status_degraded_help": "Some sources or metrics are unhealthy while core services may still be available; review the source details below", "unavailable": "Unavailable", + "metric_no_data": "No metric data", "unsupported": "Unsupported by this service version", "metric_malformed": "Malformed metric response", "fresh": "Fresh", @@ -1295,12 +1298,12 @@ "topology_view": "Topology", "node_list_view": "Node list", "cluster_nodes": "Cluster nodes", - "logical_relationship": "Logical service relationship, not network traffic", "topology_label": "HugeGraph cluster topology", "attention_nodes": "Nodes needing attention", "attention_items": "Items needing attention", "all_nodes_healthy": "All discovered nodes are healthy", "nodes_healthy_source_attention": "All nodes are healthy; the source or cluster state above is a separate signal.", + "cluster_attention": "Cluster state needs attention", "source_attention": "{{source}} source needs attention", "view_source_nodes": "View {{source}} nodes", "view_nodes": "View nodes", @@ -1347,6 +1350,8 @@ "metric_drive": "Drive", "metric_raft": "Raft", "metric_backend": "Backend", + "metric_store_backend": "Storage & partitions", + "metric_server_backend": "Graph service", "capacity_usage": "Capacity usage", "memory_usage": "Memory usage", "heap_usage": "Heap memory usage", @@ -1391,7 +1396,7 @@ "processors": "Processors", "uptime": "Uptime", "uptime_seconds": "Uptime", - "systemload_average": "System load average", + "systemload_average": "1-minute system load", "heap": "Heap memory", "nonheap": "Non-heap memory", "used": "Used", @@ -1408,15 +1413,17 @@ "threads_live": "Live threads", "heap_used_bytes": "Heap used", "nonheap_used_bytes": "Non-heap used", - "graphs": "Graphs", - "nodes": "Nodes", - "backend_counts": "Backend counts", + "garbage_collection_count": "GC count", + "garbage_collection_time": "GC time", + "graphs": "Graph count", + "nodes": "Backend node count", + "backend_counts": "Storage backends", "capacity_bytes": "Total capacity", "available_bytes": "Available capacity", - "partitions": "Partitions", - "leaders": "Leaders", + "partitions": "Partition count", + "leaders": "Leader partitions", "total_space": "Total space", - "usable_space": "Usable space", + "usable_space": "Available space", "free_space": "Free space", "size_unit": "Space unit", "groups": "Raft groups", 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 855b6adfd..987498151 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 @@ -468,6 +468,8 @@ "existing_account": "已有账号 ID", "account_not_found": "账号不存在,可创建此账号后继续分配权限。", "account_not_found_contact_admin": "账号不存在,请联系超级管理员创建新用户后再分配权限。", + "missing_response_subject": "账号", + "missing_response_predicate": "不存在", "create_account": "创建此账号", "account_check_failed": "无法确认账号是否存在,请检查连接后重试。", "batch_failed": "已成功分配 {{success}}/{{total}} 个 GraphSpace;以下空间失败:{{spaces}}。请检查后重试。", @@ -1286,6 +1288,7 @@ "status_degraded": "需关注", "status_degraded_help": "部分来源或指标异常,核心服务可能仍可用;请查看下方来源原因", "unavailable": "不可用", + "metric_no_data": "暂无指标数据", "unsupported": "当前服务版本不支持", "metric_malformed": "指标响应格式异常", "fresh": "数据新鲜", @@ -1295,12 +1298,12 @@ "topology_view": "拓扑图", "node_list_view": "节点列表", "cluster_nodes": "集群节点", - "logical_relationship": "表示逻辑服务关系,不表示网络流量", "topology_label": "HugeGraph 集群拓扑", "attention_nodes": "需关注的节点", "attention_items": "需关注项", "all_nodes_healthy": "已发现的节点均健康", "nodes_healthy_source_attention": "节点均健康;上方是独立的来源或集群状态提醒。", + "cluster_attention": "集群状态需关注", "source_attention": "{{source}} 来源需关注", "view_source_nodes": "查看 {{source}} 节点", "view_nodes": "查看节点", @@ -1345,8 +1348,10 @@ "back_to_nodes": "返回节点列表", "metric_system": "系统", "metric_drive": "磁盘", - "metric_raft": "Raft 共识", + "metric_raft": "Raft", "metric_backend": "后端", + "metric_store_backend": "存储与分区", + "metric_server_backend": "图服务", "capacity_usage": "容量使用", "memory_usage": "内存使用", "heap_usage": "堆内存使用", @@ -1391,7 +1396,7 @@ "processors": "处理器数", "uptime": "运行时间", "uptime_seconds": "运行时间", - "systemload_average": "系统平均负载", + "systemload_average": "1 分钟系统负载", "heap": "堆内存", "nonheap": "非堆内存", "used": "已用", @@ -1408,13 +1413,15 @@ "threads_live": "存活线程", "heap_used_bytes": "堆内存已用", "nonheap_used_bytes": "非堆内存已用", - "graphs": "图数", - "nodes": "节点数", - "backend_counts": "后端统计", + "garbage_collection_count": "GC 次数", + "garbage_collection_time": "GC 耗时", + "graphs": "图数量", + "nodes": "后端节点数", + "backend_counts": "存储后端", "capacity_bytes": "总容量", "available_bytes": "可用容量", "partitions": "分区数", - "leaders": "Leader 数", + "leaders": "Leader 分区数", "total_space": "总空间", "usable_space": "可用空间", "free_space": "空闲空间", diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js index 39c33392d..9fe7213e7 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js @@ -118,17 +118,9 @@ const EditLayer = ({ } }; if (superAdminChanged && profile.user_password) { - await requestUpdate(toPermissionPayload({ - user_name: profile.user_name, - permission_preset: values.is_superadmin - ? PERMISSION_PRESETS.SUPER_ADMIN - : PERMISSION_PRESETS.GS_READ_ONLY, - })); - await requestUpdate(profile); - } - else { - await requestUpdate(payload); + throw new Error(t('account.feedback.password_permission_separate')); } + await requestUpdate(payload); message.success(t('common.msg.update_success')); onCancel(); refresh(); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js index 1eee4df43..2f6220873 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js @@ -38,28 +38,27 @@ import {PERMISSION_PRESETS} from './permissionPresets'; import {loadAllPages, PAGE_ERROR_CONFIG} from './pagedRecords'; const responseRecords = response => response?.data?.records ?? []; -const errorStatus = value => ( - value?.response?.data?.status ?? value?.response?.status ?? value?.status -); const errorDetail = value => { const response = value?.response ?? value; return response?.data?.message ?? response?.message; }; -const isMissingAccount = value => { - if (errorStatus(value) === 400) { - return true; - } +const isMissingAccount = (value, t) => { const detail = errorDetail(value); if (typeof detail !== 'string') { return false; } const normalized = detail.toLowerCase(); + const subject = t('account.space_access.member.missing_response_subject') + .toLowerCase(); + const predicate = t('account.space_access.member.missing_response_predicate') + .toLowerCase(); return normalized.includes('user or group is not exist') - || normalized.includes('account does not exist'); + || normalized.includes('account does not exist') + || (normalized.includes(subject) && normalized.includes(predicate)); }; const showMutationError = (error, t) => { const detail = errorDetail(error); - if (isMissingAccount(error)) { + if (isMissingAccount(error, t)) { message.error(t('account.space_access.member.account_not_found')); return; } @@ -208,6 +207,7 @@ const SpaceAccess = ({ const [spacesError, setSpacesError] = useState(false); const [spacesRevision, setSpacesRevision] = useState(0); const spacesRequest = useRef(null); + const accountValidation = useRef(null); const [memberDialog, setMemberDialog] = useState(null); const [missingAccountId, setMissingAccountId] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -354,12 +354,16 @@ const SpaceAccess = ({ spaces, ]); const closeMember = useCallback(() => { + accountValidation.current = null; setMemberDialog(null); setMissingAccountId(null); memberForm.resetFields(); }, [memberForm]); const validateExistingAccount = useCallback(async (_, value) => { + const validation = Symbol('account-validation'); + accountValidation.current = validation; setMissingAccountId(null); + memberForm.setFieldValue('user_id', undefined); if (!value) { return; } @@ -370,6 +374,8 @@ const SpaceAccess = ({ if (!targetSpace) { return; } + const isCurrent = () => accountValidation.current === validation + && memberForm.getFieldValue('account_id') === value; let response; try { response = await api.auth.getSpaceAccount( @@ -377,7 +383,10 @@ const SpaceAccess = ({ ); } catch (error) { - if (isMissingAccount(error)) { + if (!isCurrent()) { + return; + } + if (isMissingAccount(error, t)) { setMissingAccountId(value); throw new Error(accountNotFoundMessage()); } @@ -385,9 +394,12 @@ const SpaceAccess = ({ t('account.space_access.member.account_check_failed') ); } + if (!isCurrent()) { + return; + } const account = response?.data; if (response?.status !== 200) { - if (isMissingAccount(response)) { + if (isMissingAccount(response, t)) { setMissingAccountId(value); throw new Error(accountNotFoundMessage()); } @@ -401,6 +413,11 @@ const SpaceAccess = ({ } memberForm.setFieldValue('user_id', account.user_id ?? account.id); }, [accountNotFoundMessage, memberForm, t]); + const handleAccountIdChange = useCallback(() => { + accountValidation.current = null; + setMissingAccountId(null); + memberForm.setFieldValue('user_id', undefined); + }, [memberForm]); const startAccountCreation = useCallback(() => { if (!missingAccountId || !onCreateAccount) { return; @@ -618,7 +635,7 @@ const SpaceAccess = ({ <Input disabled /> </Form.Item> <Form.Item name="user_id" hidden> - <Input /> + <Input onChange={handleAccountIdChange} /> </Form.Item> </> ) : ( diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js index 5d5eabef8..8983d9859 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js @@ -30,7 +30,12 @@ import * as api from '../../api'; let mockAuthContext; jest.mock('react-i18next', () => ({ - useTranslation: () => ({t: key => key}), + useTranslation: () => ({ + t: key => ({ + 'account.space_access.member.missing_response_subject': '账号', + 'account.space_access.member.missing_response_predicate': '不存在', + }[key] ?? key), + }), })); jest.mock('../../auth/AuthContext', () => ({ @@ -567,6 +572,109 @@ test('tells a GraphSpace administrator to contact a global administrator', async })).not.toBeInTheDocument(); }); +test('recognizes a localized missing-account response', async () => { + api.auth.getSpaceAccount.mockResolvedValue({ + status: 400, + message: '账号 missing-user 不存在,请先创建账号再分配 GraphSpace 权限。', + }); + render(<SpaceAccess />); + + await screen.findAllByText('alice'); + fireEvent.click(screen.getByRole('button', { + name: 'account.space_access.member.add', + })); + const dialog = screen.getByRole('dialog'); + const account = within(dialog).getByLabelText( + 'account.space_access.member.existing_account' + ); + fireEvent.change(account, {target: {value: 'missing-user'}}); + fireEvent.blur(account); + + expect(await within(dialog).findByText( + 'account.space_access.member.account_not_found_contact_admin' + )).toBeInTheDocument(); +}); + +test('does not describe an unrelated bad request as a missing account', async () => { + api.auth.getSpaceAccount.mockResolvedValue({ + status: 400, + message: 'Invalid permission preset', + }); + render(<SpaceAccess />); + + await screen.findAllByText('alice'); + fireEvent.click(screen.getByRole('button', { + name: 'account.space_access.member.add', + })); + const dialog = screen.getByRole('dialog'); + const account = within(dialog).getByLabelText( + 'account.space_access.member.existing_account' + ); + fireEvent.change(account, {target: {value: 'alice'}}); + fireEvent.blur(account); + + expect(await within(dialog).findByText( + 'account.space_access.member.account_check_failed' + )).toBeInTheDocument(); + expect(within(dialog).queryByText( + 'account.space_access.member.account_not_found_contact_admin' + )).not.toBeInTheDocument(); +}); + +test('ignores an older account lookup after the ID changes', async () => { + const older = deferred(); + const current = deferred(); + api.auth.getSpaceAccount.mockImplementation((_space, accountId) => ( + accountId === 'older' ? older.promise : current.promise + )); + api.auth.setSpacePreset.mockResolvedValue({status: 200}); + render(<SpaceAccess />); + + await screen.findAllByText('alice'); + fireEvent.click(screen.getByRole('button', { + name: 'account.space_access.member.add', + })); + const dialog = screen.getByRole('dialog'); + const account = within(dialog).getByLabelText( + 'account.space_access.member.existing_account' + ); + fireEvent.change(account, {target: {value: 'older'}}); + fireEvent.blur(account); + fireEvent.change(account, {target: {value: 'current'}}); + fireEvent.blur(account); + + await act(async () => current.resolve({ + status: 200, + data: {user_id: 'current-id', user_name: 'current'}, + })); + await act(async () => older.resolve({ + status: 200, + data: {user_id: 'older-id', user_name: 'older'}, + })); + + const roleSelects = within(dialog).getAllByRole('combobox'); + fireEvent.mouseDown(roleSelects[roleSelects.length - 1]); + fireEvent.click(screen.getByText('account.permission_preset.GS_READ_ONLY')); + fireEvent.click(within(dialog).getByRole('button', { + name: 'common.action.save', + })); + + await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledWith( + 'SPACE_A', + 'current-id', + 'current', + 'GS_READ_ONLY', + expect.any(Object) + )); + expect(api.auth.setSpacePreset).not.toHaveBeenCalledWith( + 'SPACE_A', + 'older-id', + expect.anything(), + expect.anything(), + expect.any(Object) + ); +}); + test('requires an explicit replacement for legacy custom access', async () => { setResponses({ members: [{ diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js index 2b88966dd..809742a66 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js @@ -317,7 +317,7 @@ test('updates a password without resubmitting unchanged permissions', async () = expect(payload).not.toHaveProperty('graphspace_permissions'); }); -test('separates simultaneous password and super administrator changes', async () => { +test('requires password and super administrator changes to be separate', async () => { mockAuthContext = { capabilities: ['accounts_manage', 'account_permission_presets'], }; @@ -343,20 +343,40 @@ test('separates simultaneous password and super administrator changes', async () '.ant-modal-footer .ant-btn-primary' )); - await waitFor(() => expect(api.auth.updateUser).toHaveBeenCalledTimes(2)); - expect(api.auth.updateUser.mock.calls[0][1]).toEqual(expect.objectContaining({ - user_name: 'alice', - permission_preset: 'SUPER_ADMIN', - is_superadmin: true, - })); - expect(api.auth.updateUser.mock.calls[0][1]) - .not.toHaveProperty('user_password'); - expect(api.auth.updateUser.mock.calls[1][1]).toEqual(expect.objectContaining({ - user_name: 'alice', - user_password: 'new-password', - })); - expect(api.auth.updateUser.mock.calls[1][1]) - .not.toHaveProperty('permission_preset'); + expect((await screen.findAllByText( + 'account.feedback.password_permission_separate' + )).length).toBeGreaterThan(0); + expect(api.auth.updateUser).not.toHaveBeenCalled(); +}); + +test('rejects a combined profile and promotion before sending it', async () => { + mockAuthContext = { + capabilities: ['accounts_manage', 'account_permission_presets'], + }; + api.auth.getUserInfo.mockResolvedValue({ + status: 200, + data: { + user_name: 'alice', + is_superadmin: false, + permission_preset: 'GS_READ_ONLY', + graphspace_permissions: [], + }, + }); + render(<EditLayer {...props} data={{id: 'alice'}} op='edit' />); + + await screen.findByDisplayValue('alice'); + fireEvent.change(screen.getByPlaceholderText( + 'account.form.default_password_placeholder' + ), {target: {value: 'new-password'}}); + fireEvent.click(screen.getByRole('switch')); + fireEvent.click(document.querySelector( + '.ant-modal-footer .ant-btn-primary' + )); + + expect((await screen.findAllByText( + 'account.feedback.password_permission_separate' + )).length).toBeGreaterThan(0); + expect(api.auth.updateUser).not.toHaveBeenCalled(); }); test('keeps GraphSpace membership out of the account profile form', async () => { diff --git a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js index f4db39cd6..f62433a02 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js +++ b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js @@ -40,11 +40,12 @@ const TitleField = ({item, onClick, onKeyDown}) => { : getResourceDisplayName(item.name, item.nickname); return ( - <> + <div className={style.card_title}> <Typography.Text - style={{maxWidth: 244}} - ellipsis={{ellipsis: true}} + className={style.card_title_text} + ellipsis={{tooltip: displayName}} title={`${displayName}`} + aria-label={displayName} onClick={onClick} onKeyDown={onKeyDown} role='button' @@ -57,7 +58,7 @@ const TitleField = ({item, onClick, onKeyDown}) => { )} {item.create_time ? moment(item.create_time).format('YYYY-MM-DD') : '--'} {t('graphspace.card.created')} </div> - </> + </div> ); }; @@ -150,7 +151,7 @@ const GraphSpaceCard = ({ backgroundImage: 'linear-gradient(180deg, ' + 'rgba(51,136,255,0.10) 0%, rgba(51,136,255,0.00) 100%)', borderBottom: 0, - height: 93, + minHeight: 93, paddingLeft: 20, }} bodyStyle={{ diff --git a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.test.js b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.test.js index bc76cea09..2dc002ede 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.test.js @@ -167,6 +167,30 @@ test('treats a backend nickname echo as an unset alias', () => { expect(screen.getByTitle('space')).toBeInTheDocument(); }); +test('keeps a long GraphSpace title accessible without a fixed text width', () => { + const displayName = 'A GraphSpace name that is much longer than its card'; + const item = { + name: 'long-space', nickname: displayName, create_time: '2026-07-10', + auth: false, max_graph_number: 10, cpu_limit: 2, memory_limit: 4, + storage_limit: 100, storage_used: 0, storage_percent: 0, + }; + + render( + <GraphSpaceCard + item={item} + editGraphspace={jest.fn()} + deleteGraphspace={jest.fn()} + handleInit={jest.fn()} + /> + ); + + const title = screen.getByRole('button', {name: displayName}); + expect(title).toHaveAttribute('title', displayName); + expect(title).toHaveClass('card_title_text'); + expect(title).not.toHaveStyle({maxWidth: '244px'}); + expect(screen.getAllByText('graphspace.card.enter').length).toBeGreaterThan(0); +}); + test('keeps public GraphSpace navigation but hides mutation actions for viewers', () => { const item = { name: 'public', nickname: 'Public', create_time: '2026-07-10', diff --git a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/index.js b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/index.js index c0436dca7..a66608735 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/index.js +++ b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/index.js @@ -376,7 +376,7 @@ const GraphSpace = () => { )} {data.map(item => { return ( - <Col span={8} key={item.name}> + <Col xs={24} lg={12} xxl={8} key={item.name}> <GraphSpaceCard item={item} deleteGraphspace={deleteGraphspace} @@ -390,7 +390,7 @@ const GraphSpace = () => { ); })} {!loading && !listError && canCreateGraphspace && ( - <Col span={8} key='add'> + <Col xs={24} lg={12} xxl={8} key='add'> <Card className={style.add_card} onClick={handleCreate} diff --git a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/index.module.scss b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/index.module.scss index 4d55f1814..65bad4496 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/index.module.scss +++ b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/index.module.scss @@ -37,6 +37,24 @@ div.add_card { cursor: pointer; height: 100%; + :global { + .ant-card-head-wrapper { + align-items: flex-start; + column-gap: 12px; + flex-wrap: nowrap; + } + + .ant-card-head-title { + flex: 1 1 auto; + min-width: 0; + } + + .ant-card-extra { + flex: 0 0 auto; + margin-left: auto; + } + } + .card_content { height: 144px; font-size: 12px; @@ -46,6 +64,31 @@ div.add_card { } } +@media (max-width: 420px) { + .card { + :global { + .ant-card-head-wrapper { + row-gap: 8px; + flex-wrap: wrap; + } + + .ant-card-head-title { + flex-basis: 100%; + } + } + } +} + +.card_title { + min-width: 0; + width: 100%; +} + +.card_title_text { + display: block; + max-width: 100%; +} + .tooltip { :global { .ant-tooltip-arrow { diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.js index 7090722a8..c02a39fee 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.js @@ -17,7 +17,7 @@ */ import {Alert, Button, Descriptions, Progress, Skeleton, Space, Statistic, Tooltip} from 'antd'; -import {ArrowLeftOutlined} from '@ant-design/icons'; +import {ArrowLeftOutlined, CheckCircleFilled} from '@ant-design/icons'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useLocation, useNavigate, useParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; @@ -44,6 +44,7 @@ const GROUPS_BY_TYPE = { const SOURCE_BY_TYPE = {SERVER: 'server', PD: 'pd', STORE: 'stores'}; const EMPTY_STATE_BY_AVAILABILITY = { + AVAILABLE: 'metric_no_data', UNSUPPORTED: 'unsupported', MALFORMED: 'metric_malformed', UNAVAILABLE: 'unavailable', @@ -53,10 +54,24 @@ const BYTE_KEYS = new Set([ 'capacity_bytes', 'available_bytes', 'heap_used_bytes', 'nonheap_used_bytes', ]); +const HIDDEN_METRIC_KEYS = { + drive: new Set(['total_space', 'free_space', 'size_unit']), + backend: new Set(['capacity_bytes', 'available_bytes']), +}; + const metricLabel = (key, t) => t(`operations.metric_labels.${key}`, { defaultValue: key.replaceAll('_', ' '), }); +const metricGroupName = (group, nodeType, t) => { + if (group === 'backend') { + return t(nodeType === 'STORE' + ? 'operations.metric_store_backend' + : 'operations.metric_server_backend'); + } + return t(`operations.metric_${group}`); +}; + const formatBytes = value => { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = Number(value); @@ -117,9 +132,15 @@ const formatDisplayValue = (key, value, parentKey, values, unavailable) => { return `${new Intl.NumberFormat(undefined, {maximumFractionDigits: 1}) .format(Number(value) * 100)}%`; } - const unit = parentKey === 'garbage_collector' && key.endsWith('_time') - ? values.time_unit : (['total_space', 'usable_space', 'free_space'].includes(key) - ? values.size_unit : ''); + if (parentKey === 'garbage_collector' && key.endsWith('_time')) { + return formatMetricValue( + new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}) + .format(Number(value)), + values.time_unit + ); + } + const unit = ['total_space', 'usable_space', 'free_space'].includes(key) + ? values.size_unit : ''; if (unit) { return formatUnitValue(value, unit) ?? unavailable; } @@ -185,30 +206,79 @@ const MemoryUsage = ({label, values, unavailable, t}) => { ); }; +const MetricStatistics = ({entries, values}) => { + const {t} = useTranslation(); + const unavailable = t('operations.unavailable'); + return ( + <div className='operations-system-statistics operations-group-statistics'> + {entries.map(([key, value]) => ( + <div className='operations-statistic-card' key={key}> + <span className='operations-statistic-title'> + {metricLabel(key, t)} + </span> + <div className='operations-statistic-value'> + {value !== null && typeof value === 'object' + && !Array.isArray(value) + ? Object.entries(value).map(([nestedKey, nestedValue]) => ( + <div key={nestedKey}> + {metricLabel(nestedKey, t)}: {' '} + {formatDisplayValue( + nestedKey, nestedValue, key, value, unavailable + )} + </div> + )) + : formatDisplayValue( + key, value, null, values, unavailable + )} + </div> + </div> + ))} + </div> + ); +}; + +const sumMetricValues = (values, suffix) => { + const matching = Object.entries(values ?? {}) + .filter(([key, value]) => key.endsWith(suffix) && Number.isFinite(Number(value))) + .map(([, value]) => Number(value)); + return matching.length > 0 + ? matching.reduce((total, value) => total + value, 0) + : null; +}; + const SystemMetricContent = ({values = {}}) => { const {t} = useTranslation(); const unavailable = t('operations.unavailable'); const basic = values.basic && typeof values.basic === 'object' ? values.basic : {}; const thread = values.thread && typeof values.thread === 'object' ? values.thread : {}; + const garbageCollector = values.garbage_collector + && typeof values.garbage_collector === 'object' + ? values.garbage_collector : {}; + const garbageCollectionCount = sumMetricValues(garbageCollector, '_count'); + const garbageCollectionTime = sumMetricValues(garbageCollector, '_time'); const runtimeStats = [ ['process_cpu_usage', values.process_cpu_usage], ['system_cpu_usage', values.system_cpu_usage], ['systemload_average', values.systemload_average ?? basic.systemload_average], ['cpu_count', values.cpu_count ?? basic.processors], - ['uptime', basic.uptime], - ['uptime_seconds', values.uptime_seconds], + ['uptime_seconds', values.uptime_seconds + ?? (basic.uptime === undefined || basic.uptime === null + ? null : Number(basic.uptime) / 1000)], + ['threads_live', values.threads_live ?? thread.count], + ['heap_used_bytes', values.heap_used_bytes], + ['nonheap_used_bytes', values.nonheap_used_bytes], + ['garbage_collection_count', garbageCollectionCount], + ['garbage_collection_time', garbageCollectionTime], ].filter(([, value]) => value !== undefined && value !== null); - const threadStats = ['count', 'daemon', 'peak'] - .filter(key => thread[key] !== undefined && thread[key] !== null); const memoryValues = [ ['heap_usage', values.heap], ['nonheap_usage', values.nonheap], ].filter(([, value]) => value && typeof value === 'object'); - const basicDetails = ['mem_total', 'mem_used'] - .filter(key => basic[key] !== undefined && basic[key] !== null); const handledKeys = new Set([ 'basic', 'heap', 'nonheap', 'thread', 'process_cpu_usage', 'system_cpu_usage', 'systemload_average', 'cpu_count', 'uptime_seconds', + 'threads_live', 'heap_used_bytes', 'nonheap_used_bytes', + 'garbage_collector', ]); const supplementalEntries = Object.entries(values) .filter(([key]) => !handledKeys.has(key)); @@ -242,60 +312,21 @@ const SystemMetricContent = ({values = {}}) => { <Statistic key={key} title={metricLabel(key, t)} - value={formatDisplayValue(key, value, null, values, unavailable)} + value={formatDisplayValue( + key, + value, + key === 'garbage_collection_time' + ? 'garbage_collector' : null, + key === 'garbage_collection_time' + ? garbageCollector : values, + unavailable + )} /> ))} </div> )} - {threadStats.length > 0 && ( - <div - className='operations-system-statistics' - role='group' - aria-label={metricLabel('thread', t)} - > - {threadStats.map(key => ( - <Statistic - key={key} - title={metricLabel(key, t)} - value={thread[key]} - /> - ))} - </div> - )} - {basicDetails.length > 0 && ( - <div className='operations-system-details'> - {basicDetails.map(key => ( - <div key={key}> - {metricLabel(key, t)}: {' '} - {formatDisplayValue(key, basic[key], 'basic', basic, unavailable)} - </div> - ))} - </div> - )} {supplementalEntries.length > 0 && ( - <Descriptions - className='operations-system-supplemental' - layout='vertical' - colon={false} - column={{xxl: 3, xl: 2, lg: 2, md: 1, sm: 1, xs: 1}} - > - {supplementalEntries.map(([key, value]) => ( - <Descriptions.Item key={key} label={metricLabel(key, t)}> - {value !== null && typeof value === 'object' && !Array.isArray(value) - ? Object.entries(value).map(([nestedKey, nestedValue]) => ( - <div key={nestedKey}> - {metricLabel(nestedKey, t)}: {' '} - {formatDisplayValue( - nestedKey, nestedValue, key, value, unavailable - )} - </div> - )) - : formatDisplayValue( - key, value, null, values, unavailable - )} - </Descriptions.Item> - ))} - </Descriptions> + <MetricStatistics entries={supplementalEntries} values={values} /> )} </div> ); @@ -305,13 +336,16 @@ const MetricGroup = ({group, name, values, status = {}, emptyMessage}) => { const {t, i18n} = useTranslation(); const entries = values && typeof values === 'object' && !Array.isArray(values) ? Object.entries(values) : []; + const visibleEntries = entries.filter(([key]) => + !HIDDEN_METRIC_KEYS[group]?.has(key) + ); const availability = status.availability ?? 'UNSUPPORTED'; const emptyState = EMPTY_STATE_BY_AVAILABILITY[availability] ?? 'unavailable'; - const observed = status.observed_at ? formatObservedAt( - status.observed_at, i18n.language, t('operations.unavailable') - ) : null; - const lastSuccess = status.last_success_at ? formatObservedAt( - status.last_success_at, i18n.language, t('operations.unavailable') + const lastObservedAt = status.stale + ? status.last_success_at ?? status.observed_at + : status.observed_at ?? status.last_success_at; + const lastObserved = lastObservedAt ? formatObservedAt( + lastObservedAt, i18n.language, t('operations.unavailable') ) : null; const availabilityLabel = t(`operations.availability_${availability.toLowerCase()}`, { defaultValue: availability, @@ -332,11 +366,20 @@ const MetricGroup = ({group, name, values, status = {}, emptyMessage}) => { role='status' aria-label={availabilityLabel} > - {status.fresh && <span>{t('operations.fresh')}</span>} + {status.fresh && ( + <Tooltip title={t('operations.fresh')}> + <CheckCircleFilled + className='operations-metric-fresh' + role='img' + aria-label={t('operations.fresh')} + /> + </Tooltip> + )} {status.stale && <span>{t('operations.stale')}</span>} {reasonLabel && <span>{reasonLabel}</span>} - {observed && <span>{t('operations.observed_at')}: {observed}</span>} - {lastSuccess && <span>{t('operations.last_success')}: {lastSuccess}</span>} + {lastObserved && ( + <span>{t('operations.last_observed')}: {lastObserved}</span> + )} </div> ); const metricHeader = ( @@ -353,7 +396,7 @@ const MetricGroup = ({group, name, values, status = {}, emptyMessage}) => { </header> ); const capacity = capacitySummary(values); - if (entries.length === 0) { + if (visibleEntries.length === 0 && !capacity) { return ( <section className='operations-surface operations-metric-group'> {metricHeader} @@ -386,31 +429,9 @@ const MetricGroup = ({group, name, values, status = {}, emptyMessage}) => { /> </div> )} - {group === 'system' ? <SystemMetricContent values={values} /> : ( - <Descriptions - layout='vertical' - colon={false} - column={{xxl: 3, xl: 2, lg: 2, md: 1, sm: 1, xs: 1}} - > - {entries.map(([key, value]) => ( - <Descriptions.Item key={key} label={metricLabel(key, t)}> - {value !== null && typeof value === 'object' && !Array.isArray(value) - ? Object.entries(value).map(([nestedKey, nestedValue]) => ( - <div key={nestedKey}> - {metricLabel(nestedKey, t)}: {' '} - {formatDisplayValue( - nestedKey, nestedValue, key, value, - t('operations.unavailable') - )} - </div> - )) - : formatDisplayValue( - key, value, null, values, t('operations.unavailable') - )} - </Descriptions.Item> - ))} - </Descriptions> - )} + {group === 'system' + ? <SystemMetricContent values={values} /> + : <MetricStatistics entries={visibleEntries} values={values} />} </section> ); }; @@ -541,7 +562,7 @@ const NodeDetail = () => { <HealthStatus status={node.status} size='large' /> </section> <div className='operations-overall-status'> - <span>{t('operations.observed_at')}: {observed}</span> + <span>{t('operations.last_observed')}: {observed}</span> {data?.stale && <strong>{t('operations.stale')}</strong>} </div> </div> @@ -587,7 +608,7 @@ const NodeDetail = () => { <MetricGroup key={group} group={group} - name={t(`operations.metric_${group}`)} + name={metricGroupName(group, node.type, t)} values={node.metrics?.[group]} status={status} /> diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.test.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.test.js index a2f5d7a89..91ef6c4cb 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.test.js @@ -52,6 +52,13 @@ test('focuses standalone Server details on applicable sources and metric cards', heap: {used: 512, max: 1024, committed: 768}, nonheap: {used: 256, max: 0, committed: 320}, thread: {count: 42, daemon: 20, peak: 56}, + garbage_collector: { + g1_young_generation_count: 3, + g1_young_generation_time: 12, + g1_old_generation_count: 1, + g1_old_generation_time: 4, + time_unit: 'ms', + }, process_cpu_usage: 0.125, system_cpu_usage: 0.25, }, @@ -107,23 +114,18 @@ test('focuses standalone Server details on applicable sources and metric cards', expect(within(system).getByText(/Committed:.*768 MB/)).toBeInTheDocument(); expect(within(system).queryByText(/NaN|Infinity/)).not.toBeInTheDocument(); - const threads = within(system).getByRole('group', {name: 'Threads'}); - expect(within(threads).getByText('Live')).toBeInTheDocument(); - expect(within(threads).getByText('42')).toBeInTheDocument(); - expect(within(threads).getByText('Daemon')).toBeInTheDocument(); - expect(within(threads).getByText('20')).toBeInTheDocument(); - expect(within(threads).getByText('Peak')).toBeInTheDocument(); - expect(within(threads).getByText('56')).toBeInTheDocument(); - - expect(within(system).getByRole('group', {name: 'CPU and runtime'})) - .toHaveTextContent('12.5%'); - expect(within(system).getByRole('group', {name: 'CPU and runtime'})) - .toHaveTextContent('25%'); - expect(within(system).getByRole('group', {name: 'CPU and runtime'})) - .toHaveTextContent('1.5'); - expect(within(system).getByRole('group', {name: 'CPU and runtime'})) - .toHaveTextContent('2m 9s'); - expect(system.querySelectorAll('.ant-statistic').length).toBeGreaterThanOrEqual(7); + const runtime = within(system).getByRole('group', {name: 'CPU and runtime'}); + expect(runtime).toHaveTextContent('12.5%'); + expect(runtime).toHaveTextContent('25%'); + expect(runtime).toHaveTextContent('1-minute system load1.5'); + expect(runtime).not.toHaveTextContent('1.5%'); + expect(runtime).toHaveTextContent('2m 9s'); + expect(runtime).toHaveTextContent('Live threads42'); + expect(runtime).not.toHaveTextContent('Daemon'); + expect(runtime).not.toHaveTextContent('Peak'); + expect(runtime).toHaveTextContent('GC count4'); + expect(runtime).toHaveTextContent('GC time16 ms'); + expect(system.querySelectorAll('.ant-statistic').length).toBeGreaterThanOrEqual(8); }); afterEach(() => jest.clearAllMocks()); @@ -185,7 +187,7 @@ test('keeps null metrics safe and distinguishes unavailable groups', async () => expect(within(identity).getByLabelText('STORE icon')).toBeInTheDocument(); expect(within(identity).getByText('Store A')).toBeInTheDocument(); expect(screen.getByRole('region', {name: 'Node metrics'})).toBeInTheDocument(); - expect(screen.getByText(/Observed:/)).toBeInTheDocument(); + expect(screen.getAllByText(/Last observed:/).length).toBeGreaterThan(0); expect(screen.getByRole('button', {name: 'Refresh'})).toHaveClass( 'operations-refresh-button', 'ant-btn-text', 'ant-btn-circle' ); @@ -298,8 +300,8 @@ test('renders each metric group from its own metric status', async () => { }, drive: { availability: 'UNAVAILABLE', - observed_at: 1000, - last_success_at: 900, + observed_at: 2000, + last_success_at: 1000, fresh: false, stale: true, reason: 'refresh_failed', @@ -333,7 +335,14 @@ test('renders each metric group from its own metric status', async () => { expect(within(drive).getByText('Unavailable')).toBeInTheDocument(); expect(within(drive).getByText('7')).toBeInTheDocument(); expect(within(drive).getByText(/Stale/)).toBeInTheDocument(); - expect(within(drive).getByText(/Last success/)).toBeInTheDocument(); + expect(within(drive).getByText(/Last observed/)).toBeInTheDocument(); + const formatter = new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'medium', + }); + expect(drive).toHaveTextContent(formatter.format(new Date(1000))); + expect(drive).not.toHaveTextContent(formatter.format(new Date(2000))); + expect(within(drive).queryByText(/Last success/)).not.toBeInTheDocument(); const raft = screen.getByRole('heading', {name: 'Raft'}).closest('section'); expect(within(raft).getByText('Unsupported')).toBeInTheDocument(); @@ -345,12 +354,18 @@ test('renders each metric group from its own metric status', async () => { 'Upgrade HugeGraph to a version that provides this metric' )).toBeInTheDocument(); - const backend = screen.getByRole('heading', {name: 'Backend'}).closest('section'); + const backend = screen.getByRole('heading', { + name: 'Storage & partitions', + }).closest('section'); expect(within(backend).getByText('Available')).toBeInTheDocument(); expect(within(backend).getByText('2')).toBeInTheDocument(); expect(within(backend).queryByText('Refresh failed')).not.toBeInTheDocument(); + expect(within(backend).getByLabelText('Fresh')).toBeInTheDocument(); + expect(within(backend).queryByText('Fresh')).not.toBeInTheDocument(); + expect(within(backend).getAllByText(/Last observed/)).toHaveLength(1); + expect(within(backend).queryByText(/Last success/)).not.toBeInTheDocument(); - for (const name of ['System', 'Drive', 'Raft', 'Backend']) { + for (const name of ['System', 'Drive', 'Raft', 'Storage & partitions']) { const group = screen.getByRole('heading', {name}).closest('section'); expect(group.querySelector('.operations-metric-header')).toBeInTheDocument(); expect(within(group).getByRole('status')).toBeInTheDocument(); @@ -385,7 +400,8 @@ test('hides metric groups that do not apply to a PD node', async () => { expect(screen.queryByRole('heading', {name: 'Drive'})).not.toBeInTheDocument(); expect(screen.queryByRole('heading', {name: 'Raft'})).not.toBeInTheDocument(); - expect(screen.queryByRole('heading', {name: 'Backend'})).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', {name: 'Storage & partitions'})) + .not.toBeInTheDocument(); expect(screen.queryByText('Unsupported by this service version')).not.toBeInTheDocument(); }); @@ -396,10 +412,21 @@ test('presents native metric labels, units and capacity instead of raw keys', as ...response.node, metrics: { system: { - basic: {mem_total: 64, mem_used: 46, uptime: 128889}, + basic: { + mem_total: 64, + mem_used: 46, + uptime: 128889, + systemload_average: 2.33, + }, process_cpu_usage: 0.125, uptime_seconds: 65, - garbage_collector: {young_count: 3}, + garbage_collector: { + young_count: 3, + young_time: 8, + old_count: 1, + old_time: 2, + time_unit: 'ms', + }, }, drive: { total_space: 233752, @@ -407,7 +434,12 @@ test('presents native metric labels, units and capacity instead of raw keys', as free_space: 5802, size_unit: 'MB', }, - backend: {capacity_bytes: 4096, available_bytes: 1024}, + backend: { + capacity_bytes: 4096, + available_bytes: 1024, + partitions: 12, + leaders: 3, + }, }, }, }); @@ -415,13 +447,18 @@ test('presents native metric labels, units and capacity instead of raw keys', as renderDetail(); await screen.findByRole('heading', {name: 'Store A'}); - expect(screen.getByText(/Total memory:.*64 MB/)).toBeInTheDocument(); + expect(screen.queryByText(/Total memory/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Used memory/)).not.toBeInTheDocument(); const runtime = screen.getByRole('group', {name: 'CPU and runtime'}); expect(runtime).toHaveTextContent('Uptime'); - expect(runtime).toHaveTextContent('2m 9s'); + expect(runtime).toHaveTextContent('1-minute system load'); + expect(runtime).toHaveTextContent('2.33'); + expect(runtime).not.toHaveTextContent('2.33%'); expect(screen.getByText('12.5%')).toBeInTheDocument(); expect(screen.getByText('1m 5s')).toBeInTheDocument(); - expect(screen.getByText(/young count:.*3/i)).toBeInTheDocument(); + expect(screen.queryByText('2m 9s')).not.toBeInTheDocument(); + expect(runtime).toHaveTextContent('GC count4'); + expect(runtime).toHaveTextContent('GC time10 ms'); expect(screen.queryByText(/mem total/)).not.toBeInTheDocument(); const capacity = screen.getAllByRole('progressbar', {name: 'Capacity usage'}); expect(capacity.some(item => item.getAttribute('aria-valuenow') === '75')).toBe(true); @@ -429,4 +466,78 @@ test('presents native metric labels, units and capacity instead of raw keys', as expect(screen.getByText('75%')).toBeInTheDocument(); expect(screen.getByText('98%')).toBeInTheDocument(); expect(screen.getByText(/222.6 GB \/ 228.3 GB/)).toBeInTheDocument(); + const drive = screen.getByRole('heading', {name: 'Drive'}).closest('section'); + expect(within(drive).getByText('Available space')).toBeInTheDocument(); + expect(within(drive).getByText('5.7 GB')).toBeInTheDocument(); + expect(within(drive).queryByText('Free space')).not.toBeInTheDocument(); + expect(within(drive).queryByText('Space unit')).not.toBeInTheDocument(); + expect(within(drive).getByText('Available space') + .closest('.operations-statistic-card')).toBeInTheDocument(); + const backend = screen.getByRole('heading', { + name: 'Storage & partitions', + }).closest('section'); + expect(within(backend).getByText('Partition count')).toBeInTheDocument(); + expect(within(backend).getByText('Leader partitions')).toBeInTheDocument(); + expect(within(backend).queryByText('Total capacity')).not.toBeInTheDocument(); + expect(within(backend).queryByText('Available capacity')).not.toBeInTheDocument(); +}); + +test('uses concise Chinese names for Raft and Store storage metrics', async () => { + i18n.changeLanguage('zh-CN'); + getNode.mockResolvedValue({ + ...response, + node: { + ...response.node, + metrics: { + system: {}, + drive: {total_space: 100, usable_space: 40, size_unit: 'MB'}, + raft: {groups: 12, enabled_groups: 12}, + backend: { + capacity_bytes: 100, + available_bytes: 40, + partitions: 12, + leaders: 3, + }, + }, + metric_statuses: { + system: {availability: 'AVAILABLE', observed_at: 1000}, + drive: {availability: 'AVAILABLE', observed_at: 1000}, + raft: {availability: 'AVAILABLE', observed_at: 1000}, + backend: {availability: 'AVAILABLE', observed_at: 1000}, + }, + }, + }); + + renderDetail(); + + expect(await screen.findByRole('heading', {name: 'Raft'})) + .toBeInTheDocument(); + expect(screen.getByRole('heading', {name: '存储与分区'})) + .toBeInTheDocument(); + expect(screen.queryByText('Raft 共识')).not.toBeInTheDocument(); +}); + +test('shows an empty state when a metric group only has hidden metadata', async () => { + getNode.mockResolvedValue({ + ...response, + node: { + ...response.node, + metrics: { + system: {}, + drive: {total_space: 100, free_space: 40, size_unit: 'MB'}, + }, + metric_statuses: { + system: {availability: 'AVAILABLE', observed_at: 1000}, + drive: {availability: 'AVAILABLE', observed_at: 1000}, + }, + }, + }); + + renderDetail(); + + const drive = (await screen.findByRole('heading', {name: 'Drive'})) + .closest('section'); + expect(within(drive).getByRole('note')).toHaveTextContent('No metric data'); + expect(drive.querySelector('.operations-group-statistics')) + .not.toBeInTheDocument(); }); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.js index e3cd98d7f..e21beea13 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.js @@ -190,6 +190,11 @@ const Overview = () => { const nodes = Array.isArray(data?.nodes) ? data.nodes : []; const attentionNodes = selectAttentionNodes(nodes); const attentionSources = selectAttentionSources(data?.sources); + const clusterReason = data?.reason?.startsWith('cluster_') + ? data.reason : null; + const showAttention = nodes.length > 0 + || attentionSources.length > 0 + || clusterReason; const facts = data?.facts ?? {}; const pdLeader = facts.pd_leader ?? nodes.find(node => ( node.type === 'PD' && node.role === 'LEADER' @@ -388,14 +393,11 @@ const Overview = () => { <div className='operations-overview-grid'> <section className='operations-topology-surface'> <div className='operations-section-heading'> - <div> - <h3> - {view === 'topology' - ? t('operations.topology') - : t('operations.node_list_view')} - </h3> - <span>{t('operations.logical_relationship')}</span> - </div> + <h3> + {view === 'topology' + ? t('operations.topology') + : t('operations.node_list_view')} + </h3> </div> {nodes.length === 0 ? <Empty description={t('operations.empty_cluster')} /> @@ -476,7 +478,7 @@ const Overview = () => { </div> <SourceStrip sources={data?.sources} /> </section> - {nodes.length > 0 && ( + {showAttention && ( <section className='operations-surface operations-attention' aria-label={t('operations.attention_items')} @@ -487,6 +489,26 @@ const Overview = () => { {t('operations.view_all_nodes')} <RightOutlined /> </Link> </div> + {clusterReason && ( + <Alert + className='operations-source-attention' + type='warning' + showIcon + message={t('operations.cluster_attention')} + description={t( + `operations.reason_${clusterReason}`, + { + defaultValue: clusterReason + .replaceAll('_', ' '), + } + )} + action={( + <Link to='/operations/nodes'> + {t('operations.view_all_nodes')} + </Link> + )} + /> + )} {attentionSources.map(source => { const type = source.name === 'stores' ? 'STORE' : source.name.toUpperCase(); @@ -527,7 +549,7 @@ const Overview = () => { /> ) : ( <p className='operations-nodes-healthy'> - {attentionSources.length > 0 + {(attentionSources.length > 0 || clusterReason) ? t('operations.nodes_healthy_source_attention') : t('operations.all_nodes_healthy')} </p> 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 54f777df9..aecd00c3a 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js @@ -484,7 +484,7 @@ test('keeps healthy freshness compact but preserves stale-source recovery contex const sources = await screen.findByRole('region', {name: 'Source freshness'}); expect(within(sources).queryByText(/Last success/)).not.toBeInTheDocument(); expect(within(sources).getByText(/Stale/)).toBeInTheDocument(); - expect(within(sources).getByLabelText(/Stale.*Last success/)) + expect(within(sources).getByLabelText(/Last observed.*Stale/)) .toBeInTheDocument(); }); @@ -505,14 +505,11 @@ test('shows a concise healthy state when no node needs attention', async () => { test('explains PD cluster attention while keeping healthy nodes explicit', async () => { getOverview.mockResolvedValue({ status: 'DEGRADED', + reason: 'cluster_not_ready', observed_at: 1000, sources: { server: {status: 'UP', availability: 'AVAILABLE'}, - pd: { - status: 'DEGRADED', - availability: 'AVAILABLE', - reason: 'cluster_not_ready', - }, + pd: {status: 'UP', availability: 'AVAILABLE'}, stores: {status: 'UP', availability: 'AVAILABLE'}, }, facts: {}, @@ -539,18 +536,46 @@ test('explains PD cluster attention while keeping healthy nodes explicit', async const attention = await screen.findByRole('region', { name: 'Items needing attention', }); - expect(within(attention).getByText('PD source needs attention')) + expect(within(attention).getByText('Cluster state needs attention')) .toBeInTheDocument(); expect(within(attention).getByText(/PD reports Cluster_Not_Ready/)) .toBeInTheDocument(); - expect(within(attention).getByRole('link', {name: 'View PD nodes'})) - .toHaveAttribute('href', '/operations/nodes?type=PD'); + expect(within(attention).getByRole('link', {name: 'View all nodes'})) + .toHaveAttribute('href', '/operations/nodes'); expect(within(attention).getByText( 'All nodes are healthy; the source or cluster state above is a separate signal.' )).toBeInTheDocument(); expect(within(attention).queryByRole('table')).not.toBeInTheDocument(); }); +test('shows failed sources even when discovery returned no nodes', async () => { + getOverview.mockResolvedValue({ + status: 'DOWN', + observed_at: 1000, + sources: { + server: { + status: 'DOWN', + availability: 'UNAVAILABLE', + reason: 'upstream_unavailable', + }, + pd: {status: 'UNSUPPORTED', availability: 'UNSUPPORTED'}, + stores: {status: 'UNSUPPORTED', availability: 'UNSUPPORTED'}, + }, + facts: {}, + nodes: [], + }); + + renderOverview(); + + const attention = await screen.findByRole('region', { + name: 'Items needing attention', + }); + expect(within(attention).getByText('Server source needs attention')) + .toBeInTheDocument(); + expect(within(attention).getByText('Upstream unavailable')) + .toBeInTheDocument(); +}); + test('switches between the topology and an accessible node list', async () => { getOverview.mockResolvedValue({ status: 'UP', diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js index 2bd2c70b7..c5bd18891 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js @@ -136,13 +136,16 @@ const SourceStrip = ({sources = {}, detailed = false, > {sourceNames.map(name => { const source = sources[name] ?? {}; - const age = source.observed_at ? formatObservedAge( - source.observed_at, + const lastObservedAt = source.stale + ? source.last_success_at ?? source.observed_at + : source.observed_at ?? source.last_success_at; + const age = lastObservedAt ? formatObservedAge( + lastObservedAt, i18n.language, t('operations.unavailable') ) : null; - const observed = source.observed_at ? formatObservedAt( - source.observed_at, + const observed = lastObservedAt ? formatObservedAt( + lastObservedAt, i18n.language, t('operations.unavailable') ) : null; @@ -152,22 +155,16 @@ const SourceStrip = ({sources = {}, detailed = false, defaultValue: source.availability ?? 'UNSUPPORTED', }); const healthSummary = sourceHealthSummary(source, t); - const lastSuccess = source.last_success_at ? formatObservedAt( - source.last_success_at, - i18n.language, - t('operations.unavailable') - ) : null; const sourceDetails = [ healthSummary, `${t('operations.source_topology_status')}: ${ displayHealthStatus(source.status ?? 'UNKNOWN', t) }`, `${t('operations.source_collection_status')}: ${availability}`, - observed ? `${t('operations.observed_at')}: ${observed}` : null, + observed + ? `${t('operations.last_observed')}: ${observed}` : null, source.stale ? t('operations.stale') : null, source.reason ? formatReason(source.reason, t) : null, - lastSuccess - ? `${t('operations.last_success')}: ${lastSuccess}` : null, ].filter(Boolean).join(' · '); const sourceLabel = displayNodeType(name === 'stores' ? 'STORE' : name.toUpperCase()); @@ -206,14 +203,13 @@ const SourceStrip = ({sources = {}, detailed = false, availability }`} {observed - ? ` · ${t('operations.observed_at')}: ${observed}` + ? ` · ${t('operations.last_observed')}: ${ + observed + }` : (age ? ` · ${age}` : '')} {source.stale ? ` · ${t('operations.stale')}` : ''} {source.reason ? ` · ${formatReason(source.reason, t)}` : ''} - {lastSuccess - ? ` · ${t('operations.last_success')}: ${lastSuccess}` - : ''} </span> )} </div> 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 562647b91..6cf5b4d8c 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js @@ -60,6 +60,32 @@ test('localizes the standalone deployment reason code', async () => { .not.toBeInTheDocument(); }); +test('uses the last successful data time for a stale source', () => { + render( + <SourceStrip + detailed + sources={{ + stores: { + status: 'UP', + availability: 'PARTIAL', + stale: true, + observed_at: 2000, + last_success_at: 1000, + }, + }} + sourceNames={['stores']} + /> + ); + + const source = screen.getByText('Store').closest('.operations-source'); + const formatter = new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'medium', + }); + expect(source).toHaveTextContent(`Last observed: ${formatter.format(new Date(1000))}`); + expect(source).not.toHaveTextContent(formatter.format(new Date(2000))); +}); + test('uses a concise Attention label and explains the degraded state', () => { render(<HealthStatus status='DEGRADED' reason='refresh_failed' />); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/operations.scss b/hugegraph-hubble/hubble-fe/src/pages/Operations/operations.scss index 13232b3e9..9cdb685dd 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/operations.scss +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/operations.scss @@ -644,12 +644,18 @@ .operations-metric-status { display: flex; + align-items: center; flex-wrap: wrap; gap: 4px 10px; color: var(--workbench-color-text-secondary); font-size: 12px; } +.operations-metric-fresh { + color: #237804; + font-size: 14px; +} + .operations-metric-empty { display: flex; align-items: center; @@ -699,8 +705,9 @@ .operations-memory-usage { min-width: 0; padding: 12px 14px; + border: 1px solid var(--workbench-color-border); border-radius: var(--workbench-radius); - background: var(--workbench-color-canvas); + background: var(--workbench-color-surface); > div:first-child { display: flex; @@ -726,7 +733,8 @@ grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 10px; - .ant-statistic { + .ant-statistic, + .operations-statistic-card { min-width: 0; padding: 10px 12px; border: 1px solid var(--workbench-color-border); @@ -739,11 +747,27 @@ } } -.operations-system-details { - display: flex; - flex-wrap: wrap; - gap: 8px 20px; +.operations-group-statistics { + padding-top: 16px; +} + +.operations-statistic-title { + display: block; color: var(--workbench-color-text-secondary); + font-size: 14px; + line-height: 22px; +} + +.operations-statistic-value { + margin-top: 4px; + color: var(--workbench-color-text); + font-size: 20px; + line-height: 30px; + + > div { + font-size: 14px; + line-height: 22px; + } } section.operations-metric-group .ant-descriptions-view .ant-descriptions-item-content {
