From ddb5bd5e1a7f5c8762835c68092ecb4fcd2fc888 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Jun 2026 12:22:20 +0100 Subject: [PATCH] Canonicalize Proxmox web interface links --- .../subsystems/frontend-primitives.md | 7 +++- .../v6/internal/subsystems/registry.json | 2 + .../internal/subsystems/unified-resources.md | 8 ++++ .../scripts/shared-template-registry.json | 21 +++++++++- .../SharedPrimitives.guardrails.test.ts | 14 +++++++ .../features/proxmox/ProxmoxNodesTable.tsx | 41 +++++-------------- .../__tests__/ProxmoxNodesTable.test.tsx | 12 +++--- .../__tests__/proxmoxHostTableModel.test.ts | 3 +- .../features/proxmox/proxmoxHostTableModel.ts | 10 ++--- 9 files changed, 70 insertions(+), 48 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index f1a886e15b..70090fa35e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2364,8 +2364,11 @@ instead of page-local table columns or one-off external-link anchors. `frontend-modern/src/components/shared/WebInterfaceNameLink.tsx` owns the resource-name link shell, new-tab safety attributes, row-click/key propagation containment, fallback text, and accessible launch labels. Workload guest rows -and standalone machine rows compose that primitive so a saved URL is opened by -clicking the resource name on every comparable runtime table. +standalone machine rows, and Proxmox node rows compose that primitive so a saved +or inferred URL is opened by clicking the resource name on every comparable +runtime table. Runtime/platform tables must not add separate `Web` columns, +page-local external-link anchors, or duplicated new-tab safety handling for +that launch affordance. Shared-template drift enforcement is registry-backed: `frontend-modern/scripts/shared-template-registry.json` is the canonical list of standardized repeated affordances, required consumers, and forbidden local diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 0912523e37..660f60bdf7 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5199,6 +5199,7 @@ "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts", "frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", @@ -5365,6 +5366,7 @@ "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts", "frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 6227655e31..ef9e71693d 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -142,6 +142,7 @@ cross-source deduplication. 120. `frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx` 121. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx` 122. `frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx` +122a. `frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts` 123. `frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx` 124. `frontend-modern/src/features/truenas/TrueNASAppsTable.tsx` 125. `frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx` @@ -222,6 +223,13 @@ affordance itself must compose `PlatformResourceDetailTableRow.tsx` contract. Future platform tables must not add page-local chevron buttons, bespoke `aria-expanded` handling, or local event-propagation variants for row detail expansion. +The split also applies to web-interface launch affordances. Unified-resource +tables own whether a row has a saved, inferred, or source-native web-interface +URL and how that URL is derived, but the visible launch affordance belongs on +the resource name through the shared `WebInterfaceNameLink` primitive. Proxmox +host table columns are governed by `proxmoxHostTableModel.ts`; that model must +not reintroduce a separate `Web` column or move the web-interface launch back +into a page-local icon cell. Product-originated resource references may arrive as registered unified resource IDs, source-specific IDs, or canonical identity aliases. The unified-resource registry owns resolving those references through diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 2265d50bdf..88812febf0 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -4,7 +4,7 @@ { "id": "runtime-web-interface-name-link", "category": "resource-launch", - "summary": "Runtime resource rows must launch saved web-interface URLs through the shared name-link template, not a page-local link or separate web column.", + "summary": "Runtime resource rows must launch saved or inferred web-interface URLs through the shared name-link template, not a page-local link or separate web column.", "canonical": { "path": "src/components/shared/WebInterfaceNameLink.tsx", "export": "WebInterfaceNameLink" @@ -15,6 +15,9 @@ }, { "path": "src/features/standalone/AgentsMachinesTable.tsx" + }, + { + "path": "src/features/proxmox/ProxmoxNodesTable.tsx" } ], "forbiddenPatterns": [ @@ -34,12 +37,26 @@ { "path": "src/features/standalone/agentMachineTableModel.ts", "patterns": ["id: 'web'", "label: 'Web'"] + }, + { + "path": "src/features/proxmox/ProxmoxNodesTable.tsx", + "patterns": [ + "target=\"_blank\"", + "rel=\"noopener noreferrer\"", + "data-proxmox-host-web-link" + ] + }, + { + "path": "src/features/proxmox/proxmoxHostTableModel.ts", + "patterns": ["id: 'web'", "label: 'Web'"] } ], "proof": [ "src/components/shared/SharedPrimitives.guardrails.test.ts", "src/features/standalone/__tests__/AgentsMachinesTable.test.tsx", - "src/components/Workloads/__tests__/GuestRow.test.tsx" + "src/components/Workloads/__tests__/GuestRow.test.tsx", + "src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx", + "src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts" ] } ], diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 8f3037be2d..f6aeb76e16 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -103,6 +103,7 @@ import dockerHostsTableSource from '@/features/docker/DockerHostsTable.tsx?raw'; import kubernetesNodesTableSource from '@/features/kubernetes/KubernetesNodesTable.tsx?raw'; import proxmoxCoverageTableSource from '@/features/proxmox/ProxmoxCoverageTable.tsx?raw'; import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw'; +import proxmoxHostTableModelSource from '@/features/proxmox/proxmoxHostTableModel.ts?raw'; import vsphereHostsTableSource from '@/features/vmware/VsphereHostsTable.tsx?raw'; import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw'; import agentMachineTableModelSource from '@/features/standalone/agentMachineTableModel.ts?raw'; @@ -925,6 +926,7 @@ describe('shared primitive guardrails', () => { rules: Array<{ id: string; canonical?: { path?: string; export?: string }; + requiredConsumers?: Array<{ path?: string }>; }>; }; const registeredRule = registry.rules.find( @@ -938,15 +940,27 @@ describe('shared primitive guardrails', () => { expect(webInterfaceNameLinkSource).toContain('rel="noopener noreferrer"'); expect(webInterfaceNameLinkSource).toContain('event.stopPropagation()'); expect(webInterfaceNameLinkSource).toContain('Open web interface for'); + expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual( + expect.arrayContaining([ + 'src/components/Workloads/GuestRow.tsx', + 'src/features/standalone/AgentsMachinesTable.tsx', + 'src/features/proxmox/ProxmoxNodesTable.tsx', + ]), + ); expect(guestRowSource).toContain('WebInterfaceNameLink'); expect(agentsMachinesTableSource).toContain('WebInterfaceNameLink'); + expect(proxmoxNodesTableSource).toContain('WebInterfaceNameLink'); expect(guestRowSource).not.toContain('target="_blank"'); expect(agentsMachinesTableSource).not.toContain('target="_blank"'); + expect(proxmoxNodesTableSource).not.toContain('target="_blank"'); expect(agentsMachinesTableSource).not.toContain('AgentMachineWebLinkCell'); expect(agentsMachinesTableSource).not.toContain('data-agent-machine-web-link'); + expect(proxmoxNodesTableSource).not.toContain('data-proxmox-host-web-link'); expect(agentMachineTableModelSource).not.toContain("id: 'web'"); expect(agentMachineTableModelSource).not.toContain("label: 'Web'"); + expect(proxmoxHostTableModelSource).not.toContain("id: 'web'"); + expect(proxmoxHostTableModelSource).not.toContain("label: 'Web'"); }); it('keeps platform table frames on the shared shell template', () => { diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index e2c84f7521..013913ca34 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -7,11 +7,11 @@ import { type Component, type JSX, } from 'solid-js'; -import ExternalLinkIcon from 'lucide-solid/icons/external-link'; import { useWebSocket } from '@/contexts/appRuntime'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { getAlertStyles } from '@/utils/alerts'; import { StatusDot } from '@/components/shared/StatusDot'; +import { WebInterfaceNameLink } from '@/components/shared/WebInterfaceNameLink'; import { ResponsiveMetricCell } from '@/components/shared/responsive'; import { NodeDrawer } from '@/components/Workloads/NodeDrawer'; import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel'; @@ -122,7 +122,7 @@ const formatPercentLabel = (value: number | null | undefined): string => { return `${Math.round(Math.max(0, normalized))}%`; }; -type HostSortKey = Exclude; +type HostSortKey = ProxmoxHostTableColumnId; const TEXT_SORT_KEYS = new Set(['node', 'version', 'cluster']); @@ -197,7 +197,6 @@ export const ProxmoxNodesTable: Component<{ const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const handleSort = (column: ProxmoxHostTableColumnId) => { - if (column === 'web') return; const key = column as HostSortKey; const defaultDirection = TEXT_SORT_KEYS.has(key) ? 'asc' : 'desc'; // Cycle: default direction → flipped → cleared. @@ -270,9 +269,7 @@ export const ProxmoxNodesTable: Component<{ {(column) => ( - - {name()} - + 0}> ); - case 'web': - return ( - - —}> - {(href) => ( - event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - - )} - - - ); default: column.id satisfies never; return <>; diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx index 30f77bcf07..be45dc6156 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx @@ -101,9 +101,12 @@ describe('ProxmoxNodesTable', () => { /> )); - const link = screen.getByRole('link', { name: 'Open pve-node-1 web interface' }); + const link = screen.getByRole('link', { name: 'Open web interface for pve-node-1' }); expect(link).toHaveAttribute('href', 'https://pve.example.com:8006'); expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveTextContent('pve-node-1'); + expect(screen.queryByRole('columnheader', { name: 'Web' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('proxmox-host-web-link')).not.toBeInTheDocument(); expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument(); }); @@ -118,10 +121,9 @@ describe('ProxmoxNodesTable', () => { /> )); - expect(screen.getByRole('link', { name: 'Open pve-node-1 web interface' })).toHaveAttribute( - 'href', - 'https://pve-node-1:8006', - ); + expect( + screen.getByRole('link', { name: 'Open web interface for pve-node-1' }), + ).toHaveAttribute('href', 'https://pve-node-1:8006'); }); it('restores the v5 row signals: alert accent, pending updates badge, full uptime, offline dimming', () => { diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts index 4f7d87d70a..160e190901 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts @@ -46,10 +46,9 @@ describe('proxmoxHostTableModel', () => { 'vms', 'cts', 'cluster', - 'web', ]); expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({ - width: '10.3093%', + width: '10.7527%', }); }); diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index b1780b9a50..235734f2b9 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -13,8 +13,7 @@ export type ProxmoxHostTableColumnId = | 'temp' | 'vms' | 'cts' - | 'cluster' - | 'web'; + | 'cluster'; export type ProxmoxHostTableColumn = { id: ProxmoxHostTableColumnId; @@ -40,7 +39,6 @@ const HOST_COLUMN_MIN_LAYOUT: Record = { vms: 5, cts: 5, cluster: 10, - web: 4, }; const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -90,7 +87,9 @@ const formatPercentage = (value: number): string => `${Number(value.toFixed(4))} // Column order follows the canonical recommended ordering documented in // columnAlignment.ts: identity → context → bars (CPU/Memory/Disk // contiguous) → diagnostic (Temp) → time (Uptime) → inventory counts -// → external owner reference at end. +// → source context at end. Web-interface launches live on the node name so +// every runtime table uses the canonical shared name-link affordance instead +// of adding a separate action column. export const PROXMOX_HOST_TABLE_COLUMNS: ProxmoxHostTableColumn[] = [ { id: 'node', label: 'Node', kind: 'name' }, { id: 'version', label: 'Version', kind: 'text' }, @@ -102,7 +101,6 @@ export const PROXMOX_HOST_TABLE_COLUMNS: ProxmoxHostTableColumn[] = [ { id: 'vms', label: 'VMs', kind: 'numeric-value' }, { id: 'cts', label: 'CTs', kind: 'numeric-value' }, { id: 'cluster', label: 'Cluster', kind: 'text' }, - { id: 'web', label: 'Web', kind: 'badge' }, ]; export const getProxmoxHostVisibleColumnsForLayout = (