feat(nacos): 完善分组服务发现交互

- 在服务发现树中按 Group 展示入口并向工作台传递初始筛选
- 统一解析分组服务名并在列表中展示服务与所属 Group
- 防止快速切换服务时旧实例响应覆盖当前选择
- 服务增删后刷新对应命名空间的侧栏分组并保护强制刷新顺序
- 补充服务路由、分组菜单、侧栏刷新和实例竞态测试
This commit is contained in:
Syngnat
2026-07-28 19:23:07 +08:00
parent 8472c56812
commit 4d42ce746c
16 changed files with 1046 additions and 48 deletions

View File

@@ -0,0 +1,239 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import NacosServiceViewer from './NacosServiceViewer';
const storeState = vi.hoisted(() => ({
connections: [{
id: 'nacos-1',
name: 'nacos',
config: { type: 'nacos', host: '127.0.0.1', port: 8848 },
}],
theme: 'light',
appearance: {
uiVersion: 'v2',
enabled: true,
opacity: 1,
blur: 0,
useNativeMacWindowControls: false,
},
}));
const nacosBackend = vi.hoisted(() => ({
NacosListServices: vi.fn(),
NacosListInstances: vi.fn(),
NacosDeleteService: vi.fn(),
}));
const antdState = vi.hoisted(() => ({
tableProps: [] as any[],
message: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('../i18n/provider', () => ({
useOptionalI18n: () => ({ language: 'en-US' }),
}));
vi.mock('./RedisResizableDivider', async () => {
const ReactModule = await import('react');
return { default: () => ReactModule.createElement('nacos-divider') };
});
vi.mock('@ant-design/icons', async () => {
const ReactModule = await import('react');
const Icon = () => ReactModule.createElement('span', { 'data-icon': true });
return {
DeleteOutlined: Icon,
PlusOutlined: Icon,
ReloadOutlined: Icon,
};
});
vi.mock('antd', async () => {
const ReactModule = await import('react');
const passthrough = (tag: string) => ({ children, ...props }: any) =>
ReactModule.createElement(tag, props, children);
const Form = Object.assign(
passthrough('form'),
{
Item: passthrough('form-item'),
useForm: () => [{
validateFields: vi.fn(),
resetFields: vi.fn(),
setFieldsValue: vi.fn(),
}],
},
);
return {
Button: ({ children, ...props }: any) => ReactModule.createElement('button', props, children),
Form,
Input: (props: any) => ReactModule.createElement('input', props),
InputNumber: (props: any) => ReactModule.createElement('input-number', props),
Modal: ({ open, children, ...props }: any) => open
? ReactModule.createElement('modal', props, children)
: null,
Popconfirm: ({ children, ...props }: any) =>
ReactModule.createElement('popconfirm', props, children),
Space: passthrough('space'),
Switch: (props: any) => ReactModule.createElement('switch-control', props),
Table: (props: any) => {
antdState.tableProps.push(props);
return ReactModule.createElement('nacos-table');
},
Tag: passthrough('tag'),
message: antdState.message,
};
});
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};
const deferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
};
const flushEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
};
const latestServiceTableProps = () =>
[...antdState.tableProps].reverse().find((props) => props.pagination !== false);
const latestInstanceTableProps = () =>
[...antdState.tableProps].reverse().find((props) => props.pagination === false);
describe('NacosServiceViewer instance request ordering', () => {
let renderer: ReactTestRenderer | null = null;
beforeEach(() => {
vi.clearAllMocks();
antdState.tableProps = [];
nacosBackend.NacosListServices.mockResolvedValue({
success: true,
data: {
count: 3,
pageNo: 1,
pageSize: 50,
serviceNames: ['GROUP_A@@alpha', 'GROUP_B@@beta', 'GROUP_C@@charlie'],
},
});
nacosBackend.NacosDeleteService.mockResolvedValue({ success: true });
vi.stubGlobal('window', {
go: { app: { App: nacosBackend } },
dispatchEvent: vi.fn(),
});
});
afterEach(() => {
renderer?.unmount();
renderer = null;
vi.unstubAllGlobals();
});
it('clears stale instances and ignores responses from an older service selection', async () => {
const betaResponse = deferred<any>();
const charlieResponse = deferred<any>();
nacosBackend.NacosListInstances.mockImplementation(
async (_config: unknown, payload: { serviceName: string }) => {
if (payload.serviceName === 'alpha') {
return {
success: true,
data: { hosts: [{ ip: '10.0.0.1', port: 8080, healthy: true, enabled: true, ephemeral: true }] },
};
}
if (payload.serviceName === 'beta') return betaResponse.promise;
return charlieResponse.promise;
},
);
await act(async () => {
renderer = create(
<NacosServiceViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
const serviceTable = latestServiceTableProps();
await act(async () => {
serviceTable.onRow(serviceTable.dataSource[0]).onClick();
});
await flushEffects();
expect(latestInstanceTableProps().dataSource).toEqual([
expect.objectContaining({ ip: '10.0.0.1' }),
]);
await act(async () => {
serviceTable.onRow(serviceTable.dataSource[1]).onClick();
});
expect(latestInstanceTableProps().dataSource).toEqual([]);
await act(async () => {
serviceTable.onRow(serviceTable.dataSource[2]).onClick();
});
charlieResponse.resolve({
success: true,
data: { hosts: [{ ip: '10.0.0.3', port: 8080, healthy: true, enabled: true, ephemeral: true }] },
});
await flushEffects();
expect(latestInstanceTableProps().dataSource).toEqual([
expect.objectContaining({ ip: '10.0.0.3' }),
]);
expect(latestInstanceTableProps().loading).toBe(false);
betaResponse.resolve({
success: true,
data: { hosts: [{ ip: '10.0.0.2', port: 8080, healthy: true, enabled: true, ephemeral: true }] },
});
await flushEffects();
expect(latestInstanceTableProps().dataSource).toEqual([
expect.objectContaining({ ip: '10.0.0.3' }),
]);
expect(latestInstanceTableProps().loading).toBe(false);
});
it('notifies the sidebar after a service is deleted', async () => {
nacosBackend.NacosListInstances.mockResolvedValue({ success: true, data: { hosts: [] } });
await act(async () => {
renderer = create(
<NacosServiceViewer connectionId="nacos-1" namespaceId="dev" namespaceName="dev" />,
);
});
await flushEffects();
const serviceTable = latestServiceTableProps();
const deleteAction = serviceTable.columns[1].render(undefined, serviceTable.dataSource[0]);
await act(async () => {
deleteAction.props.onConfirm();
await Promise.resolve();
await Promise.resolve();
});
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
const event = vi.mocked(window.dispatchEvent).mock.calls[0][0] as CustomEvent;
expect(event.type).toBe('gonavi:nacos-services-changed');
expect(event.detail).toEqual({ connectionId: 'nacos-1', namespaceId: 'dev' });
});
});

View File

@@ -34,6 +34,7 @@ import {
import { t, type I18nParams } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { parseNacosServiceName } from './nacosServiceName';
type ServicePage = {
count: number;
@@ -65,24 +66,22 @@ type NacosServiceViewerProps = {
connectionId: string;
namespaceId: string;
namespaceName?: string;
initialGroup?: string;
};
const parseServiceName = (raw: string): { groupName: string; serviceName: string } => {
const text = String(raw || '').trim();
if (text.includes('@@')) {
const [groupName, serviceName] = text.split('@@');
return {
groupName: groupName || 'DEFAULT_GROUP',
serviceName: serviceName || text,
};
}
return { groupName: 'DEFAULT_GROUP', serviceName: text };
type NacosServiceRow = {
rawName: string;
serviceName: string;
groupName: string;
};
const NACOS_SERVICES_CHANGED_EVENT = 'gonavi:nacos-services-changed';
const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
connectionId,
namespaceId,
namespaceName,
initialGroup,
}) => {
const connections = useStore((state) => state.connections);
const appTheme = useStore((state) => state.theme);
@@ -144,7 +143,7 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
const [serviceTotal, setServiceTotal] = useState(0);
const [pageNo, setPageNo] = useState(1);
const [pageSize] = useState(50);
const [groupFilter, setGroupFilter] = useState('');
const [groupFilter, setGroupFilter] = useState(() => String(initialGroup || '').trim());
const [selectedServiceRaw, setSelectedServiceRaw] = useState<string | null>(null);
const [instances, setInstances] = useState<NacosInstance[]>([]);
@@ -156,11 +155,24 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
// Left service list pane width; drag divider to adjust (same pattern as Redis).
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('38%');
const leftPanelRef = useRef<HTMLDivElement>(null);
const instanceRequestIdRef = useRef(0);
const selectedParsed = useMemo(
() => (selectedServiceRaw ? parseServiceName(selectedServiceRaw) : null),
() => (selectedServiceRaw ? parseNacosServiceName(selectedServiceRaw) : null),
[selectedServiceRaw],
);
const serviceRows = useMemo<NacosServiceRow[]>(
() => serviceNames.map((rawName) => ({ rawName, ...parseNacosServiceName(rawName) })),
[serviceNames],
);
const notifyServiceGroupsChanged = useCallback(() => {
window.dispatchEvent(new CustomEvent(NACOS_SERVICES_CHANGED_EVENT, {
detail: {
connectionId,
namespaceId: namespaceId || '',
},
}));
}, [connectionId, namespaceId]);
const loadServices = useCallback(
async (page = 1) => {
@@ -183,8 +195,10 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
setServiceTotal(Number(pageData.count) || names.length);
setPageNo(Number(pageData.pageNo) || page);
if (selectedServiceRaw && !names.includes(selectedServiceRaw)) {
instanceRequestIdRef.current += 1;
setSelectedServiceRaw(null);
setInstances([]);
setLoadingInstances(false);
}
} catch (error: any) {
message.error(error?.message || String(error));
@@ -198,7 +212,8 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
const loadInstances = useCallback(
async (rawServiceName: string) => {
if (!rpcConfig) return;
const parsed = parseServiceName(rawServiceName);
const parsed = parseNacosServiceName(rawServiceName);
const requestId = ++instanceRequestIdRef.current;
setLoadingInstances(true);
try {
const res = await (window as any).go.app.App.NacosListInstances(rpcConfig, {
@@ -206,6 +221,7 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
serviceName: parsed.serviceName,
groupName: parsed.groupName,
});
if (requestId !== instanceRequestIdRef.current) return;
if (!res?.success) {
message.error(res?.message || 'list instances failed');
return;
@@ -213,16 +229,26 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
const list = (res.data || {}) as InstanceList;
setInstances(Array.isArray(list.hosts) ? list.hosts : []);
} catch (error: any) {
if (requestId !== instanceRequestIdRef.current) return;
message.error(error?.message || String(error));
} finally {
setLoadingInstances(false);
if (requestId === instanceRequestIdRef.current) {
setLoadingInstances(false);
}
}
},
[rpcConfig, namespaceId],
);
useEffect(() => {
instanceRequestIdRef.current += 1;
setSelectedServiceRaw(null);
setInstances([]);
setLoadingInstances(false);
void loadServices(1);
return () => {
instanceRequestIdRef.current += 1;
};
}, [connectionId, namespaceId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleCreateService = async () => {
@@ -240,6 +266,7 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
return;
}
message.success(tr('nacos_service.message.service_create_success'));
notifyServiceGroupsChanged();
setServiceModalOpen(false);
serviceForm.resetFields();
await loadServices(1);
@@ -251,7 +278,7 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
const handleDeleteService = async (raw: string) => {
if (!rpcConfig || structureRestricted) return;
const parsed = parseServiceName(raw);
const parsed = parseNacosServiceName(raw);
try {
const res = await (window as any).go.app.App.NacosDeleteService(
rpcConfig,
@@ -264,9 +291,12 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
return;
}
message.success(tr('nacos_service.message.service_delete_success'));
notifyServiceGroupsChanged();
if (selectedServiceRaw === raw) {
instanceRequestIdRef.current += 1;
setSelectedServiceRaw(null);
setInstances([]);
setLoadingInstances(false);
}
await loadServices(pageNo);
} catch (error: any) {
@@ -478,8 +508,8 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
<Table
size="small"
loading={loadingServices}
rowKey={(row) => row.name}
dataSource={serviceNames.map((name) => ({ name }))}
rowKey={(row) => row.rawName}
dataSource={serviceRows}
pagination={{
current: pageNo,
pageSize,
@@ -489,30 +519,54 @@ const NacosServiceViewer: React.FC<NacosServiceViewerProps> = ({
}}
onRow={(record) => ({
onClick: () => {
setSelectedServiceRaw(record.name);
void loadInstances(record.name);
setSelectedServiceRaw(record.rawName);
setInstances([]);
void loadInstances(record.rawName);
},
})}
rowClassName={(record) =>
selectedServiceRaw === record.name ? 'ant-table-row-selected' : ''
selectedServiceRaw === record.rawName ? 'ant-table-row-selected' : ''
}
scroll={{ y: 'calc(100vh - 280px)' }}
columns={[
{
title: tr('nacos_service.field.service'),
dataIndex: 'name',
key: 'name',
dataIndex: 'serviceName',
key: 'serviceName',
ellipsis: true,
render: (_: unknown, row: NacosServiceRow) => (
<div style={{ minWidth: 0 }}>
<div
title={row.serviceName}
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{row.serviceName}
</div>
<div
title={row.groupName}
style={{
marginTop: 2,
color: workbenchTheme.textMuted,
fontSize: 12,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{row.groupName}
</div>
</div>
),
},
{
title: tr('nacos_viewer.action.delete'),
key: 'actions',
width: 90,
render: (_: unknown, row: { name: string }) => (
render: (_: unknown, row: NacosServiceRow) => (
<Popconfirm
title={tr('nacos_service.message.confirm_delete_service', { name: row.name })}
title={tr('nacos_service.message.confirm_delete_service', { name: row.rawName })}
disabled={structureRestricted}
onConfirm={() => void handleDeleteService(row.name)}
onConfirm={() => void handleDeleteService(row.rawName)}
>
<Button
size="small"

View File

@@ -199,6 +199,7 @@ import {
resolveV2ConnectionGroup,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
resolveNacosServicesDoubleClickAction,
shouldClearSidebarNodeChildrenOnCollapse,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
@@ -293,6 +294,42 @@ const { Search } = Input;
const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50;
const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;
const SIDEBAR_CACHED_DATABASE_TREE_LIMIT = 12;
const NACOS_SERVICES_CHANGED_EVENT = 'gonavi:nacos-services-changed';
type NacosServiceRefreshTreeNode = {
key: React.Key;
children?: NacosServiceRefreshTreeNode[];
};
export const resolveNacosServiceGroupsRefreshTarget = (
detail: unknown,
treeData: readonly NacosServiceRefreshTreeNode[],
expandedKeys: readonly React.Key[],
): { key: string; node: NacosServiceRefreshTreeNode; shouldReload: boolean } | null => {
if (!detail || typeof detail !== 'object') return null;
const eventDetail = detail as Record<string, unknown>;
const connectionId = String(eventDetail.connectionId || '').trim();
if (!connectionId) return null;
const namespaceId = String(eventDetail.namespaceId ?? '').trim();
const key = `${connectionId}-nacos-ns-${namespaceId || 'public'}-services`;
const findNode = (nodes: readonly NacosServiceRefreshTreeNode[]): NacosServiceRefreshTreeNode | null => {
for (const node of nodes) {
if (String(node.key) === key) return node;
const child = node.children?.length ? findNode(node.children) : null;
if (child) return child;
}
return null;
};
const node = findNode(treeData);
if (!node) return null;
return {
key,
node,
shouldReload: expandedKeys.some((expandedKey) => String(expandedKey) === key),
};
};
// resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers
@@ -688,6 +725,7 @@ const Sidebar: React.FC<{
const deferredV2CommandSearchValue = useDeferredValue(v2CommandSearchValue);
const [v2CommandActiveIndex, setV2CommandActiveIndex] = useState(0);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const expandedKeysRef = useRef<React.Key[]>([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
@@ -695,6 +733,15 @@ const Sidebar: React.FC<{
const loadingNodesRef = useRef<Set<string>>(new Set());
const databaseTreeTouchedAtRef = useRef<Record<string, number>>({});
const pruneLoadedDatabaseTreesRef = useRef<() => void>(() => {});
const loadNacosServiceGroupsRef = useRef<(
node: any,
options?: { force?: boolean },
) => Promise<boolean>>(async () => false);
const replaceTreeNodeChildrenRef = useRef<(
key: React.Key,
children: TreeNode[] | undefined,
dataRef?: unknown,
) => TreeNode[]>(() => []);
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const treeDragSelectSuppressUntilRef = useRef(0);
const treeDragSelectionSnapshotRef = useRef<{
@@ -707,6 +754,7 @@ const Sidebar: React.FC<{
activeContext: null,
});
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
expandedKeysRef.current = expandedKeys;
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]);
const unmatchedSavedQueries = useMemo(
@@ -1532,6 +1580,8 @@ const Sidebar: React.FC<{
await loadTables({ key, dataRef });
} else if (type === 'nacos-config-entry') {
await loadNacosConfigGroups({ key, dataRef });
} else if (type === 'nacos-services-entry') {
await loadNacosServiceGroups({ key, dataRef });
} else if (type === 'external-sql-root') {
await refreshGlobalExternalSQLRootNode(false);
} else if (type === 'table') {
@@ -1727,6 +1777,7 @@ const Sidebar: React.FC<{
|| type === 'nacos-config-entry'
|| type === 'nacos-config-group'
|| type === 'nacos-services-entry'
|| type === 'nacos-service-group'
) {
setActiveContext({
connectionId: dataRef.id,
@@ -1811,6 +1862,7 @@ const Sidebar: React.FC<{
|| type === 'nacos-config-entry'
|| type === 'nacos-config-group'
|| type === 'nacos-services-entry'
|| type === 'nacos-service-group'
) {
setActiveContext({
connectionId: dataRef.id,
@@ -1899,23 +1951,13 @@ const Sidebar: React.FC<{
...(isAll ? {} : { nacosGroup: groupName }),
});
return;
} else if (node.type === 'nacos-services-entry') {
const {
id,
nacosNamespaceId = '',
nacosNamespaceName = '',
} = node.dataRef || {};
const nsName = nacosNamespaceName || nacosNamespaceId || 'public';
const nsKey = nacosNamespaceId || 'public';
addTab({
id: `nacos-services-${id}-ns-${nsKey}`,
title: `${nsName} · services`,
type: 'nacos-services',
connectionId: id,
nacosNamespaceId: nacosNamespaceId || '',
nacosNamespaceName: nsName,
});
return;
} else if (node.type === 'nacos-services-entry' || node.type === 'nacos-service-group') {
const action = resolveNacosServicesDoubleClickAction(node);
if (action?.kind === 'open') {
addTab(action.tab);
return;
}
// Service explorer entry is a folder: fall through to expand/collapse + lazy load groups.
} else if (node.type === 'db-trigger') {
const { triggerName, triggerTableName, schemaName, dbName, id } = node.dataRef;
addTab({
@@ -2159,6 +2201,7 @@ const Sidebar: React.FC<{
loadJVMResources,
loadTables,
loadNacosConfigGroups,
loadNacosServiceGroups,
} = useSidebarTreeLoaders({
savedQueries,
tableSortPreference,
@@ -2178,6 +2221,35 @@ const Sidebar: React.FC<{
pruneLoadedDatabaseTrees();
},
});
loadNacosServiceGroupsRef.current = loadNacosServiceGroups;
replaceTreeNodeChildrenRef.current = replaceTreeNodeChildren;
useEffect(() => {
const handleNacosServicesChanged = (event: Event) => {
const target = resolveNacosServiceGroupsRefreshTarget(
(event as CustomEvent).detail,
treeDataRef.current,
expandedKeysRef.current,
);
if (!target) return;
replaceTreeNodeChildrenRef.current(target.key, undefined);
setLoadedKeys((prev) => prev.filter((key) => String(key) !== target.key));
if (!target.shouldReload) return;
void loadNacosServiceGroupsRef.current(
{ ...target.node, children: undefined },
{ force: true },
).then((loaded) => {
if (!loaded) return;
setLoadedKeys((prev) => prev.includes(target.key) ? prev : [...prev, target.key]);
});
};
window.addEventListener(NACOS_SERVICES_CHANGED_EVENT, handleNacosServicesChanged as EventListener);
return () => {
window.removeEventListener(NACOS_SERVICES_CHANGED_EVENT, handleNacosServicesChanged as EventListener);
};
}, []);
const openSchemaVisibilitySettings = useCallback((node: any) => {
const dbName = String(node?.dataRef?.dbName || node?.title || '').trim();

View File

@@ -4,6 +4,7 @@ import { setCurrentLanguage } from '../i18n';
import {
formatSidebarDriverAgentUpdateWarning,
formatSidebarRowCount,
resolveNacosServiceGroupsRefreshTarget,
} from './Sidebar';
describe('Sidebar v2 metadata', () => {
@@ -37,4 +38,50 @@ describe('Sidebar v2 metadata', () => {
}),
).toBe('ClickHouse 驱动代理需要重装');
});
it('targets the matching expanded Nacos service group cache for immediate reload', () => {
const serviceNode = {
key: 'nacos-1-nacos-ns-dev-services',
children: [{ key: 'nacos-1-nacos-ns-dev-service-group-GROUP_A' }],
};
const treeData = [{
key: 'nacos-1',
children: [{
key: 'nacos-1-nacos-ns-dev',
children: [serviceNode],
}],
}];
expect(resolveNacosServiceGroupsRefreshTarget(
{ connectionId: 'nacos-1', namespaceId: 'dev' },
treeData,
['nacos-1-nacos-ns-dev-services'],
)).toEqual({
key: 'nacos-1-nacos-ns-dev-services',
node: serviceNode,
shouldReload: true,
});
});
it('invalidates a collapsed public service group cache without reloading it', () => {
const serviceNode = {
key: 'nacos-1-nacos-ns-public-services',
children: [{ key: 'nacos-1-nacos-ns-public-service-group-DEFAULT_GROUP' }],
};
expect(resolveNacosServiceGroupsRefreshTarget(
{ connectionId: 'nacos-1', namespaceId: '' },
[{ key: 'nacos-1', children: [serviceNode] }],
[],
)).toEqual({
key: 'nacos-1-nacos-ns-public-services',
node: serviceNode,
shouldReload: false,
});
expect(resolveNacosServiceGroupsRefreshTarget(
{ connectionId: 'nacos-2', namespaceId: '' },
[{ key: 'nacos-1', children: [serviceNode] }],
[],
)).toBeNull();
});
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
import type { TabData } from '../types';
import WorkbenchTabContent from './WorkbenchTabContent';
vi.mock('antd', () => ({
Spin: () => <span data-spin="true" />,
}));
vi.mock('./NacosServiceViewer', () => ({
default: (props: Record<string, unknown>) => (
<div
data-nacos-services-viewer="true"
data-connection-id={props.connectionId}
data-namespace-id={props.namespaceId}
data-initial-group={props.initialGroup}
/>
),
}));
describe('WorkbenchTabContent Nacos service routing', () => {
it('passes the tab group to the service viewer as its initial filter', async () => {
const tab: TabData = {
id: 'nacos-services-nacos-1-ns-mkefu-dev-g-MKEFU_SERVICE',
title: 'mkefu development · MKEFU_SERVICE',
type: 'nacos-services',
connectionId: 'nacos-1',
nacosNamespaceId: 'mkefu-dev',
nacosNamespaceName: 'mkefu development',
nacosGroup: 'MKEFU_SERVICE',
};
let renderer: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(<WorkbenchTabContent tab={tab} isActive />);
await Promise.resolve();
await Promise.resolve();
});
const viewer = renderer!.root.findByProps({ 'data-nacos-services-viewer': 'true' });
expect(viewer.props['data-connection-id']).toBe('nacos-1');
expect(viewer.props['data-namespace-id']).toBe('mkefu-dev');
expect(viewer.props['data-initial-group']).toBe('MKEFU_SERVICE');
act(() => renderer!.unmount());
});
});

View File

@@ -129,6 +129,7 @@ export const WorkbenchTabContent: React.FC<WorkbenchTabContentProps> = React.mem
connectionId={tab.connectionId}
namespaceId={tab.nacosNamespaceId ?? ''}
namespaceName={tab.nacosNamespaceName}
initialGroup={tab.nacosGroup}
/>
);
} else if (tab.type === 'trigger') {

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
collectNacosServiceGroupsByPage,
NACOS_SERVICE_GROUP_PAGE_SIZE,
parseNacosServiceName,
} from './nacosServiceName';
describe('parseNacosServiceName', () => {
it('keeps a non-default group separate from the service name', () => {
expect(parseNacosServiceName('MKEFU_SERVICE@@mkefu-manage-service-http')).toEqual({
groupName: 'MKEFU_SERVICE',
serviceName: 'mkefu-manage-service-http',
});
});
it('uses DEFAULT_GROUP for legacy bare service names', () => {
expect(parseNacosServiceName('orders')).toEqual({
groupName: 'DEFAULT_GROUP',
serviceName: 'orders',
});
});
});
describe('collectNacosServiceGroupsByPage', () => {
it('starts at page one with 500 rows and stops when the reported count is reached', async () => {
const calls: Array<[number, number]> = [];
const groups = await collectNacosServiceGroupsByPage(async (pageNo, pageSize) => {
calls.push([pageNo, pageSize]);
if (pageNo === 1) {
return {
count: 3,
serviceNames: [
'MKEFU_SERVICE@@mkefu-manage-service-http',
'DEFAULT_GROUP@@orders',
],
};
}
return {
count: 3,
serviceNames: ['MKEFU_SERVICE@@mkefu-comm-service-http'],
};
});
expect(calls).toEqual([
[1, NACOS_SERVICE_GROUP_PAGE_SIZE],
[2, NACOS_SERVICE_GROUP_PAGE_SIZE],
]);
expect(groups).toEqual(['DEFAULT_GROUP', 'MKEFU_SERVICE']);
});
it('deduplicates and stably sorts groups until an empty page when count is absent', async () => {
const calls: Array<[number, number]> = [];
const groups = await collectNacosServiceGroupsByPage(async (pageNo, pageSize) => {
calls.push([pageNo, pageSize]);
if (pageNo === 1) {
return {
serviceNames: [
'ZETA@@z-service',
'MKEFU_SERVICE@@manage',
'orders',
],
};
}
if (pageNo === 2) {
return {
serviceNames: [
'MKEFU_SERVICE@@comm',
'',
],
};
}
return { serviceNames: [] };
});
expect(calls).toEqual([
[1, NACOS_SERVICE_GROUP_PAGE_SIZE],
[2, NACOS_SERVICE_GROUP_PAGE_SIZE],
[3, NACOS_SERVICE_GROUP_PAGE_SIZE],
]);
expect(groups).toEqual(['DEFAULT_GROUP', 'MKEFU_SERVICE', 'ZETA']);
});
});

View File

@@ -0,0 +1,69 @@
export type NacosServiceIdentity = {
groupName: string;
serviceName: string;
};
export type NacosServiceNamePage = {
count?: number;
serviceNames?: unknown[];
};
export type NacosServiceNamePageFetcher = (
pageNo: number,
pageSize: number,
) => Promise<NacosServiceNamePage | null | undefined>;
export const NACOS_SERVICE_GROUP_PAGE_SIZE = 500;
export const parseNacosServiceName = (raw: string): NacosServiceIdentity => {
const text = String(raw || '').trim();
const separator = text.indexOf('@@');
if (separator >= 0) {
const groupName = text.slice(0, separator).trim() || 'DEFAULT_GROUP';
const serviceName = text.slice(separator + 2).trim();
return { groupName, serviceName: serviceName || text };
}
return { groupName: 'DEFAULT_GROUP', serviceName: text };
};
const sortNacosServiceGroups = (groups: Iterable<string>): string[] => (
Array.from(groups).sort((left, right) => {
if (left < right) return -1;
if (left > right) return 1;
return 0;
})
);
export const collectNacosServiceGroupsByPage = async (
fetchPage: NacosServiceNamePageFetcher,
pageSize = NACOS_SERVICE_GROUP_PAGE_SIZE,
): Promise<string[]> => {
const normalizedPageSize = Number.isFinite(pageSize) && pageSize > 0
? Math.floor(pageSize)
: NACOS_SERVICE_GROUP_PAGE_SIZE;
const groups = new Set<string>();
let pageNo = 1;
let loadedServiceCount = 0;
while (true) {
const page = await fetchPage(pageNo, normalizedPageSize);
const serviceNames = Array.isArray(page?.serviceNames) ? page.serviceNames : [];
for (const rawName of serviceNames) {
const identity = parseNacosServiceName(String(rawName ?? ''));
if (identity.serviceName) {
groups.add(identity.groupName);
}
}
loadedServiceCount += serviceNames.length;
const total = Number(page?.count);
const reachedTotal = Number.isFinite(total) && total >= 0 && loadedServiceCount >= total;
if (serviceNames.length === 0 || reachedTotal) {
break;
}
pageNo += 1;
}
return sortNacosServiceGroups(groups);
};

View File

@@ -335,7 +335,7 @@ export const parseV2CommandSearchQuery = (value: unknown): V2CommandSearchQuery
/**
* shouldLoadSidebarNodeOnExpand 判断节点展开时是否需要懒加载子节点。
* 仅 connection/database/external-sql-root/table/jvm-mode/jvm-resource 类型且无已加载 children 时返回 true。
* 仅可懒加载的目录类型且无已加载 children 时返回 true。
*/
export const shouldLoadSidebarNodeOnExpand = (
node: Pick<SidebarNodeLike, 'type' | 'children' | 'isLeaf'> | null | undefined,
@@ -347,5 +347,6 @@ export const shouldLoadSidebarNodeOnExpand = (
|| node.type === 'table'
|| node.type === 'jvm-mode'
|| node.type === 'jvm-resource'
|| node.type === 'nacos-config-entry';
|| node.type === 'nacos-config-entry'
|| node.type === 'nacos-services-entry';
};

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { buildSidebarLegacyNodeMenuItems } from './sidebarLegacyNodeMenu';
describe('Nacos service group context menu', () => {
it('opens the selected service group with its group filter', () => {
const addTab = vi.fn();
const items = buildSidebarLegacyNodeMenuItems({
type: 'nacos-service-group',
dataRef: {
id: 'nacos-1',
nacosNamespaceId: 'mkefu-dev',
nacosNamespaceName: 'mkefu development',
nacosGroup: 'MKEFU_SERVICE',
},
}, { addTab }) as any[];
expect(items).toHaveLength(1);
expect(items[0]?.key).toBe('open-nacos-service-group');
items[0]?.onClick?.();
expect(addTab).toHaveBeenCalledWith(expect.objectContaining({
id: 'nacos-services-nacos-1-ns-mkefu-dev-g-MKEFU_SERVICE',
type: 'nacos-services',
nacosGroup: 'MKEFU_SERVICE',
}));
});
it('does not attach a group filter to the all-services node', () => {
const addTab = vi.fn();
const items = buildSidebarLegacyNodeMenuItems({
type: 'nacos-service-group',
dataRef: {
id: 'nacos-1',
nacosNamespaceId: 'mkefu-dev',
nacosNamespaceName: 'mkefu development',
nacosGroup: '',
},
}, { addTab }) as any[];
items[0]?.onClick?.();
const tab = addTab.mock.calls[0]?.[0];
expect(tab?.id).toBe('nacos-services-nacos-1-ns-mkefu-dev');
expect(tab).not.toHaveProperty('nacosGroup');
});
});

View File

@@ -46,6 +46,7 @@ import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { supportsTableTruncateAction } from '../tableDataDangerActions';
import { normalizeConnectionEnvironmentType } from '../../utils/connectionEnvironment';
import { noAutoCapInputProps } from '../../utils/inputAutoCap';
import { buildNacosServicesTabData } from '../sidebarV2Utils';
type NacosNamespaceFormMode = 'create' | 'edit';
@@ -918,6 +919,15 @@ export const buildSidebarLegacyNodeMenuItems = (
},
},
];
} else if (node.type === 'nacos-service-group') {
return [
{
key: 'open-nacos-service-group',
label: t('nacos_service.title.service_explorer'),
icon: <CloudOutlined />,
onClick: () => addTab(buildNacosServicesTabData(node.dataRef || {})),
},
];
} else if (node.type === 'redis-db') {
// Redis database menu
const { id, redisDB } = node.dataRef;

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useSidebarTreeLoaders } from './useSidebarTreeLoaders';
const mocks = vi.hoisted(() => ({
replaceTreeNodeChildren: vi.fn(),
setLoadedKeys: vi.fn(),
storeState: {
connections: [] as any[],
tableSortPreference: {} as Record<string, string>,
tableAccessCount: {} as Record<string, number>,
pinnedSidebarTables: [] as string[],
},
}));
vi.mock('antd', () => ({
message: {
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock('../../store', async () => {
const actual = await vi.importActual<typeof import('../../store')>('../../store');
const useStore = Object.assign(vi.fn(), {
getState: () => mocks.storeState,
});
return { ...actual, useStore };
});
vi.mock('../../../wailsjs/go/app/App', () => ({
DBGetDatabases: vi.fn(),
DBGetTables: vi.fn(),
DBQuery: vi.fn(),
GetDriverStatusList: vi.fn(),
JVMProbeCapabilities: vi.fn(),
}));
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};
const deferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
};
describe('useSidebarTreeLoaders Nacos service groups', () => {
let renderer: ReactTestRenderer | null = null;
beforeEach(() => {
vi.clearAllMocks();
mocks.replaceTreeNodeChildren.mockImplementation((_key, children) => children || []);
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
vi.unstubAllGlobals();
});
it('keeps a forced refresh result when an older group request resolves later', async () => {
const oldResponse = deferred<any>();
const refreshedResponse = deferred<any>();
const listServices = vi.fn()
.mockReturnValueOnce(oldResponse.promise)
.mockReturnValueOnce(refreshedResponse.promise);
vi.stubGlobal('window', {
go: { app: { App: { NacosListServices: listServices } } },
});
let loaders: ReturnType<typeof useSidebarTreeLoaders> | undefined;
const loadingNodesRef = { current: new Set<string>() };
const Harness = () => {
loaders = useSidebarTreeLoaders({
savedQueries: [],
tableSortPreference: {},
tableAccessCount: {},
pinnedSidebarTables: [],
isV2Ui: true,
loadingNodesRef,
setConnectionStates: vi.fn(),
setLoadedKeys: mocks.setLoadedKeys,
replaceTreeNodeChildren: mocks.replaceTreeNodeChildren,
buildRuntimeConfig: (conn) => conn.config,
buildJVMRuntimeConfig: (conn) => conn.config,
buildJVMDiagnosticTreeNodes: () => [],
resolveSavedQueryDisplayName: (name) => String(name || ''),
});
return null;
};
act(() => {
renderer = create(<Harness />);
});
const node = {
key: 'nacos-1-nacos-ns-dev-services',
dataRef: {
id: 'nacos-1',
nacosNamespaceId: 'dev',
nacosNamespaceName: 'Development',
config: { type: 'nacos', host: '127.0.0.1', port: 8848 },
},
};
const oldLoad = loaders!.loadNacosServiceGroups(node);
const refreshedLoad = loaders!.loadNacosServiceGroups(node, { force: true });
refreshedResponse.resolve({
success: true,
data: { count: 1, serviceNames: ['NEW_GROUP@@orders'] },
});
await act(async () => {
expect(await refreshedLoad).toBe(true);
});
expect(mocks.replaceTreeNodeChildren).toHaveBeenCalledTimes(1);
expect(mocks.replaceTreeNodeChildren.mock.calls[0][1].map((item: any) => item.dataRef.nacosGroup))
.toEqual(['', 'NEW_GROUP']);
oldResponse.resolve({
success: true,
data: { count: 1, serviceNames: ['OLD_GROUP@@legacy'] },
});
await act(async () => {
expect(await oldLoad).toBe(false);
});
expect(mocks.replaceTreeNodeChildren).toHaveBeenCalledTimes(1);
expect(loadingNodesRef.current.size).toBe(0);
});
});

View File

@@ -62,6 +62,7 @@ import {
} from './sidebarPartitions';
import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities } from '../../../wailsjs/go/app/App';
import type { SidebarTableMetadataSnapshot } from '../../utils/sidebarTableMetadata';
import { collectNacosServiceGroupsByPage } from '../nacosServiceName';
type DriverStatusSnapshot = {
type: string;
@@ -188,6 +189,7 @@ export const useSidebarTreeLoaders = ({
items: Record<string, DriverStatusSnapshot>;
} | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const nacosServiceGroupRequestIdsRef = useRef<Record<string, number>>({});
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
const cached = driverStatusCacheRef.current;
@@ -426,7 +428,7 @@ export const useSidebarTreeLoaders = ({
icon: <CloudOutlined style={{ color: '#13C2C2' }} />,
type: 'nacos-services-entry' as const,
dataRef: nsDataRef,
isLeaf: true,
isLeaf: false,
},
],
};
@@ -1308,10 +1310,89 @@ export const useSidebarTreeLoaders = ({
}
};
const loadNacosServiceGroups = async (
node: any,
options: { force?: boolean } = {},
): Promise<boolean> => {
const dataRef = node?.dataRef || {};
const connectionId = String(dataRef.id || '');
const namespaceId = String(dataRef.nacosNamespaceId ?? '');
const namespaceName = String(dataRef.nacosNamespaceName || namespaceId || 'public');
const nodeKeyId = namespaceId || 'public';
const loadKey = `nacos-service-groups-${connectionId}-${nodeKeyId}`;
if (!connectionId) return false;
if (loadingNodesRef.current.has(loadKey) && !options.force) return false;
const requestId = (nacosServiceGroupRequestIdsRef.current[loadKey] || 0) + 1;
nacosServiceGroupRequestIdsRef.current[loadKey] = requestId;
loadingNodesRef.current.add(loadKey);
try {
const rpcConfig = buildRpcConnectionConfig(dataRef.config || {});
const groups = await collectNacosServiceGroupsByPage(async (pageNo, pageSize) => {
const res = await (window as any).go.app.App.NacosListServices(rpcConfig, {
namespaceId,
groupName: '',
pageNo,
pageSize,
});
if (!res?.success) {
throw new Error(res?.message || 'list service groups failed');
}
return res.data || {};
});
if (nacosServiceGroupRequestIdsRef.current[loadKey] !== requestId) {
return false;
}
const allNode: TreeNode = {
title: t('nacos_viewer.label.all'),
key: `${connectionId}-nacos-ns-${nodeKeyId}-service-group-__all__`,
icon: <AppstoreOutlined style={{ color: '#13C2C2' }} />,
type: 'nacos-service-group',
dataRef: {
...dataRef,
nacosNamespaceId: namespaceId,
nacosNamespaceName: namespaceName,
nacosGroup: '',
},
isLeaf: true,
};
const groupNodes: TreeNode[] = groups.map((group) => ({
title: group,
key: `${connectionId}-nacos-ns-${nodeKeyId}-service-group-${encodeURIComponent(group)}`,
icon: <FolderOpenOutlined style={{ color: '#13C2C2' }} />,
type: 'nacos-service-group',
dataRef: {
...dataRef,
nacosNamespaceId: namespaceId,
nacosNamespaceName: namespaceName,
nacosGroup: group,
},
isLeaf: true,
}));
replaceTreeNodeChildren(node.key, [allNode, ...groupNodes], dataRef);
return true;
} catch (error: any) {
if (nacosServiceGroupRequestIdsRef.current[loadKey] !== requestId) {
return false;
}
message.error({
content: t('sidebar.message.connection_failed', { error: error?.message || String(error) }),
key: loadKey,
});
setLoadedKeys((prev) => prev.filter((k) => k !== node.key));
return false;
} finally {
if (nacosServiceGroupRequestIdsRef.current[loadKey] === requestId) {
loadingNodesRef.current.delete(loadKey);
}
}
};
return {
loadDatabases,
loadJVMResources,
loadTables,
loadNacosConfigGroups,
loadNacosServiceGroups,
};
};

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
buildNacosServicesTabData,
resolveNacosServicesDoubleClickAction,
shouldLoadSidebarNodeOnExpand as shouldLoadV2SidebarNodeOnExpand,
} from './sidebarV2Utils';
import { shouldLoadSidebarNodeOnExpand } from './sidebar/sidebarHelpers';
const namespaceData = {
id: 'nacos-1',
nacosNamespaceId: 'mkefu-dev',
nacosNamespaceName: 'mkefu development',
};
describe('Nacos service group navigation', () => {
it('keeps the service explorer entry as a lazy folder on double click', () => {
const entryNode = {
type: 'nacos-services-entry' as const,
children: [],
isLeaf: false,
};
expect(shouldLoadV2SidebarNodeOnExpand(entryNode)).toBe(true);
expect(shouldLoadSidebarNodeOnExpand(entryNode)).toBe(true);
expect(resolveNacosServicesDoubleClickAction({
type: 'nacos-services-entry',
dataRef: namespaceData,
})).toEqual({ kind: 'expand' });
});
it('opens the all-services tab without a group filter', () => {
const tab = buildNacosServicesTabData({
...namespaceData,
nacosGroup: '',
});
expect(tab).toMatchObject({
id: 'nacos-services-nacos-1-ns-mkefu-dev',
type: 'nacos-services',
connectionId: 'nacos-1',
nacosNamespaceId: 'mkefu-dev',
nacosNamespaceName: 'mkefu development',
});
expect(tab).not.toHaveProperty('nacosGroup');
});
it('opens a group-specific tab with an isolated id and filter', () => {
const action = resolveNacosServicesDoubleClickAction({
type: 'nacos-service-group',
dataRef: {
...namespaceData,
nacosGroup: 'MKEFU SERVICE',
},
});
expect(action).toEqual({
kind: 'open',
tab: expect.objectContaining({
id: 'nacos-services-nacos-1-ns-mkefu-dev-g-MKEFU%20SERVICE',
title: 'mkefu development · MKEFU SERVICE',
type: 'nacos-services',
nacosGroup: 'MKEFU SERVICE',
}),
});
});
});

View File

@@ -7,7 +7,7 @@ import {
buildSidebarTablePinKey,
resolveSidebarRootOrderTokens,
} from '../store';
import type { ConnectionTag, SavedConnection } from '../types';
import type { ConnectionTag, SavedConnection, TabData } from '../types';
import type { SidebarTableMetadataField } from '../utils/sidebarTableMetadata';
import { readTableAccessCount } from '../utils/tableAccessCount';
import { t } from '../i18n';
@@ -56,6 +56,7 @@ export type SidebarTreeNodeType =
| 'nacos-config-entry'
| 'nacos-config-group'
| 'nacos-services-entry'
| 'nacos-service-group'
| 'tag'
| 'jvm-mode'
| 'jvm-resource'
@@ -87,7 +88,50 @@ export const shouldLoadSidebarNodeOnExpand = (
|| node.type === 'table'
|| node.type === 'jvm-mode'
|| node.type === 'jvm-resource'
|| node.type === 'nacos-config-entry';
|| node.type === 'nacos-config-entry'
|| node.type === 'nacos-services-entry';
};
type NacosServicesTabDataRef = {
id?: unknown;
nacosNamespaceId?: unknown;
nacosNamespaceName?: unknown;
nacosGroup?: unknown;
};
export const buildNacosServicesTabData = (dataRef: NacosServicesTabDataRef): TabData => {
const connectionId = String(dataRef?.id || '').trim();
const namespaceId = String(dataRef?.nacosNamespaceId || '').trim();
const namespaceName = String(dataRef?.nacosNamespaceName || namespaceId || 'public').trim();
const groupName = String(dataRef?.nacosGroup || '').trim();
const namespaceKey = namespaceId || 'public';
return {
id: `nacos-services-${connectionId}-ns-${namespaceKey}${groupName ? `-g-${encodeURIComponent(groupName)}` : ''}`,
title: groupName
? `${namespaceName} · ${groupName}`
: `${namespaceName} · ${t('nacos_service.title.service_explorer')}`,
type: 'nacos-services',
connectionId,
nacosNamespaceId: namespaceId,
nacosNamespaceName: namespaceName,
...(groupName ? { nacosGroup: groupName } : {}),
};
};
export type NacosServicesDoubleClickAction =
| { kind: 'expand' }
| { kind: 'open'; tab: TabData }
| null;
export const resolveNacosServicesDoubleClickAction = (
node: Pick<SidebarTreeNode, 'type' | 'dataRef'> | null | undefined,
): NacosServicesDoubleClickAction => {
if (node?.type === 'nacos-services-entry') return { kind: 'expand' };
if (node?.type === 'nacos-service-group') {
return { kind: 'open', tab: buildNacosServicesTabData(node.dataRef || {}) };
}
return null;
};
export const resolveSidebarTableNameForCopy = (

View File

@@ -515,7 +515,7 @@ export interface TabData {
redisDB?: number; // Redis database index for redis tabs
nacosNamespaceId?: string; // Nacos namespace id (empty string means public)
nacosNamespaceName?: string; // Nacos namespace display name
nacosGroup?: string; // Nacos config group filter for config workbench
nacosGroup?: string; // Nacos group filter for config or service workbenches
triggerName?: string; // Trigger name for trigger tabs
triggerTableName?: string; // Trigger target table for trigger tabs
viewName?: string; // View name for view definition tabs