diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 70090fa35e..f9fc9d67f8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2363,12 +2363,12 @@ Saved web-interface launch affordances must also stay on a shared primitive 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 -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. +containment, fallback text, and accessible launch labels. Workload guest rows, +grouped node headers, 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/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 88812febf0..52cc3bcce5 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -10,6 +10,9 @@ "export": "WebInterfaceNameLink" }, "requiredConsumers": [ + { + "path": "src/components/shared/NodeGroupHeader.tsx" + }, { "path": "src/components/Workloads/GuestRow.tsx" }, @@ -25,6 +28,10 @@ "path": "src/components/Workloads/GuestRow.tsx", "patterns": ["target=\"_blank\"", "rel=\"noopener noreferrer\""] }, + { + "path": "src/components/shared/NodeGroupHeader.tsx", + "patterns": ["target=\"_blank\"", "rel=\"noopener noreferrer\""] + }, { "path": "src/features/standalone/AgentsMachinesTable.tsx", "patterns": [ @@ -53,6 +60,7 @@ ], "proof": [ "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/__tests__/NodeGroupHeader.test.tsx", "src/features/standalone/__tests__/AgentsMachinesTable.test.tsx", "src/components/Workloads/__tests__/GuestRow.test.tsx", "src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx", diff --git a/frontend-modern/src/components/shared/NodeGroupHeader.tsx b/frontend-modern/src/components/shared/NodeGroupHeader.tsx index 34ce7d6fc0..702b12a478 100644 --- a/frontend-modern/src/components/shared/NodeGroupHeader.tsx +++ b/frontend-modern/src/components/shared/NodeGroupHeader.tsx @@ -11,6 +11,7 @@ import { getGroupedTableRowCellClass, getGroupedTableRowClass, } from './groupedTableRowPresentation'; +import { WebInterfaceNameLink } from './WebInterfaceNameLink'; interface NodeGroupHeaderProps { node: Node; @@ -51,16 +52,12 @@ export const NodeGroupHeader: Component = (props) => { ariaLabel={nodeStatus().label} size="xs" /> - event.stopPropagation()} - > - {displayName()} - + /> ({props.node.name}) diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index f6aeb76e16..1adce6d942 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -650,7 +650,8 @@ describe('shared primitive guardrails', () => { expect(storageGroupRowSource).not.toContain('kind="scope"'); expect(unifiedResourceHostTableCardSource).not.toContain('kind="scope"'); - expect(nodeGroupHeaderSource).toContain('event.stopPropagation()'); + expect(nodeGroupHeaderSource).toContain('WebInterfaceNameLink'); + expect(webInterfaceNameLinkSource).toContain('event.stopPropagation()'); }); it('keeps upgrade navigation split across shell, runtime, and utility owners', () => { @@ -942,18 +943,22 @@ describe('shared primitive guardrails', () => { expect(webInterfaceNameLinkSource).toContain('Open web interface for'); expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual( expect.arrayContaining([ + 'src/components/shared/NodeGroupHeader.tsx', 'src/components/Workloads/GuestRow.tsx', 'src/features/standalone/AgentsMachinesTable.tsx', 'src/features/proxmox/ProxmoxNodesTable.tsx', ]), ); + expect(nodeGroupHeaderSource).toContain('WebInterfaceNameLink'); expect(guestRowSource).toContain('WebInterfaceNameLink'); expect(agentsMachinesTableSource).toContain('WebInterfaceNameLink'); expect(proxmoxNodesTableSource).toContain('WebInterfaceNameLink'); + expect(nodeGroupHeaderSource).not.toContain('target="_blank"'); expect(guestRowSource).not.toContain('target="_blank"'); expect(agentsMachinesTableSource).not.toContain('target="_blank"'); expect(proxmoxNodesTableSource).not.toContain('target="_blank"'); + expect(nodeGroupHeaderSource).not.toContain('rel="noopener noreferrer"'); expect(agentsMachinesTableSource).not.toContain('AgentMachineWebLinkCell'); expect(agentsMachinesTableSource).not.toContain('data-agent-machine-web-link'); expect(proxmoxNodesTableSource).not.toContain('data-proxmox-host-web-link'); diff --git a/frontend-modern/src/components/shared/__tests__/NodeGroupHeader.test.tsx b/frontend-modern/src/components/shared/__tests__/NodeGroupHeader.test.tsx index 89cfb3d504..d9c992ced3 100644 --- a/frontend-modern/src/components/shared/__tests__/NodeGroupHeader.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/NodeGroupHeader.test.tsx @@ -46,5 +46,10 @@ describe('NodeGroupHeader', () => { expect(screen.getByText('PVE 9.1.9')).toBeInTheDocument(); expect(screen.getByText('62°C')).toBeInTheDocument(); expect(screen.getByText('2h')).toBeInTheDocument(); + + const link = screen.getByRole('link', { name: 'Open web interface for pve1' }); + expect(link).toHaveAttribute('href', 'https://pve1:8006'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); }); });