Canonicalize node header web interface links

This commit is contained in:
rcourtman
2026-06-12 12:31:31 +01:00
parent ddb5bd5e1a
commit 43bb0184ba
5 changed files with 30 additions and 15 deletions
@@ -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
@@ -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",
@@ -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<NodeGroupHeaderProps> = (props) => {
ariaLabel={nodeStatus().label}
size="xs"
/>
<a
href={nodeUrl()}
target="_blank"
rel="noopener noreferrer"
<WebInterfaceNameLink
name={displayName()}
url={nodeUrl()}
class="transition-colors duration-150 hover:text-sky-600 dark:hover:text-sky-400"
title={`Open ${props.node.name} web interface`}
onClick={(event) => event.stopPropagation()}
>
{displayName()}
</a>
/>
<Show when={showActualName()}>
<span class="text-[10px] text-muted">({props.node.name})</span>
</Show>
@@ -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');
@@ -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');
});
});