From 87e19f5101c3df055f54f16bc8caf341fa4d0c2b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Jun 2026 15:25:25 +0100 Subject: [PATCH] Register shared status dot governance --- .../subsystems/frontend-primitives.md | 7 ++ .../internal/subsystems/storage-recovery.md | 6 ++ .../scripts/shared-template-registry.json | 44 +++++++++++ .../components/Storage/StoragePoolDetail.tsx | 9 ++- .../SharedPrimitives.guardrails.test.ts | 78 +++++++++++++++++++ .../shared/__tests__/StatusDot.test.tsx | 42 ++++++++++ .../__tests__/diskDetailPresentation.test.ts | 6 +- .../storageBackups/diskDetailPresentation.ts | 5 +- .../frontendResourceTypeBoundaries.test.ts | 5 +- 9 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 frontend-modern/src/components/shared/__tests__/StatusDot.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 824db57943..68ffebef1e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2416,6 +2416,13 @@ small leading status-dot glyph used by filter options. Page and feature surfaces must not copy the chip-dot `` factory or import the legacy `PageControls` deck for resource-list filtering; those drift checks live in `shared-template-registry.json` and run through `shared-template-audit.mjs`. +Status indicator dots are registry-backed too. `StatusDot` owns the shared +size, color-token, pulse, title, aria, and decorative-status behavior for +resource and health dots, while feature owners supply only the status +semantics. Storage linked-disk health rows must derive a `StatusDot` variant +through `getLinkedDiskHealthDotVariant` and must not recreate local rounded +green/yellow span classes in storage components or storage-backup presentation +helpers. Search controls are registry-backed too. `SearchField` owns simple search input chrome, clear affordance, keyboard forwarding, focus handling, aria labels, and trailing-control padding, while `SearchInput` owns resource-list diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 41192379ed..c94edb8689 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1678,6 +1678,12 @@ payload as its disk-health truth. When API-backed platforms such as TrueNAS raise SMART-backed disk incidents, those reasons must surface through `physicalDisk.risk.reasons` so storage rows and disk detail use the same shared disk-health contract instead of depending on incident-only side channels. +Linked-disk health indicators on storage pool detail rows keep the same owner +split: storage derives the health semantics through +`getLinkedDiskHealthDotVariant`, while the shared frontend primitive +`StatusDot` owns the visible dot chrome, size, color-token, and aria behavior. +Storage components and `features/storageBackups` presentation helpers must not +recreate raw green/yellow rounded-dot classes locally. That same storage page ownership now also includes contextual focus behavior for pools and disks. Expanding a storage row may set a focused metrics-target ID for shared summary emphasis, but `frontend-modern/src/components/Storage/StorageSummary.tsx` diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 20ab27ec50..879c596609 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -155,6 +155,31 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "status-dot-shell", + "category": "status-indicator", + "summary": "Resource and health indicator dots must compose StatusDot for shared size, variant, pulse, title, and aria behavior instead of recreating local rounded status spans.", + "canonical": { + "path": "src/components/shared/StatusDot.tsx", + "export": "StatusDot" + }, + "requiredConsumers": [ + { "path": "src/components/Settings/DiagnosticsResultsPanel.tsx" }, + { "path": "src/components/Settings/ResourcePicker.tsx" }, + { "path": "src/components/Storage/StoragePoolDetail.tsx" }, + { "path": "src/components/shared/NodeGroupHeader.tsx" }, + { "path": "src/features/alerts/AlertOverviewStatsCards.tsx" }, + { "path": "src/features/docker/DockerHostsTable.tsx" }, + { "path": "src/features/kubernetes/KubernetesNodesTable.tsx" }, + { "path": "src/features/truenas/TrueNASSystemsTable.tsx" }, + { "path": "src/features/vmware/VsphereHostsTable.tsx" } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/__tests__/StatusDot.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "platform-table-loading-state", "category": "platform-table-loading-state", @@ -661,6 +686,25 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "status-dot-local-linked-disk-health-dot", + "category": "status-indicator", + "summary": "Storage linked-disk health rows must not recreate the retired green/yellow raw dot classes owned by StatusDot.", + "canonical": { + "path": "src/components/shared/StatusDot.tsx", + "export": "StatusDot" + }, + "scopes": ["src/components/Storage", "src/features/storageBackups"], + "extensions": [".ts", ".tsx"], + "allPatterns": ["h-2 w-2 rounded-full bg-yellow-500", "h-2 w-2 rounded-full bg-green-500"], + "legacyReason": "Retired migration debt. Linked-disk health indicators must use StatusDot variants instead of local green/yellow dot classes.", + "allowedPaths": [], + "ignoredPaths": ["src/components/shared/SharedPrimitives.guardrails.test.ts"], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "filter-chip-status-dot-local-factory", "category": "filter-bar", diff --git a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx index 65a681495b..d0f368d1bb 100644 --- a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx @@ -1,6 +1,7 @@ import { Component, For, Show, createEffect, createMemo } from 'solid-js'; import { FormSelect } from '@/components/shared/FormSelect'; import { HistoryChart } from '@/components/shared/HistoryChart'; +import { StatusDot } from '@/components/shared/StatusDot'; import type { HistoryTimeRange } from '@/api/charts'; import { maxHistoryDays } from '@/stores/license'; import { @@ -8,7 +9,7 @@ import { resolveHistoryRangeWithinLimit, } from '@/components/Storage/historyRangeAccess'; import { - getLinkedDiskHealthDotClass, + getLinkedDiskHealthDotVariant, getLinkedDiskTemperatureTextClass, } from '@/features/storageBackups/diskDetailPresentation'; import { @@ -199,8 +200,10 @@ export const StoragePoolDetail: Component = (props) => { {disk.devPath} - {disk.model} diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 335c6ac0f6..2795d97066 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -38,6 +38,7 @@ import searchInputSource from '@/components/shared/SearchInput.tsx?raw'; import searchInputEnhancementsSource from '@/components/shared/SearchInputEnhancements.tsx?raw'; import searchInputEnhancementsModelSource from '@/components/shared/searchInputEnhancementsModel.ts?raw'; import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw'; +import statusDotSource from '@/components/shared/StatusDot.tsx?raw'; import statusBadgeSource from '@/components/shared/StatusBadge.tsx?raw'; import statusBadgeModelSource from '@/components/shared/statusBadgeModel.ts?raw'; import discoveryReadinessBadgeSource from '@/components/shared/DiscoveryReadinessBadge.tsx?raw'; @@ -139,7 +140,9 @@ import storageGroupPresentationSource from '@/features/storageBackups/groupPrese import storagePoolRowSource from '@/components/Storage/StoragePoolRow.tsx?raw'; import storageContentCardSource from '@/components/Storage/StorageContentCard.tsx?raw'; import storagePoolsTableSource from '@/components/Storage/StoragePoolsTable.tsx?raw'; +import storagePoolDetailSource from '@/components/Storage/StoragePoolDetail.tsx?raw'; import diskListSource from '@/components/Storage/DiskList.tsx?raw'; +import alertOverviewStatsCardsSource from '@/features/alerts/AlertOverviewStatsCards.tsx?raw'; import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw'; import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw'; import alertResourceTableDesktopSource from '@/components/Alerts/AlertResourceTableDesktop.tsx?raw'; @@ -148,6 +151,7 @@ import resourceDetailSummarySource from '@/components/Infrastructure/ResourceDet import aiSettingsDialogsSource from '@/components/Settings/AISettingsDialogs.tsx?raw'; import agentProfilesPanelSource from '@/components/Settings/AgentProfilesPanel.tsx?raw'; import apiTokenManagerSource from '@/components/Settings/APITokenManager.tsx?raw'; +import diagnosticsResultsPanelSource from '@/components/Settings/DiagnosticsResultsPanel.tsx?raw'; import generalSettingsPanelSource from '@/components/Settings/GeneralSettingsPanel.tsx?raw'; import organizationAccessMembersSectionSource from '@/components/Settings/OrganizationAccessMembersSection.tsx?raw'; import organizationIncomingSharesSectionSource from '@/components/Settings/OrganizationIncomingSharesSection.tsx?raw'; @@ -171,6 +175,7 @@ import savedViewsMenuSource from '@/components/shared/FilterBar/SavedViewsMenu.t import useSavedViewsSource from '@/components/shared/FilterBar/useSavedViews.ts?raw'; import storagePageControlsSource from '@/components/Storage/StoragePageControls.tsx?raw'; import proxmoxBackupsTableSharedSource from '@/features/proxmox/proxmoxBackupsTableShared.tsx?raw'; +import truenasSystemsTableSource from '@/features/truenas/TrueNASSystemsTable.tsx?raw'; const sharedSources = import.meta.glob(['./*.tsx', './cards/*.tsx', './responsive/*.tsx'], { query: '?raw', @@ -1126,6 +1131,79 @@ describe('shared primitive guardrails', () => { expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'"); }); + it('keeps resource status dots on the shared StatusDot primitive', () => { + const registry = JSON.parse(sharedTemplateRegistrySource) as { + rules?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + requiredConsumers?: Array<{ path?: string }>; + }>; + patternGuards?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + allPatterns?: string[]; + scopes?: string[]; + allowedPaths?: string[]; + ignoredPaths?: string[]; + }>; + }; + const registeredRule = registry.rules?.find((rule) => rule.id === 'status-dot-shell'); + const registeredGuard = registry.patternGuards?.find( + (guard) => guard.id === 'status-dot-local-linked-disk-health-dot', + ); + + expect(registeredRule?.canonical?.path).toBe('src/components/shared/StatusDot.tsx'); + expect(registeredRule?.canonical?.export).toBe('StatusDot'); + expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ + 'src/components/Settings/DiagnosticsResultsPanel.tsx', + 'src/components/Settings/ResourcePicker.tsx', + 'src/components/Storage/StoragePoolDetail.tsx', + 'src/components/shared/NodeGroupHeader.tsx', + 'src/features/alerts/AlertOverviewStatsCards.tsx', + 'src/features/docker/DockerHostsTable.tsx', + 'src/features/kubernetes/KubernetesNodesTable.tsx', + 'src/features/truenas/TrueNASSystemsTable.tsx', + 'src/features/vmware/VsphereHostsTable.tsx', + ]); + expect(registeredGuard?.canonical?.path).toBe('src/components/shared/StatusDot.tsx'); + expect(registeredGuard?.canonical?.export).toBe('StatusDot'); + expect(registeredGuard?.allPatterns).toEqual([ + 'h-2 w-2 rounded-full bg-yellow-500', + 'h-2 w-2 rounded-full bg-green-500', + ]); + expect(registeredGuard?.scopes).toEqual([ + 'src/components/Storage', + 'src/features/storageBackups', + ]); + expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0); + expect(registeredGuard?.ignoredPaths).toEqual([ + 'src/components/shared/SharedPrimitives.guardrails.test.ts', + ]); + + expect(statusDotSource).toContain('const VARIANT_CLASSES'); + expect(statusDotSource).toContain("warning: 'bg-amber-500 dark:bg-amber-400'"); + expect(statusDotSource).toContain('aria-hidden={ariaHidden()}'); + expect(storagePoolDetailSource).toContain('StatusDot'); + expect(storagePoolDetailSource).toContain('getLinkedDiskHealthDotVariant'); + expect(storagePoolDetailSource).not.toContain('getLinkedDiskHealthDotClass'); + expect(storagePoolDetailSource).not.toContain('h-2 w-2 rounded-full bg-yellow-500'); + expect(storagePoolDetailSource).not.toContain('h-2 w-2 rounded-full bg-green-500'); + + for (const source of [ + diagnosticsResultsPanelSource, + resourcePickerSource, + storagePoolDetailSource, + nodeGroupHeaderSource, + alertOverviewStatsCardsSource, + dockerHostsTableSource, + kubernetesNodesTableSource, + truenasSystemsTableSource, + vsphereHostsTableSource, + ]) { + expect(source).toContain('StatusDot'); + } + }); + it('keeps discovery readiness badges presentation-only and accessible', () => { expect(discoveryReadinessBadgeSource).toContain( "import type { DiscoveryReadinessPresentation } from '@/utils/resourceDiscoveryReadiness'", diff --git a/frontend-modern/src/components/shared/__tests__/StatusDot.test.tsx b/frontend-modern/src/components/shared/__tests__/StatusDot.test.tsx new file mode 100644 index 0000000000..7ec29a0abd --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/StatusDot.test.tsx @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { StatusDot } from '@/components/shared/StatusDot'; + +afterEach(cleanup); + +describe('StatusDot', () => { + it('renders shared variant, size, pulse, and accessible status semantics', () => { + render(() => ( + + )); + + const dot = screen.getByRole('img', { name: 'Attention' }); + + expect(dot).toHaveClass('inline-block'); + expect(dot).toHaveClass('rounded-full'); + expect(dot).toHaveClass('flex-shrink-0'); + expect(dot).toHaveClass('h-2.5'); + expect(dot).toHaveClass('w-2.5'); + expect(dot).toHaveClass('bg-amber-500'); + expect(dot).toHaveClass('animate-pulse'); + expect(dot).toHaveAttribute('title', 'Attention'); + }); + + it('hides decorative indicators from assistive tech by default', () => { + render(() => ); + + const dot = document.querySelector('span'); + + expect(dot).toHaveClass('h-1.5'); + expect(dot).toHaveClass('w-1.5'); + expect(dot).toHaveClass('bg-emerald-500'); + expect(dot).toHaveAttribute('aria-hidden', 'true'); + expect(dot).not.toHaveAttribute('role'); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts index 94b3cfbef4..828c0de7e1 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts @@ -7,7 +7,7 @@ import { getDiskDetailHistoryCharts, getDiskDetailHistoryFallbackMessage, getDiskDetailLiveBadgeLabel, - getLinkedDiskHealthDotClass, + getLinkedDiskHealthDotVariant, getLinkedDiskTemperatureTextClass, } from '@/features/storageBackups/diskDetailPresentation'; @@ -18,8 +18,8 @@ describe('diskDetailPresentation', () => { }); it('returns canonical linked-disk state presentation', () => { - expect(getLinkedDiskHealthDotClass(true)).toBe('h-2 w-2 rounded-full bg-yellow-500'); - expect(getLinkedDiskHealthDotClass(false)).toBe('h-2 w-2 rounded-full bg-green-500'); + expect(getLinkedDiskHealthDotVariant(true)).toBe('warning'); + expect(getLinkedDiskHealthDotVariant(false)).toBe('success'); expect(getLinkedDiskTemperatureTextClass(65)).toBe('text-red-500'); expect(getLinkedDiskTemperatureTextClass(55)).toBe('text-yellow-500'); }); diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts index 1eedf573a5..6990856e70 100644 --- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts @@ -2,13 +2,14 @@ import type { PhysicalDiskPresentationData } from '@/features/storageBackups/dis import type { HistoryTimeRange } from '@/api/charts'; import { formatPowerOnHours } from '@/utils/format'; import { formatTemperature } from '@/utils/temperature'; +import type { StatusIndicatorVariant } from '@/utils/status'; export function getDiskAttributeValueTextClass(ok: boolean): string { return ok ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'; } -export function getLinkedDiskHealthDotClass(hasIssue: boolean): string { - return hasIssue ? 'h-2 w-2 rounded-full bg-yellow-500' : 'h-2 w-2 rounded-full bg-green-500'; +export function getLinkedDiskHealthDotVariant(hasIssue: boolean): StatusIndicatorVariant { + return hasIssue ? 'warning' : 'success'; } export function getLinkedDiskTemperatureTextClass(tempCelsius: number): string { diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 63454a6bfd..a32d8ac9b5 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -1260,7 +1260,8 @@ describe('frontend resource type boundaries', () => { expect(diskListSource).not.toContain('function extractDiskData('); expect(diskListSource).not.toContain('const filteredDisks = createMemo(() => {'); expect(diskListSource).not.toContain('const diskDataById = createMemo(() => {'); - expect(storagePoolDetailSource).toContain('getLinkedDiskHealthDotClass'); + expect(storagePoolDetailSource).toContain('StatusDot'); + expect(storagePoolDetailSource).toContain('getLinkedDiskHealthDotVariant'); expect(storagePoolDetailSource).toContain('getLinkedDiskTemperatureTextClass'); expect(storagePoolDetailSource).toContain('getZfsScanTextClass'); expect(storagePoolDetailSource).toContain('getZfsErrorTextClass'); @@ -1714,7 +1715,7 @@ describe('frontend resource type boundaries', () => { expect(diskDetailPresentationSource).toContain( 'export function getDiskAttributeValueTextClass', ); - expect(diskDetailPresentationSource).toContain('export function getLinkedDiskHealthDotClass'); + expect(diskDetailPresentationSource).toContain('export function getLinkedDiskHealthDotVariant'); expect(diskDetailPresentationSource).toContain( 'export function getLinkedDiskTemperatureTextClass', );