diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 75c22dcc81..b97a180de4 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -8656,3 +8656,15 @@ other platforms. A TrueNAS system still opens the Overview tab unfiltered, because its table has no search box, so service drawers keep no owner link. The Overview alerts table keeps its own search. No resource identity, status or alert rule changes. + +Drawer detail rows that carry a sentence rather than a value wrap instead of +cutting off with an ellipsis: the TrueNAS pool and disk storage, protection, +rebuild, risk, posture and scan summaries, and the vSphere hardware upgrade and +VMware Tools install errors, set the row's `wrap`. Short values stay on one +line. The availability fleet view opens the shared resource drawer as a +full-width grid item directly after the selected tile (a dense grid keeps the +other tiles packed), so it lands beneath that tile's row instead of below the +whole fleet; the fleet list is reconciled by resource id so a live snapshot +updates the open drawer in place rather than remounting it, and closing it from +its header returns focus to the tile. No resource identity, status or +availability rule changes. diff --git a/frontend-modern/browser-verification/a3c25d32db667caf.json b/frontend-modern/browser-verification/a3c25d32db667caf.json new file mode 100644 index 0000000000..914b6af244 --- /dev/null +++ b/frontend-modern/browser-verification/a3c25d32db667caf.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "base_sha": "2130d8962b09529c5fb0f0551d6adf3d5a9d7339", + "verified_at": "2026-10-22T18:13:54Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "27cb22683f596c8c7d4f0db9e2793f236b2f70cd0daeb21be6b41a94d6523680", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts": "0e7e6412fc453a8a1b85969c87da13f420d57f5beaa53422968a67994bcaa850", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx": "81a497f53b7f54a22187d5fdb3c08bcea794c0f996ead5b6d49317c54b2532d1" + }, + "routes": [ + "/standalone/availability?view=fleet", + "/truenas/storage" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Embedded production build of this change on the full mock estate, signed in through the real login", + "Availability fleet: opening the second tile puts the shared resource drawer 8px beneath that tile's row, spanning the full grid width, with the remaining tiles packed after it", + "Closing the fleet drawer from its header collapse button removes it and returns keyboard focus to the tile", + "TrueNAS storage pool drawer: the storage, protection and posture sentences (\"ZFS pool archive is DEGRADED...\") wrap with no clipped values at desktop width", + "No console or page errors at either width" + ], + "interactions": [ + "Clicking a fleet tile opens the drawer below it; the drawer header's collapse button closes it", + "Expanding a TrueNAS pool row opens its drawer" + ], + "browser": { + "client": "playwright 1.62.1", + "engine": "Chromium", + "version": "chromium-1234 headless" + }, + "scope": "Full authenticated application on a mock estate; mock data, not live provider connections." +} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.coverage.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.coverage.test.ts index 105d32178c..d3af54b79a 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.coverage.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.coverage.test.ts @@ -325,6 +325,7 @@ describe('hardwareRows (via Virtual hardware section)', () => { label: 'Upgrade error', value: 'timed out', tone: 'warning', + wrap: true, }); }); @@ -400,6 +401,7 @@ describe('toolsRows (via VMware Tools section)', () => { label: 'Last install error', value: 'boom', tone: 'warning', + wrap: true, }); }); }); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts index 0e779b0499..ee39a4a3a3 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts @@ -94,7 +94,9 @@ const compactRows = compactDetailRows; const compactSections = compactDetailSections; // Identifiers and paths an operator copies (hostnames, serials, dataset -// paths, addresses) wrap in full instead of ending in an ellipsis. +// paths, addresses) and the sentences TrueNAS reports (risk, posture, +// protection, rebuild and scan summaries) wrap in full instead of ending in an +// ellipsis that a phone cannot hover to read. const wrapRow = ( label: string, value?: string | null, @@ -193,20 +195,20 @@ const buildTrueNASSystemSections = ( row('Storage risk', normalizeDelimitedLabel(truenas.storageRisk?.level), { tone: truenas.storageRisk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default', }), - row('Storage summary', asString(truenas.storageRiskSummary), { tone: 'warning' }), - row('Storage posture', asString(truenas.storagePostureSummary), { + wrapRow('Storage summary', asString(truenas.storageRiskSummary), { tone: 'warning' }), + wrapRow('Storage posture', asString(truenas.storagePostureSummary), { tone: truenas.storagePostureSummary ? 'warning' : 'default', }), row('Protection reduced', yesNoValue(truenas.protectionReduced), { tone: truenas.protectionReduced ? 'warning' : 'success', }), - row('Protection summary', asString(truenas.protectionSummary), { + wrapRow('Protection summary', asString(truenas.protectionSummary), { tone: truenas.protectionSummary ? 'warning' : 'default', }), row('Rebuild active', yesNoValue(truenas.rebuildInProgress), { tone: truenas.rebuildInProgress ? 'warning' : 'success', }), - row('Rebuild', asString(truenas.rebuildSummary)), + wrapRow('Rebuild', asString(truenas.rebuildSummary)), ]); const serviceRows = compactRows([ @@ -383,18 +385,18 @@ const buildTrueNASStorageSections = ( row('Risk', normalizeDelimitedLabel(storage.risk?.level), { tone: storage.risk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default', }), - row('Risk summary', asString(storage.riskSummary), { tone: 'warning' }), - row('Posture', asString(storage.postureSummary), { + wrapRow('Risk summary', asString(storage.riskSummary), { tone: 'warning' }), + wrapRow('Posture', asString(storage.postureSummary), { tone: storage.postureSummary ? 'warning' : 'default', }), row('Protection reduced', yesNoValue(storage.protectionReduced), { tone: storage.protectionReduced ? 'warning' : 'success', }), - row('Protection summary', asString(storage.protectionSummary), { + wrapRow('Protection summary', asString(storage.protectionSummary), { tone: storage.protectionSummary ? 'warning' : 'default', }), - row('Rebuild', asString(storage.rebuildSummary)), - row('Scan / resilver', zfsScanLabel(storage), { + wrapRow('Rebuild', asString(storage.rebuildSummary)), + wrapRow('Scan / resilver', zfsScanLabel(storage), { tone: storage.zfsPool?.scanDetails?.errors ? 'warning' : 'default', }), row('ZFS errors', zfsErrorLabel(storage), { tone: 'warning' }), diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts index f93ccb715b..c62cf53cf8 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts @@ -241,7 +241,10 @@ const hardwareRows = (vmware?: ResourceVMwareMeta): DetailRow[] => { makeRow('Upgrade status', upgradeAttention ? formatEnumLabel(hardware.upgradeStatus) : '', { tone: getWarningTone(upgradeAttention), }), - makeRow('Upgrade error', asTrimmedString(hardware.upgradeErrorMessage), { tone: 'warning' }), + makeRow('Upgrade error', asTrimmedString(hardware.upgradeErrorMessage), { + tone: 'warning', + wrap: true, + }), makeRow( 'Instant clone frozen', hardware.instantCloneFrozen === true ? formatBoolLabel(true) : '', @@ -291,7 +294,10 @@ const toolsRows = (tools: ResourceVMwareTools | undefined, poweredOff: boolean): makeRow('Guest reboot', tools.guestRebootRequested === true ? 'Requested' : '', { tone: getWarningTone(tools.guestRebootRequested === true), }), - makeRow('Last install error', asTrimmedString(tools.errorMessage), { tone: 'warning' }), + makeRow('Last install error', asTrimmedString(tools.errorMessage), { + tone: 'warning', + wrap: true, + }), ]); }; diff --git a/frontend-modern/src/features/standalone/AvailabilityFleetView.tsx b/frontend-modern/src/features/standalone/AvailabilityFleetView.tsx index 71e3fdb735..6522bd9dcf 100644 --- a/frontend-modern/src/features/standalone/AvailabilityFleetView.tsx +++ b/frontend-modern/src/features/standalone/AvailabilityFleetView.tsx @@ -1,5 +1,14 @@ import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; -import { For, Show, createEffect, createMemo, createSignal, type Component } from 'solid-js'; +import { + createComputed, + For, + Show, + createEffect, + createMemo, + createSignal, + type Component, +} from 'solid-js'; +import { createStore, reconcile } from 'solid-js/store'; import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer'; import { Button } from '@/components/shared/Button'; @@ -230,6 +239,11 @@ export const AvailabilityFleetView: Component<{ onRetryHistory?: () => void; }> = (props) => { const now = useRelativeTimeNow(); + // Live snapshots replace resource objects. Reconciled by id, each tile (and + // an open drawer) keeps its identity and updates in place instead of + // remounting, and losing the drawer's tab, on every poll. + const [fleet, setFleet] = createStore([]); + createComputed(() => setFleet(reconcile([...props.resources], { key: 'id' }))); const [selectedResourceId, setSelectedResourceId] = createSignal(); const selectedResource = createMemo(() => props.resources.find((resource) => resource.id === selectedResourceId()), @@ -241,10 +255,20 @@ export const AvailabilityFleetView: Component<{ }); const resolveResourceLabel = (resourceId: string): string | undefined => props.resources.find((resource) => resource.id === resourceId)?.name; - // Tiles are disclosures for the one drawer below the grid: a second click - // on the open tile closes it, as a second click on a table row does. + // Tiles are disclosures for one drawer: a second click on the open tile + // closes it, as a second click on a table row does. const toggleResource = (resourceId: string) => setSelectedResourceId((current) => (current === resourceId ? undefined : resourceId)); + // Closing from the drawer's header removes the focused control, so focus + // returns to the tile that opened it, as a table drawer returns it to its row. + const closeResource = (resourceId: string) => { + setSelectedResourceId(undefined); + queueMicrotask(() => + Array.from(document.querySelectorAll('[data-availability-fleet-tile]')) + .find((tile) => tile.dataset.availabilityFleetTile === resourceId) + ?.focus({ preventScroll: true }), + ); + }; return (
@@ -276,11 +300,15 @@ export const AvailabilityFleetView: Component<{ + {/* The open check's drawer is a full-width grid item right after its + tile. Dense packing lets the rest of that tile's row fill in first, + so the drawer opens directly beneath the tile at every column count + instead of below the whole fleet. */}
- + {(resource) => { const availability = () => availabilityFor(resource); const probe = () => getAvailabilityProbePresentation(resource, new Date(now())); @@ -314,104 +342,103 @@ export const AvailabilityFleetView: Component<{ }; return ( -
-
- - {probe()?.methodLabel ?? availability()?.protocol ?? 'Probe'} - - - {source()} - - Checked {checked()} - {(value) => {value()}} -
+
+ + {probe()?.methodLabel ?? availability()?.protocol ?? 'Probe'} + + + {source()} + + Checked {checked()} + {(value) => {value()}} +
-
- - } - > - - - -
- {availabilityText(history())} - - - {history()?.revisionBoundaries?.length} revision - {history()?.revisionBoundaries?.length === 1 ? '' : 's'} - +
+ + } + > + + +
+ {availabilityText(history())} + + + {history()?.revisionBoundaries?.length} revision + {history()?.revisionBoundaries?.length === 1 ? '' : 's'} + + +
-
- + + {/* The same check reads the same here as in the table view: the + table-row drawer, headed by the dot its tile shows. Keyed by + id, so switching tiles resets the drawer's tabs. */} + +
+ closeResource(resource.id)} + /> +
+
+ ); }}
- - {/* The same check reads the same here as in the table view: the - table-row drawer, headed by the dot its tile shows. Keyed by id, so - switching tiles resets the drawer's tabs. */} -
- - - {(resource) => ( -
- setSelectedResourceId(undefined)} - /> -
- )} -
-
-
); }; diff --git a/frontend-modern/src/features/standalone/__tests__/AvailabilityFleetView.test.tsx b/frontend-modern/src/features/standalone/__tests__/AvailabilityFleetView.test.tsx index 251da8c775..e039fcc62d 100644 --- a/frontend-modern/src/features/standalone/__tests__/AvailabilityFleetView.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/AvailabilityFleetView.test.tsx @@ -1,30 +1,38 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createSignal } from 'solid-js'; +import { createSignal, onCleanup } from 'solid-js'; import type { AvailabilityHistoryTarget } from '@/api/availabilityHistory'; import type { Resource } from '@/types/resource'; import { AvailabilityFleetView } from '../AvailabilityFleetView'; import { getStandaloneResourceStatusIndicator } from '../standalonePageModel'; +const drawerMounts = vi.hoisted(() => ({ count: 0 })); + vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({ ResourceDetailDrawer: (props: { resource: Resource; onClose?: () => void; presentation?: string; statusIndicator?: { variant: string; label: string }; - }) => ( -
- -
- ), + }) => { + drawerMounts.count += 1; + onCleanup(() => { + drawerMounts.count -= 1; + }); + return ( +
+ +
+ ); + }, })); const resource = (index: number): Resource => @@ -174,11 +182,17 @@ describe('AvailabilityFleetView', () => { const first = screen.getByRole('button', { name: 'Open details for Service 1' }); const second = screen.getByRole('button', { name: 'Open details for Service 5' }); - const detail = screen.getByTestId('availability-fleet-detail'); expect(first).toHaveAttribute('aria-expanded', 'false'); - expect(first).toHaveAttribute('aria-controls', detail.id); + expect(screen.queryByTestId('availability-fleet-detail')).not.toBeInTheDocument(); fireEvent.click(first); + // The drawer opens right after its tile as a full-width grid item, so it + // lands beneath that tile's row rather than below the whole fleet. + const detail = screen.getByTestId('availability-fleet-detail'); + expect(first).toHaveAttribute('aria-controls', detail.id); + expect(first.nextElementSibling).toBe(detail); + expect(detail).toHaveClass('col-span-full'); + expect(screen.getByTestId('availability-fleet-grid')).toHaveClass('grid-flow-row-dense'); expect(first).toHaveAttribute('aria-expanded', 'true'); expect(first).toHaveClass('border-blue-500'); // The same drawer the checks table opens, headed by the tile's own dot. @@ -202,6 +216,46 @@ describe('AvailabilityFleetView', () => { expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument(); }); + it('returns focus to the tile when the drawer is closed from its header', async () => { + render(() => ( + + )); + + const tile = screen.getByRole('button', { name: 'Open details for Service 2' }); + fireEvent.click(tile); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await Promise.resolve(); + expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument(); + expect(tile).toHaveAttribute('aria-expanded', 'false'); + expect(tile).toHaveFocus(); + }); + + it('keeps an open drawer mounted when a live snapshot replaces the resources', () => { + const [resources, setResources] = createSignal([resource(1), resource(2)]); + render(() => ( + + )); + + fireEvent.click(screen.getByRole('button', { name: 'Open details for Service 2' })); + expect(drawerMounts.count).toBe(1); + const drawer = screen.getByTestId('resource-detail-drawer'); + + // New objects for the same checks, as the websocket store delivers them. + setResources([resource(1), { ...resource(2), status: 'offline' } as Resource]); + + expect(screen.getByTestId('resource-detail-drawer')).toBe(drawer); + expect(drawerMounts.count).toBe(1); + expect(drawer).toHaveAttribute('data-resource-status', 'offline'); + }); + it('keeps current health visible when history is unavailable', () => { render(() => (