Canonicalize Proxmox web interface links

This commit is contained in:
rcourtman 2026-06-12 12:22:20 +01:00
parent 2169d94b41
commit ddb5bd5e1a
9 changed files with 70 additions and 48 deletions

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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"
]
}
],

View file

@ -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', () => {

View file

@ -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<ProxmoxHostTableColumnId, 'web'>;
type HostSortKey = ProxmoxHostTableColumnId;
const TEXT_SORT_KEYS = new Set<ProxmoxHostTableColumnId>(['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<{
<For each={visibleColumns()}>
{(column) => (
<TableHead
class={`${getPlatformTableHeadClassForKind(column.kind)} ${
column.id !== 'web' ? 'cursor-pointer hover:bg-surface-hover' : ''
}`}
class={`${getPlatformTableHeadClassForKind(column.kind)} cursor-pointer hover:bg-surface-hover`}
aria-sort={
sortKey() === column.id
? sortDirection() === 'asc'
@ -381,9 +378,13 @@ export const ProxmoxNodesTable: Component<{
title={node.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
<WebInterfaceNameLink
name={name()}
url={externalUrl()}
class="truncate font-semibold text-base-content transition-colors hover:text-sky-600 dark:hover:text-sky-400"
fallbackClass="truncate font-semibold text-base-content"
title={`Open ${name()} web interface`}
/>
<Show when={isOnline() && pendingUpdates() > 0}>
<span
class={`rounded px-1 py-0 text-[9px] font-medium whitespace-nowrap ${
@ -551,28 +552,6 @@ export const ProxmoxNodesTable: Component<{
</span>
</TableCell>
);
case 'web':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show when={externalUrl()} fallback={<span class="text-muted">—</span>}>
{(href) => (
<a
data-proxmox-host-web-link
href={href()}
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 dark:text-blue-400 dark:hover:bg-blue-950 dark:hover:text-blue-300"
title={`Open ${name()} web interface`}
aria-label={`Open ${name()} web interface`}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<ExternalLinkIcon class="h-3.5 w-3.5" />
</a>
)}
</Show>
</TableCell>
);
default:
column.id satisfies never;
return <></>;

View file

@ -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', () => {

View file

@ -46,10 +46,9 @@ describe('proxmoxHostTableModel', () => {
'vms',
'cts',
'cluster',
'web',
]);
expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({
width: '10.3093%',
width: '10.7527%',
});
});

View file

@ -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<ProxmoxHostTableColumnId, WorkloadTableLayo
version: 'compact',
uptime: 'compact',
cluster: 'compact',
web: 'compact',
};
// CPU, memory, and disk render the same kind of usage bar, so they share
@ -60,7 +58,6 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
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 = (