Enforce shared template registry for platform tables

This commit is contained in:
rcourtman 2026-06-12 10:44:11 +01:00
parent 93420753aa
commit 048db1c91c
7 changed files with 369 additions and 49 deletions

View file

@ -3772,6 +3772,11 @@
"kind": "file",
"evidence_tier": "test-proof"
},
{
"repo": "pulse",
"path": "frontend-modern/scripts/shared-template-registry.json",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
@ -3788,6 +3793,11 @@
"path": "frontend-modern/src/components/Workloads/GuestRow.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/standalone/agentMachineTableModel.ts",

View file

@ -153,11 +153,14 @@ work extends shared components instead of creating new local variants.
126. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts`
127. `frontend-modern/scripts/canonical-platform-audit.mjs`
128. `frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs`
129. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
130. `frontend-modern/src/utils/platformSupportManifest.ts`
131. `frontend-modern/src/utils/sourcePlatformOptions.ts`
132. `frontend-modern/src/utils/sourcePlatforms.ts`
133. `frontend-modern/src/utils/infrastructureOnboardingPresentation.ts`
129. `frontend-modern/scripts/shared-template-audit.mjs`
130. `frontend-modern/scripts/shared-template-registry.json`
131. `frontend-modern/src/features/platformPage/sharedPlatformPage.tsx`
132. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
133. `frontend-modern/src/utils/platformSupportManifest.ts`
134. `frontend-modern/src/utils/sourcePlatformOptions.ts`
135. `frontend-modern/src/utils/sourcePlatforms.ts`
136. `frontend-modern/src/utils/infrastructureOnboardingPresentation.ts`
## Shared Boundaries
@ -2362,6 +2365,20 @@ 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.
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
patterns, while `frontend-modern/scripts/shared-template-audit.mjs` enforces
that registry. Future repeated-affordance migrations must add or extend a
registry rule as part of the same change that extracts or adopts the shared
primitive.
Platform table frames are one of those registry-backed templates.
`frontend-modern/src/features/platformPage/sharedPlatformPage.tsx` owns
`PlatformTableShell`, including the canonical table card, header row, and body
divide styling. New platform tables must start from `PlatformTableShell`; the
legacy table-frame allowlist in `shared-template-registry.json` is migration
debt, not precedent, and must shrink as existing tables are moved onto the
shared shell.
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

@ -3059,6 +3059,8 @@
"frontend-modern/scripts/canonical-platform-audit.mjs",
"frontend-modern/scripts/header-audit.mjs",
"frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs",
"frontend-modern/scripts/shared-template-audit.mjs",
"frontend-modern/scripts/shared-template-registry.json",
"frontend-modern/src/components/Settings/AIChatMaintenanceSection.tsx",
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx",
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx",
@ -3124,6 +3126,7 @@
"frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx",
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx",
"frontend-modern/src/components/Toast/Toast.tsx",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/hooks/createNonSuspendingQuery.ts",
"frontend-modern/src/index.css",
"frontend-modern/src/routing/routePreload.ts",
@ -3550,6 +3553,21 @@
"frontend-modern/src/components/shared/__tests__/SearchInput.test.tsx"
]
},
{
"id": "shared-template-drift-guardrails",
"label": "shared template drift guardrail proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/scripts/shared-template-audit.mjs",
"frontend-modern/scripts/shared-template-registry.json",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts"
]
},
{
"id": "data-grid-and-summary",
"label": "data grid and summary proof",

View file

@ -1,62 +1,92 @@
import { readFileSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { extname, relative, resolve } from 'node:path';
const root = resolve(new URL('..', import.meta.url).pathname);
const registryPath = 'scripts/shared-template-registry.json';
const read = (path) => readFileSync(resolve(root, path), 'utf8');
const readJson = (path) => JSON.parse(read(path));
const fileExists = (path) => existsSync(resolve(root, path));
const toRegistryPath = (path) => relative(root, path).split('\\').join('/');
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 registry = readJson(registryPath);
const rules = Array.isArray(registry.rules) ? registry.rules : [];
const patternGuards = Array.isArray(registry.patternGuards) ? registry.patternGuards : [];
const failures = [];
for (const rule of rules) {
const canonicalSource = read(rule.canonical);
const canonicalExport = rule.canonical
const getCanonicalExport = (canonical) =>
canonical.export ??
canonical.path
.split('/')
.at(-1)
?.replace(/\.[^.]+$/, '');
if (canonicalExport && !canonicalSource.includes(canonicalExport)) {
failures.push(`${rule.id}: canonical template ${rule.canonical} does not export itself`);
const verifyCanonicalExport = (id, canonical) => {
if (!canonical?.path) {
failures.push(`${id}: missing canonical.path`);
return undefined;
}
for (const consumer of rule.requiredConsumers) {
const source = read(consumer);
if (!fileExists(canonical.path)) {
failures.push(`${id}: canonical template ${canonical.path} does not exist`);
return undefined;
}
const canonicalSource = read(canonical.path);
const canonicalExport = getCanonicalExport(canonical);
if (canonicalExport && !canonicalSource.includes(canonicalExport)) {
failures.push(`${id}: canonical template ${canonical.path} must expose ${canonicalExport}`);
}
return canonicalExport;
};
const collectFiles = (scope, extensions) => {
const scopeRoot = resolve(root, scope);
if (!existsSync(scopeRoot)) {
failures.push(`pattern guard scope ${scope} does not exist`);
return [];
}
const files = [];
const visit = (absolutePath) => {
const stat = statSync(absolutePath);
if (stat.isDirectory()) {
for (const entry of readdirSync(absolutePath)) {
visit(resolve(absolutePath, entry));
}
return;
}
if (extensions.includes(extname(absolutePath))) {
files.push(toRegistryPath(absolutePath));
}
};
visit(scopeRoot);
return files;
};
const getAllowedPath = (entry) => (typeof entry === 'string' ? entry : entry?.path);
for (const rule of rules) {
if (!rule.id) failures.push('registry rule is missing id');
if (!rule.summary) failures.push(`${rule.id}: missing summary`);
if (!rule.category) failures.push(`${rule.id}: missing category`);
if (!rule.canonical?.path) failures.push(`${rule.id}: missing canonical.path`);
if (!rule.canonical?.path) continue;
const canonicalExport = verifyCanonicalExport(rule.id, rule.canonical);
for (const consumer of rule.requiredConsumers ?? []) {
const source = read(consumer.path);
if (canonicalExport && !source.includes(canonicalExport)) {
failures.push(`${rule.id}: ${consumer} must compose ${canonicalExport}`);
failures.push(`${rule.id}: ${consumer.path} must compose ${canonicalExport}`);
}
}
for (const check of rule.forbiddenPatterns) {
for (const check of rule.forbiddenPatterns ?? []) {
const source = read(check.path);
for (const pattern of check.patterns) {
if (source.includes(pattern)) {
@ -66,6 +96,66 @@ for (const rule of rules) {
}
}
for (const guard of patternGuards) {
if (!guard.id) failures.push('pattern guard is missing id');
if (!guard.summary) failures.push(`${guard.id}: missing summary`);
if (!guard.category) failures.push(`${guard.id}: missing category`);
if (!guard.canonical?.path) failures.push(`${guard.id}: missing canonical.path`);
if (!guard.id || !guard.canonical?.path) continue;
verifyCanonicalExport(guard.id, guard.canonical);
const patterns = Array.isArray(guard.allPatterns) ? guard.allPatterns : [];
if (patterns.length === 0) {
failures.push(`${guard.id}: missing allPatterns`);
continue;
}
const scopes = Array.isArray(guard.scopes) ? guard.scopes : [];
if (scopes.length === 0) {
failures.push(`${guard.id}: missing scopes`);
continue;
}
const extensions = guard.extensions ?? ['.ts', '.tsx'];
const allowedEntries = guard.allowedPaths ?? [];
const allowedPaths = new Set(allowedEntries.map(getAllowedPath).filter(Boolean));
const files = [...new Set(scopes.flatMap((scope) => collectFiles(scope, extensions)))].sort();
for (const file of files) {
if (file === guard.canonical.path) continue;
const source = read(file);
const matches = patterns.every((pattern) => source.includes(pattern));
if (!matches) continue;
if (allowedPaths.has(file)) continue;
failures.push(
`${guard.id}: ${file} recreates the shared template; compose ${guard.canonical.path} instead`,
);
}
for (const allowedPath of allowedPaths) {
if (!fileExists(allowedPath)) {
failures.push(`${guard.id}: allowed path ${allowedPath} does not exist`);
continue;
}
if (allowedPath === guard.canonical.path) {
failures.push(`${guard.id}: canonical path ${allowedPath} must not be allowlisted`);
continue;
}
const source = read(allowedPath);
const stillMatches = patterns.every((pattern) => source.includes(pattern));
if (!stillMatches) {
failures.push(`${guard.id}: allowed path ${allowedPath} is stale; remove it from the guard`);
}
}
}
if (failures.length > 0) {
console.error('Shared template audit failed:');
for (const failure of failures) {
@ -75,8 +165,10 @@ if (failures.length > 0) {
}
console.log(
`Shared template audit passed (${rules.length} rule${rules.length === 1 ? '' : 's'} from ${relative(
`Shared template audit passed (${rules.length} rule${rules.length === 1 ? '' : 's'}, ${
patternGuards.length
} pattern guard${patternGuards.length === 1 ? '' : 's'} from ${relative(
process.cwd(),
import.meta.url.replace('file://', ''),
resolve(root, registryPath),
)})`,
);

View file

@ -0,0 +1,118 @@
{
"version": 1,
"rules": [
{
"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.",
"canonical": {
"path": "src/components/shared/WebInterfaceNameLink.tsx",
"export": "WebInterfaceNameLink"
},
"requiredConsumers": [
{
"path": "src/components/Workloads/GuestRow.tsx"
},
{
"path": "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'"]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/features/standalone/__tests__/AgentsMachinesTable.test.tsx",
"src/components/Workloads/__tests__/GuestRow.test.tsx"
]
}
],
"patternGuards": [
{
"id": "platform-table-shell-local-frame",
"category": "platform-table-shell",
"summary": "Platform tables must compose the shared PlatformTableShell frame instead of recreating the TableCard/TableHeader/TableBody shell locally.",
"canonical": {
"path": "src/features/platformPage/sharedPlatformPage.tsx",
"export": "PlatformTableShell"
},
"scopes": ["src/features", "src/components"],
"extensions": [".tsx"],
"allPatterns": [
"TableCard class={PLATFORM_TABLE_CARD_CLASS}",
"TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}",
"TableBody class={PLATFORM_TABLE_BODY_CLASS}"
],
"legacyReason": "Existing platform tables predate PlatformTableShell and are registered legacy migration debt. New tables must use the canonical shell.",
"allowedPaths": [
"src/features/docker/DockerAlertsTable.tsx",
"src/features/docker/DockerConfigsTable.tsx",
"src/features/docker/DockerContainersTable.tsx",
"src/features/docker/DockerHostsTable.tsx",
"src/features/docker/DockerImagesTable.tsx",
"src/features/docker/DockerNetworksTable.tsx",
"src/features/docker/DockerSecretsTable.tsx",
"src/features/docker/DockerServicesTable.tsx",
"src/features/docker/DockerStorageUsageTable.tsx",
"src/features/docker/DockerSwarmNodesTable.tsx",
"src/features/docker/DockerTasksTable.tsx",
"src/features/docker/DockerVolumesTable.tsx",
"src/features/kubernetes/KubernetesAlertsTable.tsx",
"src/features/kubernetes/KubernetesAutoscalingTable.tsx",
"src/features/kubernetes/KubernetesClustersTable.tsx",
"src/features/kubernetes/KubernetesConfigTable.tsx",
"src/features/kubernetes/KubernetesControllersTable.tsx",
"src/features/kubernetes/KubernetesDeploymentsTable.tsx",
"src/features/kubernetes/KubernetesEventsTable.tsx",
"src/features/kubernetes/KubernetesNetworkingTable.tsx",
"src/features/kubernetes/KubernetesNodesTable.tsx",
"src/features/kubernetes/KubernetesPodsTable.tsx",
"src/features/kubernetes/KubernetesPolicyTable.tsx",
"src/features/kubernetes/KubernetesServicesTable.tsx",
"src/features/kubernetes/KubernetesStorageTable.tsx",
"src/features/proxmox/ProxmoxBackupServersTable.tsx",
"src/features/proxmox/ProxmoxCephTable.tsx",
"src/features/proxmox/ProxmoxCoverageTable.tsx",
"src/features/proxmox/ProxmoxMailGatewayTable.tsx",
"src/features/proxmox/ProxmoxNodesTable.tsx",
"src/features/proxmox/ProxmoxRecoverableTable.tsx",
"src/features/proxmox/ProxmoxReplicationTable.tsx",
"src/features/standalone/AgentsMachinesTable.tsx",
"src/features/standalone/AvailabilityChecksTable.tsx",
"src/features/truenas/TrueNASAlertsTable.tsx",
"src/features/truenas/TrueNASAppsTable.tsx",
"src/features/truenas/TrueNASNetworkSharesTable.tsx",
"src/features/truenas/TrueNASProtectionTable.tsx",
"src/features/truenas/TrueNASServicesTable.tsx",
"src/features/truenas/TrueNASStorageTopologyTable.tsx",
"src/features/truenas/TrueNASSystemsTable.tsx",
"src/features/truenas/TrueNASVirtualMachinesTable.tsx",
"src/features/vmware/VsphereActivityTable.tsx",
"src/features/vmware/VsphereAlertsTable.tsx",
"src/features/vmware/VsphereDatastoresTable.tsx",
"src/features/vmware/VsphereHostsTable.tsx",
"src/features/vmware/VsphereNetworksTable.tsx"
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
}
]
}

View file

@ -90,6 +90,8 @@ import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField
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 sharedTemplateRegistrySource from '../../../scripts/shared-template-registry.json?raw';
import sharedPlatformPageSource from '@/features/platformPage/sharedPlatformPage.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';
@ -913,6 +915,18 @@ describe('shared primitive guardrails', () => {
});
it('keeps runtime web-interface launch on the shared resource-name template', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules: Array<{
id: string;
canonical?: { path?: string; export?: string };
}>;
};
const registeredRule = registry.rules.find(
(rule) => rule.id === 'runtime-web-interface-name-link',
);
expect(registeredRule?.canonical?.path).toBe('src/components/shared/WebInterfaceNameLink.tsx');
expect(registeredRule?.canonical?.export).toBe('WebInterfaceNameLink');
expect(webInterfaceNameLinkSource).toContain('export const WebInterfaceNameLink');
expect(webInterfaceNameLinkSource).toContain('target="_blank"');
expect(webInterfaceNameLinkSource).toContain('rel="noopener noreferrer"');
@ -929,6 +943,29 @@ describe('shared primitive guardrails', () => {
expect(agentMachineTableModelSource).not.toContain("label: 'Web'");
});
it('keeps platform table frames on the shared shell template', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
patternGuards: Array<{
id: string;
canonical?: { path?: string; export?: string };
allowedPaths?: string[];
}>;
};
const registeredGuard = registry.patternGuards.find(
(guard) => guard.id === 'platform-table-shell-local-frame',
);
expect(registeredGuard?.canonical?.path).toBe(
'src/features/platformPage/sharedPlatformPage.tsx',
);
expect(registeredGuard?.canonical?.export).toBe('PlatformTableShell');
expect(registeredGuard?.allowedPaths?.length).toBeGreaterThan(0);
expect(sharedPlatformPageSource).toContain('export function PlatformTableShell');
expect(sharedPlatformPageSource).toContain('TableCard class={props.cardClass');
expect(sharedPlatformPageSource).toContain('TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}');
expect(sharedPlatformPageSource).toContain('TableBody class={PLATFORM_TABLE_BODY_CLASS}');
});
it('keeps help icon on shell, runtime, and model owners', () => {
expect(helpIconSource).toContain('useHelpIconState');
expect(helpIconSource).not.toContain('getHelpContent(');

View file

@ -6,7 +6,9 @@ import { EmptyState } from '@/components/shared/EmptyState';
import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup';
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
import { type SearchInputProps } from '@/components/shared/SearchInput';
import { Table, TableBody, TableHeader, TableRow } from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable';
import type { Resource } from '@/types/resource';
@ -89,6 +91,32 @@ export const PLATFORM_TABLE_CARD_CLASS = 'rounded-md';
export const PLATFORM_TABLE_HEADER_ROW_CLASS = 'bg-surface-alt text-muted border-b border-border';
export const PLATFORM_TABLE_BODY_CLASS = 'divide-y divide-border';
export type PlatformTableShellProps = {
title?: JSX.Element;
actions?: JSX.Element;
tableClass?: string;
tableWrapperClass?: string;
cardClass?: string;
colgroup?: JSX.Element;
header: JSX.Element;
body: JSX.Element;
};
export function PlatformTableShell(props: PlatformTableShellProps) {
return (
<TableCard class={props.cardClass ?? PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title} actions={props.actions} />
<Table class={props.tableClass} wrapperClass={props.tableWrapperClass}>
{props.colgroup}
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>{props.header}</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>{props.body}</TableBody>
</Table>
</TableCard>
);
}
const getPlatformTableAlignClass = (align: PlatformTableCellAlign = 'left'): string => {
if (align === 'right') return 'text-right';
if (align === 'center') return 'text-center';