You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by ji...@apache.org on 2026/10/22 18:58:02 UTC

(hugegraph-toolchain) branch master updated: perf(hubble): render metadata before bounded graph statistics (#832)

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


The following commit(s) were added to refs/heads/master by this push:
     new 1d8375787 perf(hubble): render metadata before bounded graph statistics (#832)
1d8375787 is described below

commit 1d8375787f70cd723e957b8aac1dacaa47364fdd
Author: imbajin <ji...@apache.org>
AuthorDate: Fri Oct 23 02:57:56 2026 +0800

    perf(hubble): render metadata before bounded graph statistics (#832)
    
    Render graphspace/graph metadata without waiting for counts or schema scans. Page loads must not trigger full V/E statistics, and missing snapshots must not look like empty graphs.
---
 .../controller/graphs/GraphsController.java        |  15 +-
 .../controller/query/GremlinQueryController.java   |  31 +-
 .../controller/space/GraphSpaceController.java     |  19 +
 .../entity/graphs/GraphStatisticsEntity.java       |   4 +
 .../hugegraph/service/HugeClientPoolService.java   |  83 ++++
 .../hugegraph/service/graphs/GraphsService.java    | 249 +++++-----
 .../hugegraph/service/space/GraphSpaceService.java |  57 ++-
 .../controller/space/GraphSpaceControllerTest.java |  49 ++
 .../service/space/GraphSpaceServiceTest.java       | 194 +++++++-
 .../unit/GraphsControllerCanonicalTest.java        | 104 ++++
 .../hugegraph/unit/GraphsServiceDefaultTest.java   | 535 +++++++++++++++++----
 .../unit/GremlinOverviewStatisticsTest.java        |  86 ++++
 .../hugegraph/unit/HugeClientPoolServiceTest.java  | 190 ++++++++
 .../unit/StatisticsPolicyCompatibilityTest.java    | 234 +++++++++
 .../src/api/analysis-context-contract.test.js      |  13 +-
 hugegraph-hubble/hubble-fe/src/api/analysis.js     |  11 +-
 .../hubble-fe/src/api/manage-contract.test.js      |   7 +
 hugegraph-hubble/hubble-fe/src/api/manage.js       |   5 +-
 .../src/components/GraphContextSwitcher/index.js   | 206 +++++---
 .../components/GraphContextSwitcher/index.test.js  | 213 +++++++-
 .../src/i18n/resources/en-US/modules/analysis.json |   4 +-
 .../src/i18n/resources/en-US/modules/pages.json    |  14 +-
 .../src/i18n/resources/zh-CN/modules/analysis.json |   4 +-
 .../src/i18n/resources/zh-CN/modules/pages.json    |  14 +-
 .../query-context.integration.test.js              |  24 +-
 .../modules/algorithm/GraphResult/Home/index.js    |   8 +-
 .../algorithm/GraphResult/Home/index.test.js       |  19 +-
 .../hubble-fe/src/modules/algorithm/Home/index.js  |   6 +-
 .../src/modules/algorithm/Home/index.test.js       |   7 +-
 .../modules/algorithm/algorithmsForm/Home/index.js |   3 +-
 .../algorithm/algorithmsForm/Home/index.test.js    |  13 +-
 .../hubble-fe/src/modules/analysis/Home/index.js   |   6 +-
 .../src/modules/analysis/Home/index.test.js        |   7 +-
 .../analysis/QueryResult/GraphResult/Home/index.js |   2 +
 .../QueryResult/GraphResult/Home/index.test.js     |   4 +-
 .../src/modules/component/NumberCard/index.js      |  26 +-
 .../src/modules/component/NumberCard/index.test.js |  31 +-
 .../src/modules/component/TopBar/index.js          |  74 +--
 .../src/modules/component/TopBar/index.test.js     | 273 ++++++++++-
 hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js |  72 ++-
 .../hubble-fe/src/pages/Graph/Card.test.js         |  65 ++-
 .../hubble-fe/src/pages/GraphDetail/index.js       | 105 ++--
 .../hubble-fe/src/pages/GraphDetail/index.test.js  | 460 ++++++------------
 .../hubble-fe/src/pages/GraphSpace/Card.js         |  12 +-
 .../hubble-fe/src/pages/GraphSpace/Card.test.js    |  13 +-
 .../hubble-fe/src/pages/TaskEdit/BaseForm/index.js |  53 +-
 .../TaskEdit/BaseForm/name-validation.test.js      |   4 +
 .../TaskEdit/BaseForm/schema-readiness.test.js     | 175 +++++++
 .../src/pages/TaskEdit/option-recovery.test.js     |   2 +
 .../hubble-fe/src/utils/graphOptions.js            |  65 +++
 .../hubble-fe/src/utils/graphOptions.test.js       |  71 +++
 .../hubble-fe/src/utils/graphStatistics.js         |  39 ++
 52 files changed, 3124 insertions(+), 856 deletions(-)

diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java
index 0554ba625..81e68201f 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java
@@ -175,6 +175,19 @@ public class GraphsController extends BaseController {
         return ImmutableMap.of("graphs", sortedGraphs);
     }
 
+    @GetMapping(params = "brief=true")
+    public Object queryBriefPage(
+            @PathVariable("graphspace") String graphspace,
+            @RequestParam(name = "query", defaultValue = "") String query,
+            @RequestParam(name = "create_time", defaultValue = "") String createTime,
+            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
+            @RequestParam(name = "page_size", defaultValue = "100") int pageSize,
+            @RequestParam(name = "selected", defaultValue = "") String selected) {
+        return this.graphsService.queryBriefPage(
+                this.authClient(graphspace, null), graphspace,
+                query, createTime, pageNo, pageSize, selected);
+    }
+
     @GetMapping
     public Object queryPage(@PathVariable("graphspace") String graphspace,
                             @RequestParam(name = "query", required = false,
@@ -218,7 +231,7 @@ public class GraphsController extends BaseController {
     public GraphStatisticsEntity getStatistics(
             @PathVariable("graphspace") String graphspace,
             @PathVariable("graph") String graph) {
-        HugeClient client = this.authClient(graphspace, graph);
+        HugeClient client = this.authGremlinClient(graphspace, graph);
         GraphStatisticsEntity result =
                 graphsService.getStatistics(client, graphspace,
                                             graph);
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/GremlinQueryController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/GremlinQueryController.java
index bd5b6dc27..2b039058a 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/GremlinQueryController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/GremlinQueryController.java
@@ -48,7 +48,7 @@ import org.apache.hugegraph.entity.query.GremlinResult;
 import org.apache.hugegraph.exception.ExternalException;
 import org.apache.hugegraph.service.query.ExecuteHistoryService;
 import org.apache.hugegraph.service.query.QueryService;
-import org.apache.hugegraph.structure.gremlin.ResultSet;
+import org.apache.hugegraph.service.graphs.GraphsService;
 import org.apache.hugegraph.util.Ex;
 import org.apache.hugegraph.util.HubbleUtil;
 import org.apache.hugegraph.entity.enums.QueryLanguage;
@@ -75,29 +75,28 @@ public class GremlinQueryController extends GremlinController {
     private QueryService queryService;
     @Autowired
     private ExecuteHistoryService historyService;
+    @Autowired
+    private GraphsService graphsService;
 
     @GetMapping
-    public Map<String, String> execute(@PathVariable("graphspace") String graphSpace,
+    public Map<String, Object> execute(@PathVariable("graphspace") String graphSpace,
                                        @PathVariable("graph") String graph) {
 
         HugeClient client = this.authGremlinClient(graphSpace, graph);
-        Map<String, String> graphCount = new HashMap<>();
-        graphCount.put("vertexcount", queryCount(client, "g.V().count()"));
-        graphCount.put("edgecount", queryCount(client, "g.E().count()"));
+        Map<String, Object> recorded = this.graphsService.evCount(client, graphSpace, graph);
+        Map<String, Object> graphCount = new HashMap<>();
+        graphCount.put("vertexcount", recordedCount(recorded.get("vertex")));
+        graphCount.put("edgecount", recordedCount(recorded.get("edge")));
+        graphCount.put("date", recorded.get("date"));
+        graphCount.put("approximate", true);
+        if (recorded.containsKey("scan_limit")) {
+            graphCount.put("scan_limit", recorded.get("scan_limit"));
+        }
         return graphCount;
     }
 
-    private String queryCount(HugeClient client, String gremlin) {
-        try {
-            ResultSet result = this.queryService.executeQueryCount(client, gremlin);
-            if (result.data() == null || result.data().isEmpty()) {
-                return "max";
-            }
-            return String.valueOf(result.data().get(0));
-        } catch (Throwable e) {
-            LOG.warn("Failed to execute gremlin overview count: {}", gremlin, e);
-            return "max";
-        }
+    private static String recordedCount(Object count) {
+        return count == null ? null : String.valueOf(count);
     }
 
     /**
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java
index 20c60efa4..d8a911126 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java
@@ -92,6 +92,25 @@ public class GraphSpaceController extends BaseController {
         return ImmutableMap.of("graphspaces", graphSpaces);
     }
 
+    @GetMapping(params = "brief=true")
+    public Object queryBriefPage(
+            @RequestParam(name = "query", defaultValue = "") String query,
+            @RequestParam(name = "create_time", defaultValue = "") String createTime,
+            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
+            @RequestParam(name = "page_size", defaultValue = "100") int pageSize,
+            @RequestParam(name = "selected", defaultValue = "") String selected) {
+        if (!isPdEnabled()) {
+            return ImmutableMap.of("records", Collections.emptyList(),
+                                   "total", 0);
+        }
+        HugeClient client = this.authClient(null, null);
+        boolean anonymous = this.authMode != null && this.authMode.anonymous();
+        boolean administrator = !anonymous && this.userService.isSuperAdmin(client);
+        return this.graphSpaceService.queryBriefPage(
+                client, query, createTime, pageNo, pageSize,
+                anonymous, administrator, selected);
+    }
+
     @GetMapping
     public Object queryPage(@RequestParam(name = "query", required = false,
                                     defaultValue = "") String query,
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/graphs/GraphStatisticsEntity.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/graphs/GraphStatisticsEntity.java
index 0dfee7287..f71a6be34 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/graphs/GraphStatisticsEntity.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/graphs/GraphStatisticsEntity.java
@@ -40,6 +40,10 @@ public class GraphStatisticsEntity {
     public String edgeCount;
     @JsonProperty("update_time")
     public String updateTime;
+    @JsonProperty("approximate")
+    public boolean approximate;
+    @JsonProperty("scan_limit")
+    public int scanLimit;
     @JsonProperty("vertices")
     public Map<String, Object> vertices;
     @JsonProperty("edges")
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 9281e0068..d2e9d0dc6 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
@@ -24,11 +24,13 @@ import static org.apache.hugegraph.driver.factory.PDHugeClientFactory.DEFAULT_SE
 import java.net.ConnectException;
 import java.net.SocketTimeoutException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.WeakHashMap;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 
@@ -36,11 +38,15 @@ import jakarta.annotation.PostConstruct;
 import jakarta.annotation.PreDestroy;
 import javax.net.ssl.SSLException;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableList;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.collections.CollectionUtils;
+import org.apache.commons.codec.digest.DigestUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hugegraph.config.HugeConfig;
 import org.apache.hugegraph.driver.HugeClient;
@@ -49,7 +55,11 @@ import org.apache.hugegraph.entity.GraphConnection;
 import org.apache.hugegraph.exception.ParameterizedException;
 import org.apache.hugegraph.exception.ServerException;
 import org.apache.hugegraph.options.HubbleOptions;
+import org.apache.hugegraph.structure.auth.TokenPayload;
+import org.apache.hugegraph.structure.auth.User;
+import org.apache.hugegraph.util.E;
 import org.apache.hugegraph.util.HugeClientUtil;
+import org.apache.hugegraph.util.JsonUtil;
 import org.apache.hugegraph.util.UrlUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -58,6 +68,9 @@ import org.springframework.stereotype.Service;
 @Service
 public final class HugeClientPoolService {
 
+    private static final ObjectMapper POLICY_MAPPER = new ObjectMapper()
+            .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
+
     @Autowired
     private HugeConfig config;
     @Autowired
@@ -68,6 +81,56 @@ public final class HugeClientPoolService {
     private PDHugeClientFactory pdHugeClientFactory;
 
     private final Map<String, HugeClient> clients = new ConcurrentHashMap<>();
+    private final Map<HugeClient, StatisticsContext> statisticsContexts =
+            Collections.synchronizedMap(new WeakHashMap<>());
+
+    public String statisticsContext(HugeClient client) {
+        StatisticsContext context = this.statisticsContexts.get(client);
+        return context == null ? null : context.key();
+    }
+
+    /** Return null when current policy metadata cannot safely support snapshot reuse. */
+    public String statisticsPolicy(HugeClient client) {
+        StatisticsContext context = this.statisticsContexts.get(client);
+        if (context == null || (!context.bearer() && context.username() == null)) {
+            return "";
+        }
+        User.UserRole policy;
+        try {
+            Object userId;
+            if (context.bearer()) {
+                TokenPayload identity = client.auth().verifyToken();
+                E.checkState(identity != null && StringUtils.isNotEmpty(identity.userId()),
+                             "Current-user identity is unavailable");
+                userId = identity.userId();
+            } else {
+                if (!client.supportsDefaultRole()) {
+                    // Legacy lookup lists all users and may require auth administration.
+                    return null;
+                }
+                User user = client.findUserByName(context.username());
+                E.checkState(user != null && context.username().equals(user.name()),
+                             "Current-user identity is unavailable");
+                userId = user.id();
+            }
+            policy = client.auth().getUserRole(userId);
+        } catch (ServerException e) {
+            if (e.status() == 403 || e.status() == 404) {
+                // Older readers may query graph data without access to auth metadata.
+                return null;
+            }
+            throw e;
+        }
+        E.checkState(policy != null, "Current authorization policy is unavailable");
+        try {
+            return DigestUtils.sha256Hex(POLICY_MAPPER.writeValueAsBytes(policy.roles()));
+        } catch (JsonProcessingException e) {
+            throw new IllegalStateException("Unable to serialize current authorization policy", e);
+        }
+    }
+
+    private record StatisticsContext(String key, String username, boolean bearer) {
+    }
 
     private Cache<String, List<String>> urlCache = CacheBuilder.newBuilder()
             .maximumSize(HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES.defaultValue())
@@ -175,6 +238,17 @@ public final class HugeClientPoolService {
                                             token, username, password,
                                             timeoutOverride);
                     tmpclient.versionManager().getApiVersion();
+                    if (this.config.get(HubbleOptions.PD_ENABLED)) {
+                        List<String> peers = Arrays.stream(this.config.get(HubbleOptions.PD_PEERS).split(","))
+                                                   .map(String::trim).filter(peer -> !peer.isEmpty())
+                                                   .distinct().sorted().toList();
+                        // PD may select another replica for the same logical
+                        // graph. Share statistics within its cluster and auth
+                        // context, while explicit URLs remain isolated below.
+                        this.registerStatisticsContext(tmpclient,
+                                Arrays.asList("pd", peers, this.cluster),
+                                token, username, password);
+                    }
                     return tmpclient;
                 } catch (Exception e) {
                     if (tmpclient != null) {
@@ -214,10 +288,19 @@ public final class HugeClientPoolService {
         }
         this.sslService.configSSL(this.config, connection);
         HugeClient client = HugeClientUtil.tryConnect(connection);
+        this.registerStatisticsContext(client, url, token, username, password);
 
         return client;
     }
 
+    private void registerStatisticsContext(HugeClient client, Object target,
+                                           String token, String username, String password) {
+        String authType = token != null ? "bearer" : username != null ? "basic" : "anonymous";
+        String key = DigestUtils.sha256Hex(JsonUtil.toJson(
+                Arrays.asList(target, authType, token, username, password)));
+        this.statisticsContexts.put(client, new StatisticsContext(key, username, token != null));
+    }
+
     /**
      * 获取所有可用的URL列表
      *
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java
index 0c7af73f0..9f35df8f1 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java
@@ -19,8 +19,11 @@
 package org.apache.hugegraph.service.graphs;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.UncheckedExecutionException;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.lang.StringUtils;
 import org.apache.commons.lang3.time.StopWatch;
@@ -33,6 +36,7 @@ import org.apache.hugegraph.controller.query.GremlinController;
 import org.apache.hugegraph.driver.GraphsManager;
 import org.apache.hugegraph.driver.HugeClient;
 import org.apache.hugegraph.entity.GraphConnection;
+import org.apache.hugegraph.service.HugeClientPoolService;
 import org.apache.hugegraph.entity.enums.AsyncTaskStatus;
 import org.apache.hugegraph.entity.enums.ExecuteStatus;
 import org.apache.hugegraph.entity.enums.ExecuteType;
@@ -42,7 +46,6 @@ import org.apache.hugegraph.entity.query.GremlinQuery;
 import org.apache.hugegraph.entity.space.BuiltInEntity;
 import org.apache.hugegraph.exception.ServerException;
 import org.apache.hugegraph.loader.util.JsonUtil;
-import org.apache.hugegraph.options.HubbleOptions;
 import org.apache.hugegraph.service.algorithm.AsyncTaskService;
 import org.apache.hugegraph.service.auth.UserService;
 import org.apache.hugegraph.service.auth.AuthModeService;
@@ -50,7 +53,6 @@ import org.apache.hugegraph.service.load.LoadTaskService;
 import org.apache.hugegraph.service.query.ExecuteHistoryService;
 import org.apache.hugegraph.service.query.QueryService;
 import org.apache.hugegraph.service.schema.SchemaService;
-import org.apache.hugegraph.structure.GraphElement;
 import org.apache.hugegraph.structure.Task;
 import org.apache.hugegraph.structure.constant.GraphReadMode;
 import org.apache.hugegraph.structure.gremlin.ResultSet;
@@ -65,7 +67,6 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
-import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -73,6 +74,7 @@ import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
@@ -103,21 +105,26 @@ public class GraphsService {
     private LoadTaskService loadTaskService;
     @Autowired
     private org.apache.hugegraph.config.HugeConfig config;
+    @Autowired
+    private HugeClientPoolService clientPool;
 
     private static final String GRAPH_STORAGE = "v1/graph/%s/%s/g";
     private static final String RUNNING_TASKS = "running_tasks";
     private static final String STATISTICS = "statistics";
     private static final String GREMLIN_STATISTICS_VERTEX =
-            "g.V().groupCount().by(label)";
+            "g.V().limit(100000).groupCount().by(label)";
     private static final String GREMLIN_STATISTICS_EDGE =
-            "g.E().groupCount().by(label)";
-    private static final int SMALL_STATISTICS_LIMIT = 10000;
-    private static final int SMALL_STATISTICS_PAGE_SIZE = 1000;
+            "g.E().limit(100000).groupCount().by(label)";
+    private static final int STATISTICS_SCAN_LIMIT = 100000;
     private static final String GRAPH_HLM = "hlm";
     private static final String GRAPH_COVID19 = "covid19";
 
     private final ConcurrentHashMap<String, Map<String, Object>> graphStatistics =
             new ConcurrentHashMap<>();
+    private Cache<String, GraphStatisticsEntity> boundedStatistics = CacheBuilder.newBuilder()
+            .maximumSize(1024)
+            .expireAfterWrite(30, TimeUnit.MINUTES)
+            .build();
 
     public Map<String, Object> get(HugeClient client, String graphSpace,
                                    String graph,
@@ -131,9 +138,8 @@ public class GraphsService {
         info.putIfAbsent("graphspace", graphSpace);
         if (vermeerInfo.size() != 0) {
             info.put("status", vermeerInfo.get("status"));
-            String lastLoadTime = vermeerInfo.get("update_time");
             // todo date format
-            info.put("last_load_time", lastLoadTime);
+            info.put("last_load_time", vermeerInfo.get("last_load_time"));
         }
         return info;
     }
@@ -144,22 +150,70 @@ public class GraphsService {
                                                 int pageNo, int pageSize,
                                                 boolean isVermeerEnabled,
                                                 Map<String, Object> vermeerInfo) {
-        List<Map<String, Object>> results =
-                sortedGraphsProfile(client, graphSpace, query, createTime,
-                                    isVermeerEnabled, vermeerInfo);
+        return PageUtil.page(
+                sortedGraphsMetadata(client, graphSpace, query, createTime,
+                                     isVermeerEnabled, vermeerInfo),
+                pageNo, pageSize);
+    }
+
+    public Map<String, Object> queryBriefPage(
+            HugeClient client, String graphSpace, String query,
+            String createTime, int pageNo, int pageSize, String selected) {
+        PageUtil.checkPositivePage(pageNo, pageSize);
+        // The upstream SDK has no profile cursor. Read authorized profiles once,
+        // without decorating every graph or fetching the selected graph again.
+        List<Map<String, Object>> profiles = listGraphProfiles(client.graphs(), "").stream()
+                .filter(info -> info.get("create_time") == null ||
+                                info.get("create_time").toString()
+                                    .compareTo(StringUtils.defaultString(createTime)) > 0)
+                .collect(Collectors.toList());
+        Map<String, Object> selectedGraph = profiles.stream()
+                .filter(info -> info.get("name").equals(selected))
+                .findFirst().map(GraphsService::briefGraph).orElse(null);
+        String search = StringUtils.defaultString(query);
+        List<Map<String, Object>> matching = profiles.stream()
+                .filter(info -> StringUtils.containsIgnoreCase(info.get("name").toString(), search) ||
+                                StringUtils.containsIgnoreCase((String) info.get("nickname"), search))
+                .sorted(GraphsService::compareGraphs)
+                .collect(Collectors.toList());
+        IPage<Map<String, Object>> page = PageUtil.page(matching, pageNo, pageSize);
+        Map<String, Object> result = new HashMap<>();
+        result.put("records", page.getRecords().stream().map(GraphsService::briefGraph)
+                                  .collect(Collectors.toList()));
+        result.put("total", page.getTotal());
+        result.put("current", page.getCurrent());
+        result.put("size", page.getSize());
+        result.put("pages", page.getPages());
+        result.put("selected", selectedGraph);
+        return result;
+    }
 
-        for (Map<String, Object> result : results) {
-            String graph = result.get("name").toString();
-            try {
-                result.put("schemaview", schemaService.getSchemaView(
-                        client.assignGraph(graphSpace, graph)));
-            } catch (Exception e) {
-                log.warn("Failed to load the schema summary for graph '{}'",
-                         graph, e);
+    private static Map<String, Object> briefGraph(Map<String, Object> source) {
+        Map<String, Object> brief = new HashMap<>();
+        brief.put("name", source.get("name"));
+        Object nickname = source.get("nickname");
+        brief.put("nickname", nickname == null ? source.get("name") : nickname);
+        return brief;
+    }
+
+    private static int compareGraphs(Map<String, Object> first,
+                                     Map<String, Object> second) {
+        boolean firstDefault = Boolean.TRUE.equals(first.get("default"));
+        boolean secondDefault = Boolean.TRUE.equals(second.get("default"));
+        if (firstDefault != secondDefault) {
+            return Boolean.compare(secondDefault, firstDefault);
+        }
+        if (firstDefault) {
+            Object firstTime = first.get("default_update_time");
+            Object secondTime = second.get("default_update_time");
+            if (firstTime instanceof Long && secondTime instanceof Long) {
+                int order = ((Long) firstTime).compareTo((Long) secondTime);
+                if (order != 0) {
+                    return order;
+                }
             }
         }
-
-        return PageUtil.page(results, pageNo, pageSize);
+        return first.get("name").toString().compareTo(second.get("name").toString());
     }
 
     public List<Map<String, Object>> sortedGraphsProfile(HugeClient client,
@@ -168,6 +222,15 @@ public class GraphsService {
                                                          String createTime,
                                                          boolean isVermeerEnabled,
                                                          Map<String, Object> vermeerInfo) {
+        return sortedGraphsMetadata(
+                client, graphSpace, query, createTime,
+                isVermeerEnabled, vermeerInfo);
+    }
+
+    private List<Map<String, Object>> sortedGraphsMetadata(
+            HugeClient client, String graphSpace, String query,
+            String createTime, boolean isVermeerEnabled,
+            Map<String, Object> vermeerInfo) {
         // Get authorized graphs
         List<Map<String, Object>> graphs = listGraphProfiles(client.graphs(), query);
         log.info("Query all graphs in '{}' ", graphSpace);
@@ -191,10 +254,12 @@ public class GraphsService {
             }
 
             info.put("storage", info.get("data_size"));
+            // Core metadata must not depend on a graph-wide scan or a remote
+            // statistics request. Counts and schema previews load separately.
+            info.put("statistic", Collections.emptyMap());
             // Ensure graphspace field is present for frontend navigation
             info.putIfAbsent("graphspace", graphSpace);
             info.putIfAbsent("graphspace_nickname", graphSpace);
-            info.put("statistic", evCount(client, graphSpace, name));
         }
 
         List<Map<String, Object>> results =
@@ -208,27 +273,7 @@ public class GraphsService {
                           return createTimeVal.toString()
                                              .compareTo(createTime) > 0;
                       })
-                      .sorted((graph1, graph2) -> {
-                          Object d1 = graph1.get("default");
-                          Object d2 = graph2.get("default");
-                          boolean default1 = d1 instanceof Boolean && (boolean) d1;
-                          boolean default2 = d2 instanceof Boolean && (boolean) d2;
-
-                          if (default1 != default2) {
-                              return Boolean.compare(default2, default1);
-                          } else if (default1) {
-                              Object t1 = graph1.get("default_update_time");
-                              Object t2 = graph2.get("default_update_time");
-                              if (t1 instanceof Long && t2 instanceof Long) {
-                                  return ((Long) t1).compareTo((Long) t2);
-                              }
-                              return 0;
-                          } else {
-                              String name1 = graph1.get("name").toString();
-                              String name2 = graph2.get("name").toString();
-                              return name1.compareTo(name2);
-                          }
-                      })
+                      .sorted(GraphsService::compareGraphs)
                       .collect(Collectors.toList());
         return results;
 
@@ -461,12 +506,29 @@ public class GraphsService {
     public GraphStatisticsEntity getStatistics(HugeClient client,
                                                String graphSpace,
                                                String graph) {
-        GraphStatisticsEntity result;
-        // Only invalidate the cache entry of the current graph
-        this.graphStatistics.remove(getStatisticsKey(graphSpace, graph));
-        result = postSmallStatistics(client, graphSpace, graph);
+        String key = this.statisticsKey(client, graphSpace, graph);
+        if (key == null) {
+            // Snapshot reuse requires a stable target, identity and current policy.
+            return this.collectBoundedStatistics(client);
+        }
+        try {
+            return this.boundedStatistics.get(key, () -> this.collectBoundedStatistics(client));
+        } catch (ExecutionException | UncheckedExecutionException e) {
+            if (e.getCause() instanceof RuntimeException) {
+                throw (RuntimeException) e.getCause();
+            }
+            throw new IllegalStateException(e.getCause());
+        }
+    }
 
-        return result;
+    private String statisticsKey(HugeClient client, String graphSpace, String graph) {
+        String context = this.clientPool.statisticsContext(client);
+        if (context == null) {
+            return null;
+        }
+        String policy = this.clientPool.statisticsPolicy(client);
+        return policy == null ? null : "bounded:" + context + ":" + policy + ":" +
+               JsonUtil.toJson(java.util.Arrays.asList(graphSpace, graph));
     }
 
     public void postStatistics(RestClient pdClient,
@@ -617,19 +679,7 @@ public class GraphsService {
         return result;
     }
 
-    public GraphStatisticsEntity postSmallStatistics(HugeClient client,
-                                                     String graphSpace,
-                                                     String graph) {
-        try {
-            return this.postSmallGremlinStatistics(client);
-        } catch (RuntimeException e) {
-            log.warn("Gremlin statistics failed for {}/{}, falling back " +
-                     "to bounded graph reads", graphSpace, graph, e);
-            return this.postSmallGraphStatistics(client);
-        }
-    }
-
-    private GraphStatisticsEntity postSmallGremlinStatistics(HugeClient client) {
+    public GraphStatisticsEntity collectBoundedStatistics(HugeClient client) {
         ResultSet vertexResult =
                 queryService.executeQueryCount(client,
                                                GREMLIN_STATISTICS_VERTEX);
@@ -645,6 +695,8 @@ public class GraphsService {
         result.setEdges(edges);
         result.setEdgeCount(getCountFromLabels(edges));
         result.setUpdateTime(HubbleUtil.dateFormat());
+        result.setApproximate(true);
+        result.setScanLimit(STATISTICS_SCAN_LIMIT);
         return result;
     }
 
@@ -657,45 +709,6 @@ public class GraphsService {
         return HubbleUtil.uncheckedCast(data.get(0));
     }
 
-    private GraphStatisticsEntity postSmallGraphStatistics(HugeClient client) {
-        Map<String, Object> vertexCounts = new HashMap<>();
-        int vertexCount = countSmallElements(
-                client.graph().iterateVertices(SMALL_STATISTICS_PAGE_SIZE),
-                vertexCounts);
-        Map<String, Object> edgeCounts = new HashMap<>();
-        int edgeCount = countSmallElements(
-                client.graph().iterateEdges(SMALL_STATISTICS_PAGE_SIZE),
-                edgeCounts);
-
-        GraphStatisticsEntity result = GraphStatisticsEntity.emptyEntity();
-        result.setVertices(vertexCounts);
-        result.setVertexCount(String.valueOf(vertexCount));
-        result.setEdges(edgeCounts);
-        result.setEdgeCount(String.valueOf(edgeCount));
-        result.setUpdateTime(HubbleUtil.dateFormat());
-        return result;
-    }
-
-    private static int countSmallElements(
-            Iterator<? extends GraphElement> elements,
-            Map<String, Object> counts) {
-        int count = 0;
-        while (elements.hasNext()) {
-            GraphElement element = elements.next();
-            count++;
-            Ex.check(count <= SMALL_STATISTICS_LIMIT,
-                     "Small graph statistics fallback exceeds %s elements",
-                     SMALL_STATISTICS_LIMIT);
-            incrementLabel(counts, element.label());
-        }
-        return count;
-    }
-
-    private static void incrementLabel(Map<String, Object> counts, String label) {
-        Number count = (Number) counts.getOrDefault(label, 0L);
-        counts.put(label, Math.addExact(count.longValue(), 1L));
-    }
-
     public String getCountFromLabels(Map<String, Object> labels) {
         long count = 0L;
         for (Map.Entry<String, Object> entry: labels.entrySet()) {
@@ -799,25 +812,37 @@ public class GraphsService {
     }
 
     /**
-     * 统计指定单个图中的顶点总数和边总数
+     * Read recorded element counts without starting a graph-wide count.
+     * A graph without a recent statistics snapshot has unknown counts.
      */
     public Map<String, Object> evCount(HugeClient client,
                                        String graphSpace,
                                        String graph) {
         Map<String, Object> res = new HashMap<>();
+        String key = this.statisticsKey(client, graphSpace, graph);
+        GraphStatisticsEntity statistics = key == null ? null : this.boundedStatistics.getIfPresent(key);
+        if (statistics != null) {
+            res.put("date", statistics.getUpdateTime().substring(0, 10).replace("-", ""));
+            res.put("vertex", Long.valueOf(statistics.getVertexCount()));
+            res.put("edge", Long.valueOf(statistics.getEdgeCount()));
+            res.put("approximate", true);
+            res.put("scan_limit", statistics.getScanLimit());
+            return res;
+        }
         Long edgeCount = null;
         Long vertexCount = null;
         String statisticDate = HubbleUtil.dateFormatDay(HubbleUtil.nowDate());
         client.assignGraph(graphSpace, graph);
         GraphMetricsAPI.ElementCount statistic = null;
-        boolean pdEnabled = this.config != null &&
-                            this.config.get(HubbleOptions.PD_ENABLED);
-        if (!pdEnabled) {
+        try {
             statistic = client.graph().getEVCount(statisticDate);
             if (statistic == null) {
                 statisticDate = HubbleUtil.dateFormatLastDay();
                 statistic = client.graph().getEVCount(statisticDate);
             }
+        } catch (RuntimeException e) {
+            log.warn("Recorded element counts are unavailable for {}/{}",
+                     graphSpace, graph, e);
         }
 
         if (statistic != null) {
@@ -825,24 +850,12 @@ public class GraphsService {
             edgeCount = statistic.getEdges();
         } else {
             statisticDate = null;
-            try {
-                GraphStatisticsEntity live =
-                        this.postSmallGraphStatistics(client);
-                vertexCount = Long.valueOf(live.getVertexCount());
-                edgeCount = Long.valueOf(live.getEdgeCount());
-                statisticDate = HubbleUtil.dateFormatDay(HubbleUtil.nowDate());
-            } catch (RuntimeException e) {
-                vertexCount = null;
-                edgeCount = null;
-                statisticDate = null;
-                log.warn("Live element counts are unavailable for {}/{}",
-                         graphSpace, graph, e);
-            }
         }
 
         res.put("date", statisticDate);
         res.put("vertex", vertexCount);
         res.put("edge", edgeCount);
+        res.put("approximate", true);
         return res;
     }
 }
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java
index f05c93edf..61ab44297 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java
@@ -133,6 +133,58 @@ public class GraphSpaceService {
                 .collect(Collectors.toList());
     }
 
+    public Map<String, Object> queryBriefPage(
+            HugeClient client, String query, String createTime,
+            int pageNo, int pageSize, boolean anonymous, boolean administrator,
+            String selected) {
+        PageUtil.checkPositivePage(pageNo, pageSize);
+        if (administrator && !anonymous) {
+            return briefPage(queryAllGsMetadata(client, "", createTime),
+                             query, pageNo, pageSize, selected,
+                             info -> info.get("name").toString(),
+                             info -> (String) info.get("nickname"));
+        }
+        List<GraphSpace> spaces = anonymous ?
+                queryAnonymousSpaces(client, "", createTime) :
+                queryAccessibleSpaces(client, "", createTime);
+        return briefPage(spaces, query, pageNo, pageSize, selected,
+                         GraphSpace::getName, GraphSpace::getNickname);
+    }
+
+    private static <S> Map<String, Object> briefPage(
+            List<S> spaces, String query, int pageNo, int pageSize, String selected,
+            Function<S, String> name, Function<S, String> nickname) {
+        // Resolve the selected option from the same authorized list, even when
+        // it is outside the page or search. Only project the requested page.
+        Map<String, Object> selectedSpace = spaces.stream()
+                .filter(space -> name.apply(space).equals(selected)).findFirst()
+                .map(space -> briefView(name.apply(space), nickname.apply(space)))
+                .orElse(null);
+        String search = query == null ? "" : query.toLowerCase(Locale.ROOT);
+        List<S> matching = spaces.stream().filter(space ->
+                name.apply(space).toLowerCase(Locale.ROOT).contains(search) ||
+                nickname.apply(space) != null &&
+                nickname.apply(space).toLowerCase(Locale.ROOT).contains(search))
+                .collect(Collectors.toList());
+        IPage<Map<String, Object>> page = pageAndMap(matching, pageNo, pageSize,
+                space -> briefView(name.apply(space), nickname.apply(space)));
+        Map<String, Object> result = new HashMap<>();
+        result.put("records", page.getRecords());
+        result.put("total", page.getTotal());
+        result.put("current", page.getCurrent());
+        result.put("size", page.getSize());
+        result.put("pages", page.getPages());
+        result.put("selected", selectedSpace);
+        return result;
+    }
+
+    private static Map<String, Object> briefView(Object name, Object nickname) {
+        Map<String, Object> info = new HashMap<>();
+        info.put("name", name);
+        info.put("nickname", nickname == null ? name : nickname);
+        return info;
+    }
+
     private List<Map<String, Object>> queryAllGsMetadata(
             HugeClient client, String query, String createTime) {
         List<Map<String, Object>> results = client.graphSpace()
@@ -152,7 +204,7 @@ public class GraphSpaceService {
         String name = info.get("name").toString();
         info.put("graphspace_admin",
                  userService.listGraphSpaceAdmin(client, name));
-        info.put("statistic", evCount(client, name));
+        info.put("statistic", Collections.emptyMap());
         return info;
     }
 
@@ -196,7 +248,6 @@ public class GraphSpaceService {
     private Map<String, Object> accessibleView(HugeClient client,
                                                GraphSpace space) {
         GraphSpaceEntity entity = GraphSpaceEntity.fromGraphSpace(space);
-        entity.setStatistic(evCount(client, space.getName()));
         Map<String, Object> info = toView(entity);
         info.put("authed", true);
         info.put("default", false);
@@ -308,7 +359,6 @@ public class GraphSpaceService {
     private Map<String, Object> anonymousView(HugeClient client,
                                               GraphSpace space) {
         GraphSpaceEntity entity = GraphSpaceEntity.fromGraphSpace(space);
-        entity.setStatistic(evCount(client, space.getName()));
         Map<String, Object> info = toView(entity);
         info.put("authed", true);
         info.put("default", false);
@@ -534,7 +584,6 @@ public class GraphSpaceService {
             entity.graphspaceAdmin =
                     userService.listGraphSpaceAdmin(client, name);
         }
-        entity.setStatistic(evCount(client, graphSpace.getName()));
         return entity;
     }
 
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java
index ef7cb69b5..bb9e880de 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java
@@ -49,12 +49,61 @@ import org.apache.hugegraph.testutil.Assert;
 import org.apache.hugegraph.util.JsonUtil;
 
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
 public class GraphSpaceControllerTest {
 
+    @Test
+    public void testAnonymousBriefListUsesMetadataRoute() throws Exception {
+        HugeClient client = Mockito.mock(HugeClient.class);
+        GraphSpaceService service = Mockito.mock(GraphSpaceService.class);
+        TestGraphSpaceController controller = new TestGraphSpaceController(client);
+        HugeConfig config = Mockito.mock(HugeConfig.class);
+        Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+        AuthModeService authMode = Mockito.mock(AuthModeService.class);
+        Mockito.when(authMode.anonymous()).thenReturn(true);
+        controller.config = config;
+        ReflectionTestUtils.setField(controller, "authMode", authMode);
+        ReflectionTestUtils.setField(controller, "graphSpaceService", service);
+
+        MockMvcBuilders.standaloneSetup(controller).build()
+                       .perform(get("/api/v1.3/graphspaces")
+                                .param("brief", "true"))
+                       .andExpect(status().isOk());
+
+        Mockito.verify(service).queryBriefPage(client, "", "", 1, 100,
+                                               true, false, "");
+        Mockito.verifyNoMoreInteractions(service);
+    }
+
+    @Test
+    public void testBriefListForwardsSearchPageAndSelectedSpace() throws Exception {
+        HugeClient client = Mockito.mock(HugeClient.class);
+        GraphSpaceService service = Mockito.mock(GraphSpaceService.class);
+        TestGraphSpaceController controller = new TestGraphSpaceController(client);
+        HugeConfig config = Mockito.mock(HugeConfig.class);
+        Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+        AuthModeService authMode = Mockito.mock(AuthModeService.class);
+        Mockito.when(authMode.anonymous()).thenReturn(true);
+        controller.config = config;
+        ReflectionTestUtils.setField(controller, "authMode", authMode);
+        ReflectionTestUtils.setField(controller, "graphSpaceService", service);
+
+        MockMvcBuilders.standaloneSetup(controller).build()
+                       .perform(get("/api/v1.3/graphspaces")
+                                .param("brief", "true").param("query", "alias")
+                                .param("page_no", "2").param("page_size", "100")
+                                .param("selected", "late_space"))
+                       .andExpect(status().isOk());
+
+        Mockito.verify(service).queryBriefPage(client, "alias", "", 2, 100,
+                                               true, false, "late_space");
+        Mockito.verifyNoMoreInteractions(service);
+    }
+
     @Test
     public void testHttpStoragePrecisionSurvivesEntityWireWithoutCoercingLegacyConfigs() throws Exception {
         String[] values = {"524288", "\"524288\"", "null", "-1", "{}"};
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java
index ec96dabf5..146f18a9d 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java
@@ -61,6 +61,151 @@ public class GraphSpaceServiceTest {
                                      this.userService);
     }
 
+    @Test
+    public void testAnonymousBriefListFiltersBeforePagingWithoutStatistics() {
+        GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+        Mockito.when(this.client.graphSpace()).thenReturn(manager);
+        Mockito.when(manager.listGraphSpace())
+               .thenReturn(java.util.Arrays.asList("a", "protected", "b"));
+        GraphSpace first = graphSpace("a", false, "20260712");
+        first.setDpPassWord("secret");
+        GraphSpace second = graphSpace("b", false, "20260712");
+        second.setNickname("Bee");
+        Mockito.when(manager.getGraphSpace("a")).thenReturn(first);
+        Mockito.when(manager.getGraphSpace("b")).thenReturn(second);
+        Mockito.when(manager.getGraphSpace("protected"))
+               .thenReturn(graphSpace("protected", true, "20260712"));
+
+        Map<String, Object> result = this.service.queryBriefPage(
+                this.client, "", "", 2, 1, true, false, "");
+
+        Assert.assertEquals(2L, result.get("total"));
+        Assert.assertEquals("b", briefRecords(result).get(0).get("name"));
+        Assert.assertEquals("Bee", briefRecords(result).get(0).get("nickname"));
+        Assert.assertEquals(2, briefRecords(result).get(0).size());
+        Mockito.verifyNoInteractions(this.graphsService, this.userService);
+        Mockito.verify(manager, Mockito.never()).listProfile(Mockito.anyString());
+    }
+
+    @Test
+    public void testAdministratorBriefListDoesNotLoadStatisticsOrAdmins() {
+        GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+        Mockito.when(this.client.graphSpace()).thenReturn(manager);
+        Map<String, Object> profile = new HashMap<>();
+        profile.put("name", "public");
+        profile.put("create_time", "20260712");
+        profile.put("dp_password", "secret");
+        Mockito.when(manager.listProfile(""))
+               .thenReturn(java.util.Collections.singletonList(profile));
+
+        Map<String, Object> result = this.service.queryBriefPage(
+                this.client, "", "", 1, 100, false, true, "");
+
+        Assert.assertEquals("public", briefRecords(result).get(0).get("name"));
+        Assert.assertEquals(2, briefRecords(result).get(0).size());
+        Mockito.verifyNoInteractions(this.graphsService, this.userService);
+        Mockito.verify(manager, Mockito.never()).listGraphSpace();
+    }
+
+    @Test
+    public void testRegularBriefListKeepsPermissionFiltering() {
+        GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+        AuthManager auth = Mockito.mock(AuthManager.class);
+        Mockito.when(this.client.graphSpace()).thenReturn(manager);
+        Mockito.when(this.client.auth()).thenReturn(auth);
+        Mockito.when(manager.listGraphSpace())
+               .thenReturn(java.util.Arrays.asList("public", "protected"));
+        Mockito.when(manager.getGraphSpace("public"))
+               .thenReturn(graphSpace("public", false, "20260712"));
+        Mockito.when(manager.getGraphSpace("protected"))
+               .thenReturn(graphSpace("protected", true, "20260712"));
+
+        Map<String, Object> result = this.service.queryBriefPage(
+                this.client, "", "", 1, 100, false, false, "");
+
+        Assert.assertEquals(1L, result.get("total"));
+        Assert.assertEquals("public", briefRecords(result).get(0).get("name"));
+        Mockito.verify(auth).isSpaceMember("protected");
+        Mockito.verifyNoInteractions(this.graphsService);
+        Mockito.verify(manager, Mockito.never()).listProfile(Mockito.anyString());
+    }
+
+    @Test
+    public void testBriefPagesBeyondAllRecordCapKeepSelectionAndSearch() {
+        GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+        Mockito.when(this.client.graphSpace()).thenReturn(manager);
+        List<Map<String, Object>> profiles = IntStream.rangeClosed(0, 10000)
+                .mapToObj(index -> {
+                    Map<String, Object> profile = new HashMap<>();
+                    profile.put("name", String.format("space_%05d", index));
+                    profile.put("nickname", index == 10000 ? "Late Alias" : "Space " + index);
+                    profile.put("create_time", "20260712");
+                    profile.put("dp_password", "must not leave metadata");
+                    return profile;
+                }).collect(Collectors.toList());
+        Mockito.when(manager.listProfile("")).thenReturn(profiles);
+
+        Map<String, Object> first = this.service.queryBriefPage(
+                this.client, "", "", 1, 100, false, true, "space_10000");
+
+        Assert.assertEquals(10001L, first.get("total"));
+        Assert.assertEquals(100, briefRecords(first).size());
+        Assert.assertEquals("space_00000", briefRecords(first).get(0).get("name"));
+        Assert.assertEquals(Map.of("name", "space_10000", "nickname", "Late Alias"), first.get("selected"));
+        Mockito.verify(manager).listProfile("");
+        Mockito.verifyNoInteractions(this.graphsService, this.userService);
+        Mockito.verify(manager, Mockito.never()).getGraphSpace(Mockito.anyString());
+
+        Map<String, Object> last = this.service.queryBriefPage(
+                this.client, "", "", 101, 100, false, true, "space_10000");
+        Assert.assertEquals(1, briefRecords(last).size());
+        Assert.assertEquals(first.get("selected"), briefRecords(last).get(0));
+        for (String query : new String[]{"late alias", "SPACE_10000"}) {
+            Map<String, Object> searched = this.service.queryBriefPage(
+                    this.client, query, "", 1, 100, false, true, "space_00000");
+            Assert.assertEquals(1L, searched.get("total"));
+            Assert.assertEquals("space_10000", briefRecords(searched).get(0).get("name"));
+            Assert.assertEquals(Map.of("name", "space_00000", "nickname", "Space 0"), searched.get("selected"));
+        }
+        Mockito.verify(manager, Mockito.times(4)).listProfile("");
+        Mockito.verifyNoMoreInteractions(manager);
+    }
+
+    @Test
+    public void testBriefSelectionCannotRevealAnUnauthorizedSpace() {
+        GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+        AuthManager auth = Mockito.mock(AuthManager.class);
+        Mockito.when(this.client.graphSpace()).thenReturn(manager);
+        Mockito.when(this.client.auth()).thenReturn(auth);
+        Mockito.when(manager.listGraphSpace()).thenReturn(List.of("public", "private"));
+        GraphSpace publicSpace = graphSpace("public", false, "20260712");
+        publicSpace.setNickname("Public alias");
+        Mockito.when(manager.getGraphSpace("public")).thenReturn(publicSpace);
+        Mockito.when(manager.getGraphSpace("private")).thenReturn(graphSpace("private", true, "20260712"));
+
+        for (boolean anonymous : new boolean[]{true, false}) {
+            Map<String, Object> page = this.service.queryBriefPage(
+                    this.client, "private", "", 1, 100, anonymous, false, "private");
+            Assert.assertNull(page.get("selected"));
+            Assert.assertEquals(0L, page.get("total"));
+            Assert.assertTrue(briefRecords(page).isEmpty());
+        }
+        Mockito.verify(manager, Mockito.times(2)).listGraphSpace();
+        Mockito.verify(manager, Mockito.times(2)).getGraphSpace("public");
+        Mockito.verify(manager, Mockito.times(2)).getGraphSpace("private");
+        Mockito.verifyNoInteractions(this.graphsService, this.userService);
+    }
+
+    @Test
+    public void testInvalidBriefPageDoesNotEnumerateSpaces() {
+        for (int size : new int[]{-1, 0, 501}) {
+            org.apache.hugegraph.testutil.Assert.assertThrows(
+                    IllegalArgumentException.class, () -> this.service.queryBriefPage(
+                    this.client, "", "", 1, size, false, true, ""));
+        }
+        Mockito.verifyNoInteractions(this.client);
+    }
+
     @Test
     public void testViewNeverContainsDataPlaneSecrets() {
         GraphSpaceEntity entity = new GraphSpaceEntity();
@@ -110,6 +255,28 @@ public class GraphSpaceServiceTest {
         Assert.assertFalse(response.get(0).containsKey("dpUserName"));
         Assert.assertFalse(response.get(0).containsKey("dpPassWord"));
         Assert.assertFalse(response.get(0).containsKey("configs"));
+        Assert.assertEquals(java.util.Collections.emptyMap(),
+                            response.get(0).get("statistic"));
+        Mockito.verifyNoInteractions(this.graphsService);
+    }
+
+    @Test
+    public void testGraphSpaceDetailDoesNotLoadGraphStatistics() {
+        GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+        AuthManager auth = Mockito.mock(AuthManager.class);
+        Mockito.when(this.client.graphSpace()).thenReturn(manager);
+        Mockito.when(this.client.auth()).thenReturn(auth);
+        Mockito.when(manager.getGraphSpace("public"))
+               .thenReturn(graphSpace("public", false, "20260712"));
+
+        Map<String, Object> anonymous = this.service.getAnonymous(this.client, "public");
+        GraphSpaceEntity managed = this.service.getWithAdmins(this.client, "public");
+
+        Assert.assertEquals("public", anonymous.get("name"));
+        Assert.assertEquals(java.util.Collections.emptyMap(), anonymous.get("statistic"));
+        Assert.assertEquals("public", managed.getName());
+        Assert.assertTrue(managed.getStatistic().isEmpty());
+        Mockito.verifyNoInteractions(this.graphsService);
     }
 
     @Test
@@ -315,7 +482,7 @@ public class GraphSpaceServiceTest {
     }
 
     @Test
-    public void testAnonymousGraphSpacesCollectStatisticsAfterPaging() {
+    public void testAnonymousGraphSpacesReturnCoreMetadataWithoutStatistics() {
         GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
         Mockito.when(this.client.graphSpace()).thenReturn(manager);
         Mockito.when(manager.listGraphSpace())
@@ -335,16 +502,13 @@ public class GraphSpaceServiceTest {
         Assert.assertEquals(3L, response.getTotal());
         Assert.assertEquals(1, response.getRecords().size());
         Assert.assertEquals("b", response.getRecords().get(0).get("name"));
-        Mockito.verify(this.graphsService)
-               .listGraphNames(this.client, "b", "");
-        Mockito.verify(this.graphsService, Mockito.never())
-               .listGraphNames(this.client, "a", "");
-        Mockito.verify(this.graphsService, Mockito.never())
-               .listGraphNames(this.client, "c", "");
+        Assert.assertEquals(java.util.Collections.emptyMap(),
+                            response.getRecords().get(0).get("statistic"));
+        Mockito.verifyNoInteractions(this.graphsService);
     }
 
     @Test
-    public void testAccessibleGraphSpacesCollectStatisticsAfterPaging() {
+    public void testAccessibleGraphSpacesReturnCoreMetadataWithoutStatistics() {
         GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
         AuthManager auth = Mockito.mock(AuthManager.class);
         Mockito.when(this.client.graphSpace()).thenReturn(manager);
@@ -365,12 +529,9 @@ public class GraphSpaceServiceTest {
 
         Assert.assertEquals(3L, response.getTotal());
         Assert.assertEquals("b", response.getRecords().get(0).get("name"));
-        Mockito.verify(this.graphsService)
-               .listGraphNames(this.client, "b", "");
-        Mockito.verify(this.graphsService, Mockito.never())
-               .listGraphNames(this.client, "a", "");
-        Mockito.verify(this.graphsService, Mockito.never())
-               .listGraphNames(this.client, "c", "");
+        Assert.assertEquals(java.util.Collections.emptyMap(),
+                            response.getRecords().get(0).get("statistic"));
+        Mockito.verifyNoInteractions(this.graphsService);
     }
 
     @Test
@@ -555,6 +716,11 @@ public class GraphSpaceServiceTest {
         return statistic;
     }
 
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> briefRecords(Map<String, Object> page) {
+        return (List<Map<String, Object>>) page.get("records");
+    }
+
     private static GraphSpace graphSpace(String name, boolean auth,
                                          String createTime) {
         GraphSpace graphSpace = new GraphSpace(name);
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsControllerCanonicalTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsControllerCanonicalTest.java
index c13130e9b..b065bacd5 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsControllerCanonicalTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsControllerCanonicalTest.java
@@ -31,10 +31,14 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 import jakarta.servlet.ServletException;
 
 import org.apache.hugegraph.controller.graphs.GraphsController;
+import org.apache.hugegraph.controller.query.GremlinQueryController;
+import org.apache.hugegraph.common.Constant;
+import org.apache.hugegraph.service.HugeClientPoolService;
 import org.apache.hugegraph.config.HugeConfig;
 import org.apache.hugegraph.controller.BaseController;
 import org.apache.hugegraph.driver.HugeClient;
 import org.apache.hugegraph.entity.graphs.GraphCloneEntity;
+import org.apache.hugegraph.entity.graphs.GraphStatisticsEntity;
 import org.apache.hugegraph.exception.ExternalException;
 import org.apache.hugegraph.options.HubbleOptions;
 import org.apache.hugegraph.service.graphs.GraphsService;
@@ -45,10 +49,12 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 
 public class GraphsControllerCanonicalTest {
 
     private MockMvc mvc;
+    private GraphsController controller;
     private HugeClient client;
     private GraphsService graphsService;
     private RequestPostProcessor withClient;
@@ -56,6 +62,7 @@ public class GraphsControllerCanonicalTest {
     @Before
     public void setup() throws Exception {
         GraphsController controller = new GraphsController();
+        this.controller = controller;
         this.graphsService = Mockito.mock(GraphsService.class);
         this.setField(controller, "graphsService", this.graphsService);
         HugeConfig config = Mockito.mock(HugeConfig.class);
@@ -71,6 +78,103 @@ public class GraphsControllerCanonicalTest {
         this.mvc = MockMvcBuilders.standaloneSetup(controller).build();
     }
 
+    @Test
+    public void testStatisticsUsesGremlinAuthenticationCapability() throws Exception {
+        HugeClientPoolService pool = Mockito.mock(HugeClientPoolService.class);
+        HugeClient basicClient = Mockito.mock(HugeClient.class);
+        this.setField(BaseController.class, this.controller, "hugeClientPoolService", pool);
+        Mockito.when(pool.createBasicClient("DEFAULT", "graph", "admin", "fixture-password"))
+               .thenReturn(basicClient);
+
+        for (boolean basicRequired : new boolean[]{false, true}) {
+            Mockito.when(this.client.requiresBasicGremlinAuth()).thenReturn(basicRequired);
+            this.mvc.perform(get("/api/v1.3/graphspaces/DEFAULT/graphs/graph/statistics")
+                             .with(this.withClient)
+                             .sessionAttr(Constant.USERNAME_KEY, "admin")
+                             .sessionAttr(Constant.TOKEN_KEY, "fixture-token")
+                             .sessionAttr(Constant.PASSWORD_KEY, "fixture-password")
+                             .sessionAttr(Constant.PASSWORD_EXPIRE_AT_KEY,
+                                          System.currentTimeMillis() + 60000L))
+                    .andExpect(status().isOk());
+            Mockito.verify(this.graphsService).getStatistics(
+                    basicRequired ? basicClient : this.client, "DEFAULT", "graph");
+        }
+        Mockito.verify(this.client).close();
+        Mockito.verify(pool).createBasicClient("DEFAULT", "graph", "admin", "fixture-password");
+    }
+
+    @Test
+    public void testLegacyOverviewSharesTheExplicitStatisticsCredentialContext() throws Exception {
+        HugeClientPoolService pool = Mockito.mock(HugeClientPoolService.class);
+        HugeClient basicClient = Mockito.mock(HugeClient.class);
+        Mockito.when(this.client.requiresBasicGremlinAuth()).thenReturn(true);
+        Mockito.when(pool.createBasicClient("DEFAULT", "graph", "admin", "fixture-password"))
+               .thenReturn(basicClient);
+        this.setField(BaseController.class, this.controller, "hugeClientPoolService", pool);
+        GremlinQueryController overview = new GremlinQueryController();
+        this.setField(overview, "graphsService", this.graphsService);
+        this.setField(BaseController.class, overview, "hugeClientPoolService", pool);
+        HugeConfig config = Mockito.mock(HugeConfig.class);
+        Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(false);
+        this.setField(BaseController.class, overview, "config", config);
+        Mockito.when(this.graphsService.evCount(basicClient, "DEFAULT", "graph"))
+               .thenReturn(Collections.emptyMap(), java.util.Map.of("vertex", 12L, "edge", 8L));
+        Mockito.when(this.graphsService.getStatistics(basicClient, "DEFAULT", "graph"))
+               .thenReturn(GraphStatisticsEntity.emptyEntity());
+        MockMvc routes = MockMvcBuilders.standaloneSetup(this.controller, overview).build();
+        RequestPostProcessor withCredentials = request -> {
+            request.setAttribute("hugeClient", this.client);
+            request.getSession().setAttribute(Constant.USERNAME_KEY, "admin");
+            request.getSession().setAttribute(Constant.TOKEN_KEY, "fixture-token");
+            request.getSession().setAttribute(Constant.PASSWORD_KEY, "fixture-password");
+            request.getSession().setAttribute(Constant.PASSWORD_EXPIRE_AT_KEY,
+                                              System.currentTimeMillis() + 60000L);
+            return request;
+        };
+        String graphPath = "/api/v1.3/graphspaces/DEFAULT/graphs/graph/";
+
+        routes.perform(get(graphPath + "gremlin-query").with(withCredentials))
+              .andExpect(status().isOk()).andExpect(jsonPath("$.vertexcount").doesNotExist());
+        routes.perform(get(graphPath + "statistics").with(withCredentials)).andExpect(status().isOk());
+        routes.perform(get(graphPath + "gremlin-query").with(withCredentials))
+              .andExpect(status().isOk()).andExpect(jsonPath("$.vertexcount").value("12"))
+              .andExpect(jsonPath("$.edgecount").value("8"));
+
+        org.mockito.InOrder calls = Mockito.inOrder(this.graphsService);
+        calls.verify(this.graphsService).evCount(basicClient, "DEFAULT", "graph");
+        calls.verify(this.graphsService).getStatistics(basicClient, "DEFAULT", "graph");
+        calls.verify(this.graphsService).evCount(basicClient, "DEFAULT", "graph");
+        Mockito.verifyNoMoreInteractions(this.graphsService);
+    }
+
+    @Test
+    public void testBriefListUsesMetadataRoute() throws Exception {
+        this.mvc.perform(get("/api/v1.3/graphspaces/DEFAULT/graphs")
+                         .param("brief", "true")
+                         .with(this.withClient))
+                .andExpect(status().isOk());
+
+        Mockito.verify(this.graphsService)
+               .queryBriefPage(this.client, "DEFAULT", "", "", 1, 100, "");
+        Mockito.verifyNoMoreInteractions(this.graphsService);
+    }
+
+    @Test
+    public void testBriefListForwardsPageSearchAndOffPageSelection() throws Exception {
+        this.mvc.perform(get("/api/v1.3/graphspaces/DEFAULT/graphs")
+                         .param("brief", "true")
+                         .param("query", "alias")
+                         .param("page_no", "2")
+                         .param("page_size", "10")
+                         .param("selected", "late_graph")
+                         .with(this.withClient))
+                .andExpect(status().isOk());
+
+        Mockito.verify(this.graphsService)
+               .queryBriefPage(this.client, "DEFAULT", "alias", "", 2, 10, "late_graph");
+        Mockito.verifyNoMoreInteractions(this.graphsService);
+    }
+
     @Test
     public void testCanonicalCreateGraphUsesPathNameAndJsonBody()
            throws Exception {
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java
index 720871d13..88db89c4e 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java
@@ -17,8 +17,10 @@
 
 package org.apache.hugegraph.unit;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -43,10 +45,21 @@ import org.apache.hugegraph.options.HubbleOptions;
 import org.apache.hugegraph.service.graphs.GraphsService;
 import org.apache.hugegraph.service.auth.AuthModeService;
 import org.apache.hugegraph.service.query.QueryService;
-import org.apache.hugegraph.structure.graph.Edge;
-import org.apache.hugegraph.structure.graph.Vertex;
+import org.apache.hugegraph.service.HugeClientPoolService;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import com.google.common.base.Ticker;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import org.apache.hugegraph.service.schema.SchemaService;
 import org.apache.hugegraph.structure.gremlin.ResultSet;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
+
 public class GraphsServiceDefaultTest {
 
     private HugeClient client;
@@ -60,11 +73,211 @@ public class GraphsServiceDefaultTest {
         this.graphs = Mockito.mock(GraphsManager.class);
         Mockito.when(this.client.graphs()).thenReturn(this.graphs);
         this.service = new GraphsService();
+        ReflectionTestUtils.setField(this.service, "clientPool", Mockito.mock(HugeClientPoolService.class));
         this.authMode = Mockito.mock(AuthModeService.class);
         Mockito.when(this.authMode.enabled()).thenReturn(true);
         ReflectionTestUtils.setField(this.service, "authModeService", this.authMode);
     }
 
+    @Test
+    public void testSelectedGraphKeepsVermeerLoadTime() {
+        Mockito.when(this.graphs.getGraph("graph"))
+               .thenReturn(Collections.singletonMap("backend", "hstore"));
+        Map<String, String> vermeer = new HashMap<>();
+        vermeer.put("status", "loaded");
+        vermeer.put("last_load_time", "2026-10-22T01:02:03Z");
+
+        Map<String, Object> result = this.service.get(
+                this.client, "space", "graph", vermeer);
+
+        Assert.assertEquals("loaded", result.get("status"));
+        Assert.assertEquals("2026-10-22T01:02:03Z", result.get("last_load_time"));
+        Assert.assertEquals("graph", result.get("nickname"));
+    }
+
+    @Test
+    public void testGraphListReturnsCoreMetadataWithoutStatisticsOrSchema() {
+        GraphsService service = Mockito.spy(this.service);
+        SchemaService schema = Mockito.mock(SchemaService.class);
+        ReflectionTestUtils.setField(service, "schemaService", schema);
+        Map<String, Object> first = profile("z", true);
+        Map<String, Object> second = profile("b", false);
+        Map<String, Object> third = profile("a", false);
+        Mockito.when(this.graphs.listProfile(""))
+               .thenReturn(Arrays.asList(second, first, third));
+
+        IPage<Map<String, Object>> result = service.queryPage(
+                this.client, "space", "", "", "", 2, 1,
+                false, Collections.emptyMap());
+
+        Assert.assertEquals(3L, result.getTotal());
+        Assert.assertEquals("a", result.getRecords().get(0).get("name"));
+        Assert.assertEquals(Collections.emptyMap(),
+                            result.getRecords().get(0).get("statistic"));
+        Mockito.verify(service, Mockito.never())
+               .evCount(Mockito.any(), Mockito.anyString(), Mockito.anyString());
+        Mockito.verifyNoInteractions(schema);
+        Mockito.verify(this.client, Mockito.never())
+               .assignGraph(Mockito.anyString(), Mockito.anyString());
+    }
+
+    @Test
+    public void testBriefGraphListOnlyReturnsNamesWithoutStatisticsOrSchema() {
+        GraphsService service = Mockito.spy(this.service);
+        SchemaService schema = Mockito.mock(SchemaService.class);
+        ReflectionTestUtils.setField(service, "schemaService", schema);
+        Map<String, Object> graph = profile("graph", false);
+        graph.put("nickname", "Graph");
+        graph.put("pd.peers", "private");
+        Mockito.when(this.graphs.listProfile(""))
+               .thenReturn(Collections.singletonList(graph));
+
+        Map<String, Object> result = service.queryBriefPage(
+                this.client, "space", "", "", 1, 100, "graph");
+
+        Assert.assertEquals(1L, result.get("total"));
+        Assert.assertEquals("Graph", briefRecords(result).get(0).get("nickname"));
+        Assert.assertEquals(2, briefRecords(result).get(0).size());
+        Assert.assertEquals(briefRecords(result).get(0), result.get("selected"));
+        Assert.assertFalse(graph.containsKey("statistic"));
+        Assert.assertFalse(graph.containsKey("status"));
+        Mockito.verify(service, Mockito.never())
+               .evCount(Mockito.any(), Mockito.anyString(), Mockito.anyString());
+        Mockito.verifyNoInteractions(schema);
+        Mockito.verify(this.client, Mockito.never())
+               .assignGraph(Mockito.anyString(), Mockito.anyString());
+    }
+
+    @Test
+    public void testBriefGraphListPagesAboveTenThousandWithoutLosingTheSelectedGraph() {
+        GraphsService service = Mockito.spy(this.service);
+        SchemaService schema = Mockito.mock(SchemaService.class);
+        ReflectionTestUtils.setField(service, "schemaService", schema);
+        List<Map<String, Object>> authorized = new ArrayList<>();
+        for (int index = 10000; index >= 0; index--) {
+            Map<String, Object> graph = profile(String.format("graph_%05d", index), false);
+            graph.put("nickname", "Alias " + index);
+            graph.put("pd.peers", "private");
+            authorized.add(graph);
+        }
+        Mockito.when(this.graphs.listProfile("")).thenReturn(authorized);
+
+        Map<String, Object> first = service.queryBriefPage(
+                this.client, "space", "", "", 1, 100, "graph_10000");
+
+        Assert.assertEquals(10001L, first.get("total"));
+        Assert.assertEquals(100, briefRecords(first).size());
+        for (int index = 0; index < 100; index++) {
+            Map<String, Object> graph = briefRecords(first).get(index);
+            Assert.assertEquals(String.format("graph_%05d", index), graph.get("name"));
+            Assert.assertEquals("Alias " + index, graph.get("nickname"));
+            Assert.assertEquals(2, graph.size());
+        }
+        Map<?, ?> selected = (Map<?, ?>) first.get("selected");
+        Assert.assertEquals("graph_10000", selected.get("name"));
+        Assert.assertEquals("Alias 10000", selected.get("nickname"));
+        Assert.assertEquals(2, selected.size());
+        Mockito.verify(this.graphs).listProfile("");
+
+        Map<String, Object> last = service.queryBriefPage(
+                this.client, "space", "", "", 101, 100, "graph_00000");
+
+        Assert.assertEquals(1, briefRecords(last).size());
+        Assert.assertEquals("graph_10000", briefRecords(last).get(0).get("name"));
+        Assert.assertEquals("graph_00000", ((Map<?, ?>) last.get("selected")).get("name"));
+        Assert.assertTrue(authorized.stream().noneMatch(info -> info.containsKey("statistic") ||
+                                                              info.containsKey("status")));
+        Mockito.verify(this.graphs, Mockito.times(2)).listProfile("");
+        Mockito.verifyNoMoreInteractions(this.graphs);
+        Mockito.verify(service, Mockito.never())
+               .evCount(Mockito.any(), Mockito.anyString(), Mockito.anyString());
+        Mockito.verifyNoInteractions(schema);
+        Mockito.verify(this.client, Mockito.never())
+               .assignGraph(Mockito.anyString(), Mockito.anyString());
+    }
+
+    @Test
+    public void testBriefGraphSearchFindsAliasesOutsideTheFirstPageAndRetainsSelection() {
+        Map<String, Object> selected = profile("first", false);
+        selected.put("nickname", "Current graph");
+        Map<String, Object> later = profile("last", false);
+        later.put("nickname", "Late Needle Alias");
+        Mockito.when(this.graphs.listProfile("")).thenReturn(Arrays.asList(selected, later));
+
+        Map<String, Object> result = this.service.queryBriefPage(
+                this.client, "space", "needle", "", 1, 1, "first");
+
+        Assert.assertEquals(1L, result.get("total"));
+        Assert.assertEquals("last", briefRecords(result).get(0).get("name"));
+        Assert.assertEquals("first", ((Map<?, ?>) result.get("selected")).get("name"));
+        Assert.assertEquals(2, ((Map<?, ?>) result.get("selected")).size());
+        Mockito.verify(this.graphs).listProfile("");
+        Mockito.verifyNoMoreInteractions(this.graphs);
+    }
+
+    @Test
+    public void testBriefDefaultGraphPagesHaveStableNameTieBreaks() {
+        Map<String, Object> first = profile("default_a", true);
+        Map<String, Object> second = profile("default_b", true);
+        first.put("default_update_time", "2026-10-22 10:00:00");
+        second.put("default_update_time", "2026-10-22 10:00:00");
+        Mockito.when(this.graphs.listProfile(""))
+               .thenReturn(Arrays.asList(second, first), Arrays.asList(first, second));
+
+        Map<String, Object> pageOne = this.service.queryBriefPage(
+                this.client, "space", "", "", 1, 1, "");
+        Map<String, Object> pageTwo = this.service.queryBriefPage(
+                this.client, "space", "", "", 2, 1, "");
+
+        Assert.assertEquals("default_a", briefRecords(pageOne).get(0).get("name"));
+        Assert.assertEquals("default_b", briefRecords(pageTwo).get(0).get("name"));
+        Mockito.verify(this.graphs, Mockito.times(2)).listProfile("");
+        Mockito.verifyNoMoreInteractions(this.graphs);
+    }
+
+    @Test
+    public void testBriefGraphListDoesNotInventAnUnavailableSelection() {
+        Mockito.when(this.graphs.listProfile(""))
+               .thenReturn(Collections.singletonList(profile("visible", false)));
+
+        Map<String, Object> result = this.service.queryBriefPage(
+                this.client, "space", "", "", 1, 100, "private");
+
+        Assert.assertNull(result.get("selected"));
+        Assert.assertEquals("visible", briefRecords(result).get(0).get("name"));
+        Mockito.verify(this.graphs).listProfile("");
+        Mockito.verifyNoMoreInteractions(this.graphs);
+    }
+
+    @Test
+    public void testBriefGraphListDoesNotFallBackAfterForbiddenResponse() {
+        ServerException forbidden = new ServerException("forbidden");
+        forbidden.status(403);
+        Mockito.when(this.graphs.listProfile("")).thenThrow(forbidden);
+
+        try {
+            this.service.queryBriefPage(this.client, "space", "", "", 1, 100, "private");
+            Assert.fail("Expected forbidden response");
+        } catch (ServerException actual) {
+            Assert.assertSame(forbidden, actual);
+        }
+
+        Mockito.verify(this.graphs).listProfile("");
+        Mockito.verifyNoMoreInteractions(this.graphs);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> briefRecords(Map<String, Object> page) {
+        return (List<Map<String, Object>>) page.get("records");
+    }
+
+    private static Map<String, Object> profile(String name, boolean isDefault) {
+        Map<String, Object> graph = new HashMap<>();
+        graph.put("name", name);
+        graph.put("default", isDefault);
+        return graph;
+    }
+
     @Test
     public void testSetDefaultReplacesAllPreviousDefaults() {
         Map<String, Object> defaults = new LinkedHashMap<>();
@@ -216,86 +429,229 @@ public class GraphsServiceDefaultTest {
     }
 
     @Test
-    public void testSmallStatisticsFallsBackToBoundedGraphReads() {
+    public void testExplicitStatisticsAreBoundedAndCachedForThirtyMinutes() {
         QueryService query = Mockito.mock(QueryService.class);
-        Mockito.when(query.executeQueryCount(Mockito.eq(this.client),
-                                             Mockito.anyString()))
-               .thenThrow(new ExternalException("gremlin.execute.failed"));
+        HugeClientPoolService pool = Mockito.mock(HugeClientPoolService.class);
+        Mockito.when(pool.statisticsPolicy(Mockito.any())).thenReturn("policy");
+        Mockito.when(pool.statisticsContext(this.client)).thenReturn("server-a-user-a");
         ReflectionTestUtils.setField(this.service, "queryService", query);
+        ReflectionTestUtils.setField(this.service, "clientPool", pool);
+        AtomicLong now = new AtomicLong();
+        Cache<String, GraphStatisticsEntity> cache = CacheBuilder.newBuilder()
+                .maximumSize(1024)
+                .expireAfterWrite(30, TimeUnit.MINUTES)
+                .ticker(new Ticker() {
+                    @Override
+                    public long read() {
+                        return now.get();
+                    }
+                }).build();
+        ReflectionTestUtils.setField(this.service, "boundedStatistics", cache);
+        ResultSet vertices = Mockito.mock(ResultSet.class);
+        ResultSet edges = Mockito.mock(ResultSet.class);
+        Mockito.when(vertices.data()).thenReturn(Collections.singletonList(
+                Collections.singletonMap("person", 100000L)));
+        Mockito.when(edges.data()).thenReturn(Collections.singletonList(
+                Collections.singletonMap("knows", 90000L)));
+        Mockito.when(query.executeQueryCount(this.client,
+                "g.V().limit(100000).groupCount().by(label)")).thenReturn(vertices);
+        Mockito.when(query.executeQueryCount(this.client,
+                "g.E().limit(100000).groupCount().by(label)")).thenReturn(edges);
+
+        GraphStatisticsEntity first = this.service.getStatistics(this.client, "space", "graph");
+        GraphStatisticsEntity cached = this.service.getStatistics(this.client, "space", "graph");
+
+        Assert.assertSame(first, cached);
+        Assert.assertEquals("100000", first.getVertexCount());
+        Assert.assertEquals("90000", first.getEdgeCount());
+        Assert.assertTrue(first.isApproximate());
+        Assert.assertEquals(100000, first.getScanLimit());
+        Mockito.verify(query, Mockito.times(2))
+               .executeQueryCount(Mockito.eq(this.client), Mockito.anyString());
+        Mockito.verify(this.client, Mockito.never()).graph();
+        now.set(TimeUnit.SECONDS.toNanos(1799));
+        Map<String, Object> overview = this.service.evCount(this.client, "space", "graph");
+        Assert.assertEquals(100000L, overview.get("vertex"));
+        Assert.assertEquals(90000L, overview.get("edge"));
+        Assert.assertEquals(true, overview.get("approximate"));
+        Assert.assertEquals(100000, overview.get("scan_limit"));
+        Mockito.verify(this.client, Mockito.never()).graph();
+        Mockito.verify(query, Mockito.times(2))
+               .executeQueryCount(Mockito.eq(this.client), Mockito.anyString());
+
+        now.set(TimeUnit.SECONDS.toNanos(1801));
+        cache.cleanUp();
+        Assert.assertEquals(0L, cache.size());
+        this.service.getStatistics(this.client, "space", "graph");
+        Mockito.verify(query, Mockito.times(4))
+               .executeQueryCount(Mockito.eq(this.client), Mockito.anyString());
+
+        Mockito.when(pool.statisticsContext(this.client)).thenReturn("server-b-user-a");
+        this.service.getStatistics(this.client, "space", "graph");
+        Mockito.when(pool.statisticsContext(this.client)).thenReturn("server-b-user-b");
+        this.service.getStatistics(this.client, "space", "graph");
+        Mockito.verify(query, Mockito.times(8))
+               .executeQueryCount(Mockito.eq(this.client), Mockito.anyString());
+    }
 
+    @Test
+    public void testStatisticsCachePartitionsByCurrentPolicyAndPreservesSnapshots() {
+        GraphsService service = Mockito.spy(this.service);
+        HugeClientPoolService pool = Mockito.mock(HugeClientPoolService.class);
+        Mockito.when(pool.statisticsPolicy(Mockito.any())).thenReturn("policy");
+        Mockito.when(pool.statisticsContext(this.client)).thenReturn("context");
+        Mockito.when(pool.statisticsPolicy(this.client)).thenReturn("policy-a");
+        ReflectionTestUtils.setField(service, "clientPool", pool);
+        GraphStatisticsEntity first = GraphStatisticsEntity.emptyEntity();
+        first.setVertexCount("12");
+        GraphStatisticsEntity changed = GraphStatisticsEntity.emptyEntity();
+        changed.setVertexCount("2");
+        Mockito.doReturn(first, changed).when(service).collectBoundedStatistics(this.client);
         GraphManager graph = Mockito.mock(GraphManager.class);
         Mockito.when(this.client.graph()).thenReturn(graph);
-        Vertex personA = Mockito.mock(Vertex.class);
-        Vertex personB = Mockito.mock(Vertex.class);
-        Edge relation = Mockito.mock(Edge.class);
-        Mockito.when(personA.label()).thenReturn("人物");
-        Mockito.when(personB.label()).thenReturn("人物");
-        Mockito.when(relation.label()).thenReturn("关系");
-        Mockito.when(graph.iterateVertices(1000))
-               .thenReturn(Arrays.asList(personA, personB).iterator());
-        Mockito.when(graph.iterateEdges(1000))
-               .thenReturn(Collections.singletonList(relation).iterator());
-
-        GraphStatisticsEntity result =
-                this.service.postSmallStatistics(this.client, "DEFAULT", "demo");
-
-        Assert.assertEquals("2", result.getVertexCount());
-        Assert.assertEquals("1", result.getEdgeCount());
-        Assert.assertEquals(2L, result.getVertices().get("人物"));
-        Assert.assertEquals(1L, result.getEdges().get("关系"));
+
+        Assert.assertSame(first, service.getStatistics(this.client, "space", "graph"));
+        Assert.assertEquals(12L, service.evCount(this.client, "space", "graph").get("vertex"));
+        Mockito.when(pool.statisticsPolicy(this.client)).thenReturn("policy-b");
+        Assert.assertNull(service.evCount(this.client, "space", "graph").get("vertex"));
+        Assert.assertSame(changed, service.getStatistics(this.client, "space", "graph"));
+        Assert.assertEquals(2L, service.evCount(this.client, "space", "graph").get("vertex"));
+
+        ServerException unavailable = new ServerException("Current policy unavailable");
+        Mockito.when(pool.statisticsPolicy(this.client)).thenThrow(unavailable);
+        org.apache.hugegraph.testutil.Assert.assertThrows(
+                ServerException.class, () -> service.getStatistics(this.client, "space", "graph"));
+        org.apache.hugegraph.testutil.Assert.assertThrows(
+                ServerException.class, () -> service.evCount(this.client, "space", "graph"));
+        Mockito.doReturn("policy-a").when(pool).statisticsPolicy(this.client);
+        Assert.assertSame(first, service.getStatistics(this.client, "space", "graph"));
+        Assert.assertEquals(12L, service.evCount(this.client, "space", "graph").get("vertex"));
+        Mockito.verify(service, Mockito.times(2)).collectBoundedStatistics(this.client);
+
+        HugeClient reusedTarget = Mockito.mock(HugeClient.class);
+        Mockito.when(pool.statisticsContext(reusedTarget)).thenReturn("context");
+        Mockito.when(pool.statisticsPolicy(reusedTarget)).thenReturn("policy-a");
+        Assert.assertSame(first, service.getStatistics(reusedTarget, "space", "graph"));
+        Mockito.verify(service, Mockito.never()).collectBoundedStatistics(reusedTarget);
+    }
+
+    @Test
+    public void testStatisticsCacheEvictsOldIdentitiesAtCapacity() {
+        GraphsService service = Mockito.spy(this.service);
+        HugeClientPoolService pool = Mockito.mock(HugeClientPoolService.class);
+        Mockito.when(pool.statisticsPolicy(Mockito.any())).thenReturn("policy");
+        ReflectionTestUtils.setField(service, "clientPool", pool);
+        Mockito.doReturn(GraphStatisticsEntity.emptyEntity()).when(service)
+               .collectBoundedStatistics(this.client);
+        for (int identity = 0; identity < 1025; identity++) {
+            Mockito.when(pool.statisticsContext(this.client)).thenReturn("identity-" + identity);
+            service.getStatistics(this.client, "space", "graph");
+        }
+        Cache<?, ?> cache = (Cache<?, ?>) ReflectionTestUtils.getField(service, "boundedStatistics");
+        cache.cleanUp();
+        Assert.assertTrue(cache.size() <= 1024L);
+        Map<?, ?> asyncStatistics = (Map<?, ?>) ReflectionTestUtils.getField(service, "graphStatistics");
+        Assert.assertTrue(asyncStatistics.isEmpty());
+    }
+
+    @Test
+    public void testStatisticsLoadingDoesNotBlockAnotherKeyInTheSameCacheSegment() throws Exception {
+        GraphsService service = Mockito.spy(this.service);
+        HugeClient otherClient = Mockito.mock(HugeClient.class);
+        HugeClientPoolService pool = Mockito.mock(HugeClientPoolService.class);
+        Mockito.when(pool.statisticsPolicy(Mockito.any())).thenReturn("policy");
+        Mockito.when(pool.statisticsContext(Mockito.any())).thenReturn("shared-context");
+        ReflectionTestUtils.setField(service, "clientPool", pool);
+        Cache<String, GraphStatisticsEntity> cache = CacheBuilder.newBuilder()
+                .concurrencyLevel(1)
+                .maximumSize(1024)
+                .expireAfterWrite(30, TimeUnit.MINUTES)
+                .build();
+        ReflectionTestUtils.setField(service, "boundedStatistics", cache);
+        GraphStatisticsEntity slowStatistics = GraphStatisticsEntity.emptyEntity();
+        GraphStatisticsEntity otherStatistics = GraphStatisticsEntity.emptyEntity();
+        CountDownLatch collecting = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        Mockito.doAnswer(invocation -> {
+            collecting.countDown();
+            Assert.assertTrue(release.await(10, TimeUnit.SECONDS));
+            return slowStatistics;
+        }).when(service).collectBoundedStatistics(this.client);
+        Mockito.doReturn(otherStatistics).when(service).collectBoundedStatistics(otherClient);
+        ExecutorService executor = Executors.newFixedThreadPool(2);
+        try {
+            Future<GraphStatisticsEntity> slow = executor.submit(
+                    () -> service.getStatistics(this.client, "space", "slow"));
+            Assert.assertTrue(collecting.await(5, TimeUnit.SECONDS));
+            Future<GraphStatisticsEntity> other = executor.submit(
+                    () -> service.getStatistics(otherClient, "space", "other"));
+
+            Assert.assertSame(otherStatistics, other.get(5, TimeUnit.SECONDS));
+            Assert.assertFalse(slow.isDone());
+            release.countDown();
+            Assert.assertSame(slowStatistics, slow.get(5, TimeUnit.SECONDS));
+            Assert.assertSame(otherStatistics, service.getStatistics(otherClient, "space", "other"));
+            Mockito.verify(service).collectBoundedStatistics(otherClient);
+        } finally {
+            release.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testStatisticsCachePreservesOriginalFailureAndAllowsRetry() {
+        GraphsService service = Mockito.spy(this.service);
+        HugeClientPoolService pool = Mockito.mock(HugeClientPoolService.class);
+        Mockito.when(pool.statisticsPolicy(Mockito.any())).thenReturn("policy");
+        Mockito.when(pool.statisticsContext(this.client)).thenReturn("context");
+        ReflectionTestUtils.setField(service, "clientPool", pool);
+        ExternalException original = new ExternalException("gremlin.execute.failed");
+        GraphStatisticsEntity statistics = GraphStatisticsEntity.emptyEntity();
+        Mockito.doThrow(original).doReturn(statistics).when(service).collectBoundedStatistics(this.client);
+
+        try {
+            service.getStatistics(this.client, "space", "graph");
+            Assert.fail("Expected statistics failure");
+        } catch (ExternalException e) {
+            Assert.assertSame(original, e);
+        }
+        Assert.assertSame(statistics, service.getStatistics(this.client, "space", "graph"));
+        Assert.assertSame(statistics, service.getStatistics(this.client, "space", "graph"));
+        Mockito.verify(service, Mockito.times(2)).collectBoundedStatistics(this.client);
     }
 
     @Test
-    public void testSmallStatisticsStopsBeforeLoadingEdgesAboveLimit() {
+    public void testStatisticsFailureNeverFetchesRawGraphElements() {
         QueryService query = Mockito.mock(QueryService.class);
-        Mockito.when(query.executeQueryCount(Mockito.eq(this.client),
-                                             Mockito.anyString()))
-               .thenThrow(new ExternalException("gremlin.execute.failed"));
         ReflectionTestUtils.setField(this.service, "queryService", query);
-
-        GraphManager graph = Mockito.mock(GraphManager.class);
-        Mockito.when(this.client.graph()).thenReturn(graph);
-        Vertex vertex = Mockito.mock(Vertex.class);
-        Mockito.when(vertex.label()).thenReturn("person");
-        Mockito.when(graph.iterateVertices(1000))
-               .thenReturn(Collections.nCopies(10001, vertex).iterator());
+        Mockito.when(query.executeQueryCount(Mockito.eq(this.client), Mockito.anyString()))
+               .thenThrow(new ExternalException("gremlin.execute.failed"));
 
         try {
-            this.service.postSmallStatistics(this.client, "DEFAULT", "demo");
-            Assert.fail("Expected bounded statistics failure");
+            this.service.collectBoundedStatistics(this.client);
+            Assert.fail("Expected statistics failure");
         } catch (ExternalException ignored) {
-            // Expected: the fallback stops as soon as the limit is exceeded.
+            // The caller can retry; no vertex or edge properties are downloaded.
         }
-
-        Mockito.verify(graph, Mockito.never()).iterateEdges(Mockito.anyInt());
+        Mockito.verify(this.client, Mockito.never()).graph();
     }
 
     @Test
-    public void testSmallStatisticsRejectsEmptyGremlinCountResponse() {
+    public void testStatisticsRejectsMalformedResultsWithoutRawFallback() {
         QueryService query = Mockito.mock(QueryService.class);
+        ReflectionTestUtils.setField(this.service, "queryService", query);
         ResultSet empty = Mockito.mock(ResultSet.class);
         Mockito.when(empty.data()).thenReturn(Collections.emptyList());
-        Mockito.when(query.executeQueryCount(Mockito.eq(this.client),
-                                             Mockito.anyString()))
+        Mockito.when(query.executeQueryCount(Mockito.eq(this.client), Mockito.anyString()))
                .thenReturn(empty);
-        ReflectionTestUtils.setField(this.service, "queryService", query);
 
-        GraphManager graph = Mockito.mock(GraphManager.class);
-        Mockito.when(this.client.graph()).thenReturn(graph);
-        Vertex vertex = Mockito.mock(Vertex.class);
-        Edge edge = Mockito.mock(Edge.class);
-        Mockito.when(vertex.label()).thenReturn("person");
-        Mockito.when(edge.label()).thenReturn("knows");
-        Mockito.when(graph.iterateVertices(1000))
-               .thenReturn(Collections.singletonList(vertex).iterator());
-        Mockito.when(graph.iterateEdges(1000))
-               .thenReturn(Collections.singletonList(edge).iterator());
-
-        GraphStatisticsEntity result =
-                this.service.postSmallStatistics(this.client, "DEFAULT", "demo");
-
-        Assert.assertEquals("1", result.getVertexCount());
-        Assert.assertEquals("1", result.getEdgeCount());
+        try {
+            this.service.collectBoundedStatistics(this.client);
+            Assert.fail("Expected malformed statistics failure");
+        } catch (ExternalException ignored) {
+            // Malformed results do not trigger a costly raw-data fallback.
+        }
+        Mockito.verify(this.client, Mockito.never()).graph();
     }
 
     @Test
@@ -318,74 +674,53 @@ public class GraphsServiceDefaultTest {
     }
 
     @Test
-    public void testElementCountFallsBackToLiveSmallGraph() {
+    public void testMissingSnapshotNeverScansGraphData() {
         QueryService query = Mockito.mock(QueryService.class);
         ReflectionTestUtils.setField(this.service, "queryService", query);
-
         GraphManager graph = Mockito.mock(GraphManager.class);
         Mockito.when(this.client.graph()).thenReturn(graph);
         Mockito.when(graph.getEVCount(Mockito.anyString())).thenReturn(null);
-        Vertex vertex = Mockito.mock(Vertex.class);
-        Edge edge = Mockito.mock(Edge.class);
-        Mockito.when(vertex.label()).thenReturn("person");
-        Mockito.when(edge.label()).thenReturn("knows");
-        Mockito.when(graph.iterateVertices(1000))
-               .thenReturn(Collections.nCopies(7, vertex).iterator());
-        Mockito.when(graph.iterateEdges(1000))
-               .thenReturn(Collections.nCopies(6, edge).iterator());
 
         Map<String, Object> result =
                 this.service.evCount(this.client, "DEFAULT", "demo");
 
-        Assert.assertEquals(7L, result.get("vertex"));
-        Assert.assertEquals(6L, result.get("edge"));
-        Assert.assertNotNull(result.get("date"));
+        Assert.assertNull(result.get("vertex"));
+        Assert.assertNull(result.get("edge"));
+        Assert.assertNull(result.get("date"));
+        Mockito.verify(graph, Mockito.times(2)).getEVCount(Mockito.anyString());
+        Mockito.verify(graph, Mockito.never()).iterateVertices(Mockito.anyInt());
+        Mockito.verify(graph, Mockito.never()).iterateEdges(Mockito.anyInt());
         Mockito.verifyNoInteractions(query);
     }
 
     @Test
-    public void testElementCountSkipsUnavailableSnapshotsInPdMode() {
+    public void testRecordedSnapshotAlsoWorksInPdMode() {
         HugeConfig config = Mockito.mock(HugeConfig.class);
         Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
         ReflectionTestUtils.setField(this.service, "config", config);
-
-        QueryService query = Mockito.mock(QueryService.class);
-        ReflectionTestUtils.setField(this.service, "queryService", query);
-
         GraphManager graph = Mockito.mock(GraphManager.class);
         Mockito.when(this.client.graph()).thenReturn(graph);
-        Vertex vertex = Mockito.mock(Vertex.class);
-        Edge edge = Mockito.mock(Edge.class);
-        Mockito.when(vertex.label()).thenReturn("person");
-        Mockito.when(edge.label()).thenReturn("knows");
-        Mockito.when(graph.iterateVertices(1000))
-               .thenReturn(Collections.nCopies(2, vertex).iterator());
-        Mockito.when(graph.iterateEdges(1000))
-               .thenReturn(Collections.singletonList(edge).iterator());
+        GraphMetricsAPI.ElementCount snapshot = new GraphMetricsAPI.ElementCount();
+        snapshot.setVertices(2L);
+        snapshot.setEdges(1L);
+        Mockito.when(graph.getEVCount(Mockito.anyString())).thenReturn(snapshot);
 
         Map<String, Object> result =
                 this.service.evCount(this.client, "DEFAULT", "demo");
 
         Assert.assertEquals(2L, result.get("vertex"));
         Assert.assertEquals(1L, result.get("edge"));
-        Assert.assertNotNull(result.get("date"));
-        Mockito.verify(graph, Mockito.never()).getEVCount(Mockito.anyString());
-        Mockito.verifyNoInteractions(query);
+        Mockito.verify(graph).getEVCount(Mockito.anyString());
+        Mockito.verify(graph, Mockito.never()).iterateVertices(Mockito.anyInt());
+        Mockito.verify(graph, Mockito.never()).iterateEdges(Mockito.anyInt());
     }
 
     @Test
-    public void testElementCountReturnsUnavailableWhenLiveFallbackFails() {
-        QueryService query = Mockito.mock(QueryService.class);
-        Mockito.when(query.executeQueryCount(Mockito.eq(this.client),
-                                             Mockito.anyString()))
-               .thenThrow(new ExternalException("gremlin.execute.failed"));
-        ReflectionTestUtils.setField(this.service, "queryService", query);
-
+    public void testSnapshotFailureRemainsUnknownWithoutLiveFallback() {
         GraphManager graph = Mockito.mock(GraphManager.class);
         Mockito.when(this.client.graph()).thenReturn(graph);
-        Mockito.when(graph.getEVCount(Mockito.anyString())).thenReturn(null);
-        Mockito.when(graph.iterateVertices(1000))
-               .thenThrow(new ExternalException("paging.unsupported"));
+        Mockito.when(graph.getEVCount(Mockito.anyString()))
+               .thenThrow(new ExternalException("metrics.unavailable"));
 
         Map<String, Object> result =
                 this.service.evCount(this.client, "DEFAULT", "demo");
@@ -393,6 +728,8 @@ public class GraphsServiceDefaultTest {
         Assert.assertNull(result.get("vertex"));
         Assert.assertNull(result.get("edge"));
         Assert.assertNull(result.get("date"));
+        Mockito.verify(graph, Mockito.never()).iterateVertices(Mockito.anyInt());
+        Mockito.verify(graph, Mockito.never()).iterateEdges(Mockito.anyInt());
     }
 
     @Test
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GremlinOverviewStatisticsTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GremlinOverviewStatisticsTest.java
new file mode 100644
index 000000000..8b51be082
--- /dev/null
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GremlinOverviewStatisticsTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hugegraph.unit;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.hugegraph.controller.query.GremlinQueryController;
+import org.apache.hugegraph.driver.HugeClient;
+import org.apache.hugegraph.service.graphs.GraphsService;
+import org.apache.hugegraph.service.query.QueryService;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+public class GremlinOverviewStatisticsTest {
+
+    @Test
+    public void testOverviewReadsRecordedCountsWithoutGremlinExecution() {
+        HugeClient client = Mockito.mock(HugeClient.class);
+        QueryService queries = Mockito.mock(QueryService.class);
+        GraphsService graphs = Mockito.mock(GraphsService.class);
+        GremlinQueryController controller = controller(client, graphs, queries);
+        Map<String, Object> snapshot = new HashMap<>();
+        snapshot.put("vertex", 12000L);
+        snapshot.put("edge", 18000L);
+        Mockito.when(graphs.evCount(client, "space", "graph")).thenReturn(snapshot);
+
+        Map<String, Object> result = controller.execute("space", "graph");
+
+        Assert.assertEquals("12000", result.get("vertexcount"));
+        Assert.assertEquals("18000", result.get("edgecount"));
+        Mockito.verify(graphs).evCount(client, "space", "graph");
+        Mockito.verifyNoInteractions(queries);
+    }
+
+    @Test
+    public void testOverviewPreservesUnknownCountsWithoutGremlinExecution() {
+        HugeClient client = Mockito.mock(HugeClient.class);
+        QueryService queries = Mockito.mock(QueryService.class);
+        GraphsService graphs = Mockito.mock(GraphsService.class);
+        GremlinQueryController controller = controller(client, graphs, queries);
+        Map<String, Object> snapshot = new HashMap<>();
+        snapshot.put("vertex", null);
+        snapshot.put("edge", null);
+        Mockito.when(graphs.evCount(client, "space", "graph")).thenReturn(snapshot);
+
+        Map<String, Object> result = controller.execute("space", "graph");
+
+        Assert.assertTrue(result.containsKey("vertexcount"));
+        Assert.assertTrue(result.containsKey("edgecount"));
+        Assert.assertNull(result.get("vertexcount"));
+        Assert.assertNull(result.get("edgecount"));
+        Mockito.verifyNoInteractions(queries);
+    }
+
+    private static GremlinQueryController controller(HugeClient client,
+                                                      GraphsService graphs,
+                                                      QueryService queries) {
+        GremlinQueryController controller = new GremlinQueryController() {
+            @Override
+            protected HugeClient authClient(String graphSpace, String graph) {
+                return client;
+            }
+        };
+        ReflectionTestUtils.setField(controller, "graphsService", graphs);
+        ReflectionTestUtils.setField(controller, "queryService", queries);
+        return controller;
+    }
+}
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HugeClientPoolServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HugeClientPoolServiceTest.java
index 072ca7bc0..7ebbba577 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HugeClientPoolServiceTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/HugeClientPoolServiceTest.java
@@ -36,12 +36,16 @@ import com.sun.net.httpserver.HttpServer;
 import org.apache.hugegraph.config.ConfigException;
 import org.apache.hugegraph.config.HugeConfig;
 import org.apache.hugegraph.driver.HugeClient;
+import org.apache.hugegraph.driver.AuthManager;
 import org.apache.hugegraph.driver.factory.PDHugeClientFactory;
 import org.apache.hugegraph.exception.ParameterizedException;
 import org.apache.hugegraph.options.HubbleOptions;
 import org.apache.hugegraph.service.HugeClientPoolService;
 import org.apache.hugegraph.service.SettingSSLService;
+import org.apache.hugegraph.structure.auth.TokenPayload;
+import org.apache.hugegraph.structure.auth.User;
 import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.util.JsonUtil;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.Mockito;
@@ -61,6 +65,7 @@ public class HugeClientPoolServiceTest {
     public void setup() {
         HugeConfig config = Mockito.mock(HugeConfig.class);
         Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+        Mockito.when(config.get(HubbleOptions.PD_PEERS)).thenReturn("127.0.0.1:8686");
         Mockito.when(config.get(HubbleOptions.CLIENT_URL_CACHE_MAX_ENTRIES))
                .thenReturn(1024);
         this.factory = Mockito.mock(PDHugeClientFactory.class);
@@ -231,6 +236,191 @@ public class HugeClientPoolServiceTest {
         }
     }
 
+    @Test
+    public void testStatisticsContextPartitionsActualServerAndIdentity() throws Exception {
+        HttpServer first = this.versionServer(200, "0.80", new AtomicInteger());
+        HttpServer second = this.versionServer(200, "0.80", new AtomicInteger());
+        try {
+            this.cacheCandidates(this.serverUrl(first));
+            try (HugeClient a = this.service.create(this.serverUrl(first), GRAPH_SPACE, SERVICE, "fixture-a");
+                 HugeClient repeated = this.service.create(this.serverUrl(first), GRAPH_SPACE, SERVICE, "fixture-a");
+                 HugeClient otherUser = this.service.create(this.serverUrl(first), GRAPH_SPACE, SERVICE, "fixture-b");
+                 HugeClient otherServer = this.service.create(this.serverUrl(second), GRAPH_SPACE, SERVICE, "fixture-a")) {
+                String key = this.service.statisticsContext(a);
+                Assert.assertTrue(key.matches("[0-9a-f]{64}"));
+                Assert.assertEquals(key, this.service.statisticsContext(repeated));
+                Assert.assertFalse(key.equals(this.service.statisticsContext(otherUser)));
+                Assert.assertFalse(key.equals(this.service.statisticsContext(otherServer)));
+            }
+        } finally {
+            first.stop(0);
+            second.stop(0);
+        }
+    }
+
+    @Test
+    public void testStatisticsPolicyReadsCurrentBasicUserPolicyWithCanonicalMapOrder() {
+        HugeClient client = Mockito.mock(HugeClient.class);
+        Mockito.when(client.supportsDefaultRole()).thenReturn(true);
+        AuthManager auth = Mockito.mock(AuthManager.class);
+        Mockito.when(client.auth()).thenReturn(auth);
+        User user = new User();
+        user.name("reader");
+        user.setId("reader-id");
+        Mockito.when(client.findUserByName("reader")).thenReturn(user);
+        ReflectionTestUtils.invokeMethod(this.service, "registerStatisticsContext",
+                                         client, "target", null, "reader", "fixture");
+        User.UserRole first = JsonUtil.fromJson(
+                "{\"roles\":{\"space\":{\"graph\":{\"READ\":{\"VERTEX#*\":[" +
+                "{\"type\":\"VERTEX\",\"label\":\"*\",\"properties\":{\"a\":1,\"b\":2}}]," +
+                "\"EDGE#*\":[{\"type\":\"EDGE\",\"label\":\"*\"}]}}}}}", User.UserRole.class);
+        User.UserRole reordered = JsonUtil.fromJson(
+                "{\"roles\":{\"space\":{\"graph\":{\"READ\":{\"EDGE#*\":[" +
+                "{\"type\":\"EDGE\",\"label\":\"*\"}],\"VERTEX#*\":[" +
+                "{\"type\":\"VERTEX\",\"label\":\"*\",\"properties\":{\"b\":2,\"a\":1}}]}}}}}",
+                User.UserRole.class);
+        User.UserRole changed = JsonUtil.fromJson("{\"roles\":{}}", User.UserRole.class);
+        Mockito.when(auth.getUserRole("reader-id")).thenReturn(first, reordered, changed)
+               .thenThrow(new IllegalStateException("Policy unavailable"));
+
+        String context = this.service.statisticsContext(client);
+        String policy = this.service.statisticsPolicy(client);
+        Assert.assertTrue(policy.matches("[0-9a-f]{64}"));
+        Assert.assertEquals(policy, this.service.statisticsPolicy(client));
+        Assert.assertNotEquals(policy, this.service.statisticsPolicy(client));
+        Assert.assertThrows(IllegalStateException.class, () -> this.service.statisticsPolicy(client));
+        Assert.assertEquals(context, this.service.statisticsContext(client));
+        Mockito.verify(client, Mockito.times(4)).findUserByName("reader");
+        Mockito.verify(auth, Mockito.times(4)).getUserRole("reader-id");
+        Mockito.verify(auth, Mockito.never()).verifyToken();
+
+        Mockito.doReturn(JsonUtil.fromJson("{}", User.UserRole.class))
+               .when(auth).getUserRole("reader-id");
+        Assert.assertThrows(RuntimeException.class, () -> this.service.statisticsPolicy(client));
+        Mockito.doReturn(null).when(auth).getUserRole("reader-id");
+        Assert.assertThrows(IllegalStateException.class, () -> this.service.statisticsPolicy(client));
+    }
+
+    @Test
+    public void testStatisticsPolicyUsesVerifiedBearerIdentity() {
+        HugeClient client = Mockito.mock(HugeClient.class);
+        AuthManager auth = Mockito.mock(AuthManager.class);
+        Mockito.when(client.auth()).thenReturn(auth);
+        Mockito.when(auth.verifyToken()).thenReturn(JsonUtil.fromJson(
+                "{\"user_id\":\"reader-id\",\"user_name\":\"reader\"}", TokenPayload.class));
+        Mockito.when(auth.getUserRole("reader-id")).thenReturn(
+                JsonUtil.fromJson("{\"roles\":{}}", User.UserRole.class));
+        ReflectionTestUtils.invokeMethod(this.service, "registerStatisticsContext",
+                                         client, "target", "fixture", null, null);
+
+        Assert.assertTrue(this.service.statisticsPolicy(client).matches("[0-9a-f]{64}"));
+        Mockito.verify(auth).verifyToken();
+        Mockito.verify(auth).getUserRole("reader-id");
+        Mockito.verify(client, Mockito.never()).findUserByName(Mockito.anyString());
+    }
+
+    @Test
+    public void testAnonymousStatisticsPolicyDoesNotCallServer() {
+        HugeClient client = Mockito.mock(HugeClient.class);
+        ReflectionTestUtils.invokeMethod(this.service, "registerStatisticsContext",
+                                         client, "target", null, null, null);
+
+        Assert.assertEquals("", this.service.statisticsPolicy(client));
+        Mockito.verifyNoInteractions(client);
+    }
+
+    @Test
+    public void testDiscoveredStatisticsContextSharesAcrossServersInTheSamePdCluster() throws Exception {
+        AtomicInteger firstCalls = new AtomicInteger();
+        AtomicInteger secondCalls = new AtomicInteger();
+        HttpServer first = this.versionServer(200, "0.80", firstCalls);
+        HttpServer second = this.versionServer(200, "0.80", secondCalls);
+        try {
+            this.cacheCandidates(this.serverUrl(first));
+            HugeConfig config = (HugeConfig) ReflectionTestUtils.getField(this.service, "config");
+            Mockito.when(config.get(HubbleOptions.PD_PEERS)).thenReturn("pd-b:8686, pd-a:8686, pd-a:8686");
+            this.stubSuccessfulDiscovery(GRAPH_SPACE, SERVICE, this.serverUrl(first));
+            try (HugeClient a = this.service.create(null, GRAPH_SPACE, SERVICE, "fixture-a")) {
+                Mockito.when(config.get(HubbleOptions.PD_PEERS)).thenReturn("pd-a:8686,pd-b:8686");
+                this.stubSuccessfulDiscovery(GRAPH_SPACE, SERVICE, this.serverUrl(second));
+                try (HugeClient b = this.service.create(null, GRAPH_SPACE, SERVICE, "fixture-a");
+                     HugeClient explicit = this.service.create(this.serverUrl(second),
+                                                               GRAPH_SPACE, SERVICE, "fixture-a")) {
+                    Assert.assertEquals(this.service.statisticsContext(a), this.service.statisticsContext(b));
+                    Assert.assertFalse(this.service.statisticsContext(b).equals(
+                                       this.service.statisticsContext(explicit)));
+                }
+            }
+            Assert.assertTrue(firstCalls.get() > 0);
+            Assert.assertTrue(secondCalls.get() > 0);
+            Mockito.verify(this.factory, Mockito.times(2)).getURLs(CLUSTER, GRAPH_SPACE, SERVICE);
+            Mockito.verify(this.factory, Mockito.times(2)).getURLs(CLUSTER, GRAPH_SPACE, null);
+            Mockito.verify(this.factory, Mockito.times(2)).getURLs(
+                    CLUSTER, PDHugeClientFactory.DEFAULT_GRAPHSPACE, PDHugeClientFactory.DEFAULT_SERVICE);
+            Mockito.verifyNoMoreInteractions(this.factory);
+        } finally {
+            first.stop(0);
+            second.stop(0);
+        }
+    }
+
+    @Test
+    public void testDiscoveredStatisticsContextPartitionsPdClusterAndAuthentication() throws Exception {
+        HttpServer server = this.versionServer(200, "0.80", new AtomicInteger());
+        try {
+            this.cacheCandidates(this.serverUrl(server));
+            Mockito.when(this.factory.getURLs(Mockito.anyString(), Mockito.eq(GRAPH_SPACE), Mockito.eq(SERVICE)))
+                   .thenReturn(Collections.singletonList(this.serverUrl(server)));
+            HugeConfig config = (HugeConfig) ReflectionTestUtils.getField(this.service, "config");
+            try (HugeClient a = this.service.create(null, GRAPH_SPACE, SERVICE, "fixture-a");
+                 HugeClient otherUser = this.service.create(null, GRAPH_SPACE, SERVICE, "fixture-b");
+                 HugeClient anonymous = this.service.create(null, GRAPH_SPACE, SERVICE, null);
+                 HugeClient basic = this.service.createBasicClient(GRAPH_SPACE, SERVICE, "alice", "fixture-a");
+                 HugeClient otherPassword = this.service.createBasicClient(
+                                            GRAPH_SPACE, SERVICE, "alice", "fixture-b")) {
+                String key = this.service.statisticsContext(a);
+                Assert.assertFalse(key.equals(this.service.statisticsContext(otherUser)));
+                Assert.assertFalse(key.equals(this.service.statisticsContext(anonymous)));
+                Assert.assertFalse(key.equals(this.service.statisticsContext(basic)));
+                Assert.assertFalse(this.service.statisticsContext(basic).equals(
+                                   this.service.statisticsContext(otherPassword)));
+                Mockito.when(config.get(HubbleOptions.PD_PEERS)).thenReturn("other-pd:8686");
+                try (HugeClient otherPd = this.service.create(null, GRAPH_SPACE, SERVICE, "fixture-a")) {
+                    Assert.assertFalse(key.equals(this.service.statisticsContext(otherPd)));
+                }
+                Mockito.when(config.get(HubbleOptions.PD_PEERS)).thenReturn("127.0.0.1:8686");
+                ReflectionTestUtils.setField(this.service, "cluster", "other-cluster");
+                try (HugeClient otherCluster = this.service.create(null, GRAPH_SPACE, SERVICE, "fixture-a")) {
+                    Assert.assertFalse(key.equals(this.service.statisticsContext(otherCluster)));
+                }
+            }
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    public void testDirectStatisticsContextPartitionsConfiguredServer() throws Exception {
+        HttpServer first = this.versionServer(200, "0.80", new AtomicInteger());
+        HttpServer second = this.versionServer(200, "0.80", new AtomicInteger());
+        try {
+            this.cacheCandidates(this.serverUrl(first));
+            HugeConfig config = (HugeConfig) ReflectionTestUtils.getField(this.service, "config");
+            Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(false);
+            Mockito.when(config.get(HubbleOptions.SERVER_URL)).thenReturn(this.serverUrl(first));
+            try (HugeClient a = this.service.create(null, GRAPH_SPACE, SERVICE, "fixture-a")) {
+                Mockito.when(config.get(HubbleOptions.SERVER_URL)).thenReturn(this.serverUrl(second));
+                try (HugeClient b = this.service.create(null, GRAPH_SPACE, SERVICE, "fixture-a")) {
+                    Assert.assertFalse(this.service.statisticsContext(a).equals(this.service.statisticsContext(b)));
+                }
+            }
+            Mockito.verifyNoInteractions(this.factory);
+        } finally {
+            first.stop(0);
+            second.stop(0);
+        }
+    }
+
     @Test
     public void testRetryRefusedCandidateBeforeLiveServer() throws Exception {
         AtomicInteger calls = new AtomicInteger();
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/StatisticsPolicyCompatibilityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/StatisticsPolicyCompatibilityTest.java
new file mode 100644
index 000000000..a15bb4856
--- /dev/null
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/StatisticsPolicyCompatibilityTest.java
@@ -0,0 +1,234 @@
+/*
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.hugegraph.unit;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.google.common.cache.Cache;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.hugegraph.config.HugeConfig;
+import org.apache.hugegraph.driver.HugeClient;
+import org.apache.hugegraph.entity.graphs.GraphStatisticsEntity;
+import org.apache.hugegraph.exception.ServerException;
+import org.apache.hugegraph.options.HubbleOptions;
+import org.apache.hugegraph.service.HugeClientPoolService;
+import org.apache.hugegraph.service.SettingSSLService;
+import org.apache.hugegraph.service.graphs.GraphsService;
+import org.apache.hugegraph.service.query.QueryService;
+import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.util.JsonUtil;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+public class StatisticsPolicyCompatibilityTest {
+
+    @Test
+    public void testLegacyBasicReaderNeverListsUsersOrReusesSnapshots() throws Exception {
+        for (int listStatus : new int[]{200, 403}) {
+            try (Fixture fixture = new Fixture(false);
+                 HugeClient client = fixture.client()) {
+                fixture.userListStatus.set(listStatus);
+                Assert.assertTrue(client.requiresBasicGremlinAuth());
+                Assert.assertFalse(client.supportsDefaultRole());
+                Assert.assertNull(fixture.pool.statisticsPolicy(client));
+                Assert.assertEquals(7L, fixture.graphs.evCount(client, "space", "graph").get("vertex"));
+                Assert.assertEquals(7L, fixture.graphs.evCount(client, "space", "graph").get("vertex"));
+                Assert.assertEquals(0, fixture.queries.size());
+                Assert.assertEquals(2, fixture.metrics.get());
+
+                GraphStatisticsEntity first = fixture.graphs.getStatistics(client, "space", "graph");
+                GraphStatisticsEntity second = fixture.graphs.getStatistics(client, "space", "graph");
+                Assert.assertNotSame(first, second);
+                Assert.assertEquals("50", second.getVertexCount());
+                Assert.assertEquals(4, fixture.queries.size());
+                Assert.assertEquals(0L, fixture.cacheSize());
+                Assert.assertEquals(0, fixture.userLists.get());
+                Assert.assertEquals(0, fixture.namedUsers.get());
+                Assert.assertEquals(0, fixture.roles.get());
+                Assert.assertEquals(0, fixture.missingBasic.get());
+                fixture.assertBoundedQueries();
+            }
+        }
+    }
+
+    @Test
+    public void testModernUnavailableMetadataBypassesExistingCacheWithoutScanningOverview() throws Exception {
+        try (Fixture fixture = new Fixture(true);
+             HugeClient client = fixture.client()) {
+            GraphStatisticsEntity cached = fixture.graphs.getStatistics(client, "space", "graph");
+            Assert.assertSame(cached, fixture.graphs.getStatistics(client, "space", "graph"));
+            Assert.assertEquals(2, fixture.queries.size());
+            Assert.assertEquals(2, fixture.namedUsers.get());
+            Assert.assertEquals(2, fixture.roles.get());
+            for (AtomicInteger endpoint : new AtomicInteger[]{fixture.identityStatus, fixture.roleStatus}) {
+                for (int status : new int[]{403, 404}) {
+                    endpoint.set(status);
+                    Assert.assertNull(fixture.pool.statisticsPolicy(client));
+                    Assert.assertEquals(7L, fixture.graphs.evCount(client, "space", "graph").get("vertex"));
+                    Assert.assertEquals(2, fixture.queries.size());
+                    endpoint.set(200);
+                }
+            }
+            fixture.roleStatus.set(403);
+            Assert.assertNotSame(cached, fixture.graphs.getStatistics(client, "space", "graph"));
+            Assert.assertNotSame(cached, fixture.graphs.getStatistics(client, "space", "graph"));
+            Assert.assertEquals(6, fixture.queries.size());
+            Assert.assertEquals(1L, fixture.cacheSize());
+            fixture.roleStatus.set(200);
+            Assert.assertSame(cached, fixture.graphs.getStatistics(client, "space", "graph"));
+            Assert.assertEquals(6, fixture.queries.size());
+            Assert.assertEquals(0, fixture.userLists.get());
+            Assert.assertEquals(0, fixture.missingBasic.get());
+            fixture.assertBoundedQueries();
+        }
+    }
+
+    @Test
+    public void testOtherMetadataFailuresStillFailBeforeCachedOrNativeStatistics() throws Exception {
+        try (Fixture fixture = new Fixture(true);
+             HugeClient client = fixture.client()) {
+            fixture.graphs.getStatistics(client, "space", "graph");
+            for (AtomicInteger endpoint : new AtomicInteger[]{fixture.identityStatus, fixture.roleStatus}) {
+                for (int status : new int[]{401, 503}) {
+                    endpoint.set(status);
+                    Throwable overview = Assert.assertThrows(ServerException.class,
+                            () -> fixture.graphs.evCount(client, "space", "graph"));
+                    Assert.assertEquals(status, ((ServerException) overview).status());
+                    Assert.assertThrows(ServerException.class,
+                            () -> fixture.graphs.getStatistics(client, "space", "graph"));
+                    endpoint.set(200);
+                }
+            }
+            Assert.assertEquals(2, fixture.queries.size());
+            Assert.assertEquals(0, fixture.metrics.get());
+            Assert.assertEquals(0, fixture.userLists.get());
+        }
+    }
+
+    private static final class Fixture implements AutoCloseable {
+
+        private final HttpServer server;
+        private final HugeClientPoolService pool = new HugeClientPoolService();
+        private final GraphsService graphs = new GraphsService();
+        private final AtomicInteger identityStatus = new AtomicInteger(200);
+        private final AtomicInteger roleStatus = new AtomicInteger(200);
+        private final AtomicInteger userLists = new AtomicInteger();
+        private final AtomicInteger userListStatus = new AtomicInteger(403);
+        private final AtomicInteger namedUsers = new AtomicInteger();
+        private final AtomicInteger roles = new AtomicInteger();
+        private final AtomicInteger metrics = new AtomicInteger();
+        private final AtomicInteger missingBasic = new AtomicInteger();
+        private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
+
+        private Fixture(boolean modern) throws IOException {
+            this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+            this.server.createContext("/", exchange -> this.respond(exchange, modern));
+            this.server.start();
+            HugeConfig config = Mockito.mock(HugeConfig.class);
+            Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(false);
+            Mockito.when(config.get(HubbleOptions.SERVER_URL)).thenReturn(
+                    "http://127.0.0.1:" + this.server.getAddress().getPort());
+            Mockito.when(config.get(HubbleOptions.CLIENT_REQUEST_TIMEOUT)).thenReturn(2);
+            ReflectionTestUtils.setField(this.pool, "config", config);
+            ReflectionTestUtils.setField(this.pool, "sslService", Mockito.mock(SettingSSLService.class));
+            ReflectionTestUtils.setField(this.graphs, "clientPool", this.pool);
+            ReflectionTestUtils.setField(this.graphs, "queryService", new QueryService());
+        }
+
+        private HugeClient client() {
+            return this.pool.createBasicClient("space", "graph", "reader", "fixture");
+        }
+
+        private long cacheSize() {
+            return ((Cache<?, ?>) ReflectionTestUtils.getField(this.graphs, "boundedStatistics")).size();
+        }
+
+        private void assertBoundedQueries() {
+            for (String query : this.queries) {
+                Assert.assertTrue(query.equals("g.V().limit(100000).groupCount().by(label)") ||
+                                  query.equals("g.E().limit(100000).groupCount().by(label)"));
+            }
+        }
+
+        private void respond(HttpExchange exchange, boolean modern) throws IOException {
+            String path = exchange.getRequestURI().getPath();
+            int status = 200;
+            String json;
+            String auth = exchange.getRequestHeaders().getFirst("Authorization");
+            if (!path.equals("/versions") && (auth == null || !auth.startsWith("Basic "))) {
+                this.missingBasic.incrementAndGet();
+            }
+            if (path.equals("/versions")) {
+                json = "{\"versions\":{\"api\":\"" + (modern ? "0.80" : "0.71") +
+                       "\",\"core\":\"1.7.0\"}}";
+            } else if (path.endsWith("/auth/users")) {
+                if (exchange.getRequestURI().getQuery() != null &&
+                    exchange.getRequestURI().getQuery().startsWith("name=")) {
+                    this.namedUsers.incrementAndGet();
+                    status = this.identityStatus.get();
+                    json = "{\"id\":\"reader-id\",\"user_name\":\"reader\"}";
+                } else {
+                    this.userLists.incrementAndGet();
+                    status = this.userListStatus.get();
+                    json = "{\"users\":[{\"id\":\"reader-id\",\"user_name\":\"reader\"}]}";
+                }
+            } else if (path.endsWith("/auth/users/reader-id/role")) {
+                this.roles.incrementAndGet();
+                status = this.roleStatus.get();
+                json = "{\"roles\":{}}";
+            } else if (path.contains("/metrics/elementcount/")) {
+                this.metrics.incrementAndGet();
+                json = "{\"vertices\":7,\"edges\":3}";
+            } else if (path.equals("/gremlin")) {
+                Map<?, ?> request = JsonUtil.fromJson(new String(exchange.getRequestBody().readAllBytes(),
+                                                                StandardCharsets.UTF_8), Map.class);
+                String query = (String) request.get("gremlin");
+                this.queries.add(query);
+                String labels = query.startsWith("g.V()") ? "{\"person\":50}" : "{\"link\":20}";
+                json = "{\"status\":{\"code\":200},\"result\":{\"data\":[" + labels + "]}}";
+            } else {
+                status = 404;
+                json = "{}";
+            }
+            if (status != 200) {
+                json = "{\"exception\":\"MetadataUnavailable\",\"message\":\"fixture rejection\"}";
+            }
+            byte[] body = json.getBytes(StandardCharsets.UTF_8);
+            exchange.getResponseHeaders().set("Content-Type", "application/json");
+            exchange.sendResponseHeaders(status, body.length);
+            try (java.io.OutputStream output = exchange.getResponseBody()) {
+                output.write(body);
+            }
+        }
+
+        @Override
+        public void close() {
+            this.server.stop(0);
+        }
+    }
+}
diff --git a/hugegraph-hubble/hubble-fe/src/api/analysis-context-contract.test.js b/hugegraph-hubble/hubble-fe/src/api/analysis-context-contract.test.js
index 18ff71d61..0f44ee362 100644
--- a/hugegraph-hubble/hubble-fe/src/api/analysis-context-contract.test.js
+++ b/hugegraph-hubble/hubble-fe/src/api/analysis-context-contract.test.js
@@ -31,15 +31,11 @@ test('forwards inline error ownership for graph context reads', () => {
     const config = {suppressBusinessErrorToast: true};
 
     analysis.getGraphSpaceList(config);
-    analysis.getGraphList('DEFAULT', config);
     analysis.getOlapMode('DEFAULT', 'g', config);
 
     expect(request.get).toHaveBeenNthCalledWith(1, '/graphspaces/list', config);
     expect(request.get).toHaveBeenNthCalledWith(
-        2, '/graphspaces/DEFAULT/graphs/list', config
-    );
-    expect(request.get).toHaveBeenNthCalledWith(
-        3, '/graphspaces/DEFAULT/graphs/g/graph_read_mode', config
+        2, '/graphspaces/DEFAULT/graphs/g/graph_read_mode', config
     );
 });
 
@@ -115,3 +111,10 @@ test.each([
         {...config, suppressBusinessErrorToast: true});
     expect(request.get).not.toHaveBeenCalled();
 });
+
+
+test('lets deferred statistics own business error feedback', () => {
+    const config = {suppressBusinessErrorToast: true};
+    analysis.getGraphData('DEFAULT', 'g', config);
+    expect(request.get).toHaveBeenCalledWith('/graphspaces/DEFAULT/graphs/g/gremlin-query', config);
+});
diff --git a/hugegraph-hubble/hubble-fe/src/api/analysis.js b/hugegraph-hubble/hubble-fe/src/api/analysis.js
index 7a98f5c6a..34e36856c 100644
--- a/hugegraph-hubble/hubble-fe/src/api/analysis.js
+++ b/hugegraph-hubble/hubble-fe/src/api/analysis.js
@@ -28,11 +28,6 @@ const getGraphSpaceList = config => {
         : request.get('/graphspaces/list');
 };
 
-const getGraphList = (graphSpace, config) => {
-    const url = `/graphspaces/${graphSpace}/graphs/list`;
-    return config ? request.get(url, config) : request.get(url);
-};
-
 const getOlapMode = (graphSpace, graph, config) => {
     const url = `/graphspaces/${graphSpace}/graphs/${graph}/graph_read_mode`;
     return config ? request.get(url, config) : request.get(url);
@@ -103,8 +98,9 @@ const getText2GqlConfig = (graphspace, graph, config = {}) => {
         {...config, suppressBusinessErrorToast: true});
 };
 
-const getGraphData = (graphspace, graph) => {
-    return request.get(`/graphspaces/${graphspace}/graphs/${graph}/gremlin-query`);
+const getGraphData = (graphspace, graph, config) => {
+    const url = `/graphspaces/${graphspace}/graphs/${graph}/gremlin-query`;
+    return config ? request.get(url, config) : request.get(url);
 };
 
 const putExecutionQuery = (graphspace, graph, params) => {
@@ -243,7 +239,6 @@ const loadVermeerTask = (params, config) => {
 
 export {
     getGraphSpaceList,
-    getGraphList,
     getOlapMode,
     switchOlapMode,
     getExecutionLogs,
diff --git a/hugegraph-hubble/hubble-fe/src/api/manage-contract.test.js b/hugegraph-hubble/hubble-fe/src/api/manage-contract.test.js
index 75016fec7..a52a9a3f4 100644
--- a/hugegraph-hubble/hubble-fe/src/api/manage-contract.test.js
+++ b/hugegraph-hubble/hubble-fe/src/api/manage-contract.test.js
@@ -209,3 +209,10 @@ test.each([
         {suppressBusinessErrorToast: true}
     );
 });
+
+
+test('lets deferred schema previews own business error feedback', () => {
+    const config = {suppressBusinessErrorToast: true};
+    manage.getGraphView('DEFAULT', 'g', config);
+    expect(request.get).toHaveBeenCalledWith('/graphspaces/DEFAULT/graphs/g/schema/graphview', config);
+});
diff --git a/hugegraph-hubble/hubble-fe/src/api/manage.js b/hugegraph-hubble/hubble-fe/src/api/manage.js
index f9b06d129..2fc702b84 100644
--- a/hugegraph-hubble/hubble-fe/src/api/manage.js
+++ b/hugegraph-hubble/hubble-fe/src/api/manage.js
@@ -111,8 +111,9 @@ const delGraph = (graphspace, graph) => {
     return request.delete(`/graphspaces/${graphspace}/graphs/${graph}`);
 };
 
-const getGraphView = (graphspace, graph) => {
-    return request.get(`/graphspaces/${graphspace}/graphs/${graph}/schema/graphview`);
+const getGraphView = (graphspace, graph, config) => {
+    const url = `/graphspaces/${graphspace}/graphs/${graph}/schema/graphview`;
+    return config ? request.get(url, config) : request.get(url);
 };
 
 const setDefaultGraph = (graphspace, graph, config) => {
diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js
index cf87bb5a5..a0b1f2796 100644
--- a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js
+++ b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js
@@ -17,10 +17,9 @@
  */
 
 import {Alert, Button, Select, Space} from 'antd';
-import {useCallback, useEffect, useMemo, useState} from 'react';
+import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
 import {useLocation, useNavigate} from 'react-router-dom';
 import {useTranslation} from 'react-i18next';
-import * as api from '../../api';
 import {isPdEnabled} from '../../utils/config';
 import {DEFAULT_GRAPHSPACE} from '../../utils/productMode';
 import {
@@ -32,9 +31,8 @@ import {
 } from '../../utils/workbenchGraphContext';
 import style from './index.module.scss';
 import {getResourceDisplayName} from '../../utils/displayName';
+import {loadGraphOptions, loadGraphspaceOptions, mergeGraphOptions} from '../../utils/graphOptions';
 
-const GRAPHSPACE_LIST_PARAMS = {all: true};
-const GRAPH_LIST_PARAMS = {page_no: 1, page_size: -1};
 const INLINE_ERROR_CONFIG = {suppressBusinessErrorToast: true};
 const SELECT_MIN_WIDTH = 112;
 const SELECT_MAX_WIDTH = 240;
@@ -59,18 +57,6 @@ const getAnalysisModule = pathname => pathname.match(
     /^\/(gremlin|algorithms|asyncTasks)(?:\/|$)/
 )?.[1];
 
-const getRecords = response => {
-    if (response?.status !== 200) {
-        const error = new Error(`Unexpected graph context response: ${response?.status}`);
-        error.status = response?.status;
-        throw error;
-    }
-    if (Array.isArray(response.data)) {
-        return response.data;
-    }
-    return response.data?.records ?? [];
-};
-
 const errorStatus = error => error?.status ?? error?.response?.data?.status
                      ?? error?.response?.status;
 
@@ -98,6 +84,15 @@ const GraphContextSwitcher = () => {
         pdEnabled ? [] : [{name: DEFAULT_GRAPHSPACE, nickname: DEFAULT_GRAPHSPACE}]
     ));
     const [graphs, setGraphs] = useState([]);
+    const [graphspaceBrowse, setGraphspaceBrowse] = useState({query: '', pageNo: 1});
+    const graphspaceRequest = useRef(null);
+    const graphspaceQuery = graphspaceBrowse.query;
+    const graphspacePageNo = graphspaceBrowse.pageNo;
+    const [graphBrowse, setGraphBrowse] = useState({});
+    const graphRequest = useRef(null);
+    const graphQuery = graphBrowse.graphspace === context.graphspace ? graphBrowse.query ?? '' : '';
+    const graphPageNo = graphBrowse.graphspace === context.graphspace ? graphBrowse.pageNo ?? 1 : 1;
+    const graphScope = JSON.stringify([context.graphspace, context.graph]);
     const [loading, setLoading] = useState({graphspaces: pdEnabled, graphs: true});
     const [errors, setErrors] = useState({graphspaces: false, graphs: false});
     const [reloadTokens, setReloadTokens] = useState({graphspaces: 0, graphs: 0});
@@ -122,27 +117,49 @@ const GraphContextSwitcher = () => {
             return undefined;
         }
 
+        const request = Symbol('graphspace-options');
+        graphspaceRequest.current = request;
         setLoading(value => ({...value, graphspaces: true}));
-        api.manage.getGraphSpaceList(GRAPHSPACE_LIST_PARAMS, INLINE_ERROR_CONFIG)
-            .then(response => {
-                if (!cancelled) {
-                    const records = getRecords(response);
-                    setGraphspaces(records);
-                    setErrors(value => ({...value, graphspaces: false}));
-                    setLoading(value => ({...value, graphspaces: false}));
-                }
-            })
-            .catch(error => {
-                if (!cancelled) {
-                    setErrors(value => ({...value, graphspaces: errorKind(error)}));
-                    setLoading(value => ({...value, graphspaces: false}));
-                }
-            });
-
+        const fetchPage = () => loadGraphspaceOptions({
+            pageNo: graphspacePageNo,
+            query: graphspaceQuery,
+            selected: context.graphspace,
+        }, INLINE_ERROR_CONFIG).then(options => {
+            if (!cancelled) {
+                setGraphspaces(current => mergeGraphOptions(
+                    graphspacePageNo > 1 ? [...current, ...options.records] : options.records,
+                    options.selected
+                ));
+                setGraphspaceBrowse({
+                    query: graphspaceQuery,
+                    pageNo: graphspacePageNo,
+                    hasMore: options.hasMore,
+                    scope: context.graphspace,
+                });
+                setErrors(value => ({...value, graphspaces: false}));
+            }
+        }).catch(error => {
+            if (!cancelled) {
+                setErrors(value => ({...value, graphspaces: errorKind(error)}));
+            }
+        }).finally(() => {
+            if (!cancelled) {
+                graphspaceRequest.current = null;
+                setLoading(value => ({...value, graphspaces: false}));
+            }
+        });
+        const timer = graphspaceQuery && graphspacePageNo === 1 ? setTimeout(fetchPage, 200) : undefined;
+        if (!timer) {
+            fetchPage();
+        }
         return () => {
             cancelled = true;
+            clearTimeout(timer);
+            if (graphspaceRequest.current === request) {
+                graphspaceRequest.current = null;
+            }
         };
-    }, [navigate, pdEnabled, reloadTokens.graphspaces]);
+    }, [context.graphspace, graphspacePageNo, graphspaceQuery, pdEnabled, reloadTokens.graphspaces]);
 
     useEffect(() => {
         let cancelled = false;
@@ -152,42 +169,67 @@ const GraphContextSwitcher = () => {
             return undefined;
         }
 
+        const request = Symbol('graph-options');
+        graphRequest.current = request;
         setLoading(value => ({...value, graphs: true}));
         setErrors(value => ({...value, graphs: false}));
-        const requestedGraphspace = context.graphspace;
-        api.manage.getGraphList(requestedGraphspace, GRAPH_LIST_PARAMS, INLINE_ERROR_CONFIG)
-            .then(response => {
-                if (!cancelled) {
-                    const records = getRecords(response);
-                    setGraphs(records);
+        const fetchPage = () => loadGraphOptions(context.graphspace, {
+            pageNo: graphPageNo,
+            query: graphQuery,
+            selected: context.graph,
+        }, INLINE_ERROR_CONFIG).then(options => {
+            if (!cancelled) {
+                setGraphs(current => mergeGraphOptions(
+                    graphPageNo > 1 ? [...current, ...options.records] : options.records,
+                    options.selected
+                ));
+                setGraphBrowse({
+                    graphspace: context.graphspace,
+                    query: graphQuery,
+                    pageNo: graphPageNo,
+                    hasMore: options.hasMore,
+                    scope: graphScope,
+                });
+                setErrors(value => ({...value, graphs: false}));
+            }
+        }).catch(error => {
+            if (!cancelled) {
+                const kind = errorKind(error);
+                if (pdEnabled && unavailableGraphSpace(error)) {
+                    clearWorkbenchGraphContext(localStorage);
+                    setContext({});
+                    setGraphs([]);
                     setErrors(value => ({...value, graphs: false}));
-                    setLoading(value => ({...value, graphs: false}));
+                    navigate('/navigation', {replace: true});
                 }
-            })
-            .catch(error => {
-                if (!cancelled) {
-                    const kind = errorKind(error);
-                    if (pdEnabled && unavailableGraphSpace(error)) {
-                        clearWorkbenchGraphContext(localStorage);
-                        setContext({});
-                        setGraphs([]);
-                        setErrors(value => ({...value, graphs: false}));
-                        navigate('/navigation', {replace: true});
-                    }
-                    else {
-                        setErrors(value => ({...value, graphs: kind}));
-                    }
-                    setLoading(value => ({...value, graphs: false}));
+                else {
+                    setErrors(value => ({...value, graphs: kind}));
                 }
-            });
-
+            }
+        }).finally(() => {
+            if (!cancelled) {
+                graphRequest.current = null;
+                setLoading(value => ({...value, graphs: false}));
+            }
+        });
+        // Avoid rebuilding the upstream profile list for every search keystroke.
+        const timer = graphQuery && graphPageNo === 1 ? setTimeout(fetchPage, 200) : undefined;
+        if (!timer) {
+            fetchPage();
+        }
         return () => {
             cancelled = true;
+            clearTimeout(timer);
+            if (graphRequest.current === request) {
+                graphRequest.current = null;
+            }
         };
-    }, [context.graphspace, navigate, pdEnabled, reloadTokens.graphs]);
+    }, [context.graph, context.graphspace, graphPageNo, graphQuery, graphScope, navigate, pdEnabled,
+        reloadTokens.graphs]);
 
     useEffect(() => {
-        if (!pdEnabled || loading.graphspaces || errors.graphspaces || !context.graphspace || (
+        if (!pdEnabled || graphspaceBrowse.scope !== context.graphspace || loading.graphspaces ||
+            errors.graphspaces || !context.graphspace || (
             graphspaces.some(item => item.name === context.graphspace)
         )) {
             return;
@@ -201,13 +243,14 @@ const GraphContextSwitcher = () => {
         context.graphspace,
         errors.graphspaces,
         graphspaces,
+        graphspaceBrowse.scope,
         loading.graphspaces,
         navigate,
         pdEnabled,
     ]);
 
     useEffect(() => {
-        if (loading.graphs || errors.graphs || !context.graph || (
+        if (graphBrowse.scope !== graphScope || loading.graphs || errors.graphs || !context.graph || (
             graphs.some(item => item.name === context.graph)
         )) {
             return;
@@ -224,6 +267,8 @@ const GraphContextSwitcher = () => {
         context.graph,
         context.graphspace,
         errors.graphs,
+        graphBrowse.scope,
+        graphScope,
         graphs,
         loading.graphs,
         navigate,
@@ -255,6 +300,7 @@ const GraphContextSwitcher = () => {
 
     const selectGraphspace = useCallback(graphspace => {
         const nextContext = {graphspace};
+        setGraphspaceBrowse({query: '', pageNo: 1});
         setGraphs([]);
         setErrors(value => ({...value, graphs: false}));
         setLoading(value => ({...value, graphs: true}));
@@ -270,6 +316,7 @@ const GraphContextSwitcher = () => {
             return;
         }
         const nextContext = {graphspace: context.graphspace, graph};
+        setGraphBrowse({graphspace: context.graphspace, query: '', pageNo: 1});
         setContext(nextContext);
         writeWorkbenchGraphContext(localStorage, nextContext);
         const analysisModule = getAnalysisModule(location.pathname);
@@ -280,6 +327,34 @@ const GraphContextSwitcher = () => {
             : `/graphspace/${graphspacePath}/graph/${graphPath}/detail`);
     }, [context.graphspace, location.pathname, navigate]);
 
+    const searchGraphs = useCallback(query => {
+        setGraphBrowse(current => ({...current, graphspace: context.graphspace, query, pageNo: 1, hasMore: false}));
+    }, [context.graphspace]);
+
+    const searchGraphspaces = useCallback(query => {
+        setGraphspaceBrowse(current => ({...current, query, pageNo: 1, hasMore: false}));
+    }, []);
+
+    const loadMoreGraphspaces = useCallback(event => {
+        const list = event.currentTarget;
+        if (!graphspaceBrowse.hasMore || graphspaceRequest.current || loading.graphspaces ||
+            list.scrollTop + list.clientHeight < list.scrollHeight - 16) {
+            return;
+        }
+        graphspaceRequest.current = Symbol('next-graphspace-page');
+        setGraphspaceBrowse(current => ({...current, pageNo: current.pageNo + 1}));
+    }, [graphspaceBrowse.hasMore, loading.graphspaces]);
+
+    const loadMoreGraphs = useCallback(event => {
+        const list = event.currentTarget;
+        if (!graphBrowse.hasMore || graphRequest.current || loading.graphs ||
+            list.scrollTop + list.clientHeight < list.scrollHeight - 16) {
+            return;
+        }
+        graphRequest.current = Symbol('next-graph-page');
+        setGraphBrowse(current => ({...current, pageNo: (current.pageNo ?? 1) + 1}));
+    }, [graphBrowse.hasMore, loading.graphs]);
+
     const retryGraphspaces = useCallback(() => {
         setLoading(value => ({...value, graphspaces: true}));
         setErrors(value => ({...value, graphspaces: false}));
@@ -306,6 +381,10 @@ const GraphContextSwitcher = () => {
                     className={style.graphspace}
                     disabled={!pdEnabled}
                     loading={loading.graphspaces}
+                    showSearch
+                    filterOption={false}
+                    onSearch={searchGraphspaces}
+                    onPopupScroll={loadMoreGraphspaces}
                     onChange={selectGraphspace}
                     placeholder={t('workbench.context.select_graphspace')}
                     title={selectedGraphspaceLabel}
@@ -324,8 +403,13 @@ const GraphContextSwitcher = () => {
                     aria-description={selectedGraphLabel}
                     aria-label={t('workbench.context.graph')}
                     className={style.graph}
-                    disabled={!context.graphspace || loading.graphs || errors.graphs}
+                    disabled={!context.graphspace || Boolean(errors.graphs) ||
+                        loading.graphs && graphBrowse.scope !== graphScope}
                     loading={loading.graphs}
+                    showSearch
+                    filterOption={false}
+                    onSearch={searchGraphs}
+                    onPopupScroll={loadMoreGraphs}
                     onChange={selectGraph}
                     placeholder={t('workbench.context.select_graph')}
                     title={selectedGraphLabel}
diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js
index 87986cf1c..460c103cb 100644
--- a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js
@@ -39,10 +39,14 @@ jest.mock('antd', () => {
         options = [],
         disabled,
         onChange,
+        onSearch,
+        onPopupScroll,
         title,
         value,
         style,
     }) => (
+        <div>
+            {onSearch && <input aria-label={`${ariaLabel}.search`} onChange={event => onSearch(event.target.value)} />}
         <select
             aria-description={ariaDescription}
             aria-label={ariaLabel}
@@ -51,12 +55,14 @@ jest.mock('antd', () => {
             value={value || ''}
             style={style}
             onChange={event => onChange?.(event.target.value)}
+            onScroll={onPopupScroll}
         >
             <option value="" />
             {options.map(option => (
                 <option key={option.value} value={option.value} title={option.title}>{option.label}</option>
             ))}
         </select>
+        </div>
     );
     return {
         Alert: ({action, title}) => <div role="alert">{title}{action}</div>,
@@ -119,7 +125,7 @@ describe('GraphContextSwitcher', () => {
         await waitFor(() => {
             expect(api.manage.getGraphList).toHaveBeenCalledWith(
                 'DEFAULT',
-                {page_no: 1, page_size: -1},
+                expect.objectContaining({brief: true, page_no: 1, page_size: 100}),
                 expect.any(Object)
             );
         });
@@ -127,15 +133,15 @@ describe('GraphContextSwitcher', () => {
         expect(screen.getByRole('group', {name: 'workbench.context.name'})).toBeInTheDocument();
     });
 
-    test('loads every PD GraphSpace without pagination truncation', async () => {
+    test('loads a bounded PD GraphSpace page', async () => {
         sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
         api.manage.getGraphSpaceList.mockResolvedValueOnce({
             status: 200,
-            data: [
+            data: {records: [
                 {name: 'DEFAULT', nickname: 'Default'},
                 {name: 'demo_space', nickname: 'Demo Space'},
                 {name: 'loader_space', nickname: 'Loader Space'},
-            ],
+            ]},
         });
         renderSwitcher('/graphspace/demo_space');
 
@@ -143,7 +149,7 @@ describe('GraphContextSwitcher', () => {
         expect(screen.getByRole('combobox', {name: 'workbench.context.graphspace'}))
             .toHaveValue('demo_space');
         expect(api.manage.getGraphSpaceList).toHaveBeenCalledWith(
-            {all: true},
+            {brief: true, page_no: 1, page_size: 100, query: '', selected: 'demo_space'},
             expect.any(Object)
         );
     });
@@ -185,7 +191,7 @@ describe('GraphContextSwitcher', () => {
         sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
         api.manage.getGraphSpaceList.mockResolvedValueOnce({
             status: 200,
-            data: [{name: 'space_b', nickname: 'Space B'}],
+            data: {records: [{name: 'space_b', nickname: 'Space B'}]},
         });
         renderSwitcher('/gremlin/deleted_space/old_graph');
 
@@ -383,7 +389,7 @@ describe('GraphContextSwitcher', () => {
         await waitFor(() => {
             expect(api.manage.getGraphList).toHaveBeenCalledWith(
                 'space_b',
-                {page_no: 1, page_size: -1},
+                expect.objectContaining({brief: true, page_no: 1, page_size: 100}),
                 expect.any(Object)
             );
         });
@@ -450,4 +456,197 @@ describe('GraphContextSwitcher', () => {
         }));
         await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalledTimes(2));
     });
+    test('keeps an authorized selected graph beyond the first page without loading every page', async () => {
+        sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: false}));
+        api.manage.getGraphList.mockResolvedValue({
+            status: 200,
+            data: {
+                records: [{name: 'graph_00000'}], total: 10001,
+                selected: {name: 'graph_10000', nickname: 'Late graph'},
+            },
+        });
+
+        renderSwitcher('/gremlin/DEFAULT/graph_10000');
+
+        expect(await screen.findByRole('option', {name: 'Late graph'})).toBeInTheDocument();
+        expect(screen.getByRole('combobox', {name: 'workbench.context.graph'})).toHaveValue('graph_10000');
+        expect(screen.getByText('/gremlin/DEFAULT/graph_10000')).toBeInTheDocument();
+        expect(api.manage.getGraphList).toHaveBeenCalledTimes(1);
+        expect(api.manage.getGraphList).toHaveBeenCalledWith('DEFAULT', {
+            brief: true, page_no: 1, page_size: 100, query: '', selected: 'graph_10000',
+        }, expect.any(Object));
+    });
+
+    test('loads the next graph page only when the dropdown reaches the bottom and selects its graph', async () => {
+        sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: false}));
+        const first = Array.from({length: 100}, (_, index) => ({name: `graph_${index}`}));
+        const late = {name: 'late_graph', nickname: 'Late graph'};
+        api.manage.getGraphList.mockImplementation((space, params) => Promise.resolve({
+            status: 200,
+            data: {
+                records: params.page_no === 2 ? [late] : first,
+                total: 101,
+                selected: params.selected === late.name ? late : null,
+            },
+        }));
+        renderSwitcher('/navigation');
+        await screen.findByRole('option', {name: 'graph_0'});
+        expect(api.manage.getGraphList).toHaveBeenCalledTimes(1);
+        expect(screen.queryByRole('option', {name: 'Late graph'})).not.toBeInTheDocument();
+        const graph = screen.getByRole('combobox', {name: 'workbench.context.graph'});
+        Object.defineProperties(graph, {clientHeight: {value: 100}, scrollHeight: {value: 200}});
+        fireEvent.scroll(graph, {target: {scrollTop: 100}});
+
+        expect(await screen.findByRole('option', {name: 'Late graph'})).toBeInTheDocument();
+        expect(api.manage.getGraphList).toHaveBeenCalledTimes(2);
+        expect(api.manage.getGraphList).toHaveBeenLastCalledWith('DEFAULT', expect.objectContaining({
+            page_no: 2, page_size: 100,
+        }), expect.any(Object));
+        fireEvent.change(graph, {target: {value: 'late_graph'}});
+        expect(await screen.findByText('/graphspace/DEFAULT/graph/late_graph/detail')).toBeInTheDocument();
+        await waitFor(() => expect(graph).toHaveValue('late_graph'));
+    });
+
+    test.each(['late_graph', 'Late Alias'])('searches all graph names and aliases remotely for %s', async query => {
+        sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: false}));
+        const late = {name: 'late_graph', nickname: 'Late Alias'};
+        api.manage.getGraphList.mockImplementation((space, params) => Promise.resolve({
+            status: 200,
+            data: {records: params.query ? [late] : [{name: 'first'}], total: params.query ? 1 : 10001,
+                selected: params.selected === late.name ? late : null},
+        }));
+        renderSwitcher('/navigation');
+        await screen.findByRole('option', {name: 'first'});
+        fireEvent.change(screen.getByRole('textbox', {name: 'workbench.context.graph.search'}), {
+            target: {value: query},
+        });
+
+        expect(await screen.findByRole('option', {name: 'Late Alias'})).toBeInTheDocument();
+        expect(api.manage.getGraphList).toHaveBeenCalledTimes(2);
+        expect(api.manage.getGraphList).toHaveBeenLastCalledWith('DEFAULT', expect.objectContaining({
+            query, page_no: 1, page_size: 100,
+        }), expect.any(Object));
+        fireEvent.change(screen.getByRole('combobox', {name: 'workbench.context.graph'}), {
+            target: {value: 'late_graph'},
+        });
+        expect(await screen.findByText('/graphspace/DEFAULT/graph/late_graph/detail')).toBeInTheDocument();
+    });
+
+    test('ignores a late search response after a new search completes', async () => {
+        sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: false}));
+        let resolveOld;
+        api.manage.getGraphList.mockImplementation((space, params) => {
+            if (params.query === 'old') {
+                return new Promise(resolve => {resolveOld = resolve;});
+            }
+            return Promise.resolve({status: 200, data: {
+                records: [{name: params.query || 'first'}], total: 1, selected: null,
+            }});
+        });
+        renderSwitcher('/navigation');
+        await screen.findByRole('option', {name: 'first'});
+        const search = screen.getByRole('textbox', {name: 'workbench.context.graph.search'});
+        fireEvent.change(search, {target: {value: 'old'}});
+        await waitFor(() => expect(resolveOld).toBeDefined());
+        fireEvent.change(search, {target: {value: 'new'}});
+        expect(await screen.findByRole('option', {name: 'new'})).toBeInTheDocument();
+
+        await act(async () => resolveOld({status: 200, data: {records: [{name: 'old'}], total: 1}}));
+
+        expect(screen.getByRole('option', {name: 'new'})).toBeInTheDocument();
+        expect(screen.queryByRole('option', {name: 'old'})).not.toBeInTheDocument();
+    });
+
+    test('keeps an authorized GraphSpace beyond the first page with one bounded request', async () => {
+        sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
+        const selected = {name: 'space_10000', nickname: 'Late space'};
+        api.manage.getGraphSpaceList.mockImplementation(params => Promise.resolve(params.page_size === -1
+            ? {status: 400, message: 'GraphSpace list exceeds the maximum all-record limit of 10000'}
+            : {status: 200, data: {records: [{name: 'first'}], total: 10001, selected}}));
+
+        renderSwitcher('/graphspace/space_10000');
+
+        expect(await screen.findByRole('option', {name: 'Late space'})).toBeInTheDocument();
+        expect(screen.getByRole('combobox', {name: 'workbench.context.graphspace'})).toHaveValue(selected.name);
+        expect(screen.getByText('/graphspace/space_10000')).toBeInTheDocument();
+        expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(1);
+        expect(api.manage.getGraphSpaceList).toHaveBeenCalledWith({
+            brief: true, page_no: 1, page_size: 100, query: '', selected: selected.name,
+        }, expect.any(Object));
+        expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+    });
+
+    test('loads the next GraphSpace page only at the bottom and selects its space', async () => {
+        sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
+        const first = Array.from({length: 100}, (_, index) => ({name: `space_${index}`}));
+        const late = {name: 'late_space', nickname: 'Late space'};
+        api.manage.getGraphSpaceList.mockImplementation(params => Promise.resolve({status: 200, data: {
+            records: params.page_no === 2 ? [late] : first, total: 101,
+            selected: params.selected === late.name ? late : null,
+        }}));
+        renderSwitcher('/navigation');
+        await screen.findByRole('option', {name: 'space_0'});
+        expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(1);
+        expect(screen.queryByRole('option', {name: 'Late space'})).not.toBeInTheDocument();
+        const select = screen.getByRole('combobox', {name: 'workbench.context.graphspace'});
+        Object.defineProperties(select, {clientHeight: {value: 100}, scrollHeight: {value: 200}});
+        fireEvent.scroll(select, {target: {scrollTop: 0}});
+        expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(1);
+        fireEvent.scroll(select, {target: {scrollTop: 100}});
+
+        expect(await screen.findByRole('option', {name: 'Late space'})).toBeInTheDocument();
+        expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(2);
+        expect(api.manage.getGraphSpaceList).toHaveBeenLastCalledWith({
+            brief: true, page_no: 2, page_size: 100, query: '', selected: '',
+        }, expect.any(Object));
+        fireEvent.change(select, {target: {value: late.name}});
+        expect(await screen.findByText('/graphspace/late_space')).toBeInTheDocument();
+        await waitFor(() => expect(select).toHaveValue(late.name));
+    });
+
+    test.each(['space_10000', 'Late Alias'])('searches GraphSpace names and aliases for %s', async query => {
+        sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
+        const current = {name: 'space_a', nickname: 'Space A'};
+        const late = {name: 'space_10000', nickname: 'Late Alias'};
+        api.manage.getGraphSpaceList.mockImplementation(params => Promise.resolve({status: 200, data: {
+            records: params.query ? [late] : [current], total: params.query ? 1 : 10001, selected: current,
+        }}));
+        renderSwitcher('/graphspace/space_a');
+        await screen.findByRole('option', {name: 'Space A'});
+        fireEvent.change(screen.getByRole('textbox', {name: 'workbench.context.graphspace.search'}), {
+            target: {value: query},
+        });
+
+        expect(await screen.findByRole('option', {name: 'Late Alias'})).toBeInTheDocument();
+        expect(screen.getByRole('combobox', {name: 'workbench.context.graphspace'})).toHaveValue(current.name);
+        expect(screen.getByRole('option', {name: 'Space A'})).toBeInTheDocument();
+        expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(2);
+        expect(api.manage.getGraphSpaceList).toHaveBeenLastCalledWith({
+            brief: true, page_no: 1, page_size: 100, query, selected: current.name,
+        }, expect.any(Object));
+    });
+
+    test('ignores an old GraphSpace search response after the next search completes', async () => {
+        sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
+        let resolveOld;
+        api.manage.getGraphSpaceList.mockImplementation(params => {
+            if (params.query === 'old') return new Promise(resolve => {resolveOld = resolve;});
+            return Promise.resolve({status: 200, data: {
+                records: [{name: params.query || 'first'}], total: 1, selected: null,
+            }});
+        });
+        renderSwitcher('/navigation');
+        await screen.findByRole('option', {name: 'first'});
+        const search = screen.getByRole('textbox', {name: 'workbench.context.graphspace.search'});
+        fireEvent.change(search, {target: {value: 'old'}});
+        await waitFor(() => expect(resolveOld).toBeDefined());
+        fireEvent.change(search, {target: {value: 'new'}});
+        expect(await screen.findByRole('option', {name: 'new'})).toBeInTheDocument();
+
+        await act(async () => resolveOld({status: 200, data: {records: [{name: 'old'}], total: 1}}));
+
+        expect(screen.getByRole('option', {name: 'new'})).toBeInTheDocument();
+        expect(screen.queryByRole('option', {name: 'old'})).not.toBeInTheDocument();
+    });
+
 });
diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/analysis.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/analysis.json
index b1a72d32e..b719b2ba6 100644
--- a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/analysis.json
+++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/analysis.json
@@ -166,7 +166,9 @@
         "loading": "Loading",
         "unavailable": "Unavailable",
         "node_summary": "Nodes: {{current}} in this result, {{total}} in the full graph",
-        "edge_summary": "Edges: {{current}} in this result, {{total}} in the full graph"
+        "edge_summary": "Edges: {{current}} in this result, {{total}} in the full graph",
+        "approximate_hint": "Approximate recorded counts; cached statistics may be reused for 30 minutes",
+        "bounded_hint": "At most {{limit}} nodes and {{limit}} edges are scanned; cached statistics may be reused for 30 minutes"
       },
       "import_failed": "Import failed",
       "export_json": "Export JSON",
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 969bcfa49..0101717bf 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
@@ -329,7 +329,7 @@
       "data_status": "Data status",
       "available": "Available",
       "partial": "Partial",
-      "loading": "Loading",
+      "loading": "Loading statistics",
       "retry_page": "Retry graph details",
       "back_to_graphs": "Back to graphs",
       "retry_statistics": "Retry statistics",
@@ -341,14 +341,18 @@
       "empty_title": "This graph has no schema or data yet",
       "empty_description": "Create schema, prepare data, or run a safe query to get started",
       "create_schema": "Create schema",
-      "prepare_data": "Prepare data"
+      "prepare_data": "Prepare data",
+      "not_collected": "Not collected",
+      "collect_statistics": "Collect statistics",
+      "snapshot_hint": "Recorded snapshot; counts may be approximate and are not real-time totals; cached statistics may be reused for 30 minutes",
+      "bounded_statistics_hint": "Counts use at most {{limit}} vertices and {{limit}} edges and may be approximate; cached statistics may be reused for 30 minutes"
     },
     "card": {
       "more_actions": "More actions for {{graph}}",
       "storage": "Storage",
       "schema_types": "{{vertices}} vertex types · {{edges}} edge types",
       "element_counts": "Vertices: {{vertices}} / Edges: {{edges}}",
-      "counts_tooltip": "Actual vertex and edge counts reported by the graph list API",
+      "counts_tooltip": "Last recorded vertex and edge counts; they may be out of date",
       "overview": "Overview",
       "schema_info": "Schema Info",
       "view_schema": "View Schema for {{graph}}",
@@ -356,7 +360,9 @@
       "empty_schema_help": "Model this graph or run a safe query",
       "open_schema": "Create schema",
       "query_graph": "Query graph",
-      "detail_tooltip": "View the current vertex and edge counts for this graph"
+      "detail_tooltip": "View the current vertex and edge counts for this graph",
+      "schema_loading": "Loading schema",
+      "schema_unavailable": "Schema preview is unavailable"
     },
     "empty": {
       "description": "This GraphSpace is ready but has no graphs yet. Create the first graph to model, import, and query data",
diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/analysis.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/analysis.json
index 4d7a13c6c..f19779a71 100644
--- a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/analysis.json
+++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/analysis.json
@@ -166,7 +166,9 @@
         "loading": "加载中",
         "unavailable": "不可用",
         "node_summary": "节点:当前结果 {{current}},全图 {{total}}",
-        "edge_summary": "边:当前结果 {{current}},全图 {{total}}"
+        "edge_summary": "边:当前结果 {{current}},全图 {{total}}",
+        "approximate_hint": "已有统计可能为近似值,可复用30分钟缓存",
+        "bounded_hint": "最多扫描 {{limit}} 个点和 {{limit}} 条边,可复用30分钟缓存"
       },
       "import_failed": "导入失败",
       "export_json": "导出 JSON",
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 cedd315aa..9df58d7fb 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
@@ -329,7 +329,7 @@
       "data_status": "数据状态",
       "available": "可用",
       "partial": "部分可用",
-      "loading": "加载中",
+      "loading": "统计中",
       "retry_page": "重试图详情",
       "back_to_graphs": "返回图列表",
       "retry_statistics": "重试统计数据",
@@ -341,14 +341,18 @@
       "empty_title": "当前图还没有 Schema 或数据",
       "empty_description": "创建 Schema、准备数据或运行安全查询,即可开始使用",
       "create_schema": "创建 Schema",
-      "prepare_data": "准备数据"
+      "prepare_data": "准备数据",
+      "not_collected": "未统计",
+      "collect_statistics": "统计点边",
+      "snapshot_hint": "已有统计快照,可能为近似值,并非实时精确总量,可复用30分钟缓存",
+      "bounded_statistics_hint": "最多扫描 {{limit}} 个点和 {{limit}} 条边,统计可能为近似值,可复用30分钟缓存"
     },
     "card": {
       "more_actions": "{{graph}} 的更多操作",
       "storage": "存储空间",
       "schema_types": "{{vertices}} 个顶点类型 · {{edges}} 个边类型",
       "element_counts": "点:{{vertices}} / 边:{{edges}}",
-      "counts_tooltip": "来自图列表 API 的实际点边统计",
+      "counts_tooltip": "最近一次记录的点边数,可能不是实时值",
       "overview": "概览",
       "schema_info": "Schema 信息",
       "view_schema": "查看 {{graph}} 的 Schema",
@@ -356,7 +360,9 @@
       "empty_schema_help": "可以先建立模型,或运行安全查询",
       "open_schema": "创建 Schema",
       "query_graph": "查询此图",
-      "detail_tooltip": "点击可以查看本图目前存储的点边的数量"
+      "detail_tooltip": "点击可以查看本图目前存储的点边的数量",
+      "schema_loading": "Schema 加载中",
+      "schema_unavailable": "Schema 预览暂不可用"
     },
     "empty": {
       "description": "这个 GraphSpace 已可用,但还没有图。创建第一张图后即可配置 Schema、导入和查询数据",
diff --git a/hugegraph-hubble/hubble-fe/src/modules/GraphAnalysis/query-context.integration.test.js b/hugegraph-hubble/hubble-fe/src/modules/GraphAnalysis/query-context.integration.test.js
index e96fe008f..449f74dde 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/GraphAnalysis/query-context.integration.test.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/GraphAnalysis/query-context.integration.test.js
@@ -26,7 +26,6 @@ import * as api from '../../api';
 jest.mock('../../api', () => ({
     analysis: {
         getGraphSpaceList: jest.fn(),
-        getGraphList: jest.fn(),
         getOlapMode: jest.fn(),
         getExecutionLogs: jest.fn(),
         fetchFavoriteQueries: jest.fn(),
@@ -86,7 +85,7 @@ const ExecuteOnNavigation = ({action}) => {
     return null;
 };
 const graphs = [{name: 'hugegraph'}, {name: 'java17_legacy17_ui'}];
-const graphResponse = {status: 200, data: {graphs}};
+const graphResponse = {status: 200, data: {records: graphs}};
 const records = {status: 200, data: {records: [], total: 0}};
 
 it.each(['button', 'shortcut'])(
@@ -101,12 +100,8 @@ it.each(['button', 'shortcut'])(
             status: 200, data: {graphspaces: ['DEFAULT']},
         });
         let resolveGraphs;
-        api.analysis.getGraphList
-            .mockImplementationOnce(() => new Promise(resolve => {
-                resolveGraphs = resolve;
-            }))
-            .mockResolvedValue(graphResponse);
-        api.manage.getGraphList.mockResolvedValue({status: 200, data: {records: graphs}});
+        const pendingGraphs = new Promise(resolve => { resolveGraphs = resolve; });
+        api.manage.getGraphList.mockReturnValue(pendingGraphs);
         api.analysis.getOlapMode.mockResolvedValue({status: 200, data: {status: '1'}});
         api.analysis.getExecutionLogs.mockResolvedValue(records);
         api.analysis.fetchFavoriteQueries.mockResolvedValue(records);
@@ -135,7 +130,10 @@ it.each(['button', 'shortcut'])(
                 <ExecuteOnNavigation action={action} />
             </MemoryRouter>
         );
-        await waitFor(() => expect(api.analysis.getGraphList).toHaveBeenCalled());
+        await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalled());
+        expect(api.manage.getGraphList).toHaveBeenCalledWith(
+            'DEFAULT', expect.objectContaining({brief: true, page_no: 1, page_size: 100}), {suppressBusinessErrorToast: true}
+        );
         const run = () => screen.getByRole('button', {name: 'analysis.query.execute_query'});
         expect(run()).toBeDisabled();
         fireEvent.click(run());
@@ -143,6 +141,7 @@ it.each(['button', 'shortcut'])(
         expect(api.analysis.getGraphData).not.toHaveBeenCalled();
 
         await act(async () => {
+            api.manage.getGraphList.mockResolvedValue(graphResponse);
             resolveGraphs(graphResponse);
         });
         await waitFor(() => expect(run()).toBeEnabled());
@@ -153,7 +152,7 @@ it.each(['button', 'shortcut'])(
         await screen.findByText('/gremlin/DEFAULT/java17_legacy17_ui');
         expect(api.analysis.getExecutionQuery).not.toHaveBeenCalled();
         await waitFor(() => expect(api.analysis.getGraphData)
-            .toHaveBeenCalledWith('DEFAULT', 'java17_legacy17_ui'));
+            .toHaveBeenCalledWith('DEFAULT', 'java17_legacy17_ui', {suppressBusinessErrorToast: true}));
         await waitFor(() => expect(run()).toBeEnabled());
         await act(async () => {
             fireEvent.click(run());
@@ -177,8 +176,7 @@ it('executes the ready dropdown graph while the previous graph query is pending'
     api.analysis.getGraphSpaceList.mockResolvedValue({
         status: 200, data: {graphspaces: ['DEFAULT']},
     });
-    api.analysis.getGraphList.mockResolvedValue(graphResponse);
-    api.manage.getGraphList.mockResolvedValue({status: 200, data: {records: graphs}});
+    api.manage.getGraphList.mockResolvedValue(graphResponse);
     api.analysis.getOlapMode.mockResolvedValue({status: 200, data: {status: '1'}});
     api.analysis.getText2GqlConfig.mockResolvedValue({status: 200, data: {available: false}});
     api.analysis.getExecutionLogs.mockResolvedValue(records);
@@ -227,7 +225,7 @@ it('executes the ready dropdown graph while the previous graph query is pending'
     await act(async () => fireEvent.click(graphOption));
     await screen.findByText('/gremlin/DEFAULT/java17_legacy17_ui');
     await waitFor(() => expect(api.analysis.getGraphData)
-        .toHaveBeenCalledWith('DEFAULT', 'java17_legacy17_ui'));
+        .toHaveBeenCalledWith('DEFAULT', 'java17_legacy17_ui', {suppressBusinessErrorToast: true}));
     await waitFor(() => expect(run()).toBeEnabled());
     await act(async () => fireEvent.click(run()));
     expect(api.analysis.getExecutionQuery).toHaveBeenCalledTimes(2);
diff --git a/hugegraph-hubble/hubble-fe/src/modules/algorithm/GraphResult/Home/index.js b/hugegraph-hubble/hubble-fe/src/modules/algorithm/GraphResult/Home/index.js
index c4dcc2957..eac4823a2 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/algorithm/GraphResult/Home/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/algorithm/GraphResult/Home/index.js
@@ -31,6 +31,7 @@ import GraphMenuBar from '../GraphMenuBar';
 import GraphToolBar from '../GraphToolBar';
 import Menu from '../../../component/Menu';
 import NumberCard from '../../../component/NumberCard';
+import {isKnownZeroCount} from '../../../../utils/graphStatistics';
 import EditElement from '../../../component/EditElement';
 import Search from '../../../component/Search';
 import SettingConfigPanel from '../../../component/SettingConfigPanel';
@@ -108,9 +109,8 @@ const GraphResult = props => {
     const {edgeMeta, vertexMeta} = metaData || {};
     const hasGraphData = Number(graphNums?.vertexCount) > 0
         || Number(graphNums?.edgeCount) > 0;
-    const hasKnownEmptyCounts = graphNums?.vertexCount !== null && graphNums?.edgeCount !== null
-        && Number(graphNums?.vertexCount) === 0
-        && Number(graphNums?.edgeCount) === 0;
+    const hasKnownEmptyCounts = isKnownZeroCount(graphNums?.vertexCount, graphNums)
+        && isKnownZeroCount(graphNums?.edgeCount, graphNums);
     const hasKnownEmptySchema = Array.isArray(vertexMeta) && Array.isArray(edgeMeta)
         && vertexMeta.length === 0 && edgeMeta.length === 0;
     const isEmptyGraph = !hasGraphData && (hasKnownEmptyCounts || hasKnownEmptySchema);
@@ -462,6 +462,8 @@ const GraphResult = props => {
                     currentGraphEdgesNum: graphData.edges.length,
                     allGraphNodesNum: graphNums?.vertexCount,
                     allGraphEdgesNum: graphNums?.edgeCount,
+                    approximate: graphNums?.approximate,
+                    scan_limit: graphNums?.scan_limit,
                 }}
             />
         </div>
diff --git a/hugegraph-hubble/hubble-fe/src/modules/algorithm/GraphResult/Home/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/algorithm/GraphResult/Home/index.test.js
index c98ad29d4..dfef89510 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/algorithm/GraphResult/Home/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/algorithm/GraphResult/Home/index.test.js
@@ -117,6 +117,21 @@ test.each([null, undefined])('unavailable graph counts (%s) do not imply an empt
     expect(mockCounts.mock.lastCall[0].data).toMatchObject({allGraphNodesNum: count, allGraphEdgesNum: count});
 });
 
+test('an approximate zero snapshot keeps the current graph view available', () => {
+    mockModel = {...mockModel, graphData: {nodes: [{id: 'existing'}], edges: []}};
+    render(<GraphResult data={{vertices: [{id: 'existing', label: 'person'}], edges: []}}
+        metaData={{vertexMeta: [{name: 'person'}], edgeMeta: [{name: 'knows'}]}}
+        graphNums={{vertexCount: '0', edgeCount: '0', approximate: true, scan_limit: 100000}}
+        queryStatus={GRAPH_STATUS.STANDBY} panelType={PANEL_TYPE.CLOSED}
+        graphRenderMode={GRAPH_RENDER_MODE.CANVAS2D} updatePanelType={jest.fn()} />);
+
+    expect(screen.queryByText('analysis.algorithm.empty_graph_title')).not.toBeInTheDocument();
+    expect(screen.getByText('graph menu')).toBeInTheDocument();
+    expect(mockCounts.mock.lastCall[0].data).toMatchObject({
+        allGraphNodesNum: '0', allGraphEdgesNum: '0', approximate: true, scan_limit: 100000,
+    });
+});
+
 test('only projects raw node choices while the edge drawer is visible', () => {
     const raw = {id: 'alice', label: 'person'};
     const reads = jest.fn(() => raw);
@@ -145,12 +160,12 @@ test.each(['node', 'edge'])('returns %s write-apply failures to the drawer witho
         addElements: jest.fn().mockRejectedValue(error)};
     render(<GraphResult data={{vertices: [{id: 'existing', label: 'person'}], edges: []}} isQueryMode
         queryStatus={GRAPH_STATUS.SUCCESS} panelType={PANEL_TYPE.CLOSED}
-        graphNums={{vertexCount: 7, edgeCount: 9}} onRefreshGraphNums={onRefreshGraphNums}
+        graphNums={{vertexCount: 7, edgeCount: 9, approximate: true, scan_limit: 100000}} onRefreshGraphNums={onRefreshGraphNums}
         graphRenderMode={GRAPH_RENDER_MODE.CANVAS2D} updatePanelType={jest.fn()} />);
     const callback = kind === 'node' ? mockNodeDrawer.mock.lastCall[0].onOK : mockEdgeDrawer.mock.lastCall[0].onOk;
     await act(async () => { await expect(callback({vertices: [{id: 'saved'}], edges: [{id: 'saved-edge'}]})).rejects.toBe(error); });
     expect(onRefreshGraphNums).toHaveBeenCalledTimes(1);
-    expect(mockCounts.mock.lastCall[0].data).toMatchObject({allGraphNodesNum: 7, allGraphEdgesNum: 9});
+    expect(mockCounts.mock.lastCall[0].data).toMatchObject({allGraphNodesNum: 7, allGraphEdgesNum: 9, approximate: true, scan_limit: 100000});
 });
 
 test.each(['applied', 'superseded', 'disposed', undefined])('preserves %s write-apply outcome while refreshing server totals independently', async status => {
diff --git a/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.js b/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.js
index 55417a081..2669b12c2 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.js
@@ -27,6 +27,7 @@ import LogsDetail from '../LogsDetail/Home';
 import GraphAnalysisContext from '../../Context';
 import {GRAPH_STATUS, PANEL_TYPE, GRAPH_RENDER_MODE, useTranslatedConstants} from '../../../utils/constants';
 import * as api from '../../../api';
+import {toGraphCounts} from '../../../utils/graphStatistics';
 import {USER_CHANGE_EVENT} from '../../../utils/user';
 import _ from 'lodash';
 import c from './index.module.scss';
@@ -179,7 +180,7 @@ const AlgorithmHome = () => {
             graphNumsRequest.current = request;
             let response;
             try {
-                response = await api.analysis.getGraphData(graphSpace, graph);
+                response = await api.analysis.getGraphData(graphSpace, graph, {suppressBusinessErrorToast: true});
             } catch {
                 response = null;
             }
@@ -190,8 +191,7 @@ const AlgorithmHome = () => {
                 setGraphNums({vertexCount: null, edgeCount: null});
                 return {status: 'unavailable'};
             }
-            const {vertexcount, edgecount} = response.data || {};
-            setGraphNums({vertexCount: vertexcount, edgeCount: edgecount});
+            setGraphNums(toGraphCounts(response.data));
             return {status: 'refreshed'};
         },
         [graph, graphSpace, graphNumsScope]
diff --git a/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.test.js
index fd1b3af32..69eb703c7 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/algorithm/Home/index.test.js
@@ -170,15 +170,16 @@ const countsHomeFor = (graph = 'graph-a', graphSpace = 'DEFAULT') => (
 );
 
 it('refreshes server graph totals through the callback passed to the result without resetting confirmed counts', async () => {
-    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: 7, edgecount: 9}});
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: 7, edgecount: 9, approximate: true, scan_limit: 100000}});
     render(countsHomeFor());
     expect(await screen.findByText('algorithm result counts 7 9')).toBeInTheDocument();
+    expect(mockGraphResultProps.mock.lastCall[0].graphNums).toMatchObject({approximate: true, scan_limit: 100000});
     const initialCalls = api.analysis.getGraphData.mock.calls.length;
     const onRefreshGraphNums = mockGraphResultProps.mock.lastCall[0].onRefreshGraphNums;
     api.analysis.getGraphData.mockResolvedValueOnce({status: 200, data: {vertexcount: 7, edgecount: 9}});
     await act(async () => onRefreshGraphNums());
     expect(api.analysis.getGraphData).toHaveBeenCalledTimes(initialCalls + 1);
-    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('DEFAULT', 'graph-a');
+    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('DEFAULT', 'graph-a', {suppressBusinessErrorToast: true});
     expect(screen.getByText('algorithm result counts 7 9')).toBeInTheDocument();
     api.analysis.getGraphData.mockResolvedValueOnce({status: 200, data: {vertexcount: 8, edgecount: 12}});
     await act(async () => onRefreshGraphNums());
@@ -297,7 +298,7 @@ it.each([false, true].flatMap(returnToAlice =>
     });
     expect(synchronousCalls).toBe(beforeIdentityChange);
     expect(api.analysis.getGraphData).toHaveBeenCalledTimes(beforeIdentityChange + 1);
-    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('DEFAULT', 'graph-a');
+    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('DEFAULT', 'graph-a', {suppressBusinessErrorToast: true});
     expect(screen.getByText('algorithm result counts -1 -1')).toBeInTheDocument();
     await act(async () => resolveCurrent({status: 200, data: {vertexcount: 20, edgecount: 21}}));
     expect(screen.getByText('algorithm result counts 20 21')).toBeInTheDocument();
diff --git a/hugegraph-hubble/hubble-fe/src/modules/algorithm/algorithmsForm/Home/index.js b/hugegraph-hubble/hubble-fe/src/modules/algorithm/algorithmsForm/Home/index.js
index 8ad0f5496..ce093dc15 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/algorithm/algorithmsForm/Home/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/algorithm/algorithmsForm/Home/index.js
@@ -33,6 +33,7 @@ import c from './index.module.scss';
 import _ from 'lodash';
 import {useTranslation} from 'react-i18next';
 import {clearPersistedAlgorithmForms} from '../algorithmFormPersistence';
+import {isKnownZeroCount} from '../../../../utils/graphStatistics';
 
 const AlgorithmFormHome = props => {
     const {
@@ -60,7 +61,7 @@ const AlgorithmFormHome = props => {
     const isListEmpty = _.isEmpty(
         Object.values(ALGORITHM_NAME).filter(item => isAlgorithmNameMatched(item, search, t))
     );
-    const canRunLouvain = Number(graphNums?.edgeCount) > 0;
+    const canRunLouvain = !isKnownZeroCount(graphNums?.edgeCount, graphNums);
 
     return (
         <div className={c.algorithmSidebar}>
diff --git a/hugegraph-hubble/hubble-fe/src/modules/algorithm/algorithmsForm/Home/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/algorithm/algorithmsForm/Home/index.test.js
index 4f89fb9e1..4f6dbdca6 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/algorithm/algorithmsForm/Home/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/algorithm/algorithmsForm/Home/index.test.js
@@ -46,7 +46,7 @@ test('keeps data preparation guidance out of the algorithm list column', () => {
     expect(screen.queryByText('analysis.algorithm.empty_graph_title')).not.toBeInTheDocument();
 });
 
-test('requires at least one edge before enabling Louvain', () => {
+test('only confirmed zero edges prevent Louvain', () => {
     const view = renderForm({vertexCount: 3, edgeCount: 0});
     expect(screen.getByTestId('olap-readiness')).toHaveTextContent('false');
 
@@ -59,3 +59,14 @@ test('requires at least one edge before enabling Louvain', () => {
     );
     expect(screen.getByTestId('olap-readiness')).toHaveTextContent('true');
 });
+
+
+test.each([null, undefined, -1])('lets the server validate Louvain when edge counts are unknown (%s)', edgeCount => {
+    renderForm({vertexCount: null, edgeCount});
+    expect(screen.getByTestId('olap-readiness')).toHaveTextContent('true');
+});
+
+test.each([0, '0'])('lets the server validate Louvain after an approximate zero snapshot (%s)', edgeCount => {
+    renderForm({vertexCount: 0, edgeCount, approximate: true, scan_limit: 100000});
+    expect(screen.getByTestId('olap-readiness')).toHaveTextContent('true');
+});
diff --git a/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.js b/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.js
index 1737846e2..50b2b0949 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.js
@@ -33,6 +33,7 @@ import ResultsLayout from '../ResultsLayout';
 import {GREMLIN_EXECUTES_MODE, ANALYSIS_TYPE, GRAPH_STATUS, PANEL_TYPE,
     FAVORITE_TYPE, EXECUTION_LOGS_TYPE, GRAPH_RENDER_MODE} from '../../../utils/constants';
 import * as api from '../../../api';
+import {toGraphCounts} from '../../../utils/graphStatistics';
 import _ from 'lodash';
 import {scopedStorageKey, USER_CHANGE_EVENT} from '../../../utils/user';
 import {queryFailure} from '../../../utils/queryFailure';
@@ -334,7 +335,7 @@ const AnalysisHome = ({toolbarExtra}) => {
             graphNumsRequest.current = request;
             let response;
             try {
-                response = await api.analysis.getGraphData(graphSpace, graph);
+                response = await api.analysis.getGraphData(graphSpace, graph, {suppressBusinessErrorToast: true});
             } catch {
                 response = null;
             }
@@ -345,8 +346,7 @@ const AnalysisHome = ({toolbarExtra}) => {
                 setGraphNums({vertexCount: null, edgeCount: null});
                 return {status: 'unavailable'};
             }
-            const {vertexcount, edgecount} = response.data || {};
-            setGraphNums({vertexCount: vertexcount, edgeCount: edgecount});
+            setGraphNums(toGraphCounts(response.data));
             return {status: 'refreshed'};
         },
         [graph, graphSpace, graphNumsScope]
diff --git a/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.test.js
index 6055ba5c4..40d785515 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/analysis/Home/index.test.js
@@ -1603,15 +1603,16 @@ const countsHomeFor = (graph = 'graph-a', graphSpace = 'DEFAULT') => (
 );
 
 it('refreshes server graph totals through the callback passed to the result without resetting confirmed counts', async () => {
-    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: 7, edgecount: 9}});
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: 7, edgecount: 9, approximate: true, scan_limit: 100000}});
     render(countsHomeFor());
     expect(await screen.findByText('graph counts 7 9')).toBeInTheDocument();
+    expect(mockQueryResultProps.mock.lastCall[0].graphNums).toMatchObject({approximate: true, scan_limit: 100000});
     const initialCalls = api.analysis.getGraphData.mock.calls.length;
     const onRefreshGraphNums = mockQueryResultProps.mock.lastCall[0].onRefreshGraphNums;
     api.analysis.getGraphData.mockResolvedValueOnce({status: 200, data: {vertexcount: 7, edgecount: 9}});
     await act(async () => onRefreshGraphNums());
     expect(api.analysis.getGraphData).toHaveBeenCalledTimes(initialCalls + 1);
-    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('DEFAULT', 'graph-a');
+    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('DEFAULT', 'graph-a', {suppressBusinessErrorToast: true});
     expect(screen.getByText('graph counts 7 9')).toBeInTheDocument();
     api.analysis.getGraphData.mockResolvedValueOnce({status: 200, data: {vertexcount: 8, edgecount: 12}});
     await act(async () => onRefreshGraphNums());
@@ -1711,7 +1712,7 @@ it.each([false, true].flatMap(returnToAlice =>
     });
     expect(synchronousCalls).toBe(beforeIdentityChange);
     expect(api.analysis.getGraphData).toHaveBeenCalledTimes(beforeIdentityChange + 1);
-    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('DEFAULT', 'graph-a');
+    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('DEFAULT', 'graph-a', {suppressBusinessErrorToast: true});
     expect(screen.getByText('graph counts -1 -1')).toBeInTheDocument();
     await act(async () => resolveCurrent({status: 200, data: {vertexcount: 20, edgecount: 21}}));
     expect(screen.getByText('graph counts 20 21')).toBeInTheDocument();
diff --git a/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryResult/GraphResult/Home/index.js b/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryResult/GraphResult/Home/index.js
index f773eec19..60f557f32 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryResult/GraphResult/Home/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryResult/GraphResult/Home/index.js
@@ -485,6 +485,8 @@ const GraphResult = props => {
                     currentGraphEdgesNum: graphData.edges.length,
                     allGraphNodesNum: graphNums?.vertexCount,
                     allGraphEdgesNum: graphNums?.edgeCount,
+                    approximate: graphNums?.approximate,
+                    scan_limit: graphNums?.scan_limit,
                 }}
             />
         </div>
diff --git a/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryResult/GraphResult/Home/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryResult/GraphResult/Home/index.test.js
index b16940164..16653d953 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryResult/GraphResult/Home/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryResult/GraphResult/Home/index.test.js
@@ -197,12 +197,12 @@ test.each(['node', 'edge'])('returns %s write-apply failures to the drawer witho
         addElements: jest.fn().mockRejectedValue(error)};
     render(<GraphResult data={{vertices: [{id: 'existing', label: 'person'}], edges: []}} isQueryMode
         queryStatus={GRAPH_STATUS.SUCCESS} panelType={PANEL_TYPE.CLOSED}
-        graphNums={{vertexCount: 7, edgeCount: 9}} onRefreshGraphNums={onRefreshGraphNums}
+        graphNums={{vertexCount: 7, edgeCount: 9, approximate: true, scan_limit: 100000}} onRefreshGraphNums={onRefreshGraphNums}
         graphRenderMode={GRAPH_RENDER_MODE.CANVAS2D} updatePanelType={jest.fn()} />);
     const callback = kind === 'node' ? mockNodeDrawer.mock.lastCall[0].onOK : mockEdgeDrawer.mock.lastCall[0].onOk;
     await act(async () => { await expect(callback({vertices: [{id: 'saved'}], edges: [{id: 'saved-edge'}]})).rejects.toBe(error); });
     expect(onRefreshGraphNums).toHaveBeenCalledTimes(1);
-    expect(mockCounts.mock.lastCall[0].data).toMatchObject({allGraphNodesNum: 7, allGraphEdgesNum: 9});
+    expect(mockCounts.mock.lastCall[0].data).toMatchObject({allGraphNodesNum: 7, allGraphEdgesNum: 9, approximate: true, scan_limit: 100000});
 });
 
 test.each(['applied', 'superseded', 'disposed', undefined])('preserves %s write-apply outcome while refreshing server totals independently', async status => {
diff --git a/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.js b/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.js
index 448692645..17b9a0c34 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.js
@@ -23,6 +23,7 @@
 import c from './index.module.scss';
 import classnames from 'classnames';
 import {useTranslation} from 'react-i18next';
+import {isKnownCount, formatRecordedCount} from '../../../utils/graphStatistics';
 
 const isLoadingCount = value => {
     if (value === null || value === undefined
@@ -37,25 +38,24 @@ const getCountDisplay = (value, t) => {
     if (isLoadingCount(value)) {
         return t('analysis.canvas.number_card.loading');
     }
-    if (value === null || value === undefined
-        || (typeof value === 'string' && value.trim() === '')) {
-        return t('analysis.canvas.number_card.unavailable');
-    }
-    const count = Number(value);
-    return Number.isFinite(count)
-        ? value
-        : t('analysis.canvas.number_card.unavailable');
+    return isKnownCount(value) ? value : t('analysis.canvas.number_card.unavailable');
 };
 
 const NumberCard = props => {
     const {t} = useTranslation();
     const {pathNum, data, hasPadding} = props;
-    const {currentGraphNodesNum, currentGraphEdgesNum, allGraphNodesNum, allGraphEdgesNum} = data;
+    const {currentGraphNodesNum, currentGraphEdgesNum, allGraphNodesNum, allGraphEdgesNum,
+        approximate, scan_limit: scanLimit} = data;
+    const totalDisplay = value => isKnownCount(value)
+        ? formatRecordedCount(value, data) : getCountDisplay(value, t);
+    const statisticsHint = scanLimit > 0
+        ? t('analysis.canvas.number_card.bounded_hint', {limit: scanLimit})
+        : approximate ? t('analysis.canvas.number_card.approximate_hint') : '';
 
     const currentGraphNodes = getCountDisplay(currentGraphNodesNum, t);
     const currentGraphEdges = getCountDisplay(currentGraphEdgesNum, t);
-    const allGraphNodes = getCountDisplay(allGraphNodesNum, t);
-    const allGraphEdges = getCountDisplay(allGraphEdgesNum, t);
+    const allGraphNodes = totalDisplay(allGraphNodesNum);
+    const allGraphEdges = totalDisplay(allGraphEdgesNum);
     const nodeSummary = t('analysis.canvas.number_card.node_summary', {
         current: currentGraphNodes,
         total: allGraphNodes,
@@ -90,7 +90,7 @@ const NumberCard = props => {
                     className={c.numberCardInfo}
                     role='group'
                     aria-label={nodeSummary}
-                    title={nodeSummary}
+                    title={statisticsHint ? `${nodeSummary}\n${statisticsHint}` : nodeSummary}
                 >
                     <span className={c.numberCur} aria-hidden='true'>
                         {currentGraphNodes}
@@ -109,7 +109,7 @@ const NumberCard = props => {
                     className={c.numberCardInfo}
                     role='group'
                     aria-label={edgeSummary}
-                    title={edgeSummary}
+                    title={statisticsHint ? `${edgeSummary}\n${statisticsHint}` : edgeSummary}
                 >
                     <span className={c.numberCur} aria-hidden='true'>
                         {currentGraphEdges}
diff --git a/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.test.js
index 068fe5393..bc7e3b7c1 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.test.js
@@ -27,7 +27,7 @@ const mockResources = {
 };
 
 const mockInterpolate = (value, values = {}) => Object.entries(values).reduce(
-    (text, [key, replacement]) => text.replace(`{{${key}}}`, replacement),
+    (text, [key, replacement]) => text.replaceAll(`{{${key}}}`, replacement),
     value
 );
 
@@ -147,7 +147,7 @@ test('marks missing and invalid counts unavailable instead of rendering blanks',
 });
 
 test('ships symmetric Chinese and English NumberCard copy', () => {
-    expect(zhAnalysis.analysis.canvas.number_card).toEqual({
+    expect(zhAnalysis.analysis.canvas.number_card).toMatchObject({
         paths: '路径',
         nodes: '节点',
         edges: '边',
@@ -156,7 +156,7 @@ test('ships symmetric Chinese and English NumberCard copy', () => {
         node_summary: '节点:当前结果 {{current}},全图 {{total}}',
         edge_summary: '边:当前结果 {{current}},全图 {{total}}',
     });
-    expect(enAnalysis.analysis.canvas.number_card).toEqual({
+    expect(enAnalysis.analysis.canvas.number_card).toMatchObject({
         paths: 'Paths',
         nodes: 'Nodes',
         edges: 'Edges',
@@ -166,3 +166,28 @@ test('ships symmetric Chinese and English NumberCard copy', () => {
         edge_summary: 'Edges: {{current}} in this result, {{total}} in the full graph',
     });
 });
+
+
+test('marks recorded totals approximate and explains cache reuse', () => {
+    render(<NumberCard data={{...counts, approximate: true}} />);
+    expect(screen.getByText('≈3')).toBeInTheDocument();
+    expect(screen.getByRole('group', {name: 'Nodes: 1 in this result, ≈3 in the full graph'}))
+        .toHaveAttribute('title', expect.stringContaining('cached statistics may be reused for 30 minutes'));
+});
+
+test('shows a capped scan as a lower bound instead of an exact denominator', () => {
+    render(<NumberCard data={{...counts, allGraphNodesNum: 100000, allGraphEdgesNum: 20,
+        approximate: true, scan_limit: 100000}} />);
+    expect(screen.getByText('≥100000')).toBeInTheDocument();
+    expect(screen.getByText('≈20')).toBeInTheDocument();
+    expect(screen.getByRole('group', {name: 'Nodes: 1 in this result, ≥100000 in the full graph'}))
+        .toHaveAttribute('title', expect.stringContaining('At most 100000 nodes and 100000 edges are scanned'));
+});
+
+test('does not add approximate markers to unavailable totals', () => {
+    render(<NumberCard data={{...counts, allGraphNodesNum: null, allGraphEdgesNum: -1,
+        approximate: true, scan_limit: 100000}} />);
+    expect(screen.getByText('Unavailable')).toBeInTheDocument();
+    expect(screen.getByText('Loading')).toBeInTheDocument();
+    expect(screen.queryByText(/≈Unavailable|≥Unavailable/)).not.toBeInTheDocument();
+});
diff --git a/hugegraph-hubble/hubble-fe/src/modules/component/TopBar/index.js b/hugegraph-hubble/hubble-fe/src/modules/component/TopBar/index.js
index 009fc203d..38ecc05fc 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/component/TopBar/index.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/component/TopBar/index.js
@@ -31,6 +31,7 @@ import {useNavigate, useParams} from 'react-router-dom';
 import {useTranslation} from 'react-i18next';
 import _ from 'lodash';
 import c from './index.module.scss';
+import {loadGraphOptions} from '../../../utils/graphOptions';
 
 const {
     LOADED,
@@ -60,8 +61,7 @@ const TopBar = props => {
     } = useParams();
 
     const {isVermeer} = useContext(GraphAnalysisContext);
-    const [graphSpaceList, setGraphSpaceList] = useState([]);
-    const [currentGraphSpace, setCurrentGraphSpace] = useState();
+    const [currentGraphSpace, setCurrentGraphSpace] = useState(graphSpaceFromParam);
     const [, setGraphSpaceLoading] = useState(false);
     const [graphSpaceError, setGraphSpaceError] = useState(false);
     const [currentGraph, setCurrentGraph] = useState({});
@@ -77,7 +77,8 @@ const TopBar = props => {
         last_load_time: currentGraphLoadTime,
     } = currentGraph;
 
-    const showVermeerGraphInfo = isVermeer && !_.isEmpty(currentGraph);
+    const showVermeerGraphInfo = isVermeer && !_.isEmpty(currentGraph) &&
+                                Boolean(currentGraphStatus);
 
     const renderLoadTime = () => {
         return format(Date.parse(currentGraphLoadTime), 'yyyy-MM-dd');
@@ -117,7 +118,6 @@ const TopBar = props => {
                     throw new Error('graphspaces unavailable');
                 }
                 const {graphspaces} = data;
-                setGraphSpaceList(graphspaces);
                 const routeGraphSpace = graphspaces.includes(graphSpaceFromParam)
                     ? graphSpaceFromParam
                     : undefined;
@@ -144,18 +144,33 @@ const TopBar = props => {
             setGraphLoading(true);
             setGraphError(false);
             try {
-                const response = await api.analysis.getGraphList(
-                    currentGraphSpace, INLINE_ERROR_CONFIG
+                const options = await loadGraphOptions(
+                    currentGraphSpace, {selected: graphFromParam}, INLINE_ERROR_CONFIG
                 );
                 if (graphRequest.current !== request) {
                     return;
                 }
-                const {status, data} = response || {};
-                if (status !== 200 || !Array.isArray(data?.graphs)) {
-                    throw new Error('graphs unavailable');
+                let graph = graphFromParam ? options.selected : options.records[0];
+                if (graphFromParam && !graph) {
+                    throw new Error('selected graph unavailable');
+                }
+                graph = graph || {};
+                if (isVermeer && graph.name) {
+                    const detail = await api.manage.getGraph(
+                        currentGraphSpace, graph.name, INLINE_ERROR_CONFIG
+                    );
+                    if (graphRequest.current !== request) {
+                        return;
+                    }
+                    if (detail?.status !== 200 || !detail.data ||
+                        typeof detail.data !== 'object' || Array.isArray(detail.data)) {
+                        throw new Error('graph details unavailable');
+                    }
+                    graph = {
+                        ...graph, ...detail.data, name: graph.name,
+                        status: detail.data.status || CREATED,
+                    };
                 }
-                const {graphs = []} = data;
-                const graph = _.find(graphs, {name: graphFromParam}) || graphs[0] || {};
                 setCurrentGraph(current => (
                     _.isEmpty(graph) && _.isEmpty(current) ? current : graph
                 ));
@@ -172,7 +187,7 @@ const TopBar = props => {
                 }
             }
         },
-        [currentGraphSpace, graphFromParam]
+        [currentGraphSpace, graphFromParam, isVermeer]
     );
 
     useEffect(() => () => {
@@ -192,11 +207,21 @@ const TopBar = props => {
 
     useEffect(
         () => {
-            if (currentGraphSpace && _.isEmpty(currentGraph)) {
-                getGraphs();
+            if (!currentGraphSpace || (
+                graphSpaceFromParam && graphSpaceFromParam !== currentGraphSpace
+            )) {
+                return undefined;
             }
+            setCurrentGraph(current => (
+                graphFromParam && current.name && current.name !== graphFromParam
+                    ? {} : current
+            ));
+            getGraphs();
+            return () => {
+                graphRequest.current = null;
+            };
         },
-        [currentGraph, currentGraphSpace, getGraphs]
+        [currentGraphSpace, graphFromParam, graphSpaceFromParam, getGraphs]
     );
 
     useEffect(
@@ -206,23 +231,6 @@ const TopBar = props => {
         [currentGraph, currentGraphSpace, onGraphInfoChange]
     );
 
-    useEffect(() => {
-        if (!graphFromParam
-            || !currentGraph?.name
-            || graphSpaceFromParam !== currentGraphSpace
-            || graphFromParam === currentGraph.name) {
-            return;
-        }
-        graphRequest.current = null;
-        setCurrentGraph({});
-        setGraphError(false);
-    }, [
-        currentGraph?.name,
-        currentGraphSpace,
-        graphFromParam,
-        graphSpaceFromParam,
-    ]);
-
     useEffect(
         () => {
             if (taskId || !moduleName || !currentGraphSpace || !currentGraph?.name) {
@@ -259,14 +267,12 @@ const TopBar = props => {
 
     useEffect(() => {
         if (graphSpaceFromParam
-            && graphSpaceList.includes(graphSpaceFromParam)
             && graphSpaceFromParam !== currentGraphSpace) {
             handleGraphSpaceChange(graphSpaceFromParam);
         }
     }, [
         currentGraphSpace,
         graphSpaceFromParam,
-        graphSpaceList,
         handleGraphSpaceChange,
     ]);
 
diff --git a/hugegraph-hubble/hubble-fe/src/modules/component/TopBar/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/component/TopBar/index.test.js
index 445f2f66e..491cb9b6f 100644
--- a/hugegraph-hubble/hubble-fe/src/modules/component/TopBar/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/modules/component/TopBar/index.test.js
@@ -32,9 +32,9 @@ jest.mock('react-router-dom', () => ({
 jest.mock('../../../api', () => ({
     analysis: {
         getGraphSpaceList: jest.fn(),
-        getGraphList: jest.fn(),
         loadVermeerTask: jest.fn(),
     },
+    manage: {getGraph: jest.fn(), getGraphList: jest.fn()},
 }));
 jest.mock('react-i18next', () => ({
     useTranslation: () => ({t: key => key}),
@@ -68,7 +68,8 @@ beforeEach(() => {
         status: 200,
         data: {graphspaces: ['DEFAULT']},
     });
-    api.analysis.getGraphList.mockResolvedValue({status: 200, data: {graphs: []}});
+    api.manage.getGraphList.mockResolvedValue({status: 200, data: {records: []}});
+    api.manage.getGraph.mockResolvedValue({status: 200, data: {status: 'loaded'}});
 });
 
 const deferred = () => {
@@ -88,6 +89,224 @@ it('does not repeat the global GraphSpace and graph selectors', async () => {
     await waitFor(() => expect(api.analysis.getGraphSpaceList).toHaveBeenCalledTimes(1));
 });
 
+it('uses route context and brief authorized graph metadata without enumerating all spaces', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'graph-b'});
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200,
+        data: {records: [{name: 'graph-a'}, {name: 'graph-b'}]},
+    });
+    const onGraphInfoChange = jest.fn();
+
+    renderTopBar({isVermeer: false}, {onGraphInfoChange});
+
+    await waitFor(() => expect(onGraphInfoChange).toHaveBeenCalledWith(
+        'A', expect.objectContaining({name: 'graph-b'})
+    ));
+    expect(api.manage.getGraphList).toHaveBeenCalledWith(
+        'A', expect.objectContaining({brief: true, page_no: 1, page_size: 100}), {suppressBusinessErrorToast: true}
+    );
+    expect(api.analysis.getGraphSpaceList).not.toHaveBeenCalled();
+    expect(api.manage.getGraph).not.toHaveBeenCalled();
+    expect(mockNavigate).not.toHaveBeenCalled();
+});
+
+it('does not select a fallback graph when route access is forbidden', async () => {
+    Object.assign(mockParams, {graphSpace: 'protected', graph: 'private'});
+    api.manage.getGraphList.mockRejectedValueOnce(
+        Object.assign(new Error('forbidden'), {status: 403})
+    );
+    const onGraphInfoChange = jest.fn();
+
+    renderTopBar({isVermeer: false}, {onGraphInfoChange});
+
+    expect(await screen.findByText('analysis.topbar.graphs_failed')).toBeInTheDocument();
+    expect(api.analysis.getGraphSpaceList).not.toHaveBeenCalled();
+    expect(api.manage.getGraph).not.toHaveBeenCalled();
+    expect(mockNavigate).not.toHaveBeenCalled();
+    expect(onGraphInfoChange.mock.calls.some(([, graph]) => graph?.name)).toBe(false);
+});
+
+it('does not replace a missing route-selected graph with another graph', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'missing'});
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200, data: {records: [{name: 'other'}]},
+    });
+    const onGraphInfoChange = jest.fn();
+
+    renderTopBar({isVermeer: false}, {onGraphInfoChange});
+
+    expect(await screen.findByText('analysis.topbar.graphs_failed')).toBeInTheDocument();
+    expect(api.manage.getGraphList).toHaveBeenCalledTimes(1);
+    expect(onGraphInfoChange.mock.calls.some(([, graph]) => graph?.name)).toBe(false);
+    expect(mockNavigate).not.toHaveBeenCalled();
+});
+
+it('loads status only for the selected Vermeer graph and respects its loading state', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'graph-b'});
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200,
+        data: {records: [{name: 'graph-a'}, {name: 'graph-b'}]},
+    });
+    api.manage.getGraph.mockResolvedValue({status: 200, data: {status: 'loading'}});
+
+    renderTopBar({isVermeer: true});
+
+    expect(await screen.findByRole('button', {
+        name: 'analysis.topbar.load_to_vermeer',
+    })).toBeDisabled();
+    expect(api.manage.getGraph).toHaveBeenCalledTimes(1);
+    expect(api.manage.getGraph).toHaveBeenCalledWith(
+        'A', 'graph-b', {suppressBusinessErrorToast: true}
+    );
+    expect(api.analysis.getGraphSpaceList).not.toHaveBeenCalled();
+});
+
+it('retains the selected loaded graph status and load date', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'graph-a'});
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200, data: {records: [{name: 'graph-a'}]},
+    });
+    api.manage.getGraph.mockResolvedValue({
+        status: 200,
+        data: {status: 'loaded', last_load_time: '2026-10-22T12:00:00Z'},
+    });
+
+    renderTopBar({isVermeer: true});
+
+    expect(await screen.findByRole('button', {
+        name: 'analysis.topbar.reload_to_vermeer',
+    })).toBeEnabled();
+    expect(screen.getByText('2026-10-22')).toBeInTheDocument();
+});
+
+it('refreshes selected graph details when the Vermeer capability resolves after the brief list', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'graph-a'});
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200, data: {records: [{name: 'graph-a'}]},
+    });
+    api.manage.getGraph.mockResolvedValue({
+        status: 200,
+        data: {status: 'loaded', last_load_time: '2026-10-22T12:00:00Z'},
+    });
+    const onGraphInfoChange = jest.fn();
+    const capability = deferred();
+    const view = renderTopBar({isVermeer: false}, {onGraphInfoChange});
+    const capabilityReady = capability.promise.then(isVermeer => view.rerender(
+        <GraphAnalysisContext.Provider value={{isVermeer}}>
+            <TopBar moduleName='gremlin' onGraphInfoChange={onGraphInfoChange} />
+        </GraphAnalysisContext.Provider>
+    ));
+    await waitFor(() => expect(onGraphInfoChange).toHaveBeenCalledWith(
+        'A', expect.objectContaining({name: 'graph-a'})
+    ));
+    expect(api.manage.getGraph).not.toHaveBeenCalled();
+    onGraphInfoChange.mockClear();
+
+    await act(async () => {
+        capability.resolve(true);
+        await capabilityReady;
+    });
+
+    expect(await screen.findByRole('button', {
+        name: 'analysis.topbar.reload_to_vermeer',
+    })).toBeEnabled();
+    expect(screen.getByText('2026-10-22')).toBeInTheDocument();
+    expect(api.manage.getGraphList).toHaveBeenCalledTimes(2);
+    expect(api.manage.getGraph).toHaveBeenCalledTimes(1);
+    expect(onGraphInfoChange.mock.calls.every(([, graph]) => graph?.name === 'graph-a')).toBe(true);
+});
+
+it('does not automatically retry a forbidden detail request after capability activation', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'graph-a'});
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200, data: {records: [{name: 'graph-a'}]},
+    });
+    api.manage.getGraph
+        .mockRejectedValueOnce(Object.assign(new Error('forbidden'), {status: 403}))
+        .mockResolvedValueOnce({status: 200, data: {status: 'loaded'}});
+    const onGraphInfoChange = jest.fn();
+    const view = renderTopBar({isVermeer: false}, {onGraphInfoChange});
+    await waitFor(() => expect(onGraphInfoChange).toHaveBeenCalledWith(
+        'A', expect.objectContaining({name: 'graph-a'})
+    ));
+
+    view.rerender(
+        <GraphAnalysisContext.Provider value={{isVermeer: true}}>
+            <TopBar moduleName='gremlin' onGraphInfoChange={onGraphInfoChange} />
+        </GraphAnalysisContext.Provider>
+    );
+
+    expect(await screen.findByText('analysis.topbar.graphs_failed')).toBeInTheDocument();
+    expect(api.manage.getGraphList).toHaveBeenCalledTimes(2);
+    expect(api.manage.getGraph).toHaveBeenCalledTimes(1);
+    expect(mockNavigate).not.toHaveBeenCalled();
+    fireEvent.click(screen.getByRole('button', {name: 'analysis.topbar.retry_graphs'}));
+    expect(await screen.findByRole('button', {
+        name: 'analysis.topbar.reload_to_vermeer',
+    })).toBeEnabled();
+    expect(api.manage.getGraph).toHaveBeenCalledTimes(2);
+});
+
+it('ignores pending capability-triggered details after the route selects a different graph', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'graph-a'});
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200, data: {records: [{name: 'graph-a'}, {name: 'graph-b'}]},
+    });
+    const oldDetail = deferred();
+    api.manage.getGraph.mockImplementation((space, graph) => (
+        graph === 'graph-a' ? oldDetail.promise : Promise.resolve({
+            status: 200, data: {status: 'loaded', last_load_time: '2026-10-22T12:00:00Z'},
+        })
+    ));
+    const onGraphInfoChange = jest.fn();
+    const view = renderTopBar({isVermeer: false}, {onGraphInfoChange});
+    await waitFor(() => expect(onGraphInfoChange).toHaveBeenCalledWith(
+        'A', expect.objectContaining({name: 'graph-a'})
+    ));
+    const enabledTopBar = (
+        <GraphAnalysisContext.Provider value={{isVermeer: true}}>
+            <TopBar moduleName='gremlin' onGraphInfoChange={onGraphInfoChange} />
+        </GraphAnalysisContext.Provider>
+    );
+    view.rerender(enabledTopBar);
+    await waitFor(() => expect(api.manage.getGraph).toHaveBeenCalledWith(
+        'A', 'graph-a', expect.any(Object)
+    ));
+    onGraphInfoChange.mockClear();
+    Object.assign(mockParams, {graph: 'graph-b'});
+    view.rerender(
+        <GraphAnalysisContext.Provider value={{isVermeer: true}}>
+            <TopBar moduleName='gremlin' onGraphInfoChange={onGraphInfoChange} />
+        </GraphAnalysisContext.Provider>
+    );
+    await waitFor(() => expect(onGraphInfoChange).toHaveBeenCalledWith(
+        'A', expect.objectContaining({name: 'graph-b', status: 'loaded'})
+    ));
+
+    await act(async () => oldDetail.resolve({
+        status: 200, data: {status: 'loading', last_load_time: '2026-10-21T12:00:00Z'},
+    }));
+
+    expect(onGraphInfoChange.mock.calls.some(([, graph]) => graph?.name === 'graph-a')).toBe(false);
+    expect(screen.getByText('2026-10-22')).toBeInTheDocument();
+    expect(screen.queryByText('2026-10-21')).not.toBeInTheDocument();
+});
+
+it('treats a graph without Vermeer status as not yet loaded', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'graph-a'});
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200, data: {records: [{name: 'graph-a'}]},
+    });
+    api.manage.getGraph.mockResolvedValue({status: 200, data: {backend: 'hstore'}});
+
+    renderTopBar({isVermeer: true});
+
+    expect(await screen.findByText('analysis.topbar.not_loaded')).toBeInTheDocument();
+    expect(screen.getByRole('button', {
+        name: 'analysis.topbar.load_to_vermeer',
+    })).toBeEnabled();
+});
+
 it('recovers a rejected GraphSpace request without leaving the selector loading', async () => {
     api.analysis.getGraphSpaceList
         .mockRejectedValueOnce(new Error('offline'))
@@ -107,9 +326,9 @@ it('recovers a rejected GraphSpace request without leaving the selector loading'
 });
 
 it('recovers a rejected graph request and ignores the failed result', async () => {
-    api.analysis.getGraphList
+    api.manage.getGraphList
         .mockRejectedValueOnce(new Error('offline'))
-        .mockResolvedValueOnce({status: 200, data: {graphs: []}});
+        .mockResolvedValueOnce({status: 200, data: {records: []}});
 
     renderTopBar();
 
@@ -118,7 +337,7 @@ it('recovers a rejected graph request and ignores the failed result', async () =
     });
     fireEvent.click(retry);
 
-    await waitFor(() => expect(api.analysis.getGraphList).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalledTimes(2));
     await waitFor(() => expect(screen.queryByText(
         'analysis.topbar.graphs_failed'
     )).not.toBeInTheDocument());
@@ -126,9 +345,9 @@ it('recovers a rejected graph request and ignores the failed result', async () =
 
 it('settles a rejected Vermeer load and lets the user retry', async () => {
     jest.spyOn(message, 'error').mockImplementation(() => undefined);
-    api.analysis.getGraphList.mockResolvedValue({
+    api.manage.getGraphList.mockResolvedValue({
         status: 200,
-        data: {graphs: [{name: 'hugegraph', status: 'loaded'}]},
+        data: {records: [{name: 'hugegraph', status: 'loaded'}]},
     });
     api.analysis.loadVermeerTask
         .mockRejectedValueOnce(new Error('offline'))
@@ -150,7 +369,7 @@ it('settles a rejected Vermeer load and lets the user retry', async () => {
     });
     fireEvent.click(reload);
     await waitFor(() => expect(api.analysis.loadVermeerTask).toHaveBeenCalledTimes(2));
-    await waitFor(() => expect(api.analysis.getGraphList).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalledTimes(2));
     await waitFor(() => expect(reload).toBeEnabled());
 });
 
@@ -161,18 +380,18 @@ it('ignores a late graph response after the route switches GraphSpace', async ()
         status: 200,
         data: {graphspaces: ['A', 'B']},
     });
-    api.analysis.getGraphList.mockImplementation(graphSpace => (
+    api.manage.getGraphList.mockImplementation(graphSpace => (
         graphSpace === 'A'
             ? oldGraphs.promise
             : Promise.resolve({
                 status: 200,
-                data: {graphs: [{name: 'graph-b', status: 'created'}]},
+                data: {records: [{name: 'graph-b', status: 'created'}]},
             })
     ));
 
     const view = renderTopBar();
-    await waitFor(() => expect(api.analysis.getGraphList).toHaveBeenCalledWith(
-        'A', expect.any(Object)
+    await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalledWith(
+        'A', expect.objectContaining({brief: true, page_no: 1, page_size: 100}), expect.any(Object)
     ));
 
     Object.assign(mockParams, {graphSpace: 'B'});
@@ -189,8 +408,8 @@ it('ignores a late graph response after the route switches GraphSpace', async ()
             />
         </GraphAnalysisContext.Provider>
     );
-    await waitFor(() => expect(api.analysis.getGraphList).toHaveBeenCalledWith(
-        'B', expect.any(Object)
+    await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalledWith(
+        'B', expect.objectContaining({brief: true, page_no: 1, page_size: 100}), expect.any(Object)
     ));
     await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(
         '/gremlin/B/graph-b', {replace: true}
@@ -198,7 +417,7 @@ it('ignores a late graph response after the route switches GraphSpace', async ()
 
     await act(async () => oldGraphs.resolve({
         status: 200,
-        data: {graphs: [{name: 'graph-a', status: 'created'}]},
+        data: {records: [{name: 'graph-a', status: 'created'}]},
     }));
     expect(mockNavigate).not.toHaveBeenCalledWith(
         '/gremlin/A/graph-a', expect.any(Object)
@@ -213,9 +432,9 @@ it.each(['gremlin', 'algorithms', 'asyncTasks'])(
             status: 200,
             data: {graphspaces: ['A']},
         });
-        api.analysis.getGraphList.mockResolvedValue({
+        api.manage.getGraphList.mockResolvedValue({
             status: 200,
-            data: {graphs: [
+            data: {records: [
                 {name: 'graph-a', status: 'created'},
                 {name: 'graph-b', status: 'created'},
             ]},
@@ -255,3 +474,23 @@ it.each(['gremlin', 'algorithms', 'asyncTasks'])(
         );
     }
 );
+
+
+it('resolves a route-selected late-page graph from the same bounded response', async () => {
+    Object.assign(mockParams, {graphSpace: 'A', graph: 'graph_10000'});
+    const selected = {name: 'graph_10000', nickname: 'Late graph'};
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200,
+        data: {records: [{name: 'graph_00000'}], total: 10001, selected},
+    });
+    const onGraphInfoChange = jest.fn();
+
+    renderTopBar({isVermeer: false}, {onGraphInfoChange});
+
+    await waitFor(() => expect(onGraphInfoChange).toHaveBeenCalledWith('A', selected));
+    expect(api.manage.getGraphList).toHaveBeenCalledTimes(1);
+    expect(api.manage.getGraphList).toHaveBeenCalledWith('A', {
+        brief: true, page_no: 1, page_size: 100, query: '', selected: 'graph_10000',
+    }, expect.any(Object));
+    expect(mockNavigate).not.toHaveBeenCalled();
+});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js
index 895ea63c0..5c61ca114 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js
@@ -16,7 +16,7 @@
  * under the License.
  */
 
-import {useCallback, useMemo} from 'react';
+import {useCallback, useEffect, useMemo, useState} from 'react';
 import {Button, Card, Dropdown, Space, Typography, Tooltip} from 'antd';
 import {UnorderedListOutlined} from '@ant-design/icons';
 import {Link, useNavigate} from 'react-router-dom';
@@ -29,6 +29,8 @@ import {isPdEnabled} from '../../utils/config';
 import style from './index.module.scss';
 import {byteConvert} from '../../utils/format';
 import {getResourceDisplayName} from '../../utils/displayName';
+import * as api from '../../api';
+import {formatRecordedCount} from '../../utils/graphStatistics';
 
 const EMPTY_SCHEMA = {vertices: [], edges: []};
 const CARD_LAYOUT = {type: 'dagre', options: {rankdir: 'LR', nodesep: 30, ranksep: 60}};
@@ -69,7 +71,7 @@ const TitleField = ({item, onClick, onKeyDown, schemaTypeCounts}) => {
                 {' / '}
                 {t('graph.col.create_time')}: {createdAt}
                 {' / '}
-                {t('graph.card.schema_types', schemaTypeCounts)}
+                {schemaTypeCounts ? t('graph.card.schema_types', schemaTypeCounts) : t('graph.card.schema_info')}
             </div>
         </>
     );
@@ -79,24 +81,44 @@ const GraphCard = ({item, menus, canMutate = true}) => {
     const {t} = useTranslation();
     const navigate = useNavigate();
     const graphName = getResourceDisplayName(item.name, item.nickname);
-    const schemaView = item.schemaview || EMPTY_SCHEMA;
+    const graphspace = item.graphspace || 'DEFAULT';
+    const key = `${graphspace}/${item.name}`;
+    const [schema, setSchema] = useState(null);
+    const [snapshot, setSnapshot] = useState(null);
+    const schemaView = item.schemaview || (schema?.key === key ? schema.data : null);
+    const statistic = item.statistic?.vertex != null && item.statistic?.edge != null
+        ? item.statistic : snapshot?.key === key ? snapshot.data : null;
+    useEffect(() => {
+        let active = true;
+        if (!item.schemaview) {
+            api.manage.getGraphView(graphspace, item.name, {suppressBusinessErrorToast: true}).then(res => {
+                if (active) setSchema({key, data: res.status === 200 ? res.data : null, loaded: true});
+            }).catch(() => {
+                if (active) setSchema({key, data: null, loaded: true});
+            });
+        }
+        if (item.statistic?.vertex == null || item.statistic?.edge == null) {
+            api.analysis.getGraphData(graphspace, item.name, {suppressBusinessErrorToast: true}).then(res => {
+                if (active) setSnapshot({key, data: res.status === 200 ? {
+                    ...res.data, vertex: res.data?.vertexcount, edge: res.data?.edgecount,
+                } : null, error: res.status !== 200});
+            }).catch(() => {
+                if (active) setSnapshot({key, data: null, error: true});
+            });
+        }
+        return () => { active = false; };
+    }, [graphspace, item.name, item.schemaview, item.statistic, key]);
     const prepared = useMemo(() => {
         try {
-            return {data: adaptHubbleSchema(schemaView), error: null};
+            return {data: adaptHubbleSchema(schemaView || EMPTY_SCHEMA), error: null};
         }
         catch (error) {
             return {data: {nodes: [], edges: []}, error};
         }
     }, [schemaView]);
     const schemaIsEmpty = !prepared.error && prepared.data.nodes.length === 0 && prepared.data.edges.length === 0;
-    const hasElementCounts = item.statistic
-        && item.statistic.vertex !== null
-        && item.statistic.vertex !== undefined
-        && item.statistic.edge !== null
-        && item.statistic.edge !== undefined
-        && Number.isFinite(Number(item.statistic.vertex))
-        && Number.isFinite(Number(item.statistic.edge));
-    const graphspace = item.graphspace || 'DEFAULT';
+    const hasElementCounts = statistic?.vertex != null && statistic?.edge != null
+        && Number.isFinite(Number(statistic.vertex)) && Number.isFinite(Number(statistic.edge));
     const overviewPath = `/graphspace/${graphspace}/graph/${item.name}/detail`;
     const schemaPath = `/graphspace/${graphspace}/graph/${item.name}/meta`;
 
@@ -125,10 +147,10 @@ const GraphCard = ({item, menus, canMutate = true}) => {
                     item={item}
                     onClick={handleGotoSchema}
                     onKeyDown={handleSchemaKeyDown}
-                    schemaTypeCounts={{
+                    schemaTypeCounts={schemaView ? {
                         vertices: schemaView.vertices?.length || 0,
                         edges: schemaView.edges?.length || 0,
-                    }}
+                    } : null}
                 />
             )}
 
@@ -145,23 +167,33 @@ const GraphCard = ({item, menus, canMutate = true}) => {
                 </Dropdown>
             )}
             actions={[
-                hasElementCounts && (
-                    <Tooltip key='counts' title={t('graph.card.counts_tooltip')}>
+                hasElementCounts ? (
+                    <Tooltip key='counts' title={t(statistic.scan_limit > 0
+                        ? 'graph.detail.bounded_statistics_hint' : 'graph.card.counts_tooltip',
+                        {limit: statistic.scan_limit})}>
                         <span className={style.element_counts}>
                             {t('graph.card.element_counts', {
-                                vertices: item.statistic.vertex,
-                                edges: item.statistic.edge,
+                                vertices: formatRecordedCount(statistic.vertex, statistic),
+                                edges: formatRecordedCount(statistic.edge, statistic),
                             })}
                         </span>
                     </Tooltip>
-                ),
+                ) : <span key='counts'>{t(snapshot?.key === key
+                    ? snapshot.error ? 'graph.detail.statistics_unavailable' : 'graph.detail.not_collected'
+                    : 'graph.detail.loading')}</span>,
                 <Link key='overview' to={overviewPath}>{t('graph.card.overview')}</Link>,
                 <Link key='query' to={`/gremlin/${graphspace}/${item.name}`}>
                     {t('graph.card.query_graph')}
                 </Link>,
             ].filter(Boolean)}
         >
-            {prepared.error ? (
+            {!schemaView ? (
+                <div className={style.empty_schema}>
+                    <span>{t(schema?.key === key && schema.loaded
+                        ? 'graph.card.schema_unavailable' : 'graph.card.schema_loading')}</span>
+                    <Button size='small' onClick={handleGotoSchema}>{t('graph.menu.view_schema')}</Button>
+                </div>
+            ) : prepared.error ? (
                 <div role='alert'>{t('schema.image_view.load_failed')}</div>
             ) : schemaIsEmpty ? (
                 <div className={style.empty_schema}>
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.test.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.test.js
index c781bc08f..42883b51f 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.test.js
@@ -18,14 +18,23 @@
 
 import fs from 'fs';
 import path from 'path';
-import {fireEvent, render, screen} from '@testing-library/react';
+import {act, fireEvent, render, screen} from '@testing-library/react';
 import {MemoryRouter, useLocation} from 'react-router-dom';
 import GraphCard from './Card';
+import * as api from '../../api';
+
+jest.mock('../../api', () => ({
+    manage: {getGraphView: jest.fn(() => new Promise(() => {}))},
+    analysis: {getGraphData: jest.fn(() => new Promise(() => {}))},
+}));
 
 const mockT = (key, values) => {
     if (key === 'graph.card.element_counts') {
         return `${key}:${values.vertices}/${values.edges}`;
     }
+    if (key === 'graph.detail.bounded_statistics_hint') {
+        return `${key}:${values.limit}`;
+    }
     if (key === 'graph.card.view_schema') {
         return `${key}:${values.graph}`;
     }
@@ -255,3 +264,57 @@ test('does not invent point and edge counts when the list API omits them', () =>
 
     expect(screen.queryByText(/graph\.card\.element_counts/)).not.toBeInTheDocument();
 });
+
+
+test('renders core graph actions before schema and snapshot statistics complete', async () => {
+    let resolveSchema;
+    let resolveSnapshot;
+    api.manage.getGraphView.mockReturnValueOnce(new Promise(resolve => { resolveSchema = resolve; }));
+    api.analysis.getGraphData.mockReturnValueOnce(new Promise(resolve => { resolveSnapshot = resolve; }));
+    render(<MemoryRouter><GraphCard item={{name: 'g', graphspace: 'space'}} menus={[]} /></MemoryRouter>);
+    expect(screen.getByRole('link', {name: 'graph.card.overview'})).toHaveAttribute('href', '/graphspace/space/graph/g/detail');
+    expect(screen.getByRole('link', {name: 'graph.card.query_graph'})).toHaveAttribute('href', '/gremlin/space/g');
+    expect(screen.getByText('graph.card.schema_loading', {selector: 'span'})).toBeInTheDocument();
+    expect(screen.queryByText('graph.card.empty_schema')).not.toBeInTheDocument();
+    expect(api.manage.getGraphView).toHaveBeenLastCalledWith('space', 'g', {suppressBusinessErrorToast: true});
+    expect(api.analysis.getGraphData).toHaveBeenLastCalledWith('space', 'g', {suppressBusinessErrorToast: true});
+    await act(async () => {
+        resolveSnapshot({status: 200, data: {vertexcount: null, edgecount: null}});
+    });
+    expect(screen.getByText('graph.detail.not_collected')).toBeInTheDocument();
+    expect(screen.queryByText('graph.detail.statistics_unavailable')).not.toBeInTheDocument();
+    await act(async () => {
+        resolveSchema({status: 200, data: {vertices: [{id: 'person', label: 'person'}], edges: []}});
+    });
+    expect(screen.getByTestId('preview-contract')).toBeInTheDocument();
+});
+
+
+test('shows a capped snapshot as a lower bound with its scan limit', async () => {
+    api.analysis.getGraphData.mockResolvedValueOnce({status: 200, data: {
+        vertexcount: '100000', edgecount: '20', approximate: true, scan_limit: 100000,
+    }});
+    render(<MemoryRouter><GraphCard item={{name: 'g', graphspace: 'space',
+        schemaview: {vertices: [], edges: []}}} menus={[]} /></MemoryRouter>);
+    const counts = await screen.findByText('graph.card.element_counts:≥100000/≈20');
+    fireEvent.mouseEnter(counts);
+    expect(await screen.findByText('graph.detail.bounded_statistics_hint:100000')).toBeInTheDocument();
+});
+
+test.each([
+    ['network failure', () => Promise.reject(new Error('network failure'))],
+    ['non-success response', () => Promise.resolve({status: 503})],
+])('shows a snapshot error for %s while keeping core actions available', async (_, response) => {
+    const calls = api.analysis.getGraphData.mock.calls.length;
+    api.analysis.getGraphData.mockImplementationOnce(response);
+    render(<MemoryRouter><GraphCard item={{name: 'g', graphspace: 'space',
+        schemaview: {vertices: [], edges: []}}} menus={[]} /></MemoryRouter>);
+
+    expect(await screen.findByText('graph.detail.statistics_unavailable')).toBeInTheDocument();
+    expect(screen.queryByText('graph.detail.not_collected')).not.toBeInTheDocument();
+    expect(screen.getByRole('link', {name: 'graph.card.overview'}))
+        .toHaveAttribute('href', '/graphspace/space/graph/g/detail');
+    expect(screen.getByRole('link', {name: 'graph.card.query_graph'}))
+        .toHaveAttribute('href', '/gremlin/space/g');
+    expect(api.analysis.getGraphData).toHaveBeenCalledTimes(calls + 1);
+});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/GraphDetail/index.js b/hugegraph-hubble/hubble-fe/src/pages/GraphDetail/index.js
index 37a2e628c..37b019f80 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/GraphDetail/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/GraphDetail/index.js
@@ -18,8 +18,8 @@
 
 import PageHeader from '../../components/PageHeader';
 import {
-    Alert, Row, Col, Button, Spin, message, Space, Table,
-    Card, Empty, Statistic, Tag,
+    Alert, Row, Col, Button, Spin, Space, Table,
+    Card, Empty, Statistic, Tag, Tooltip,
 } from 'antd';
 import {
     NodeIndexOutlined, ReloadOutlined, SearchOutlined, ShareAltOutlined,
@@ -30,19 +30,19 @@ import * as api from '../../api';
 import GraphJourneyNav from '../../components/GraphJourneyNav';
 import style from './index.module.scss';
 import {useTranslation} from 'react-i18next';
+import {isKnownZeroCount, formatRecordedCount} from '../../utils/graphStatistics';
 
 const GraphDetail = () => {
     const [pageLoading, setPageLoading] = useState(true);
     const [statisticsLoading, setStatisticsLoading] = useState(true);
-    const [updating, setUpdating] = useState(false);
     const [statistic, setStatistic] = useState({});
     const [statisticsStatus, setStatisticsStatus] = useState('loading');
+    const [emptySchema, setEmptySchema] = useState(null);
     const [pageDataRoute, setPageDataRoute] = useState(null);
     const [statisticsDataRoute, setStatisticsDataRoute] = useState(null);
     const [pageError, setPageError] = useState(false);
     const pageRequest = useRef(null);
     const statisticsRequest = useRef(null);
-    const updateRequest = useRef(null);
     const {graphspace, graph} = useParams();
     const navigate = useNavigate();
     const {t} = useTranslation();
@@ -111,7 +111,7 @@ const GraphDetail = () => {
         }
     }, [graphspace, graph, routeKey]);
 
-    const loadStatistics = useCallback(async () => {
+    const loadStatistics = useCallback(async (full = false) => {
         const token = Symbol('graph-detail-statistics');
         statisticsRequest.current = token;
         setStatisticsLoading(true);
@@ -119,17 +119,25 @@ const GraphDetail = () => {
         setStatistic({});
         const inlineErrorConfig = {suppressBusinessErrorToast: true};
         try {
-            const res = await api.manage.getGraphStatistic(
-                graphspace,
-                graph,
-                inlineErrorConfig
-            );
+            // The initial read only uses an existing snapshot. Full graph
+            // statistics are requested explicitly and never block core metadata.
+            const res = full
+                ? await api.manage.getGraphStatistic(graphspace, graph, inlineErrorConfig)
+                : await api.analysis.getGraphData(graphspace, graph, inlineErrorConfig);
             if (statisticsRequest.current !== token) {
                 return;
             }
             if (res.status === 200) {
-                setStatistic(res.data);
-                setStatisticsStatus('success');
+                const data = full ? res.data : {
+                    vertex_count: res.data?.vertexcount,
+                    edge_count: res.data?.edgecount,
+                    update_time: res.data?.date,
+                    approximate: res.data?.approximate,
+                    scan_limit: res.data?.scan_limit,
+                };
+                setStatistic(data);
+                setStatisticsStatus(data?.vertex_count != null && data?.edge_count != null
+                    ? 'success' : 'idle');
                 setStatisticsDataRoute(routeKey);
                 return;
             }
@@ -152,38 +160,6 @@ const GraphDetail = () => {
         }
     }, [graphspace, graph, routeKey]);
 
-    const handleUpdate = useCallback(async () => {
-        const token = Symbol('graph-detail-update');
-        const requestedRoute = routeKey;
-        updateRequest.current = token;
-        setUpdating(true);
-        try {
-            const res = await api.manage.updateGraphStatistic(graphspace, graph);
-            if (updateRequest.current !== token
-                || currentRoute.current !== requestedRoute) {
-                return;
-            }
-            if (res.status !== 200) {
-                message.error(t('graph.detail.update_failed'));
-                return;
-            }
-            message.success(t('graph.detail.update_success'));
-            await loadStatistics();
-        }
-        catch {
-            if (updateRequest.current === token
-                && currentRoute.current === requestedRoute) {
-                message.error(t('graph.detail.update_failed'));
-            }
-        }
-        finally {
-            if (updateRequest.current === token
-                && currentRoute.current === requestedRoute) {
-                setUpdating(false);
-            }
-        }
-    }, [graphspace, graph, loadStatistics, routeKey, t]);
-
     useEffect(() => {
         if (!graphspace || !graph) {
             return undefined;
@@ -193,8 +169,6 @@ const GraphDetail = () => {
         return () => {
             pageRequest.current = null;
             statisticsRequest.current = null;
-            updateRequest.current = null;
-            setUpdating(false);
         };
     }, [graphspace, graph, loadPage, loadStatistics]);
 
@@ -204,11 +178,22 @@ const GraphDetail = () => {
         ? statisticsStatus
         : 'loading';
     const visibleStatistic = isStatisticsDataCurrent ? statistic : {};
-    const isEmptyGraph = visibleStatisticsStatus === 'success'
-        && Number(visibleStatistic.vertex_count ?? 0) === 0
-        && Number(visibleStatistic.edge_count ?? 0) === 0
-        && formatList(visibleStatistic.vertices).length === 0
-        && formatList(visibleStatistic.edges).length === 0;
+    const hasZeroCounts = isStatisticsDataCurrent && isKnownZeroCount(statistic.vertex_count, statistic)
+        && isKnownZeroCount(statistic.edge_count, statistic);
+    useEffect(() => {
+        // Zero recorded data does not prove the schema is empty. Check schema
+        // metadata only when it can affect the empty-graph guidance.
+        if (!hasZeroCounts) return undefined;
+        let active = true;
+        api.manage.getGraphView(graphspace, graph, {suppressBusinessErrorToast: true}).then(res => {
+            if (active) setEmptySchema({owner: statistic, data: res.status === 200 ? res.data : null});
+        }).catch(() => {});
+        return () => { active = false; };
+    }, [graphspace, graph, hasZeroCounts, statistic]);
+    const confirmedSchema = emptySchema?.owner === statistic ? emptySchema.data : null;
+    const isEmptyGraph = visibleStatisticsStatus === 'success' && hasZeroCounts
+        && Array.isArray(confirmedSchema?.vertices) && confirmedSchema.vertices.length === 0
+        && Array.isArray(confirmedSchema?.edges) && confirmedSchema.edges.length === 0;
 
     return (
         <Spin spinning={pageLoading || !isPageDataCurrent}>
@@ -260,6 +245,9 @@ const GraphDetail = () => {
                                 <div className={style.statusBar}>
                                     <Space size='middle'>
                                         <span>{t('graph.detail.data_status')}</span>
+                                        <Tooltip title={visibleStatisticsStatus === 'success' ? t(visibleStatistic.scan_limit != null
+                                            ? 'graph.detail.bounded_statistics_hint' : 'graph.detail.snapshot_hint',
+                                            {limit: visibleStatistic.scan_limit ?? 100000}) : undefined}>
                                         <Tag
                                             color={visibleStatisticsStatus === 'error'
                                                 ? 'gold'
@@ -271,8 +259,11 @@ const GraphDetail = () => {
                                                 ? t('graph.detail.partial')
                                                 : visibleStatisticsStatus === 'success'
                                                     ? t('graph.detail.available')
-                                                    : t('graph.detail.loading')}
+                                                    : visibleStatisticsStatus === 'loading'
+                                                        ? t('graph.detail.loading')
+                                                        : t('graph.detail.not_collected')}
                                         </Tag>
+                                        </Tooltip>
                                         <span className={style.updatedAt}>
                                             {t('graph.detail.last_update')}
                                             {visibleStatistic.update_time ?? '--/--'}
@@ -280,10 +271,10 @@ const GraphDetail = () => {
                                     </Space>
                                     <Button
                                         icon={<ReloadOutlined />}
-                                        loading={updating}
-                                        onClick={handleUpdate}
+                                        loading={statisticsLoading}
+                                        onClick={() => loadStatistics(true)}
                                     >
-                                        {t('graph.detail.update_data')}
+                                        {t('graph.detail.collect_statistics')}
                                     </Button>
                                 </div>
 
@@ -297,7 +288,7 @@ const GraphDetail = () => {
                                             <Button
                                                 size='small'
                                                 loading={statisticsLoading}
-                                                onClick={loadStatistics}
+                                                onClick={() => loadStatistics(true)}
                                             >
                                                 {t('graph.detail.retry_statistics')}
                                             </Button>
@@ -341,6 +332,7 @@ const GraphDetail = () => {
                                             >
                                                 <Statistic
                                                     loading={statisticsLoading}
+                                                    formatter={value => formatRecordedCount(value, visibleStatistic)}
                                                     value={visibleStatisticsStatus !== 'success'
                                                         ? '--'
                                                         : visibleStatistic.vertex_count ?? 0}
@@ -369,6 +361,7 @@ const GraphDetail = () => {
                                             >
                                                 <Statistic
                                                     loading={statisticsLoading}
+                                                    formatter={value => formatRecordedCount(value, visibleStatistic)}
                                                     value={visibleStatisticsStatus !== 'success'
                                                         ? '--'
                                                         : visibleStatistic.edge_count ?? 0}
diff --git a/hugegraph-hubble/hubble-fe/src/pages/GraphDetail/index.test.js b/hugegraph-hubble/hubble-fe/src/pages/GraphDetail/index.test.js
index 6909600eb..437e8760e 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/GraphDetail/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/GraphDetail/index.test.js
@@ -15,20 +15,20 @@
  * limitations under the License.
  */
 
-import {act, render, screen, waitFor} from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
+import {act, fireEvent, render, screen} from '@testing-library/react';
 import {MemoryRouter, Route, Routes, useNavigate} from 'react-router-dom';
 import GraphDetail from './index';
 import * as api from '../../api';
-import {message} from 'antd';
 
 jest.mock('../../api', () => ({
     manage: {
         getGraphSpace: jest.fn(),
         getGraph: jest.fn(),
         getGraphStatistic: jest.fn(),
+        getGraphView: jest.fn(),
         updateGraphStatistic: jest.fn(),
     },
+    analysis: {getGraphData: jest.fn()},
 }));
 
 jest.mock('antd', () => ({
@@ -57,7 +57,9 @@ jest.mock('react-i18next', () => ({
         'graph.detail.data_status': 'Data status',
         'graph.detail.available': 'Available',
         'graph.detail.partial': 'Partial',
-        'graph.detail.loading': 'Loading',
+        'graph.detail.loading': 'Loading statistics',
+        'graph.detail.not_collected': 'Not collected',
+        'graph.detail.collect_statistics': 'Collect statistics',
         'graph.detail.retry_page': 'Retry graph details',
         'graph.detail.back_to_graphs': 'Back to graphs',
         'graph.detail.retry_statistics': 'Retry statistics',
@@ -71,6 +73,7 @@ jest.mock('react-i18next', () => ({
 
 beforeEach(() => {
     jest.resetAllMocks();
+    api.manage.getGraphView.mockResolvedValue({status: 200, data: null});
     Object.defineProperty(window, 'matchMedia', {
         writable: true,
         value: query => ({
@@ -82,338 +85,179 @@ beforeEach(() => {
     });
 });
 
-test('renders one actionable page error when all initialization requests fail', async () => {
-    api.manage.getGraphSpace.mockRejectedValue(new Error('connection refused'));
-    api.manage.getGraph.mockRejectedValue(new Error('connection refused'));
-    api.manage.getGraphStatistic.mockRejectedValue(new Error('connection refused'));
-
-    render(
-        <MemoryRouter
-            initialEntries={['/graphspace/DEFAULT/graph/g/detail']}
-            future={{
-                v7_relativeSplatPath: true,
-                v7_startTransition: true,
-            }}
-        >
-            <Routes>
-                <Route
-                    path='/graphspace/:graphspace/graph/:graph/detail'
-                    element={<GraphDetail />}
-                />
-            </Routes>
-        </MemoryRouter>
-    );
+const deferred = () => {
+    let resolve;
+    const promise = new Promise(done => { resolve = done; });
+    return {promise, resolve};
+};
 
-    expect(await screen.findByText(
-        'Graph details are unavailable. Check the server and retry.'
-    )).toBeInTheDocument();
-    expect(screen.getAllByRole('alert')).toHaveLength(1);
-    expect(screen.getByRole('link', {name: 'Back to graphs'}))
-        .toHaveAttribute('href', '/graphspace/DEFAULT');
-    expect(message.error).not.toHaveBeenCalled();
-});
+const Harness = () => {
+    const navigate = useNavigate();
+    return <>
+        <button onClick={() => navigate('/graphspace/B/graph/b/detail')}>Switch graph</button>
+        <Routes><Route path='/graphspace/:graphspace/graph/:graph/detail' element={<GraphDetail />} /></Routes>
+    </>;
+};
 
-test('shows one inline fallback without duplicating the transport toast', async () => {
-    api.manage.getGraphSpace.mockResolvedValue({
-        status: 200,
-        data: {nickname: 'Space'},
-    });
-    api.manage.getGraph.mockResolvedValue({
-        status: 200,
-        data: {nickname: 'Graph'},
-    });
-    api.manage.getGraphStatistic.mockResolvedValue({
-        status: 400,
-        message: 'Gremlin execution failed, details: ',
-    });
+const renderDetail = () => render(<MemoryRouter initialEntries={['/graphspace/A/graph/a/detail']}>
+    <Harness />
+</MemoryRouter>);
 
-    render(
-        <MemoryRouter
-            initialEntries={['/graphspace/DEFAULT/graph/g/detail']}
-            future={{
-                v7_relativeSplatPath: true,
-                v7_startTransition: true,
-            }}
-        >
-            <Routes>
-                <Route
-                    path='/graphspace/:graphspace/graph/:graph/detail'
-                    element={<GraphDetail />}
-                />
-            </Routes>
-        </MemoryRouter>
-    );
+const readyCore = () => {
+    api.manage.getGraphSpace.mockResolvedValue({status: 200, data: {name: 'A'}});
+    api.manage.getGraph.mockResolvedValue({status: 200, data: {name: 'a'}});
+};
 
-    expect(await screen.findByText('Statistics are unavailable. Retry later.'))
-        .toBeInTheDocument();
-    await waitFor(() => expect(api.manage.getGraphStatistic).toHaveBeenCalledTimes(1));
-    expect(message.error).not.toHaveBeenCalled();
+test('renders core graph navigation while the snapshot read is still pending', async () => {
+    readyCore();
+    const snapshot = deferred();
+    api.analysis.getGraphData.mockReturnValue(snapshot.promise);
+    renderDetail();
+    expect(await screen.findByText('Detail')).toBeInTheDocument();
+    expect(screen.getByRole('link', {name: 'Schema'})).toHaveAttribute('href', '/graphspace/A/graph/a/meta');
+    expect(screen.getByRole('button', {name: /Query this graph/})).toBeEnabled();
+    expect(screen.getByText('Loading statistics')).toBeInTheDocument();
+    expect(api.manage.getGraphStatistic).not.toHaveBeenCalled();
+    await act(async () => { snapshot.resolve({status: 200, data: {vertexcount: null, edgecount: null}}); });
+    expect(screen.getByText('Not collected')).toBeInTheDocument();
+    expect(screen.getAllByText('--')).toHaveLength(2);
 });
 
-test('exposes overview, schema and query as one graph journey', async () => {
-    api.manage.getGraphSpace.mockResolvedValue({
-        status: 200,
-        data: {nickname: 'Space'},
-    });
-    api.manage.getGraph.mockResolvedValue({
-        status: 200,
-        data: {nickname: 'Graph'},
-    });
-    api.manage.getGraphStatistic.mockResolvedValue({
-        status: 200,
-        data: {
-            vertex_count: 312,
-            edge_count: 608,
-            vertices: {person: 312},
-            edges: {knows: 608},
-            update_time: '2026-07-12 05:29:21',
-        },
-    });
-
-    render(
-        <MemoryRouter
-            initialEntries={['/graphspace/DEFAULT/graph/g/detail']}
-            future={{
-                v7_relativeSplatPath: true,
-                v7_startTransition: true,
-            }}
-        >
-            <Routes>
-                <Route
-                    path='/graphspace/:graphspace/graph/:graph/detail'
-                    element={<GraphDetail />}
-                />
-            </Routes>
-        </MemoryRouter>
-    );
-
-    expect(await screen.findAllByText('312')).not.toHaveLength(0);
-    expect(screen.getAllByText('608')).not.toHaveLength(0);
-    expect(screen.getByRole('link', {name: 'Schema'}))
-        .toHaveAttribute('href', '/graphspace/DEFAULT/graph/g/meta');
-    expect(screen.getByRole('button', {name: /Query this graph/}))
-        .toBeInTheDocument();
-    expect(screen.getByRole('link', {name: 'Overview'}))
-        .toHaveAttribute('aria-current', 'page');
+test('uses a recorded snapshot without running full graph statistics', async () => {
+    readyCore();
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: 312, edgecount: 608}});
+    renderDetail();
+    expect(await screen.findByText('312')).toBeInTheDocument();
+    expect(screen.getByText('608')).toBeInTheDocument();
     expect(screen.getByText('Available')).toBeInTheDocument();
-    expect(screen.getByText('Detail')).toBeInTheDocument();
-    expect(screen.queryByText('Space - Graph - Detail')).not.toBeInTheDocument();
+    expect(api.manage.getGraphStatistic).not.toHaveBeenCalled();
+    expect(api.manage.getGraphView).not.toHaveBeenCalled();
+    expect(api.manage.updateGraphStatistic).not.toHaveBeenCalled();
 });
 
-test('turns zero statistics into an actionable empty graph journey', async () => {
-    api.manage.getGraphSpace.mockResolvedValue({status: 200, data: {nickname: 'Space'}});
-    api.manage.getGraph.mockResolvedValue({status: 200, data: {nickname: 'Graph'}});
-    api.manage.getGraphStatistic.mockResolvedValue({
-        status: 200,
-        data: {vertex_count: 0, edge_count: 0, vertices: {}, edges: {}},
+test('runs a full count only after an explicit action and keeps core navigation usable', async () => {
+    readyCore();
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: null, edgecount: null}});
+    const full = deferred();
+    api.manage.getGraphStatistic.mockReturnValue(full.promise);
+    renderDetail();
+    expect(await screen.findByText('Not collected')).toBeInTheDocument();
+    expect(api.manage.getGraphStatistic).not.toHaveBeenCalled();
+    await act(async () => { fireEvent.click(screen.getByRole('button', {name: /Collect statistics/})); });
+    expect(api.manage.getGraphStatistic).toHaveBeenCalledTimes(1);
+    expect(screen.getByText('Loading statistics')).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: /Query this graph/})).toBeEnabled();
+    expect(screen.getByRole('link', {name: 'Schema'})).toHaveAttribute('href', '/graphspace/A/graph/a/meta');
+    await act(async () => {
+        full.resolve({status: 200, data: {vertex_count: 12, edge_count: 20, vertices: {person: 12}, edges: {knows: 20}, approximate: true, scan_limit: 100000}});
     });
-
-    render(
-        <MemoryRouter
-            initialEntries={['/graphspace/DEFAULT/graph/g/detail']}
-            future={{v7_relativeSplatPath: true, v7_startTransition: true}}
-        >
-            <Routes>
-                <Route
-                    path='/graphspace/:graphspace/graph/:graph/detail'
-                    element={<GraphDetail />}
-                />
-            </Routes>
-        </MemoryRouter>
-    );
-
-    expect(await screen.findByText('This graph has no schema or data yet'))
-        .toBeInTheDocument();
-    expect(screen.getByRole('button', {name: 'Create schema'})).toBeInTheDocument();
-    expect(screen.getByRole('button', {name: 'Prepare data'})).toBeInTheDocument();
-    expect(screen.getAllByRole('button', {name: /Query this graph/}).length)
-        .toBeGreaterThan(0);
+    expect(screen.getByText('≈12')).toBeInTheDocument();
+    expect(screen.getByText('≈20')).toBeInTheDocument();
+    expect(screen.getByText('12')).toBeInTheDocument();
+    expect(screen.getByText('20')).toBeInTheDocument();
+    fireEvent.mouseEnter(screen.getByText('Available'));
+    expect(await screen.findByText('graph.detail.bounded_statistics_hint')).toBeInTheDocument();
 });
 
-test('does not represent unavailable statistics as zero and retries only statistics', async () => {
-    api.manage.getGraphSpace.mockResolvedValue({
-        status: 200,
-        data: {nickname: 'Space'},
-    });
-    api.manage.getGraph.mockResolvedValue({
-        status: 200,
-        data: {nickname: 'Graph'},
-    });
-    api.manage.getGraphStatistic
-        .mockResolvedValueOnce({status: 503})
-        .mockResolvedValueOnce({
-            status: 200,
-            data: {vertex_count: 12, edge_count: 20, vertices: {}, edges: {}},
-        });
-
-    render(
-        <MemoryRouter
-            initialEntries={['/graphspace/DEFAULT/graph/g/detail']}
-            future={{
-                v7_relativeSplatPath: true,
-                v7_startTransition: true,
-            }}
-        >
-            <Routes>
-                <Route
-                    path='/graphspace/:graphspace/graph/:graph/detail'
-                    element={<GraphDetail />}
-                />
-            </Routes>
-        </MemoryRouter>
-    );
-
-    expect(await screen.findByText('Partial')).toBeInTheDocument();
+test('shows a failed statistic read without hiding core metadata or inventing zeros', async () => {
+    readyCore();
+    api.analysis.getGraphData.mockResolvedValue({status: 503});
+    api.manage.getGraphStatistic.mockResolvedValue({status: 200, data: {vertex_count: 2, edge_count: 3}});
+    renderDetail();
+    expect(await screen.findByText('Statistics are unavailable. Retry later.')).toBeInTheDocument();
     expect(screen.queryByText('0')).not.toBeInTheDocument();
-    expect(screen.getAllByText('--')).toHaveLength(2);
-
-    await userEvent.click(screen.getByRole('button', {name: 'Retry statistics'}));
-
-    expect(await screen.findByText('12')).toBeInTheDocument();
-    expect(screen.getByText('20')).toBeInTheDocument();
-    expect(api.manage.getGraphStatistic).toHaveBeenCalledTimes(2);
+    expect(screen.getByText('Detail')).toBeInTheDocument();
+    await act(async () => { fireEvent.click(screen.getByRole('button', {name: 'Retry statistics'})); });
+    expect(await screen.findByText('2')).toBeInTheDocument();
     expect(api.manage.getGraphSpace).toHaveBeenCalledTimes(1);
     expect(api.manage.getGraph).toHaveBeenCalledTimes(1);
 });
 
-const createDeferred = () => {
-    let resolve;
-    const promise = new Promise(done => {
-        resolve = done;
-    });
-    return {promise, resolve};
-};
-
-const RouteHarness = () => {
-    const navigate = useNavigate();
-    return (
-        <>
-            <button onClick={() => navigate('/graphspace/B/graph/b/detail')}>
-                switch graph
-            </button>
-            <Routes>
-                <Route
-                    path='/graphspace/:graphspace/graph/:graph/detail'
-                    element={<GraphDetail />}
-                />
-            </Routes>
-        </>
-    );
-};
-
-test('does not claim statistics are available before the current request succeeds', async () => {
-    const statistics = createDeferred();
-    api.manage.getGraphSpace.mockResolvedValue({status: 200, data: {nickname: 'Space'}});
-    api.manage.getGraph.mockResolvedValue({status: 200, data: {nickname: 'Graph'}});
-    api.manage.getGraphStatistic.mockReturnValue(statistics.promise);
+test('ignores full statistics from the previous graph after navigation', async () => {
+    readyCore();
+    api.analysis.getGraphData.mockImplementation((space) => Promise.resolve({status: 200,
+        data: space === 'A' ? {vertexcount: null, edgecount: null} : {vertexcount: 22, edgecount: 33}}));
+    const fullA = deferred();
+    api.manage.getGraphStatistic.mockReturnValue(fullA.promise);
+    renderDetail();
+    expect(await screen.findByText('Not collected')).toBeInTheDocument();
+    await act(async () => { fireEvent.click(screen.getByRole('button', {name: /Collect statistics/})); });
+    await act(async () => { fireEvent.click(screen.getByRole('button', {name: 'Switch graph'})); });
+    expect(await screen.findByText('22')).toBeInTheDocument();
+    await act(async () => { fullA.resolve({status: 200, data: {vertex_count: 999, edge_count: 888}}); });
+    expect(screen.queryByText('999')).not.toBeInTheDocument();
+    expect(screen.getByText('22')).toBeInTheDocument();
+    expect(screen.getByRole('link', {name: 'Schema'})).toHaveAttribute('href', '/graphspace/B/graph/b/meta');
+});
 
-    render(
-        <MemoryRouter
-            initialEntries={['/graphspace/DEFAULT/graph/g/detail']}
-            future={{v7_relativeSplatPath: true, v7_startTransition: true}}
-        >
-            <Routes>
-                <Route
-                    path='/graphspace/:graphspace/graph/:graph/detail'
-                    element={<GraphDetail />}
-                />
-            </Routes>
-        </MemoryRouter>
-    );
+test('renders one actionable core metadata error', async () => {
+    api.manage.getGraphSpace.mockRejectedValue(new Error('connection refused'));
+    api.manage.getGraph.mockRejectedValue(new Error('connection refused'));
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: null, edgecount: null}});
+    renderDetail();
+    expect(await screen.findByText('Graph details are unavailable. Check the server and retry.')).toBeInTheDocument();
+    expect(screen.getAllByRole('alert')).toHaveLength(1);
+    expect(screen.getByRole('link', {name: 'Back to graphs'})).toHaveAttribute('href', '/graphspace/A');
+});
 
-    expect(await screen.findByText('Loading')).toBeInTheDocument();
-    expect(screen.queryByText('Available')).not.toBeInTheDocument();
 
+test('zero recorded counts keep the normal page while schema loads and when schema already exists', async () => {
+    readyCore();
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: '0', edgecount: '0'}});
+    const schema = deferred();
+    api.manage.getGraphView.mockReturnValue(schema.promise);
+    renderDetail();
+    expect(await screen.findByText('Total vertices')).toBeInTheDocument();
+    expect(screen.queryByText('This graph has no schema or data yet')).not.toBeInTheDocument();
+    expect(screen.getByRole('button', {name: /Query this graph/})).toBeEnabled();
     await act(async () => {
-        statistics.resolve({status: 200, data: {vertex_count: 1, edge_count: 2}});
-        await statistics.promise;
+        schema.resolve({status: 200, data: {vertices: [{id: 'person'}], edges: []}});
     });
-    expect(await screen.findByText('Available')).toBeInTheDocument();
+    expect(screen.getByText('Total vertices')).toBeInTheDocument();
+    expect(screen.queryByText('This graph has no schema or data yet')).not.toBeInTheDocument();
+    expect(api.manage.getGraphStatistic).not.toHaveBeenCalled();
 });
 
-test('keeps a failed route body free of stale graph identity', async () => {
-    const graphspaceB = createDeferred();
-    const graphB = createDeferred();
-    api.manage.getGraphSpace
-        .mockResolvedValueOnce({status: 200, data: {nickname: 'Space A'}})
-        .mockReturnValueOnce(graphspaceB.promise);
-    api.manage.getGraph
-        .mockResolvedValueOnce({status: 200, data: {nickname: 'Graph A'}})
-        .mockReturnValueOnce(graphB.promise);
-    api.manage.getGraphStatistic.mockResolvedValue({status: 200, data: {}});
-
-    render(
-        <MemoryRouter
-            initialEntries={['/graphspace/A/graph/a/detail']}
-            future={{v7_relativeSplatPath: true, v7_startTransition: true}}
-        >
-            <RouteHarness />
-        </MemoryRouter>
-    );
-
-    expect(await screen.findByText('Detail')).toBeInTheDocument();
-    await userEvent.click(screen.getByRole('button', {name: 'switch graph'}));
-    expect(screen.queryByText(/Space A|Graph A/)).not.toBeInTheDocument();
-
+test('shows empty graph guidance only after schema is confirmed empty', async () => {
+    readyCore();
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {vertexcount: '0', edgecount: '0'}});
+    const schema = deferred();
+    api.manage.getGraphView.mockReturnValue(schema.promise);
+    renderDetail();
+    expect(await screen.findByText('Total vertices')).toBeInTheDocument();
+    expect(screen.queryByText('This graph has no schema or data yet')).not.toBeInTheDocument();
     await act(async () => {
-        graphspaceB.resolve({status: 503});
-        graphB.resolve({status: 503});
-        await Promise.all([graphspaceB.promise, graphB.promise]);
+        schema.resolve({status: 200, data: {vertices: [], edges: []}});
     });
-
-    expect(await screen.findByText(
-        'Graph details are unavailable. Check the server and retry.'
-    )).toBeInTheDocument();
-    expect(screen.queryByText(/Space A|Graph A/)).not.toBeInTheDocument();
+    expect(screen.getByText('This graph has no schema or data yet')).toBeInTheDocument();
+    expect(api.manage.getGraphStatistic).not.toHaveBeenCalled();
 });
 
-test('ignores an update completion after navigating to another graph', async () => {
-    const update = createDeferred();
-    const statisticsA = createDeferred();
-    const statisticsB = createDeferred();
-    api.manage.getGraphSpace.mockImplementation(space => Promise.resolve({
-        status: 200,
-        data: {nickname: `Space ${space}`},
-    }));
-    api.manage.getGraph.mockImplementation((space, graph) => Promise.resolve({
-        status: 200,
-        data: {nickname: `Graph ${graph}`},
-    }));
-    api.manage.getGraphStatistic
-        .mockReturnValueOnce(statisticsA.promise)
-        .mockReturnValueOnce(statisticsB.promise);
-    api.manage.updateGraphStatistic.mockReturnValue(update.promise);
-
-    render(
-        <MemoryRouter
-            initialEntries={['/graphspace/A/graph/a/detail']}
-            future={{v7_relativeSplatPath: true, v7_startTransition: true}}
-        >
-            <RouteHarness />
-        </MemoryRouter>
-    );
 
-    expect(await screen.findByText('Detail')).toBeInTheDocument();
-    await act(async () => {
-        statisticsA.resolve({status: 200, data: {vertex_count: 1, edge_count: 2}});
-        await statisticsA.promise;
-    });
-    expect(await screen.findByText('1')).toBeInTheDocument();
-    await userEvent.click(screen.getByRole('button', {name: /Update Data/}));
-    await userEvent.click(screen.getByRole('button', {name: 'switch graph'}));
-    await waitFor(() => expect(screen.getByRole('link', {name: 'Schema'}))
-        .toHaveAttribute('href', '/graphspace/B/graph/b/meta'));
-    await act(async () => {
-        statisticsB.resolve({status: 200, data: {vertex_count: 22, edge_count: 2}});
-        await statisticsB.promise;
-    });
-    expect(await screen.findByText('22')).toBeInTheDocument();
+test('marks bounded snapshot totals consistently with graph cards and canvas counts', async () => {
+    readyCore();
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {
+        vertexcount: '100000', edgecount: '20', approximate: true, scan_limit: 100000,
+    }});
+    renderDetail();
+    expect(await screen.findByText('≥100000')).toBeInTheDocument();
+    expect(screen.getByText('≈20')).toBeInTheDocument();
+    expect(api.manage.getGraphStatistic).not.toHaveBeenCalled();
+    expect(api.manage.getGraphView).not.toHaveBeenCalled();
+});
 
-    await act(async () => {
-        update.resolve({status: 200});
-        await update.promise;
-    });
-    await waitFor(() => expect(message.success).not.toHaveBeenCalled());
-    expect(api.manage.getGraphStatistic).toHaveBeenCalledTimes(2);
-    expect(screen.getByText('22')).toBeInTheDocument();
+test('an approximate zero remains visible without declaring the current graph empty', async () => {
+    readyCore();
+    api.analysis.getGraphData.mockResolvedValue({status: 200, data: {
+        vertexcount: '0', edgecount: '0', approximate: true, scan_limit: 100000,
+    }});
+    api.manage.getGraphView.mockResolvedValue({status: 200, data: {vertices: [], edges: []}});
+    renderDetail();
+
+    expect(await screen.findAllByText('≈0')).toHaveLength(2);
+    expect(screen.getByText('Total vertices')).toBeInTheDocument();
+    expect(screen.queryByText('This graph has no schema or data yet')).not.toBeInTheDocument();
+    expect(screen.getByRole('button', {name: /Query this graph/})).toBeEnabled();
+    expect(api.manage.getGraphView).not.toHaveBeenCalled();
+    expect(api.manage.getGraphStatistic).not.toHaveBeenCalled();
 });
diff --git a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js
index 2176e489d..993529f6d 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.js
@@ -24,6 +24,7 @@ import style from './index.module.scss';
 import {useCallback} from 'react';
 import {useTranslation} from 'react-i18next';
 import {getResourceDisplayName} from '../../utils/displayName';
+import {isKnownCount} from '../../utils/graphStatistics';
 import {formatResourceLimit, formatStorageUsed, formatStorageLimit,
     storageUsagePercent, formatComputeLimit} from './storageUsage';
 
@@ -144,6 +145,7 @@ const GraphSpaceCard = ({
     const memory = formatResourceLimit(item.memory_limit, t('graphspace.unit.memory'), unlimited);
     const storage = formatStorageLimit(item.storage_limit, unlimited);
     const statisticDate = item.statistic?.date;
+    const hasStatistics = isKnownCount(item.statistic?.vertex) && isKnownCount(item.statistic?.edge);
 
     return (
         <Card styles={{header: {
@@ -173,11 +175,10 @@ const GraphSpaceCard = ({
                     </Dropdown>
                 </Space.Compact>
             )}
-            actions={
-                [
+            actions={hasStatistics ? [
                     <Space key="1" size={8} wrap>
-                        <span>{t('graphspace.card.vertex')}: {item.statistic?.vertex ?? '-'}</span>
-                        <span>{t('graphspace.card.edge')}: {item.statistic?.edge ?? '-'}</span>
+                        <span>{t('graphspace.card.vertex')}: {item.statistic.vertex}</span>
+                        <span>{t('graphspace.card.edge')}: {item.statistic.edge}</span>
                         <Typography.Text type='secondary'>
                             {statisticDate
                                 ? t('graphspace.card.daily_snapshot', {date: statisticDate})
@@ -200,8 +201,7 @@ const GraphSpaceCard = ({
                             </Tooltip>
                         </span>
                     </Space>,
-                ]
-            }
+                ] : undefined}
         >
             <div
                 className={style.card_content}
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 7c6fa10bc..9f44277c9 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/GraphSpace/Card.test.js
@@ -84,6 +84,8 @@ test('does not expose a default GraphSpace mutation action', async () => {
     expect(screen.queryByText('Default')).not.toBeInTheDocument();
     expect(screen.getByText('graphspace.card.daily_snapshot')).toBeInTheDocument();
     expect(screen.getByLabelText('graphspace.card.statistic_help')).toBeInTheDocument();
+    expect(screen.getByText('graphspace.card.vertex: 0')).toBeInTheDocument();
+    expect(screen.getByText('graphspace.card.edge: 0')).toBeInTheDocument();
 });
 
 test('ships a localized default GraphSpace name in both languages', () => {
@@ -131,10 +133,11 @@ test.each([0, 1024])('keeps full or exceeded quota in the exception state (%s KB
     expect(screen.getByRole('img', {name: 'info'})).toBeInTheDocument();
 });
 
-test('falls back to the daily-update label when statistics are unavailable', () => {
+test('omits unavailable totals without claiming that no snapshot exists', () => {
     const item = {
         name: 'empty',
         nickname: 'Empty',
+        statistic: {},
         create_time: '2026-07-10',
         auth: false,
         max_graph_number: 10,
@@ -154,9 +157,11 @@ test('falls back to the daily-update label when statistics are unavailable', ()
         />
     );
 
-    expect(screen.getByText('graphspace.card.vertex: -')).toBeInTheDocument();
-    expect(screen.getByText('graphspace.card.edge: -')).toBeInTheDocument();
-    expect(screen.getByText('graphspace.card.daily_update')).toBeInTheDocument();
+    expect(screen.queryByText(/^graphspace.card.vertex:/)).not.toBeInTheDocument();
+    expect(screen.queryByText(/^graphspace.card.edge:/)).not.toBeInTheDocument();
+    expect(screen.queryByText('graph.detail.not_collected')).not.toBeInTheDocument();
+    expect(screen.getByTitle('Empty')).toBeInTheDocument();
+    expect(screen.getByText(/graphspace.card.max_graph/)).toHaveTextContent('10');
 });
 
 test('treats a backend nickname echo as an unset alias', () => {
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
index 78920b578..8c3648041 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
@@ -82,6 +82,12 @@ const BaseForm = ({cancel, visible, loading}) => {
         setLastDemo('');
     }, [selectedGraph, selectedGraphspace]);
 
+    useEffect(() => {
+        if (selectedGraphspace && selectedGraph) {
+            baseForm.validateFields([['ingestion_option', 'graph']]).catch(() => {});
+        }
+    }, [baseForm, selectedGraph, selectedGraphspace]);
+
     const checkExistName = () => ({
         validator: async (_, value) => {
             const res = await api.manage.getTaskList({
@@ -145,9 +151,6 @@ const BaseForm = ({cancel, visible, loading}) => {
                 const options = res.data.records.map(item => ({
                     label: getResourceDisplayName(item.name, item.nickname),
                     value: item.name,
-                    schemaReady: !item.schemaview
-                        || item.schemaview.vertices.length > 0
-                        || item.schemaview.edges.length > 0,
                 }));
                 setGraphOptions(options);
 
@@ -290,10 +293,7 @@ const BaseForm = ({cancel, visible, loading}) => {
             if (res.status !== 200) {
                 throw new Error(res.message || t('graph.sample.failed'));
             }
-            setGraphOptions(options => options.map(option => {
-                return option.value === selectedGraph
-                    ? {...option, schemaReady: true} : option;
-            }));
+            baseForm.validateFields([['ingestion_option', 'graph']]).catch(() => {});
             setDemoError('');
             message.success(t('graph.sample.success', {
                 vertices: res.data.vertices,
@@ -448,19 +448,42 @@ const BaseForm = ({cancel, visible, loading}) => {
                         </Form.Item>
                         <Form.Item
                             name={['ingestion_option', 'graph']}
+                            hasFeedback
+                            validateTrigger={[]}
                             rules={[
                                 rules.required(t('task.edit.select_graph')),
                                 {
-                                    validator: (_, value) => {
-                                        const selected = graphOptions.find(
-                                            option => option.value === value
+                                    validator: async (_, value) => {
+                                        if (!value) {
+                                            return;
+                                        }
+                                        const graphspace = baseForm.getFieldValue(
+                                            ['ingestion_option', 'graphspace']
                                         );
-                                        if (selected && !selected.schemaReady) {
-                                            return Promise.reject(
-                                                t('task.edit.prepare_schema_first')
-                                            );
+                                        if (!graphspace || !graphOptions.some(option => option.value === value)) {
+                                            throw new Error(t('task.edit.select_graph'));
+                                        }
+                                        let response;
+                                        try {
+                                            response = await api.manage.getGraphView(graphspace, value, {
+                                                suppressBusinessErrorToast: true,
+                                            });
+                                        }
+                                        catch {
+                                            throw new Error(t('graph.card.schema_unavailable'));
+                                        }
+                                        const current = baseForm.getFieldValue('ingestion_option') || {};
+                                        if (current.graphspace !== graphspace || current.graph !== value) {
+                                            throw new Error(t('task.edit.select_graph'));
+                                        }
+                                        const view = response?.data;
+                                        if (response?.status !== 200 ||
+                                            !Array.isArray(view?.vertices) || !Array.isArray(view?.edges)) {
+                                            throw new Error(t('graph.card.schema_unavailable'));
+                                        }
+                                        if (!view.vertices.length && !view.edges.length) {
+                                            throw new Error(t('task.edit.prepare_schema_first'));
                                         }
-                                        return Promise.resolve();
                                     },
                                 },
                             ]}
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/name-validation.test.js b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/name-validation.test.js
index f27a94c12..0f572d2a9 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/name-validation.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/name-validation.test.js
@@ -26,6 +26,7 @@ jest.mock('../../../api', () => ({
     manage: {
         getDatasourceList: jest.fn(),
         getGraphList: jest.fn(),
+        getGraphView: jest.fn(),
         getTaskList: jest.fn(),
     },
 }));
@@ -48,6 +49,9 @@ beforeAll(() => {
 beforeEach(() => {
     jest.clearAllMocks();
     isPdEnabled.mockReturnValue(false);
+    api.manage.getGraphView.mockResolvedValue({
+        status: 200, data: {vertices: [{name: 'person'}], edges: []},
+    });
     api.manage.getDatasourceList.mockResolvedValue({
         status: 200,
         data: {records: [{datasource_id: '9', datasource_name: 'fixture.csv'}]},
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/schema-readiness.test.js b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/schema-readiness.test.js
new file mode 100644
index 000000000..c74c4b3b9
--- /dev/null
+++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/schema-readiness.test.js
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {Form} from 'antd';
+import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {MemoryRouter} from 'react-router-dom';
+import BaseForm from './index';
+import * as api from '../../../api';
+import {isPdEnabled} from '../../../utils/config';
+
+jest.mock('../../../api', () => ({
+    manage: {
+        getDatasourceList: jest.fn(),
+        getGraphList: jest.fn(),
+        getGraphSpaceList: jest.fn(),
+        getGraphView: jest.fn(),
+        getTaskList: jest.fn(),
+    },
+}));
+jest.mock('../../../utils/config', () => ({isPdEnabled: jest.fn()}));
+jest.mock('../../../utils/rules', () => ({required: () => ({required: true}), isNoramlName: () => ({})}));
+jest.mock('react-i18next', () => ({useTranslation: () => ({t: key => key})}));
+
+const graphList = {status: 200, data: {records: [{name: 'selected'}, {name: 'other'}]}};
+const readySchema = {status: 200, data: {vertices: [{name: 'person'}], edges: []}};
+const emptySchema = {status: 200, data: {vertices: [], edges: []}};
+const inlineConfig = {suppressBusinessErrorToast: true};
+const deferred = () => {
+    let resolve;
+    const promise = new Promise(done => {resolve = done;});
+    return {promise, resolve};
+};
+const renderForm = onFormFinish => {
+    const view = render(
+        <MemoryRouter future={{v7_startTransition: true, v7_relativeSplatPath: true}}>
+            <Form.Provider onFormFinish={onFormFinish}>
+                <BaseForm visible cancel={jest.fn()} loading={false} />
+            </Form.Provider>
+        </MemoryRouter>
+    );
+    fireEvent.change(screen.getByPlaceholderText('task.edit.name_placeholder'), {
+        target: {value: 'import_task'},
+    });
+    return view;
+};
+
+beforeAll(() => {
+    window.matchMedia = window.matchMedia || (() => ({
+        matches: false, addListener: jest.fn(), removeListener: jest.fn(),
+    }));
+});
+beforeEach(() => {
+    jest.resetAllMocks();
+    localStorage.clear();
+    sessionStorage.clear();
+    localStorage.setItem('hubble_workbench_graph_context', JSON.stringify({
+        graphspace: 'DEFAULT', graph: 'selected',
+    }));
+    isPdEnabled.mockReturnValue(false);
+    api.manage.getDatasourceList.mockResolvedValue({
+        status: 200, data: {records: [{datasource_id: '9', datasource_name: 'fixture.csv'}]},
+    });
+    api.manage.getGraphList.mockResolvedValue(graphList);
+    api.manage.getGraphView.mockResolvedValue(readySchema);
+    api.manage.getTaskList.mockResolvedValue({status: 200, data: {records: []}});
+});
+
+it('does not fetch schema for graph options before a target is selected', async () => {
+    localStorage.clear();
+    renderForm(jest.fn());
+    await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalled());
+    fireEvent.mouseDown(document.querySelector('#base_form_ingestion_option_graph'));
+
+    expect(await screen.findByText('other', {selector: '.ant-select-item-option-content'})).toBeInTheDocument();
+    expect(api.manage.getGraphView).not.toHaveBeenCalled();
+});
+
+it('checks only the selected metadata-only graph and blocks submission while pending or empty', async () => {
+    const schema = deferred();
+    api.manage.getGraphView.mockReturnValue(schema.promise);
+    const finish = jest.fn();
+    renderForm(finish);
+
+    await waitFor(() => expect(api.manage.getGraphView).toHaveBeenCalledWith(
+        'DEFAULT', 'selected', inlineConfig
+    ));
+    expect(api.manage.getGraphView).toHaveBeenCalledTimes(1);
+    fireEvent.click(screen.getByRole('button', {name: 'common.action.next'}));
+    await waitFor(() => expect(api.manage.getGraphView).toHaveBeenCalledTimes(2));
+    expect(finish).not.toHaveBeenCalled();
+    await act(async () => schema.resolve(emptySchema));
+
+    expect(await screen.findByText('task.edit.prepare_schema_first')).toBeInTheDocument();
+    expect(finish).not.toHaveBeenCalled();
+    expect(api.manage.getGraphView.mock.calls.every(([, graph]) => graph === 'selected')).toBe(true);
+});
+
+it('allows submission when the selected graph schema is ready', async () => {
+    const finish = jest.fn();
+    renderForm(finish);
+    await waitFor(() => expect(api.manage.getGraphView).toHaveBeenCalledTimes(1));
+
+    fireEvent.click(screen.getByRole('button', {name: 'common.action.next'}));
+
+    await waitFor(() => expect(finish).toHaveBeenCalledWith('base_form', expect.objectContaining({
+        values: expect.objectContaining({ingestion_option: {graphspace: 'DEFAULT', graph: 'selected'}}),
+    })));
+});
+
+it.each([
+    ['request failure', () => Promise.reject(new Error('transport secret'))],
+    ['non-success response', () => Promise.resolve({status: 503})],
+    ['missing schema arrays', () => Promise.resolve({status: 200, data: {}})],
+])('blocks %s and retries readiness when the user submits again', async (_, response) => {
+    api.manage.getGraphView.mockImplementation(response);
+    const finish = jest.fn();
+    renderForm(finish);
+
+    expect(await screen.findByText('graph.card.schema_unavailable')).toBeInTheDocument();
+    fireEvent.click(screen.getByRole('button', {name: 'common.action.next'}));
+    await waitFor(() => expect(api.manage.getGraphView).toHaveBeenCalledTimes(2));
+    expect(finish).not.toHaveBeenCalled();
+    expect(screen.queryByText('transport secret')).not.toBeInTheDocument();
+
+    api.manage.getGraphView.mockResolvedValue(readySchema);
+    fireEvent.click(screen.getByRole('button', {name: 'common.action.next'}));
+    await waitFor(() => expect(finish).toHaveBeenCalledTimes(1));
+});
+
+it('ignores ready schema from a previous space with the same graph name', async () => {
+    isPdEnabled.mockReturnValue(true);
+    localStorage.setItem('hubble_workbench_graph_context', JSON.stringify({
+        graphspace: 'SPACE_A', graph: 'selected',
+    }));
+    api.manage.getGraphSpaceList.mockResolvedValue({
+        status: 200, data: {records: [{name: 'SPACE_A'}, {name: 'SPACE_B'}]},
+    });
+    const oldSchema = deferred();
+    api.manage.getGraphView.mockImplementation(space => (
+        space === 'SPACE_A' ? oldSchema.promise : Promise.resolve(emptySchema)
+    ));
+    const finish = jest.fn();
+    renderForm(finish);
+    await waitFor(() => expect(api.manage.getGraphView).toHaveBeenCalledWith(
+        'SPACE_A', 'selected', inlineConfig
+    ));
+    fireEvent.mouseDown(document.querySelector('#base_form_ingestion_option_graphspace'));
+    fireEvent.click(await screen.findByText('SPACE_B', {selector: '.ant-select-item-option-content'}));
+    await waitFor(() => expect(api.manage.getGraphList).toHaveBeenCalledWith('SPACE_B', {page_size: -1}));
+    fireEvent.mouseDown(document.querySelector('#base_form_ingestion_option_graph'));
+    fireEvent.click(await screen.findByText('selected', {selector: '.ant-select-item-option-content'}));
+    expect(await screen.findByText('task.edit.prepare_schema_first')).toBeInTheDocument();
+
+    await act(async () => oldSchema.resolve(readySchema));
+    fireEvent.click(screen.getByRole('button', {name: 'common.action.next'}));
+    await waitFor(() => expect(api.manage.getGraphView).toHaveBeenCalledWith(
+        'SPACE_B', 'selected', inlineConfig
+    ));
+    expect(screen.getByText('task.edit.prepare_schema_first')).toBeInTheDocument();
+    expect(finish).not.toHaveBeenCalled();
+});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/option-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/option-recovery.test.js
index 83efed492..756750a30 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/option-recovery.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/option-recovery.test.js
@@ -27,6 +27,7 @@ jest.mock('../../api', () => ({
     manage: {
         getDatasourceList: jest.fn(),
         getGraphList: jest.fn(),
+        getGraphView: jest.fn(),
         getGraphSpaceList: jest.fn(),
         getTaskList: jest.fn(),
         getDatasourceSchema: jest.fn(),
@@ -76,6 +77,7 @@ beforeEach(() => {
     window.localStorage.clear();
     window.sessionStorage.clear();
     isPdEnabled.mockReturnValue(false);
+    api.manage.getGraphView.mockResolvedValue({status: 200, data: {vertices: [], edges: []}});
     api.manage.getGraphList.mockResolvedValue({status: 200, data: {records: []}});
     api.manage.getTaskList.mockResolvedValue({status: 200, data: {total: 0}});
     jest.spyOn(message, 'success').mockImplementation(() => undefined);
diff --git a/hugegraph-hubble/hubble-fe/src/utils/graphOptions.js b/hugegraph-hubble/hubble-fe/src/utils/graphOptions.js
new file mode 100644
index 000000000..d8d76e11a
--- /dev/null
+++ b/hugegraph-hubble/hubble-fe/src/utils/graphOptions.js
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as api from '../api';
+
+export const GRAPH_OPTION_PAGE_SIZE = 100;
+
+const optionsPage = (response, pageNo, selected) => {
+    if (response?.status !== 200 || !Array.isArray(response.data?.records)) {
+        const error = new Error('Graph options unavailable');
+        error.status = response?.status;
+        throw error;
+    }
+    const {records} = response.data;
+    return {
+        records,
+        selected: Object.hasOwn(response.data, 'selected')
+            ? response.data.selected : records.find(item => item.name === selected) ?? null,
+        hasMore: pageNo * GRAPH_OPTION_PAGE_SIZE < Number(response.data.total ?? records.length),
+    };
+};
+
+export const loadGraphOptions = async (graphspace, {pageNo = 1, query = '', selected = ''} = {}, config = {}) => {
+    const response = await api.manage.getGraphList(graphspace, {
+        brief: true,
+        page_no: pageNo,
+        page_size: GRAPH_OPTION_PAGE_SIZE,
+        query,
+        selected,
+    }, config);
+    return optionsPage(response, pageNo, selected);
+};
+
+export const loadGraphspaceOptions = async ({pageNo = 1, query = '', selected = ''} = {}, config = {}) => {
+    const response = await api.manage.getGraphSpaceList({
+        brief: true,
+        page_no: pageNo,
+        page_size: GRAPH_OPTION_PAGE_SIZE,
+        query,
+        selected,
+    }, config);
+    return optionsPage(response, pageNo, selected);
+};
+
+export const mergeGraphOptions = (records, selected) => {
+    const unique = new Map(records.map(item => [item.name, item]));
+    if (selected && !unique.has(selected.name)) {
+        return [selected, ...unique.values()];
+    }
+    return [...unique.values()];
+};
diff --git a/hugegraph-hubble/hubble-fe/src/utils/graphOptions.test.js b/hugegraph-hubble/hubble-fe/src/utils/graphOptions.test.js
new file mode 100644
index 000000000..e8039ff5c
--- /dev/null
+++ b/hugegraph-hubble/hubble-fe/src/utils/graphOptions.test.js
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {loadGraphOptions, loadGraphspaceOptions, mergeGraphOptions} from './graphOptions';
+import * as api from '../api';
+
+jest.mock('../api', () => ({manage: {getGraphList: jest.fn(), getGraphSpaceList: jest.fn()}}));
+
+beforeEach(() => jest.clearAllMocks());
+
+test('loads one bounded page and its late selected option without aggregating all pages', async () => {
+    const records = Array.from({length: 100}, (_, index) => ({name: `graph_${index}`}));
+    const selected = {name: 'graph_10000', nickname: 'Late graph'};
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200, data: {records, total: 10001, selected},
+    });
+    const config = {signal: new AbortController().signal, suppressBusinessErrorToast: true};
+
+    const page = await loadGraphOptions('space', {query: 'Late', selected: selected.name}, config);
+
+    expect(api.manage.getGraphList).toHaveBeenCalledTimes(1);
+    expect(api.manage.getGraphList).toHaveBeenCalledWith('space', {
+        brief: true, page_no: 1, page_size: 100, query: 'Late', selected: 'graph_10000',
+    }, config);
+    expect(page).toEqual({records, selected, hasMore: true});
+    expect(mergeGraphOptions(records, selected)).toHaveLength(101);
+});
+
+test('merges a selected graph only once when its page is reached', () => {
+    const selected = {name: 'late', nickname: 'Late graph'};
+    expect(mergeGraphOptions([selected, {name: 'first'}, selected], selected))
+        .toEqual([selected, {name: 'first'}]);
+});
+
+test('preserves forbidden status for the context permission boundary', async () => {
+    api.manage.getGraphList.mockResolvedValue({status: 403});
+    await expect(loadGraphOptions('space')).rejects.toMatchObject({status: 403});
+});
+
+test('loads one GraphSpace page and its authorized late selection', async () => {
+    const records = [{name: 'first'}];
+    const selected = {name: 'space_10000', nickname: 'Late space'};
+    api.manage.getGraphSpaceList.mockResolvedValue({status: 200, data: {records, total: 10001, selected}});
+    const config = {suppressBusinessErrorToast: true};
+
+    expect(await loadGraphspaceOptions({query: 'alias', selected: selected.name}, config))
+        .toEqual({records, selected, hasMore: true});
+    expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(1);
+    expect(api.manage.getGraphSpaceList).toHaveBeenCalledWith({
+        brief: true, page_no: 1, page_size: 100, query: 'alias', selected: selected.name,
+    }, config);
+});
+
+test.each([403, 503])('rejects GraphSpace response %s without losing the status', async status => {
+    api.manage.getGraphSpaceList.mockResolvedValue({status});
+    await expect(loadGraphspaceOptions()).rejects.toMatchObject({status});
+});
diff --git a/hugegraph-hubble/hubble-fe/src/utils/graphStatistics.js b/hugegraph-hubble/hubble-fe/src/utils/graphStatistics.js
new file mode 100644
index 000000000..101292a47
--- /dev/null
+++ b/hugegraph-hubble/hubble-fe/src/utils/graphStatistics.js
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Counts can be unavailable while the graph itself is ready. Preserve the
+// statistics metadata when adapting the API names for shared canvas state.
+const toGraphCounts = data => {
+    const {vertexcount = null, edgecount = null, ...metadata} = data || {};
+    return {vertexCount: vertexcount, edgeCount: edgecount, ...metadata};
+};
+
+const isKnownCount = value => value != null && String(value).trim() !== ''
+    && Number.isFinite(Number(value)) && Number(value) >= 0;
+
+// A recorded approximate zero can outlive later graph writes. Keep displaying
+// the snapshot, but let the Server decide whether current data can be queried.
+const isKnownZeroCount = (value, statistics = {}) => !statistics?.approximate
+    && isKnownCount(value) && Number(value) === 0;
+
+const formatRecordedCount = (value, {approximate, scan_limit: scanLimit} = {}) => {
+    if (!isKnownCount(value)) return value;
+    if (scanLimit > 0 && Number(value) >= scanLimit) return `≥${value}`;
+    return approximate ? `≈${value}` : value;
+};
+
+export {toGraphCounts, isKnownCount, isKnownZeroCount, formatRecordedCount};