Enforce shared templates for web interface links

Refs #1276
This commit is contained in:
rcourtman 2026-06-12 10:06:01 +01:00
parent 841100caa7
commit 93420753aa
11 changed files with 270 additions and 133 deletions

View file

@ -3718,6 +3718,87 @@
"evidence_tier": "test-proof"
}
]
},
{
"id": "RA33",
"summary": "Comparable Pulse pages must compose repeated user-facing affordances from shared templates once a pattern exists: resource actions, fields, state badges, table controls, row affordances, and launch links must be owned in one shared primitive/model boundary with automated usage guardrails instead of drifting into page-local variants.",
"kind": "invariant",
"blocking_level": "repo-ready",
"proof_type": "automated",
"lane_ids": [
"L8",
"L13"
],
"subsystem_ids": [
"frontend-primitives",
"monitoring"
],
"release_gate_ids": [],
"proof_commands": [
{
"id": "shared-primitive-guardrails",
"run": [
"npm",
"--prefix",
"frontend-modern",
"test",
"--",
"--run",
"src/components/shared/SharedPrimitives.guardrails.test.ts"
]
},
{
"id": "shared-template-drift-guardrails",
"run": [
"node",
"frontend-modern/scripts/shared-template-audit.mjs"
]
}
],
"evidence": [
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/status.json",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/scripts/shared-template-audit.mjs",
"kind": "file",
"evidence_tier": "test-proof"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"kind": "file",
"evidence_tier": "test-proof"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/shared/WebInterfaceNameLink.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Workloads/GuestRow.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/standalone/agentMachineTableModel.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
"kind": "file"
}
]
}
],
"evidence_reference_policy": {

View file

@ -2355,6 +2355,13 @@ Missing-suggested-URL diagnostics remain useful only when the operator has no
saved or entered URL; once a custom web-interface URL is present, the shared
field must suppress "no suggested URL" warnings so Discovery does not make a
valid manual endpoint look broken.
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
and standalone machine rows compose that primitive so a saved URL is opened by
clicking the resource name on every comparable runtime table.
The shared help icon now follows that same owner split.
`frontend-modern/src/components/shared/HelpIcon.tsx` stays the render shell,
`frontend-modern/src/components/shared/useHelpIconState.ts` owns open state,

View file

@ -161,6 +161,11 @@ regression protection.
`docker.uptimeSeconds`, `kubernetes.uptimeSeconds`); platforms whose
adapters only populate the canonical field — vSphere is the working
example — would otherwise render blank uptime cells for every row.
Workload guest rows may compose the shared saved web-interface name-link
shell from `frontend-primitives`, but row rendering must receive any saved
URL from already-loaded row metadata and must not add per-row metadata
fetches, Discovery probes, or page-local external-link logic on the table
hot path.
4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path.
The same rule applies to public setup-script lifecycle routes: `/api/auto-register`
and `/api/auto-unregister` may bypass the global auth wall so their handlers can

View file

@ -0,0 +1,82 @@
import { readFileSync } from 'node:fs';
import { relative, resolve } from 'node:path';
const root = resolve(new URL('..', import.meta.url).pathname);
const read = (path) => readFileSync(resolve(root, path), 'utf8');
const rules = [
{
id: 'runtime-web-interface-name-link',
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.',
canonical: 'src/components/shared/WebInterfaceNameLink.tsx',
requiredConsumers: [
'src/components/Workloads/GuestRow.tsx',
'src/features/standalone/AgentsMachinesTable.tsx',
],
forbiddenPatterns: [
{
path: 'src/components/Workloads/GuestRow.tsx',
patterns: ['target="_blank"', 'rel="noopener noreferrer"'],
},
{
path: 'src/features/standalone/AgentsMachinesTable.tsx',
patterns: [
'target="_blank"',
'rel="noopener noreferrer"',
'AgentMachineWebLinkCell',
'data-agent-machine-web-link',
],
},
{
path: 'src/features/standalone/agentMachineTableModel.ts',
patterns: ["id: 'web'", "label: 'Web'"],
},
],
},
];
const failures = [];
for (const rule of rules) {
const canonicalSource = read(rule.canonical);
const canonicalExport = rule.canonical
.split('/')
.at(-1)
?.replace(/\.[^.]+$/, '');
if (canonicalExport && !canonicalSource.includes(canonicalExport)) {
failures.push(`${rule.id}: canonical template ${rule.canonical} does not export itself`);
}
for (const consumer of rule.requiredConsumers) {
const source = read(consumer);
if (canonicalExport && !source.includes(canonicalExport)) {
failures.push(`${rule.id}: ${consumer} must compose ${canonicalExport}`);
}
}
for (const check of rule.forbiddenPatterns) {
const source = read(check.path);
for (const pattern of check.patterns) {
if (source.includes(pattern)) {
failures.push(`${rule.id}: ${check.path} must not contain ${JSON.stringify(pattern)}`);
}
}
}
}
if (failures.length > 0) {
console.error('Shared template audit failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(
`Shared template audit passed (${rules.length} rule${rules.length === 1 ? '' : 's'} from ${relative(
process.cwd(),
import.meta.url.replace('file://', ''),
)})`,
);

View file

@ -26,6 +26,7 @@ import {
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
import { WebInterfaceNameLink } from '@/components/shared/WebInterfaceNameLink';
import type { GuestRowProps } from './guestRowModel';
import { useGuestRowState } from './useGuestRowState';
import type { WorkloadTableMetric } from './workloadMetricHistoryModel';
@ -191,30 +192,12 @@ export function GuestRow(props: GuestRowProps) {
size="xs"
/>
<div class="flex items-center gap-1.5 min-w-0 group/name">
<Show
when={customUrl()}
fallback={
<span
class="text-[11px] font-medium text-base-content select-none truncate"
title={props.guest.name}
>
{props.guest.name}
</span>
}
>
{(href) => (
<a
href={href()}
target="_blank"
rel="noopener noreferrer"
class="text-[11px] font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline select-none truncate"
title={`Open ${href()}`}
onClick={(event) => event.stopPropagation()}
>
{props.guest.name}
</a>
)}
</Show>
<WebInterfaceNameLink
name={props.guest.name}
url={customUrl()}
class="text-[11px] font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline select-none truncate"
fallbackClass="text-[11px] font-medium text-base-content select-none truncate"
/>
{/* Show backup indicator in name cell only if backup column is hidden */}
<Show when={!isColVisible('backup') && supportsBackup()}>
<BackupIndicator

View file

@ -753,6 +753,8 @@ describe('GuestRow', () => {
const link = container.querySelector('a[href="https://example.com"]');
expect(link).toBeTruthy();
expect(link?.getAttribute('target')).toBe('_blank');
expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
expect(link?.getAttribute('aria-label')).toBe('Open web interface for test-vm');
expect(link?.textContent).toBe('test-vm');
expect(container.querySelector('td[data-workload-col="link"]')).toBeNull();
});

View file

@ -89,6 +89,7 @@ import selectionCardGroupStateSource from '@/components/shared/useSelectionCardG
import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw';
import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw';
import webInterfaceUrlFieldStateSource from '@/components/shared/useWebInterfaceUrlFieldState.ts?raw';
import webInterfaceNameLinkSource from '@/components/shared/WebInterfaceNameLink.tsx?raw';
import upgradeNavigationSource from '@/utils/upgradeNavigation.ts?raw';
import guestRowSource from '@/components/Workloads/GuestRow.tsx?raw';
import workloadsTableSource from '@/components/Workloads/WorkloadsTable.tsx?raw';
@ -96,6 +97,8 @@ import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
import guestRowStateSource from '@/components/Workloads/useGuestRowState.ts?raw';
import workloadSelectionStateSource from '@/components/Workloads/useWorkloadSelectionState.ts?raw';
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw';
import agentMachineTableModelSource from '@/features/standalone/agentMachineTableModel.ts?raw';
import unifiedResourceHostTableCardSource from '@/components/Infrastructure/UnifiedResourceHostTableCard.tsx?raw';
import unifiedResourceServiceInfrastructureCardSource from '@/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx?raw';
import unifiedResourcePBSTableSectionSource from '@/components/Infrastructure/UnifiedResourcePBSTableSection.tsx?raw';
@ -268,7 +271,9 @@ describe('shared primitive guardrails', () => {
expect(aiModelPickerSource).toContain('const handleOptionKeyDown = (');
expect(aiModelPickerSource).toContain('role="dialog"');
expect(aiModelPickerSource).toContain('role="listbox"');
expect(aiModelPickerSource).toContain('aria-controls={isOpen() ? `${pickerId}-listbox` : undefined}');
expect(aiModelPickerSource).toContain(
'aria-controls={isOpen() ? `${pickerId}-listbox` : undefined}',
);
expect(aiModelPickerSource).toContain('onKeyDown={(event) => handleOptionKeyDown');
expect(aiModelPickerSource).toContain('aria-selected={isSelectedRoute(model.id)}');
expect(aiModelPickerSource).toContain('aria-label={optionAriaLabel(');
@ -907,6 +912,23 @@ describe('shared primitive guardrails', () => {
expect(webInterfaceUrlFieldModelSource).toContain('shouldShowWebInterfaceSuggestedUrl');
});
it('keeps runtime web-interface launch on the shared resource-name template', () => {
expect(webInterfaceNameLinkSource).toContain('export const WebInterfaceNameLink');
expect(webInterfaceNameLinkSource).toContain('target="_blank"');
expect(webInterfaceNameLinkSource).toContain('rel="noopener noreferrer"');
expect(webInterfaceNameLinkSource).toContain('event.stopPropagation()');
expect(webInterfaceNameLinkSource).toContain('Open web interface for');
expect(guestRowSource).toContain('WebInterfaceNameLink');
expect(agentsMachinesTableSource).toContain('WebInterfaceNameLink');
expect(guestRowSource).not.toContain('target="_blank"');
expect(agentsMachinesTableSource).not.toContain('target="_blank"');
expect(agentsMachinesTableSource).not.toContain('AgentMachineWebLinkCell');
expect(agentsMachinesTableSource).not.toContain('data-agent-machine-web-link');
expect(agentMachineTableModelSource).not.toContain("id: 'web'");
expect(agentMachineTableModelSource).not.toContain("label: 'Web'");
});
it('keeps help icon on shell, runtime, and model owners', () => {
expect(helpIconSource).toContain('useHelpIconState');
expect(helpIconSource).not.toContain('getHelpContent(');

View file

@ -0,0 +1,49 @@
import { Show, type Component, type JSX } from 'solid-js';
import { asTrimmedString } from '@/utils/stringUtils';
export interface WebInterfaceNameLinkProps {
name: string;
url?: string | null;
class?: string;
fallbackClass?: string;
title?: string;
ariaLabel?: string;
children?: JSX.Element;
}
export const WebInterfaceNameLink: Component<WebInterfaceNameLinkProps> = (props) => {
const url = () => asTrimmedString(props.url) ?? '';
const label = () => props.children ?? props.name;
const fallbackClass = () => props.fallbackClass ?? props.class ?? '';
const linkClass = () => props.class ?? '';
const linkTitle = () => props.title ?? `Open ${url()}`;
const ariaLabel = () => props.ariaLabel ?? `Open web interface for ${props.name}`;
return (
<Show
when={url()}
fallback={
<span class={fallbackClass()} title={props.name}>
{label()}
</span>
}
>
{(href) => (
<a
href={href()}
target="_blank"
rel="noopener noreferrer"
class={linkClass()}
title={linkTitle()}
aria-label={ariaLabel()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{label()}
</a>
)}
</Show>
);
};
export default WebInterfaceNameLink;

View file

@ -1,8 +1,6 @@
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import Loader2Icon from 'lucide-solid/icons/loader-2';
import MoreHorizontalIcon from 'lucide-solid/icons/more-horizontal';
import PlusIcon from 'lucide-solid/icons/plus';
import Trash2Icon from 'lucide-solid/icons/trash-2';
import {
For,
@ -23,6 +21,7 @@ import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import { StatusDot } from '@/components/shared/StatusDot';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import { WebInterfaceNameLink } from '@/components/shared/WebInterfaceNameLink';
import {
Table,
TableBody,
@ -721,65 +720,6 @@ const AgentMachineRaidCell: Component<{
);
};
const AgentMachineWebLinkCell: Component<{
url: string;
name: string;
canConfigure: boolean;
onConfigure: () => void;
}> = (props) => {
const url = () => asTrimmedString(props.url) ?? '';
const label = () => `Open web interface for ${props.name}`;
const configureLabel = () => `Add web interface URL for ${props.name}`;
return (
<div class="flex justify-center">
<Show
when={url()}
fallback={
<Show
when={props.canConfigure}
fallback={
<span class="inline-flex h-7 w-7 items-center justify-center text-xs text-muted">
—
</span>
}
>
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60"
title="Add web interface URL"
aria-label={configureLabel()}
onClick={(event) => {
event.stopPropagation();
props.onConfigure();
}}
onKeyDown={(event) => event.stopPropagation()}
>
<PlusIcon class="h-3.5 w-3.5 opacity-60" />
</button>
</Show>
}
>
{(href) => (
<a
data-agent-machine-web-link
href={href()}
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-7 w-7 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={href()}
aria-label={label()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<ExternalLinkIcon class="h-3.5 w-3.5" />
</a>
)}
</Show>
</div>
);
};
const AgentMachineActionsCell: Component<{
name: string;
agentId: string;
@ -1129,8 +1069,6 @@ const machineColumnWidthClass = (columnId: AgentMachineColumnId): string => {
return 'hidden md:table-cell md:w-[5%]';
case 'lastSeen':
return 'hidden lg:table-cell lg:w-[6%]';
case 'web':
return 'hidden xl:table-cell xl:w-[4%]';
case 'ip':
return 'hidden xl:table-cell xl:w-[8%]';
case 'raid':
@ -1246,7 +1184,6 @@ export const AgentsMachinesTable: Component<{
);
const drawer = createPlatformResourceDetailState({ idPrefix: 'agents-machine-drawer' });
const [agentMetadataById, setAgentMetadataById] = createSignal<Record<string, AgentMetadata>>({});
const [accessTargetResourceId, setAccessTargetResourceId] = createSignal<string | null>(null);
const [actionsMenuResourceId, setActionsMenuResourceId] = createSignal<string | null>(null);
const [confirmingRemovalResourceId, setConfirmingRemovalResourceId] = createSignal<string | null>(
null,
@ -1335,7 +1272,6 @@ export const AgentsMachinesTable: Component<{
await removeAgentMetadata(agentMetadataIdFor(machine));
setLocallyRemovedResourceIds((current) => ({ ...current, [resourceId]: true }));
drawer.close(machine);
if (accessTargetResourceId() === resourceId) setAccessTargetResourceId(null);
notificationStore.success(`${name} removed from Pulse`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to remove machine';
@ -1561,13 +1497,8 @@ export const AgentsMachinesTable: Component<{
const agentMetadataId = () => agentMetadataIdFor(machine);
const agentRemovalId = () => agentRemovalIdFor(machine);
const savedWebInterfaceUrl = () => savedAgentCustomUrlFor(agentMetadataId());
const clearAccessTargetIfCurrent = () => {
if (accessTargetResourceId() === machine.id) setAccessTargetResourceId(null);
};
const toggleDetails = () => {
const wasExpanded = isExpanded();
drawer.toggle(machine);
if (wasExpanded) clearAccessTargetIfCurrent();
};
const handleDetailsActivationKey: JSX.EventHandler<
HTMLTableRowElement,
@ -1605,9 +1536,12 @@ export const AgentsMachinesTable: Component<{
title={machine.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
<WebInterfaceNameLink
name={name()}
url={savedWebInterfaceUrl()}
class="truncate font-semibold text-blue-600 hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
fallbackClass="truncate font-semibold text-base-content"
/>
</div>
<Show
when={machineSubtitle()}
@ -1756,21 +1690,6 @@ export const AgentsMachinesTable: Component<{
{lastSeenLabel()}
</TableCell>
</Show>
<Show when={columnVisibility.isColumnVisible('web')}>
<TableCell
class={`${getPlatformTableCellClassForKind('badge')} ${machineColumnWidthClass('web')} text-base-content`}
>
<AgentMachineWebLinkCell
url={savedWebInterfaceUrl()}
name={name()}
canConfigure={Boolean(agentMetadataId())}
onConfigure={() => {
setAccessTargetResourceId(machine.id);
drawer.open(machine);
}}
/>
</TableCell>
</Show>
<Show when={columnVisibility.isColumnVisible('ip')}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} ${machineColumnWidthClass('ip')} text-base-content`}
@ -1828,10 +1747,8 @@ export const AgentsMachinesTable: Component<{
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={detailColspan()}
initialShowAccessContext={accessTargetResourceId() === machine.id}
onClose={() => {
drawer.close(machine);
clearAccessTargetIfCurrent();
}}
/>
</>

View file

@ -500,7 +500,7 @@ describe('AgentsMachinesTable', () => {
});
});
it('opens saved agent web interface URLs from machine rows', async () => {
it('opens saved agent web interface URLs from the machine name', async () => {
getAllAgentMetadataMock.mockResolvedValueOnce({
'web-host': { id: 'web-host', customUrl: 'https://web-host.local:9443' },
});
@ -522,9 +522,14 @@ describe('AgentsMachinesTable', () => {
const link = await screen.findByRole('link', { name: 'Open web interface for Web Host' });
expect(link).toHaveAttribute('href', 'https://web-host.local:9443');
expect(link).toHaveTextContent('Web Host');
expect(screen.queryByRole('columnheader', { name: 'Web' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Add web interface URL for Web Host' }),
).not.toBeInTheDocument();
});
it('keeps machine web links in sync when detail metadata changes', async () => {
it('keeps machine name web links in sync when detail metadata changes', async () => {
render(() => (
<AgentsMachinesTable
resources={[
@ -540,15 +545,6 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(
screen.getByRole('button', { name: 'Add web interface URL for Event Host' }),
);
expect(screen.getByTestId('resource-detail-drawer')).toBeInTheDocument();
expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
'data-initial-show-access-context',
'true',
);
window.dispatchEvent(
new CustomEvent(RESOURCE_METADATA_CHANGED_EVENT, {
detail: {

View file

@ -16,7 +16,6 @@ export type AgentMachineColumnId =
| 'uptime'
| 'temp'
| 'lastSeen'
| 'web'
| 'ip'
| 'raid'
| 'arch'
@ -65,12 +64,6 @@ export const AGENT_MACHINE_COLUMNS: AgentMachineColumn[] = [
sortKey: 'lastSeen',
toggleable: true,
},
{
id: 'web',
label: 'Web',
kind: 'badge',
toggleable: true,
},
{
id: 'ip',
label: 'IP',