This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git
commit 57dbc8606c813bd57c8cee6f8844cd01732b058b Author: dark <[email protected]> AuthorDate: Sat Aug 29 22:07:03 2026 +0800 fix(hubble): align operations and auth health - align and summarize operations source status - preserve authentication recovery flows - periodically revalidate verified authentication mode --- .../service/op/LiveOperationsCollector.java | 58 ++++++++++- .../service/op/LiveOperationsCollectorTest.java | 57 ++++++++++ hugegraph-hubble/hubble-fe/src/App.js | 20 +++- hugegraph-hubble/hubble-fe/src/App.test.js | 92 +++++++++++++++++ .../src/i18n/resources/en-US/modules/pages.json | 6 ++ .../src/i18n/resources/zh-CN/modules/pages.json | 6 ++ .../src/pages/Operations/Overview.test.js | 13 ++- .../hubble-fe/src/pages/Operations/components.js | 115 +++++++++++++++------ .../src/pages/Operations/components.test.js | 11 +- .../hubble-fe/src/pages/Operations/operations.scss | 18 ++++ .../hubble-fe/src/pages/Schema/index.js | 5 +- .../src/pages/Schema/schema-list-recovery.test.js | 25 +++++ 12 files changed, 379 insertions(+), 47 deletions(-) diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java index 98fa3a3fb..c21f1be62 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java @@ -259,7 +259,10 @@ public class LiveOperationsCollector implements OperationsCollector { now); this.mergeNodes(nodes, topology.getNodes()); facts.putAll(topology.getFacts()); - pdStatus = available(topology.getStatus(), now, + pdStatus = available(this.moreSevereStatus( + topology.getStatus(), + this.nodeStatus(topology.getNodes(), "PD")), + now, topology.getReason()); clusterParsed = true; } catch (MalformedUpstreamException e) { @@ -272,6 +275,9 @@ public class LiveOperationsCollector implements OperationsCollector { stores, now); this.mergeNodes(nodes, topology.getNodes()); facts.putAll(topology.getFacts()); + storesStatus = available(this.nodeStatus( + topology.getNodes(), "STORE"), now); + this.reconcileStoreFacts(nodes, facts); storesParsed = true; } catch (MalformedUpstreamException e) { storesStatus = malformed(now); @@ -291,6 +297,56 @@ public class LiveOperationsCollector implements OperationsCollector { sources.put("stores", storesStatus); } + private String nodeStatus(List<Node> nodes, String type) { + long total = nodes.stream() + .filter(node -> type.equals(node.getType())) + .count(); + long up = nodes.stream() + .filter(node -> type.equals(node.getType())) + .filter(node -> "UP".equals(node.getStatus())) + .count(); + long down = nodes.stream() + .filter(node -> type.equals(node.getType())) + .filter(node -> "DOWN".equals(node.getStatus())) + .count(); + if (total == 0L) { + return "UNKNOWN"; + } + if (up == total) { + return "UP"; + } + if (down == total) { + return "DOWN"; + } + return "DEGRADED"; + } + + private String moreSevereStatus(String first, String second) { + if ("DOWN".equals(first) || "DOWN".equals(second)) { + return "DOWN"; + } + if ("DEGRADED".equals(first) || "DEGRADED".equals(second)) { + return "DEGRADED"; + } + if ("UNKNOWN".equals(first) || "UNKNOWN".equals(second)) { + return "UNKNOWN"; + } + return "UP"; + } + + private void reconcileStoreFacts(List<Node> nodes, + Map<String, Long> facts) { + long stores = nodes.stream() + .filter(node -> "STORE".equals(node.getType())) + .count(); + long storesUp = nodes.stream() + .filter(node -> "STORE".equals(node.getType())) + .filter(node -> "UP".equals(node.getStatus())) + .count(); + facts.put("stores", stores); + facts.put("stores_up", storesUp); + } + private void mergeNodes(List<Node> nodes, List<Node> additions) { for (Node addition : additions) { int existingIndex = -1; diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java index dfab65386..91160cbc9 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java @@ -202,6 +202,63 @@ public class LiveOperationsCollectorTest { .allMatch(node -> "UP".equals(node.getStatus()))); } + @Test + public void testOfflinePdDegradesPdSourceWhileClusterHasQuorum() + throws IOException { + String cluster = "{\"status\":0,\"data\":{" + + "\"state\":\"Cluster_OK\"," + + "\"pdList\":[" + + "{\"restUrl\":\"http://pd-1:8620\"," + + "\"state\":\"Up\",\"role\":\"Leader\"}," + + "{\"raftUrl\":\"pd-2:8610\"," + + "\"state\":\"Offline\",\"role\":\"Follower\"}]," + + "\"pdLeader\":{\"restUrl\":\"http://pd-1:8620\"," + + "\"state\":\"Up\",\"role\":\"Leader\"}}}"; + HttpServer pd = pdServer(200, cluster, 200, stores()); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), false); + } finally { + pd.stop(0); + } + + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("DEGRADED", + snapshot.getSources().get("pd").getStatus()); + Assert.assertEquals(1L, snapshot.getNodes().stream() + .filter(node -> "PD".equals(node.getType())) + .filter(node -> "DOWN".equals(node.getStatus())) + .count()); + } + + @Test + public void testOfflineStoreDegradesStoreSource() throws IOException { + String stores = "{\"status\":0,\"data\":{\"stores\":[" + + "{\"storeId\":\"1\",\"address\":\"store-1:8500\"," + + "\"state\":\"Up\"}," + + "{\"storeId\":\"2\",\"address\":\"store-2:8500\"," + + "\"state\":\"Offline\"}]}}"; + HttpServer pd = pdServer(200, cluster(), 200, stores); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), false); + } finally { + pd.stop(0); + } + + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + Assert.assertEquals("DEGRADED", + snapshot.getSources().get("stores").getStatus()); + Assert.assertEquals(Long.valueOf(2L), + snapshot.getFacts().get("stores")); + Assert.assertEquals(Long.valueOf(1L), + snapshot.getFacts().get("stores_up")); + Assert.assertEquals(1L, snapshot.getNodes().stream() + .filter(node -> "STORE".equals(node.getType())) + .filter(node -> "DOWN".equals(node.getStatus())) + .count()); + } + @Test public void testPdNotReadyReasonSurvivesMetricsFailure() throws IOException { String notReady = cluster().replace("Cluster_OK", "Cluster_Not_Ready"); diff --git a/hugegraph-hubble/hubble-fe/src/App.js b/hugegraph-hubble/hubble-fe/src/App.js index e08c9a6c7..6ea227328 100644 --- a/hugegraph-hubble/hubble-fe/src/App.js +++ b/hugegraph-hubble/hubble-fe/src/App.js @@ -28,6 +28,7 @@ import {setConfig} from './utils/config'; import {useEffect, useState} from 'react'; const CONFIG_RETRY_DELAY_MS = 2000; +const CONFIG_REVALIDATE_DELAY_MS = 30_000; function App() { const [configReady, setConfigReady] = useState(false); @@ -36,6 +37,7 @@ function App() { useEffect(() => { let active = true; + let hasSafeConfig = false; let retryTimer; const loadConfig = () => { api.config.getConfig().then(response => { @@ -43,19 +45,27 @@ function App() { throw new Error('invalid_hubble_config'); } if (active) { + hasSafeConfig = true; setConfig(response.data); setConfigRevision(value => value + 1); setConfigReady(true); setConfigError(false); - if (response.data.server_capabilities_verified === false) { + const unverified = response.data + .server_capabilities_verified === false; + const delay = unverified + ? CONFIG_RETRY_DELAY_MS : CONFIG_REVALIDATE_DELAY_MS; + retryTimer = window.setTimeout(loadConfig, delay); + } + }).catch(() => { + if (active) { + if (hasSafeConfig) { retryTimer = window.setTimeout( loadConfig, CONFIG_RETRY_DELAY_MS ); } - } - }).catch(() => { - if (active) { - setConfigError(true); + else { + setConfigError(true); + } } }); }; diff --git a/hugegraph-hubble/hubble-fe/src/App.test.js b/hugegraph-hubble/hubble-fe/src/App.test.js index 9c0e69361..5407c8b60 100644 --- a/hugegraph-hubble/hubble-fe/src/App.test.js +++ b/hugegraph-hubble/hubble-fe/src/App.test.js @@ -122,3 +122,95 @@ test('revalidates fail-closed server capabilities until verified', async () => { .toHaveAttribute('data-auth-enabled', 'false'); jest.useRealTimers(); }); + +test('continues revalidation after a transient retry failure', async () => { + jest.useFakeTimers(); + api.config.getConfig + .mockResolvedValueOnce({ + status: 200, + data: { + pd_enabled: true, + auth_enabled: true, + server_capabilities_verified: false, + }, + }) + .mockRejectedValueOnce(new Error('temporarily unavailable')) + .mockResolvedValueOnce({ + status: 200, + data: { + pd_enabled: true, + auth_enabled: false, + server_capabilities_verified: true, + }, + }); + + render( + <MemoryRouter + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <App /> + </MemoryRouter> + ); + expect(await screen.findByTestId('app-route')).toHaveAttribute( + 'data-auth-enabled', 'true' + ); + + await act(async () => { + jest.advanceTimersByTime(2000); + }); + await waitFor(() => expect(api.config.getConfig).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByTestId('app-route')).toHaveAttribute( + 'data-auth-enabled', 'true' + ); + + await act(async () => { + jest.advanceTimersByTime(2000); + }); + await waitFor(() => expect(api.config.getConfig).toHaveBeenCalledTimes(3)); + expect(screen.getByTestId('app-route')).toHaveAttribute( + 'data-auth-enabled', 'false' + ); + jest.useRealTimers(); +}); + +test('periodically revalidates a verified authentication mode', async () => { + jest.useFakeTimers(); + api.config.getConfig + .mockResolvedValueOnce({ + status: 200, + data: { + pd_enabled: true, + auth_enabled: false, + server_capabilities_verified: true, + }, + }) + .mockResolvedValueOnce({ + status: 200, + data: { + pd_enabled: true, + auth_enabled: true, + server_capabilities_verified: true, + }, + }); + + render( + <MemoryRouter + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <App /> + </MemoryRouter> + ); + expect(await screen.findByTestId('app-route')).toHaveAttribute( + 'data-auth-enabled', 'false' + ); + + await act(async () => { + jest.advanceTimersByTime(30_000); + }); + await waitFor(() => expect(api.config.getConfig).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('app-route')).toHaveAttribute( + 'data-auth-enabled', 'true' + ); + jest.useRealTimers(); +}); 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 d5bd10cfc..4fd812f84 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 @@ -1357,6 +1357,12 @@ "availability_unsupported": "Unsupported", "availability_not_applicable": "Not applicable", "availability_malformed": "Malformed", + "source_health_normal": "Normal", + "source_health_partial": "Abnormal - Partially available", + "source_health_unavailable": "Abnormal - Unavailable", + "source_health_not_applicable": "Not applicable in this deployment", + "source_topology_status": "Node status", + "source_collection_status": "Metrics collection", "metric_scope_store_only": "{{metric}} metrics are collected from Store nodes, not {{nodeType}} nodes.", "metric_scope_backend": "Backend metrics are provided by Server and Store nodes, not {{nodeType}} nodes.", "reason_upstream_timeout": "Upstream timed out", 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 e9bf99f62..e6ea0732b 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 @@ -1357,6 +1357,12 @@ "availability_unsupported": "不支持", "availability_not_applicable": "不适用", "availability_malformed": "响应异常", + "source_health_normal": "正常", + "source_health_partial": "异常 - 部分可用", + "source_health_unavailable": "异常 - 不可用", + "source_health_not_applicable": "当前部署不适用", + "source_topology_status": "节点状态", + "source_collection_status": "指标采集", "metric_scope_store_only": "{{metric}}指标从 Store 节点采集,不会在 {{nodeType}} 节点采集。", "metric_scope_backend": "后端指标由 Server 和 Store 节点提供,不会在 {{nodeType}} 节点采集。", "reason_upstream_timeout": "上游超时", diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js index 268ab9652..c3fe5fa9b 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js @@ -80,8 +80,10 @@ test('keeps unknown and partial source states explicit', async () => { expect(await screen.findByText('Attention')).toBeInTheDocument(); expect(screen.getByRole('radiogroup', {name: 'Overview view'})) .toBeInTheDocument(); - expect(screen.getByText('Malformed')).toBeInTheDocument(); - expect(screen.getByText('Unsupported')).toBeInTheDocument(); + expect(screen.getByLabelText(/PD.*Abnormal - Unavailable.*Malformed/)) + .toBeInTheDocument(); + expect(screen.getByLabelText(/Store.*Not applicable in this deployment/)) + .toBeInTheDocument(); expect(screen.getByText(/stale/i)).toBeInTheDocument(); }); @@ -448,7 +450,8 @@ test('keeps every failed source visible when the whole cluster is down', async ( const sources = await screen.findByRole('region', {name: 'Source freshness'}); expect(within(sources).getAllByText('DOWN')).toHaveLength(3); - expect(within(sources).getAllByText('Unavailable')).toHaveLength(3); + expect(within(sources).getAllByLabelText(/Abnormal - Unavailable/)) + .toHaveLength(3); expect(screen.getByRole('region', {name: 'Items needing attention'})) .toBeInTheDocument(); }); @@ -478,8 +481,10 @@ test('keeps healthy freshness compact but preserves stale-source recovery contex renderOverview(); const sources = await screen.findByRole('region', {name: 'Source freshness'}); - expect(within(sources).getAllByText(/Last success/)).toHaveLength(1); + expect(within(sources).queryByText(/Last success/)).not.toBeInTheDocument(); expect(within(sources).getByText(/Stale/)).toBeInTheDocument(); + expect(within(sources).getByLabelText(/Stale.*Last success/)) + .toBeInTheDocument(); }); test('shows a concise healthy state when no node needs attention', async () => { diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js index 213151785..036d78954 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js @@ -65,6 +65,21 @@ const displayHealthStatus = (status, t) => ( status === 'DEGRADED' ? t('operations.status_degraded') : status ); +const sourceHealthSummary = (source, t) => { + const status = source.status ?? 'UNKNOWN'; + const availability = source.availability ?? 'UNSUPPORTED'; + if (availability === 'UNSUPPORTED') { + return t('operations.source_health_not_applicable'); + } + if (status === 'UP' && availability === 'AVAILABLE') { + return t('operations.source_health_normal'); + } + if (status === 'DOWN' || ['UNAVAILABLE', 'MALFORMED'].includes(availability)) { + return t('operations.source_health_unavailable'); + } + return t('operations.source_health_partial'); +}; + const HealthStatus = ({status = 'UNKNOWN', reason, stale = false, size = 'normal'}) => { const {t} = useTranslation(); const normalized = STATUS_ICON[status] ? status : 'UNKNOWN'; @@ -124,36 +139,78 @@ const SourceStrip = ({sources = {}, detailed = false, i18n.language, t('operations.unavailable') ) : null; - const showLastSuccess = detailed || source.stale - || source.status !== 'UP' || source.availability !== 'AVAILABLE'; + const availability = t(`operations.availability_${( + source.availability ?? 'UNSUPPORTED' + ).toLowerCase()}`, { + defaultValue: source.availability ?? 'UNSUPPORTED', + }); + const healthSummary = sourceHealthSummary(source, t); + const lastSuccess = source.last_success_at ? formatObservedAt( + source.last_success_at, + i18n.language, + t('operations.unavailable') + ) : null; + const sourceDetails = [ + healthSummary, + `${t('operations.source_topology_status')}: ${ + displayHealthStatus(source.status ?? 'UNKNOWN', t) + }`, + `${t('operations.source_collection_status')}: ${availability}`, + observed ? `${t('operations.observed_at')}: ${observed}` : null, + source.stale ? t('operations.stale') : null, + source.reason ? formatReason(source.reason, t) : null, + lastSuccess + ? `${t('operations.last_success')}: ${lastSuccess}` : null, + ].filter(Boolean).join(' · '); + const sourceLabel = displayNodeType(name === 'stores' + ? 'STORE' : name.toUpperCase()); + const sourceSummary = `${sourceLabel} ${ + displayHealthStatus(source.status ?? 'UNKNOWN', t) + } · ${sourceDetails}`; return ( - <div className='operations-source' key={name}> - <strong> - {displayNodeType(name === 'stores' - ? 'STORE' : name.toUpperCase())} - </strong> - <HealthStatus - status={source.status} - reason={source.reason} - /> - <span className='operations-source-state'> - {t(`operations.availability_${( - source.availability ?? 'UNSUPPORTED' - ).toLowerCase()}`, {defaultValue: source.availability ?? 'UNSUPPORTED'})} - {detailed && observed - ? ` · ${t('operations.observed_at')}: ${observed}` - : (age ? ` · ${age}` : '')} - {source.stale ? ` · ${t('operations.stale')}` : ''} - {source.reason ? ` · ${formatReason(source.reason, t)}` : ''} - {showLastSuccess && source.last_success_at - ? ` · ${t('operations.last_success')}: ${formatObservedAt( - source.last_success_at, - i18n.language, - t('operations.unavailable') - )}` - : ''} - </span> - </div> + <Tooltip key={name} title={detailed ? null : sourceDetails}> + <div + className='operations-source' + tabIndex={detailed ? undefined : 0} + aria-label={detailed ? undefined : sourceSummary} + > + <strong>{sourceLabel}</strong> + <HealthStatus + status={source.status} + reason={detailed ? source.reason : undefined} + /> + {!detailed && ( + <span className='operations-source-summary'> + {healthSummary} + {age ? ` · ${age}` : ''} + {source.stale + ? ` · ${t('operations.stale')}` : ''} + </span> + )} + {detailed && ( + <span className='operations-source-state'> + {healthSummary} + {` · ${t('operations.source_topology_status')}: ${ + displayHealthStatus( + source.status ?? 'UNKNOWN', t + ) + }`} + {` · ${t('operations.source_collection_status')}: ${ + availability + }`} + {observed + ? ` · ${t('operations.observed_at')}: ${observed}` + : (age ? ` · ${age}` : '')} + {source.stale ? ` · ${t('operations.stale')}` : ''} + {source.reason + ? ` · ${formatReason(source.reason, t)}` : ''} + {lastSuccess + ? ` · ${t('operations.last_success')}: ${lastSuccess}` + : ''} + </span> + )} + </div> + </Tooltip> ); })} </section> diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js index 432f8e368..e3152cf89 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js @@ -53,12 +53,11 @@ test('localizes the standalone deployment reason code', async () => { /> ); - expect(screen.getByText(/当前部署模式不支持/)).toBeInTheDocument(); - expect(screen.queryByText(/deployment mode unsupported/)).not.toBeInTheDocument(); - const statusInfo = screen.getAllByRole('img', {name: /当前无法确认/}) - .find(element => element.getAttribute('aria-label').includes('当前部署模式不支持')); - expect(statusInfo).toHaveAccessibleName(/当前部署模式不支持/); - expect(statusInfo).not.toHaveAccessibleName(/观测时间|最近成功/); + expect(screen.queryByText(/当前部署模式不支持/)).not.toBeInTheDocument(); + expect(screen.getByLabelText(/PD.*当前部署不适用.*当前部署模式不支持/)) + .toHaveAccessibleName(/当前部署模式不支持/); + expect(screen.queryByText(/deployment mode unsupported/)) + .not.toBeInTheDocument(); }); test('uses a concise Attention label and explains the degraded state', () => { diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/operations.scss b/hugegraph-hubble/hubble-fe/src/pages/Operations/operations.scss index c95a6f7dd..13232b3e9 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/operations.scss +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/operations.scss @@ -184,6 +184,16 @@ text-align: right; } +.operations-source-summary { + min-width: 0; + margin-left: auto; + overflow: hidden; + color: var(--workbench-color-text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + .operations-source-strip.is-detailed .operations-source { align-items: flex-start; flex-wrap: wrap; @@ -335,6 +345,14 @@ .operations-health { grid-column: 2; + min-width: 0; + flex-wrap: wrap; + row-gap: 2px; + white-space: normal; + + .operations-health-stale { + white-space: nowrap; + } } } diff --git a/hugegraph-hubble/hubble-fe/src/pages/Schema/index.js b/hugegraph-hubble/hubble-fe/src/pages/Schema/index.js index c6514214f..59b49307b 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Schema/index.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Schema/index.js @@ -111,6 +111,7 @@ const Schema = () => { const {canManage, canWrite} = useGraphspaceAccess(graphspace); const {context: authContext} = useAuthContext(); const username = authContext?.username; + const anonymous = authContext?.mode === 'NON_AUTH'; const templateWriteEnabled = pdMode && canWrite; const workbenchContext = readWorkbenchGraphContext(); const currentGraph = workbenchContext.graphspace === graphspace @@ -124,8 +125,8 @@ const Schema = () => { const listKey = JSON.stringify([graphspace, query, current]); const canEditTemplate = useCallback(row => ( - canManage || (canWrite && row.creator === username) - ), [canManage, canWrite, username]); + canManage || (canWrite && (anonymous || row.creator === username)) + ), [anonymous, canManage, canWrite, username]); const editSchema = useCallback(data => { if (!canEditTemplate(data)) { diff --git a/hugegraph-hubble/hubble-fe/src/pages/Schema/schema-list-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/Schema/schema-list-recovery.test.js index e1dce7c69..7230ed522 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Schema/schema-list-recovery.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Schema/schema-list-recovery.test.js @@ -239,6 +239,31 @@ it('lets read-write users manage only their own saved templates', async () => { .not.toBeInTheDocument(); }); +it('lets anonymous PD users manage saved templates', async () => { + mockAccess = {canManage: false, canWrite: true}; + mockAuthContext = {context: {mode: 'NON_AUTH'}}; + api.manage.getGraphSpace.mockResolvedValue({ + status: 200, + data: {nickname: 'Space'}, + }); + api.manage.getSchemaList.mockResolvedValue({ + status: 200, + data: { + records: [{name: 'anonymous_schema', creator: null}], + total: 1, + }, + }); + + render(<Schema />); + + expect(await screen.findByRole('button', {name: 'Create template'})) + .toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Edit anonymous_schema'})) + .toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Delete anonymous_schema'})) + .toBeInTheDocument(); +}); + it('links a non-PD read-only library without graph context to the graph overview', async () => { mockGraphspace = 'DEFAULT'; mockPdEnabled = false;
