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};