diff --git a/frontend/src/components/NacosServiceViewer.interaction.test.tsx b/frontend/src/components/NacosServiceViewer.interaction.test.tsx index bf0cfd67..2ef92f93 100644 --- a/frontend/src/components/NacosServiceViewer.interaction.test.tsx +++ b/frontend/src/components/NacosServiceViewer.interaction.test.tsx @@ -65,8 +65,10 @@ vi.mock('@ant-design/icons', async () => { const Icon = () => ReactModule.createElement('span', { 'data-icon': true }); return { DeleteOutlined: Icon, + DownOutlined: Icon, PlusOutlined: Icon, ReloadOutlined: Icon, + RightOutlined: Icon, }; }); @@ -160,7 +162,7 @@ const instanceHealthSwitch = (renderer: ReactTestRenderer, endpoint: string) => const instanceAction = ( renderer: ReactTestRenderer, endpoint: string, - action: 'edit' | 'deregister', + action: 'toggle-details' | 'edit' | 'deregister', ) => instanceRow(renderer, endpoint)?.find( (node) => node.type === 'button' && node.props['data-instance-action'] === action, ); @@ -262,6 +264,11 @@ describe('NacosServiceViewer interactions', () => { enabled: true, ephemeral: false, clusterName: 'EDGE', + metadata: { + version: '20260728163719', + slot: 'blue', + activeConnections: '2', + }, }, { ip: '2001:db8::8', port: 8848, @@ -295,6 +302,11 @@ describe('NacosServiceViewer interactions', () => { const row = instanceRow(renderer!, '10.0.0.8:8848'); expect(row?.props.role).toBe('listitem'); + expect(row?.props['data-instance-details-expanded']).toBe('false'); + expect( + row?.findByProps({ className: 'gn-nacos-instance-row__main' }) + .findAllByProps({ className: 'gn-nacos-instance-row__actions' }), + ).toHaveLength(1); expect(row?.findByProps({ className: 'gn-nacos-instance-row__endpoint' }).children) .toEqual(['10.0.0.8:8848']); expect(row?.findAllByType('dt').map((item) => item.children.join(''))).toEqual([ @@ -302,10 +314,43 @@ describe('NacosServiceViewer interactions', () => { 'Weight', 'Type', ]); + expect( + row?.findAllByProps({ className: 'gn-nacos-instance-row__metadata-panel' }), + ).toHaveLength(0); + expect( + instanceAction(renderer!, '10.0.0.8:8848', 'toggle-details')?.props['aria-expanded'], + ).toBe(false); + + await act(async () => { + instanceAction(renderer!, '10.0.0.8:8848', 'toggle-details')?.props.onClick(); + instanceAction(renderer!, '[2001:db8::8]:8848', 'toggle-details')?.props.onClick(); + }); + + const expandedRow = instanceRow(renderer!, '10.0.0.8:8848'); + expect(expandedRow?.props['data-instance-details-expanded']).toBe('true'); + expect( + instanceAction(renderer!, '10.0.0.8:8848', 'toggle-details')?.props['aria-expanded'], + ).toBe(true); + expect( + expandedRow?.findByProps({ className: 'gn-nacos-instance-row__metadata-count' }).children, + ).toEqual(['3 entries']); + expect( + expandedRow?.findAllByProps({ className: 'gn-nacos-instance-row__metadata-key' }) + .map((item) => item.children.join('')), + ).toEqual(['activeConnections', 'slot', 'version']); + expect( + expandedRow?.findAllByProps({ className: 'gn-nacos-instance-row__metadata-value' }) + .map((item) => item.children.join('')), + ).toEqual(['2', 'blue', '20260728163719']); + expect( + instanceRow(renderer!, '[2001:db8::8]:8848') + ?.findByProps({ className: 'gn-nacos-instance-row__metadata-empty' }).children, + ).toEqual(['This instance has no metadata']); expect(instanceHealthSwitch(renderer!, '10.0.0.8:8848')?.props['aria-label']) .toContain('10.0.0.8:8848'); expect(instanceAction(renderer!, '10.0.0.8:8848', 'edit')?.props['aria-label']) .toContain('10.0.0.8:8848'); + expect(instanceAction(renderer!, '10.0.0.8:8848', 'edit')?.props.type).toBe('text'); expect(instanceAction(renderer!, '10.0.0.8:8848', 'deregister')?.props['aria-label']) .toContain('10.0.0.8:8848'); }); diff --git a/frontend/src/components/NacosServiceViewer.tsx b/frontend/src/components/NacosServiceViewer.tsx index ddcddcd8..eee97e0d 100644 --- a/frontend/src/components/NacosServiceViewer.tsx +++ b/frontend/src/components/NacosServiceViewer.tsx @@ -15,8 +15,10 @@ import { } from 'antd'; import { DeleteOutlined, + DownOutlined, PlusOutlined, ReloadOutlined, + RightOutlined, } from '@ant-design/icons'; import RedisResizableDivider from './RedisResizableDivider'; import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; @@ -103,6 +105,13 @@ const formatNacosInstanceEndpoint = (ip: string, port: number): string => { return `${displayHost}:${port}`; }; +const getNacosInstanceMetadataEntries = ( + metadata?: Record, +): Array<[string, string]> => Object.entries(metadata || {}) + .filter(([key]) => key.trim().length > 0) + .map(([key, value]): [string, string] => [key, String(value ?? '')]) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)); + const NACOS_SERVICES_CHANGED_EVENT = 'gonavi:nacos-services-changed'; const NacosServiceViewer: React.FC = ({ @@ -176,6 +185,9 @@ const NacosServiceViewer: React.FC = ({ const [selectedServiceRaw, setSelectedServiceRaw] = useState(null); const [selectedServiceDetail, setSelectedServiceDetail] = useState(null); const [instances, setInstances] = useState([]); + const [expandedInstanceKeys, setExpandedInstanceKeys] = useState>( + () => new Set(), + ); const [serviceModalOpen, setServiceModalOpen] = useState(false); const [savingService, setSavingService] = useState(false); @@ -208,6 +220,22 @@ const NacosServiceViewer: React.FC = ({ () => (selectedServiceRaw ? parseNacosServiceName(selectedServiceRaw) : null), [selectedServiceRaw], ); + + useEffect(() => { + setExpandedInstanceKeys(new Set()); + }, [connectionId, namespaceId, selectedServiceRaw]); + + const toggleInstanceDetails = useCallback((instanceKey: string) => { + setExpandedInstanceKeys((current) => { + const next = new Set(current); + if (next.has(instanceKey)) { + next.delete(instanceKey); + } else { + next.add(instanceKey); + } + return next; + }); + }, []); const serviceRows = useMemo( () => serviceNames.map((rawName) => ({ rawName, ...parseNacosServiceName(rawName) })), [serviceNames], @@ -1024,15 +1052,33 @@ const NacosServiceViewer: React.FC = ({ ) : null} {instances.map((instance) => { const endpoint = formatNacosInstanceEndpoint(instance.ip, instance.port); + const instanceKey = `${endpoint}:${instance.clusterName || ''}`; + const detailsExpanded = expandedInstanceKeys.has(instanceKey); + const metadataEntries = getNacosInstanceMetadataEntries(instance.metadata); return (
+
+
+ + void handleDeregister(instance)} + > + + +
{tr('nacos_service.field.cluster')}
@@ -1077,35 +1154,45 @@ const NacosServiceViewer: React.FC = ({
-
-
- - void handleDeregister(instance)} - > - - +
+ {tr('nacos_service.field.instance_metadata')} + + {tr('nacos_service.field.metadata_count', { + count: metadataEntries.length, + })} + +
+ {metadataEntries.length > 0 ? ( +
    + {metadataEntries.map(([key, value]) => ( +
  • + + {key} + + + {value || '—'} + +
  • + ))} +
+ ) : ( + + {tr('nacos_service.message.instance_metadata_empty')} + + )} + + ) : null}
); diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index fd81dfde..53567111 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -8998,11 +8998,15 @@ "nacos_service.field.ephemeral": "Ephemeral", "nacos_service.field.persistent": "Persistent", "nacos_service.field.type": "Typ", + "nacos_service.field.instance_metadata": "Instanzmetadaten", + "nacos_service.field.metadata_count": "{{count}} Einträge", "nacos_service.field.protect_threshold": "Protect-Threshold", "nacos_service.action.create_service": "Service erstellen", "nacos_service.action.register_instance": "Instanz registrieren", "nacos_service.action.edit_instance": "Bearbeiten", "nacos_service.action.deregister": "Abmelden", + "nacos_service.action.expand_instance_details": "Instanzdetails aufklappen", + "nacos_service.action.collapse_instance_details": "Instanzdetails einklappen", "nacos_service.message.select_service": "Wählen Sie links einen Service, um Instanzen zu sehen", "nacos_service.message.service_create_success": "Service erstellt", "nacos_service.message.service_delete_success": "Service gelöscht", @@ -9010,6 +9014,7 @@ "nacos_service.message.instance_update_success": "Instanz aktualisiert", "nacos_service.message.instance_deregister_success": "Instanz abgemeldet", "nacos_service.message.instance_health_success": "Health aktualisiert", + "nacos_service.message.instance_metadata_empty": "Diese Instanz enthält keine Metadaten", "nacos_service.message.ephemeral_registration_unavailable": "GoNavi erneuert flüchtige Instanzen nicht; die manuelle Registrierung ist nur für persistente Services verfügbar.", "nacos_service.message.confirm_delete_service": "Service {{name}} löschen? Nur möglich, wenn keine Instanzen existieren.", "nacos_service.message.confirm_deregister": "Instanz {{ip}}:{{port}} abmelden?", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 0a90e5f1..32348b43 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -8998,11 +8998,15 @@ "nacos_service.field.ephemeral": "Ephemeral", "nacos_service.field.persistent": "Persistent", "nacos_service.field.type": "Type", + "nacos_service.field.instance_metadata": "Instance metadata", + "nacos_service.field.metadata_count": "{{count}} entries", "nacos_service.field.protect_threshold": "Protect threshold", "nacos_service.action.create_service": "Create service", "nacos_service.action.register_instance": "Register instance", "nacos_service.action.edit_instance": "Edit", "nacos_service.action.deregister": "Deregister", + "nacos_service.action.expand_instance_details": "Expand instance details", + "nacos_service.action.collapse_instance_details": "Collapse instance details", "nacos_service.message.select_service": "Select a service on the left to view instances", "nacos_service.message.service_create_success": "Service created", "nacos_service.message.service_delete_success": "Service deleted", @@ -9010,6 +9014,7 @@ "nacos_service.message.instance_update_success": "Instance updated", "nacos_service.message.instance_deregister_success": "Instance deregistered", "nacos_service.message.instance_health_success": "Health updated", + "nacos_service.message.instance_metadata_empty": "This instance has no metadata", "nacos_service.message.ephemeral_registration_unavailable": "GoNavi does not renew ephemeral instances; manual registration is only available for persistent services.", "nacos_service.message.confirm_delete_service": "Delete service {{name}}? Server only allows this when it has no instances.", "nacos_service.message.confirm_deregister": "Deregister instance {{ip}}:{{port}}?", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index b89fbcd2..df3bacff 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -8998,11 +8998,15 @@ "nacos_service.field.ephemeral": "Ephemeral", "nacos_service.field.persistent": "永続", "nacos_service.field.type": "タイプ", + "nacos_service.field.instance_metadata": "インスタンスメタデータ", + "nacos_service.field.metadata_count": "{{count}} 件", "nacos_service.field.protect_threshold": "保護閾値", "nacos_service.action.create_service": "サービス作成", "nacos_service.action.register_instance": "インスタンス登録", "nacos_service.action.edit_instance": "編集", "nacos_service.action.deregister": "抹消", + "nacos_service.action.expand_instance_details": "インスタンス詳細を展開", + "nacos_service.action.collapse_instance_details": "インスタンス詳細を折りたたむ", "nacos_service.message.select_service": "左のサービスを選択してインスタンスを表示", "nacos_service.message.service_create_success": "サービスを作成しました", "nacos_service.message.service_delete_success": "サービスを削除しました", @@ -9010,6 +9014,7 @@ "nacos_service.message.instance_update_success": "インスタンスを更新しました", "nacos_service.message.instance_deregister_success": "インスタンスを抹消しました", "nacos_service.message.instance_health_success": "ヘルスを更新しました", + "nacos_service.message.instance_metadata_empty": "このインスタンスにはメタデータがありません", "nacos_service.message.ephemeral_registration_unavailable": "GoNavi は一時インスタンスを更新しないため、手動登録は永続サービスでのみ利用できます。", "nacos_service.message.confirm_delete_service": "サービス {{name}} を削除しますか?インスタンスがない場合のみ可能です。", "nacos_service.message.confirm_deregister": "インスタンス {{ip}}:{{port}} を抹消しますか?", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index aa178e31..d3cb8b1e 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -8998,11 +8998,15 @@ "nacos_service.field.ephemeral": "Ephemeral", "nacos_service.field.persistent": "Постоянный", "nacos_service.field.type": "Тип", + "nacos_service.field.instance_metadata": "Метаданные экземпляра", + "nacos_service.field.metadata_count": "{{count}} записей", "nacos_service.field.protect_threshold": "Protect threshold", "nacos_service.action.create_service": "Создать сервис", "nacos_service.action.register_instance": "Зарегистрировать инстанс", "nacos_service.action.edit_instance": "Изменить", "nacos_service.action.deregister": "Снять", + "nacos_service.action.expand_instance_details": "Развернуть сведения об инстансе", + "nacos_service.action.collapse_instance_details": "Свернуть сведения об инстансе", "nacos_service.message.select_service": "Выберите сервис слева, чтобы увидеть инстансы", "nacos_service.message.service_create_success": "Сервис создан", "nacos_service.message.service_delete_success": "Сервис удалён", @@ -9010,6 +9014,7 @@ "nacos_service.message.instance_update_success": "Инстанс обновлён", "nacos_service.message.instance_deregister_success": "Регистрация снята", "nacos_service.message.instance_health_success": "Health обновлён", + "nacos_service.message.instance_metadata_empty": "У экземпляра нет метаданных", "nacos_service.message.ephemeral_registration_unavailable": "GoNavi не продлевает временные экземпляры; ручная регистрация доступна только для постоянных сервисов.", "nacos_service.message.confirm_delete_service": "Удалить сервис {{name}}? Только если нет инстансов.", "nacos_service.message.confirm_deregister": "Снять регистрацию {{ip}}:{{port}}?", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index b1e1d408..e904c6b1 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -8998,11 +8998,15 @@ "nacos_service.field.ephemeral": "临时", "nacos_service.field.persistent": "持久", "nacos_service.field.type": "类型", + "nacos_service.field.instance_metadata": "实例元数据", + "nacos_service.field.metadata_count": "{{count}} 项", "nacos_service.field.protect_threshold": "保护阈值", "nacos_service.action.create_service": "新建服务", "nacos_service.action.register_instance": "注册实例", "nacos_service.action.edit_instance": "编辑", "nacos_service.action.deregister": "注销", + "nacos_service.action.expand_instance_details": "展开实例详情", + "nacos_service.action.collapse_instance_details": "折叠实例详情", "nacos_service.message.select_service": "请选择左侧服务以查看实例", "nacos_service.message.service_create_success": "服务已创建", "nacos_service.message.service_delete_success": "服务已删除", @@ -9010,6 +9014,7 @@ "nacos_service.message.instance_update_success": "实例已更新", "nacos_service.message.instance_deregister_success": "实例已注销", "nacos_service.message.instance_health_success": "健康状态已更新", + "nacos_service.message.instance_metadata_empty": "该实例未上报元数据", "nacos_service.message.ephemeral_registration_unavailable": "GoNavi 不会续约临时实例,仅支持向持久服务手工注册实例。", "nacos_service.message.confirm_delete_service": "确认删除服务 {{name}}?仅当无实例时服务端才允许删除。", "nacos_service.message.confirm_deregister": "确认注销实例 {{ip}}:{{port}}?", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 05154ad0..f0d88f4b 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -8998,11 +8998,15 @@ "nacos_service.field.ephemeral": "臨時", "nacos_service.field.persistent": "持久", "nacos_service.field.type": "類型", + "nacos_service.field.instance_metadata": "實例中繼資料", + "nacos_service.field.metadata_count": "{{count}} 項", "nacos_service.field.protect_threshold": "保護閾值", "nacos_service.action.create_service": "新建服務", "nacos_service.action.register_instance": "註冊實例", "nacos_service.action.edit_instance": "編輯", "nacos_service.action.deregister": "註銷", + "nacos_service.action.expand_instance_details": "展開實例詳情", + "nacos_service.action.collapse_instance_details": "摺疊實例詳情", "nacos_service.message.select_service": "請選擇左側服務以查看實例", "nacos_service.message.service_create_success": "服務已建立", "nacos_service.message.service_delete_success": "服務已刪除", @@ -9010,6 +9014,7 @@ "nacos_service.message.instance_update_success": "實例已更新", "nacos_service.message.instance_deregister_success": "實例已註銷", "nacos_service.message.instance_health_success": "健康狀態已更新", + "nacos_service.message.instance_metadata_empty": "此實例未回報中繼資料", "nacos_service.message.ephemeral_registration_unavailable": "GoNavi 不會續約臨時執行個體,僅支援向持久服務手動註冊執行個體。", "nacos_service.message.confirm_delete_service": "確認刪除服務 {{name}}?僅當無實例時服務端才允許刪除。", "nacos_service.message.confirm_deregister": "確認註銷實例 {{ip}}:{{port}}?",