diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a790716874..34c44bf0e5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8f9ae5d4f3..41192379ed 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index bb02f87f12..01e29d2217 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 2a6e496e89..f1539ddbd6 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -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", diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 2bbb1be5c3..04edce3c07 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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<{ diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index a8981be60e..9ba2d61621 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -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<{ 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 - - } + onRefresh={() => void refetch()} /> } > diff --git a/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx index 0fc19df3cd..bd3420e659 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx @@ -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<{ 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 - - } + onRefresh={() => props.onRetry()} /> } > diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index 7176ebbf2a..8a955a0251 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -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', + ); + }); });