Canonicalize platform error states

This commit is contained in:
rcourtman 2026-06-12 13:52:53 +01:00
parent 0109361296
commit 51ac42870c
8 changed files with 179 additions and 28 deletions

View file

@ -2398,6 +2398,12 @@ Platform table loading states are registry-backed too.
`role="status"` loading row for platform pages and tables; platform consumers
own only the title and description copy. Platform feature surfaces must not
recreate the `TableCard` plus compact status-row shell locally.
Platform load-failure states are registry-backed as well.
`PlatformErrorState` owns the repeated table-card error shell, warning icon,
and Refresh action for platform page and table load failures; platform
consumers own only the failure title, description, and refresh callback.
Platform feature surfaces must not recreate an `EmptyState` plus local Refresh
button for `Could not load...` states.
Filter bars are registry-backed too. `FilterBar` owns resource-list filtering
as a catalog of `FilterDef` entries, while `filterChipStatusDot` owns the
small leading status-dot glyph used by filter options. Page and feature

View file

@ -82,6 +82,12 @@ interpreted as backup visibility, recovery readiness, restore capability, or a
storage/recovery-owned command path. Suppressing those notices when the API has
no deployable agent update target is likewise lifecycle/frontend behavior and
does not change Proxmox backup or recovery evidence.
Proxmox backup inventory loading and load-failure chrome is likewise a
frontend-primitives dependency. `ProxmoxBackupsTable` owns the backup API
queries, recovery model, filters, coverage split, and backup-specific error
copy, but repeated loading and retry shells must compose
`PlatformTableLoadingState` and `PlatformErrorState`; storage/recovery must not
fork local table-card loading rows or refresh-button error empty states.
Tenant report branding settings are adjacent tenant-local configuration, not a
storage or recovery product state. `reportBranding` persisted in a tenant
runtime's `system.json` should be preserved by the existing tenant data

View file

@ -224,6 +224,13 @@ the source-specific empty-state vocabulary, action choice, and evidence rule
that decides why a table is empty, but the table-card empty-state shell itself
must compose the frontend-primitives-owned `PlatformTableEmptyState` primitive
instead of importing `EmptyState` directly or wrapping it in a page-local card.
Platform loading and load-failure states follow the same boundary.
Unified-resource consumers such as `ProxmoxReplicationTable` own the API query,
resource-specific copy, retry callback, and evidence rule that decides which
state applies, but repeated loading rows and retry shells must compose the
frontend-primitives-owned `PlatformTableLoadingState` and `PlatformErrorState`
primitives instead of recreating page-local table-card status rows or local
Refresh-button error empty states.
The same split applies to inline detail row shells on platform, native-resource,
workload, and infrastructure tables. Unified-resource consumers own row
identity, source-specific drawer content, expansion state, and resource

View file

@ -125,7 +125,34 @@
"requiredConsumers": [
{ "path": "src/features/docker/DockerPageSurface.tsx" },
{ "path": "src/features/kubernetes/KubernetesPageSurface.tsx" },
{ "path": "src/features/proxmox/ProxmoxBackupsTable.tsx" },
{ "path": "src/features/proxmox/ProxmoxPageSurface.tsx" },
{ "path": "src/features/proxmox/ProxmoxReplicationTable.tsx" },
{ "path": "src/features/standalone/StandalonePageSurface.tsx" },
{ "path": "src/features/truenas/TrueNASPageSurface.tsx" },
{ "path": "src/features/truenas/TrueNASProtectionTable.tsx" },
{ "path": "src/features/vmware/VmwarePageSurface.tsx" }
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "platform-error-state",
"category": "platform-error-state",
"summary": "Platform page and table load failures must compose PlatformErrorState instead of recreating a local EmptyState plus Refresh action.",
"canonical": {
"path": "src/features/platformPage/sharedPlatformPage.tsx",
"export": "PlatformErrorState"
},
"requiredConsumers": [
{ "path": "src/features/docker/DockerPageSurface.tsx" },
{ "path": "src/features/kubernetes/KubernetesPageSurface.tsx" },
{ "path": "src/features/proxmox/ProxmoxBackupsTable.tsx" },
{ "path": "src/features/proxmox/ProxmoxPageSurface.tsx" },
{ "path": "src/features/proxmox/ProxmoxReplicationTable.tsx" },
{ "path": "src/features/standalone/StandalonePageSurface.tsx" },
{ "path": "src/features/truenas/TrueNASPageSurface.tsx" },
{ "path": "src/features/truenas/TrueNASProtectionTable.tsx" },
@ -384,6 +411,31 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "platform-error-state-required",
"category": "platform-error-state",
"summary": "Platform feature files that surface load-failure copy must compose PlatformErrorState for the canonical retry shell.",
"canonical": {
"path": "src/features/platformPage/sharedPlatformPage.tsx",
"export": "PlatformErrorState"
},
"scopes": [
"src/features/docker",
"src/features/kubernetes",
"src/features/proxmox",
"src/features/standalone",
"src/features/truenas",
"src/features/vmware"
],
"extensions": [".tsx"],
"triggerPatterns": ["title=\"Could not load"],
"requiredPatterns": ["PlatformErrorState"],
"allowedPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "named-detail-table-row-shared-shell",
"category": "row-detail",

View file

@ -1056,7 +1056,6 @@ describe('shared primitive guardrails', () => {
for (const source of [
dockerHostsTableSource,
kubernetesNodesTableSource,
proxmoxBackupsTableSource,
proxmoxCephTableSource,
proxmoxCoverageTableSource,
proxmoxMailGatewayTableSource,
@ -1096,7 +1095,9 @@ describe('shared primitive guardrails', () => {
const requiredConsumerPaths = [
'src/features/docker/DockerPageSurface.tsx',
'src/features/kubernetes/KubernetesPageSurface.tsx',
'src/features/proxmox/ProxmoxBackupsTable.tsx',
'src/features/proxmox/ProxmoxPageSurface.tsx',
'src/features/proxmox/ProxmoxReplicationTable.tsx',
'src/features/standalone/StandalonePageSurface.tsx',
'src/features/truenas/TrueNASPageSurface.tsx',
'src/features/truenas/TrueNASProtectionTable.tsx',
@ -1137,7 +1138,9 @@ describe('shared primitive guardrails', () => {
for (const source of [
dockerPageSurfaceSource,
kubernetesPageSurfaceSource,
proxmoxBackupsTableSource,
proxmoxPageSurfaceSource,
proxmoxReplicationTableSource,
standalonePageSurfaceSource,
truenasPageSurfaceSource,
truenasProtectionTableSource,
@ -1148,6 +1151,87 @@ describe('shared primitive guardrails', () => {
}
});
it('keeps platform load-failure states on the shared error template', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{
id: string;
canonical?: { path?: string; export?: string };
requiredConsumers?: Array<{ path?: string }>;
}>;
requiredPatternGuards?: Array<{
id: string;
canonical?: { path?: string; export?: string };
triggerPatterns?: string[];
requiredPatterns?: string[];
scopes?: string[];
allowedPaths?: string[];
}>;
};
const registeredRule = registry.rules?.find((rule) => rule.id === 'platform-error-state');
const requiredGuard = registry.requiredPatternGuards?.find(
(guard) => guard.id === 'platform-error-state-required',
);
const requiredConsumerPaths = [
'src/features/docker/DockerPageSurface.tsx',
'src/features/kubernetes/KubernetesPageSurface.tsx',
'src/features/proxmox/ProxmoxBackupsTable.tsx',
'src/features/proxmox/ProxmoxPageSurface.tsx',
'src/features/proxmox/ProxmoxReplicationTable.tsx',
'src/features/standalone/StandalonePageSurface.tsx',
'src/features/truenas/TrueNASPageSurface.tsx',
'src/features/truenas/TrueNASProtectionTable.tsx',
'src/features/vmware/VmwarePageSurface.tsx',
];
const localRefreshButtonClass =
'inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover';
expect(registeredRule?.canonical?.path).toBe(
'src/features/platformPage/sharedPlatformPage.tsx',
);
expect(registeredRule?.canonical?.export).toBe('PlatformErrorState');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual(
expect.arrayContaining(requiredConsumerPaths),
);
expect(requiredGuard?.canonical?.path).toBe('src/features/platformPage/sharedPlatformPage.tsx');
expect(requiredGuard?.canonical?.export).toBe('PlatformErrorState');
expect(requiredGuard?.triggerPatterns).toEqual(['title="Could not load']);
expect(requiredGuard?.requiredPatterns).toEqual(['PlatformErrorState']);
expect(requiredGuard?.allowedPaths ?? []).toHaveLength(0);
expect(requiredGuard?.scopes).toEqual(
expect.arrayContaining([
'src/features/docker',
'src/features/kubernetes',
'src/features/proxmox',
'src/features/standalone',
'src/features/truenas',
'src/features/vmware',
]),
);
expect(sharedPlatformPageSource).toContain('export function PlatformErrorState');
expect(sharedPlatformPageSource).toContain('TriangleAlertIcon');
expect(sharedPlatformPageSource).toContain('onClick={props.onRefresh}');
expect(sharedPlatformPageSource).toContain('Refresh');
for (const source of [
dockerPageSurfaceSource,
kubernetesPageSurfaceSource,
proxmoxBackupsTableSource,
proxmoxPageSurfaceSource,
proxmoxReplicationTableSource,
standalonePageSurfaceSource,
truenasPageSurfaceSource,
truenasProtectionTableSource,
vmwarePageSurfaceSource,
]) {
expect(source).toContain('PlatformErrorState');
}
for (const source of [proxmoxBackupsTableSource, proxmoxReplicationTableSource]) {
expect(source).not.toContain(localRefreshButtonClass);
}
});
it('keeps platform row-detail disclosure controls on the shared toggle primitive', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
requiredPatternGuards?: Array<{

View file

@ -6,7 +6,10 @@ import { useSearchParams } from '@solidjs/router';
import { FilterBar, type FilterDef, type FilterSelectOption } from '@/components/shared/FilterBar';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { apiFetch } from '@/utils/apiClient';
import { PlatformTableEmptyState } from '@/features/platformPage/sharedPlatformPage';
import {
PlatformErrorState,
PlatformTableLoadingState,
} from '@/features/platformPage/sharedPlatformPage';
import {
getRecoveryFilterDateLabel,
recoveryDateKeyFromTimestamp,
@ -437,27 +440,17 @@ export const ProxmoxBackupsTable: Component<{
<Show
when={!backups.error}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
<PlatformErrorState
title="Could not load Proxmox backup inventory"
description={(backups.error as Error | undefined)?.message ?? 'Refresh to retry.'}
actions={
<button
type="button"
onClick={() => void refetch()}
class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover"
>
Refresh
</button>
}
onRefresh={() => void refetch()}
/>
}
>
<Show
when={backups() !== undefined}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
<PlatformTableLoadingState
title="Loading Proxmox backup inventory"
description="Reading PBS snapshots, PVE backup files, guest snapshots, and recent backup tasks."
/>

View file

@ -8,10 +8,12 @@ import { apiFetch } from '@/utils/apiClient';
import { formatRelativeTime } from '@/utils/format';
import {
PlatformTableToolbar,
PlatformErrorState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
PlatformTableEmptyState,
PlatformTableLoadingState,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import type { ReplicationJob, ReplicationJobsResponse } from '@/types/api';
@ -197,27 +199,17 @@ export const ProxmoxReplicationTable: Component<{
<Show
when={!props.error}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
<PlatformErrorState
title="Could not load replication jobs"
description={(props.error as Error | undefined)?.message ?? 'Refresh to retry.'}
actions={
<button
type="button"
onClick={() => props.onRetry()}
class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover"
>
Refresh
</button>
}
onRefresh={() => props.onRetry()}
/>
}
>
<Show
when={props.jobs !== undefined}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
<PlatformTableLoadingState
title="Loading replication jobs"
description="Reading scheduled replication state from PVE."
/>

View file

@ -4,6 +4,7 @@ import type { JSX } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProxmoxBackupsTable } from '../ProxmoxBackupsTable';
import proxmoxBackupsTableSource from '../ProxmoxBackupsTable.tsx?raw';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
@ -278,4 +279,14 @@ describe('ProxmoxBackupsTable', () => {
);
expect(emptyStateHeading.closest('.border-dashed')).not.toBeNull();
});
it('routes top-level loading and error states through shared platform primitives', () => {
expect(proxmoxBackupsTableSource).toContain('PlatformErrorState');
expect(proxmoxBackupsTableSource).toContain('PlatformTableLoadingState');
expect(proxmoxBackupsTableSource).toContain('title="Could not load Proxmox backup inventory"');
expect(proxmoxBackupsTableSource).toContain('title="Loading Proxmox backup inventory"');
expect(proxmoxBackupsTableSource).not.toContain(
'inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover',
);
});
});