diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index bf1638080e..7019a6713d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -542,6 +542,28 @@ squeezing the figures out of the card. `DisksCard.test.tsx` covers both readings and SharedPrimitives.guardrails.test.ts pins the branch and the drawer wiring. +### Retained RAID state + +RaidCard takes the same optional `lastKnownReason`, and the machine drawer +passes it on the same signal as the Disks card. The card keeps every array and +member state, because an array that degraded before the machine went quiet is +evidence. Each state reads " (last known)" in muted text beside a muted +dot, titled "Last known reading, not current: ". A rebuild figure is +not progress once the agent is silent, so where the card shows one (above 0 and +below 100%) it reads "Rebuild was at N%" without the rebuild speed. Member +badges drop their status colour, so a member that was not healthy names its +state in the badge ("/dev/sdb2 · faulty"). The Machines table's RAID column +applies the same rule from `agent.stale` to what it shows: a muted summary and +a tooltip headed "RAID Arrays (last known)" without the rebuild bar or speed. +The array header row wraps, so on a phone the longer state takes its own line +instead of truncating the array name. The card's empty guard is tracked, so a +drawer opened before the first RAID report shows the card once arrays arrive. +`raidPresentation.ts` owns member health: mdadm's healthy "active sync" +members read healthy, where they used to take the amber warning badge. +`RaidCard.test.tsx`, `raidPresentation.test.ts` and +`AgentsMachinesTable.test.tsx` cover both readings and the transitions between +them, and SharedPrimitives.guardrails.test.ts pins the branch and both wirings. + ### Ollama credential editing The provider panel exposes the existing Basic Auth configuration. Saved passwords are represented by presence text, never a placeholder secret or input value. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index c191173944..53e49325b6 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2290,6 +2290,12 @@ keyed on `agent.stale` because those rows are that agent's own sensors. Its Disks card reads the agent's retained filesystem usage as last known on the same signal (`agentReadingsLastKnownReason`): each figure stays, muted and titled with the reason, without threshold colour or usage bars. +Its RAID card and the Machines RAID column and tooltip read the agent's +retained arrays the same way: every array and member state they show stays, as +evidence of what the array was doing when the agent went quiet, but without +live status colour. Where a retained rebuild percentage is shown, it reads +"Rebuild was at N%" with no speed or progress bar, and a member that was not +healthy names its state in its badge. For SMART disk temperatures on rows that still render, the provenance travels on `agent.sensors.smart[].collection`: the Machines temperature cell diff --git a/frontend-modern/browser-verification/05a685ccb29f5883.json b/frontend-modern/browser-verification/05a685ccb29f5883.json new file mode 100644 index 0000000000..ba9bd85237 --- /dev/null +++ b/frontend-modern/browser-verification/05a685ccb29f5883.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "base_sha": "812c712abb97bd5b2824601d8d14010ad9f865ad", + "verified_at": "2026-10-07T10:48:43Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/shared/cards/RaidCard.tsx", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx", + "frontend-modern/src/utils/raidPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "e36aa008a1406010073cf5652591f7d6f381a76f7975abd617fa5442c728ef4e", + "frontend-modern/src/components/shared/cards/RaidCard.tsx": "a14817b710c2bc5d44829673fb2cb0d04cdb471a6074a2f8779b27254307b275", + "frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "26f5ce7c77c598a9e4adb3f984d14bc85268b28ba4126b3d660938edaf085aef", + "frontend-modern/src/utils/raidPresentation.ts": "f195d08190b6bf83f28624c4a1c6afae5808d48a1fbd0a73cb634fa2fb54e0a8" + }, + "routes": [ + "/standalone/machines" + ], + "viewports": [ + { + "width": 1920, + "height": 1080 + }, + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from 812c712abb (PR 2644 head) plus this change, with a local-only go build -overlay of internal/mock/generator.go: host-linux-1 (Apollo-114) held offline past its lease (LastSeen frozen 14 minutes back, readings retained) and seeded with md0 raid1 clean (sda1, sdb1 active sync) and md1 raid1 'clean, degraded, recovering' at rebuildPercent 45, 98000K/sec (sda2 active sync, sdc2 spare rebuilding, sdb2 faulty). The live control Spectrum-709 carries the same two arrays. /api/resources reports Apollo-114 offline with agent.stale true and Spectrum-709 online", + "Base behaviour, read from the base source and its unit tests rather than re-run in the browser: the drawer RAID card and the Machines tooltip gave a silent host's arrays the same live emerald/amber/red states, dots and member badges, 'Rebuild: 45% · 98000K/sec' and the amber rebuild bar as a live host, beside Disks and Thermals already reading last known; healthy 'active sync' members fell to the amber warning badge", + "Patched drawer, silent host (1920, 1440, 390): both arrays render data-raid-reading=last-known; states read 'clean (last known)' and 'clean, degraded, recovering (last known)' in text-muted with a slate dot, titled 'Last known reading, not current: host agent stopped reporting'; md1 reads 'Rebuild was at 45%' with no speed; badges are neutral border-border/bg-surface-alt/text-muted, reading /dev/sda2, '/dev/sdc2 · spare rebuilding', '/dev/sdb2 · faulty'; no emerald, amber or red class anywhere in the card; Disks reads Last known N% and Thermals 45°C (last known) beside it", + "Patched drawer, live control Spectrum-709 (all widths): unchanged colours, md0 clean emerald with emerald badges (active sync now healthy), md1 amber state, 'Rebuild: 45% · 98000K/sec', sdc2 amber and sdb2 red badges, data-raid-reading=current", + "390 wide: before the wrap fix, 'clean, degraded, recovering (last known)' squeezed the md1 name to 'm..' and the level to 'r...'; with the header row wrapping, the name and level keep their width and the state takes its own right-aligned line; live states still fit on one line; no horizontal page scroll at any width", + "Machines table at 1920 (the RAID column is container-hidden at 1440 and 390): silent row summary '1/2 degraded' in text-muted with sr-only ', last known' and aria-label 'RAID details: 1/2 degraded, last known'; live row summary in text-base-content", + "Machines RAID tooltip, silent host: header 'RAID Arrays (last known)', muted states with slate dots, 'Rebuild was at 45%' with no Rebuilding label, bar or speed, Failed 1 in base text, neutral badges naming spare rebuilding and faulty members; zero emerald/amber/red classes in the tooltip. Live host tooltip: unchanged emerald/amber/red classes, amber Rebuilding bar at 45% and 98000K/sec", + "Dark mode at 1440: silent states and badges rgb(148,163,184) on rgb(30,41,59) badge fill; live states emerald rgb(52,211,153) and amber rgb(251,191,36) with tinted badges", + "No page errors or console errors at any width" + ], + "interactions": [ + "Opened the Machines tab with every default-hidden column revealed via localStorage", + "Hovered the RAID cell trigger of the silent and live rows at 1920 to open the RAID tooltip", + "Clicked (desktop) and tapped (390) each row to open its inline drawer, expanded Platform details and pressed Machine 'Show details' to reach the RAID card", + "Scrolled the RAID card into view and read state, rebuild and badge text, classes and titles", + "Repeated the drawer pass with prefers-color-scheme dark at 1440", + "Reloaded after the header-wrap fix and re-ran the full pass at all three widths", + "Re-ran the full pass at all three widths and the dark-mode drawer pass on the final code, after RaidCard's empty guard became tracked" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 3821dbe757..e97666fc42 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -183,7 +183,10 @@ const HostDetailsDisclosure: Component<{ disks={agent().disks} lastKnownReason={props.drawer.agentReadingsLastKnownReason()} /> - + diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 2a6f7284e9..f330a47371 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -364,6 +364,23 @@ describe('shared primitive guardrails', () => { ).toContain('lastKnownReason={props.drawer.agentReadingsLastKnownReason()}'); }); + it("keeps a silent agent's retained RAID state out of live colour and rebuild progress", () => { + const source = readFrontendSource('src/components/shared/cards/RaidCard.tsx'); + expect(source).toContain('lastKnownReason?: string;'); + expect(source).toContain('`Last known reading, not current: ${reason}`'); + expect(source).toMatch(/ { expect(HOST_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([ 'utilization', diff --git a/frontend-modern/src/components/shared/cards/RaidCard.tsx b/frontend-modern/src/components/shared/cards/RaidCard.tsx index 3da1831773..213d99eea3 100644 --- a/frontend-modern/src/components/shared/cards/RaidCard.tsx +++ b/frontend-modern/src/components/shared/cards/RaidCard.tsx @@ -3,7 +3,9 @@ import type { HostRAIDArray } from '@/types/api'; import { InfoCardFrame } from '@/components/shared/InfoCardFrame'; import { StatusDot } from '@/components/shared/StatusDot'; import { + RAID_LAST_KNOWN_DEVICE_BADGE_CLASS, getRaidDeviceBadgeClass, + getRaidLastKnownDeviceLabel, getRaidStateTextClass, getRaidStateVariant, } from '@/utils/raidPresentation'; @@ -11,89 +13,154 @@ import { interface RaidCardProps { arrays?: HostRAIDArray[]; title?: string; + /** + * Why the array states are retained rather than current, such as a host + * agent past its reporting lease. The card keeps every state, since an + * array that degraded before the machine went quiet is evidence, but reads + * them as last known, without live status colour or rebuild progress. + */ + lastKnownReason?: string; } export const RaidCard: Component = (props) => { - if (!props.arrays || props.arrays.length === 0) return null; + const lastKnownTitle = () => { + const reason = props.lastKnownReason?.trim(); + return reason ? `Last known reading, not current: ${reason}` : undefined; + }; + // Tracked, so a drawer opened before the first RAID report still shows it. return ( - -
- {props.title || 'RAID'} -
+ + +
+ {props.title || 'RAID'} +
-
- - {(array) => { - const label = () => - (array.name || '').trim() || (array.device || '').trim() || 'RAID array'; - const variant = () => getRaidStateVariant(array.state); - const stateText = () => (array.state || '').trim() || 'unknown'; - const levelText = () => (array.level || '').trim() || 'unknown'; - const rebuildPercent = () => array.rebuildPercent ?? 0; - const rebuilding = () => - Number.isFinite(rebuildPercent()) && rebuildPercent() > 0 && rebuildPercent() < 100; +
+ + {(array) => { + const label = () => + (array.name || '').trim() || (array.device || '').trim() || 'RAID array'; + const variant = () => getRaidStateVariant(array.state); + const stateText = () => (array.state || '').trim() || 'unknown'; + const levelText = () => (array.level || '').trim() || 'unknown'; + const rebuildPercent = () => array.rebuildPercent ?? 0; + const rebuilding = () => + Number.isFinite(rebuildPercent()) && rebuildPercent() > 0 && rebuildPercent() < 100; - return ( -
-
-
-
+ {/* The state drops to its own line rather than squeezing the + array name when both do not fit, as on a phone. */} +
+
+
+ {label()} +
+
+ {levelText()} +
+
+ + + + + {stateText()} (last known) + +
+ } > - {label()} -
-
- {levelText()} -
-
- -
- - - {stateText()} - -
-
- - -
- Rebuild:{' '} - - {Math.round(rebuildPercent())}% - - - · - {array.rebuildSpeed} +
+ + + {stateText()} + +
-
- 0}> -
- - {(device) => ( - + - {device.device} + Rebuild was at {Math.round(rebuildPercent())}% +
+ } + > +
+ Rebuild:{' '} + + {Math.round(rebuildPercent())}% - )} - -
-
-
- ); - }} -
-
- + + · + {array.rebuildSpeed} + +
+
+ + + 0}> +
+ + {(device) => ( + + {lastKnownTitle() ? getRaidLastKnownDeviceLabel(device) : device.device} + + )} + +
+
+ + ); + }} + + +
+ ); }; diff --git a/frontend-modern/src/components/shared/cards/__tests__/RaidCard.test.tsx b/frontend-modern/src/components/shared/cards/__tests__/RaidCard.test.tsx new file mode 100644 index 0000000000..08815e4989 --- /dev/null +++ b/frontend-modern/src/components/shared/cards/__tests__/RaidCard.test.tsx @@ -0,0 +1,124 @@ +import { cleanup, render, screen, within } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { HostRAIDArray } from '@/types/api'; +import { RaidCard } from '../RaidCard'; + +afterEach(() => { + cleanup(); +}); + +const degradedArray = (): HostRAIDArray => ({ + device: '/dev/md0', + name: 'md0', + level: 'raid1', + state: 'clean, degraded, recovering', + totalDevices: 2, + activeDevices: 1, + workingDevices: 2, + failedDevices: 0, + spareDevices: 1, + devices: [ + { device: '/dev/sda1', state: 'active sync', slot: 0 }, + { device: '/dev/sdb1', state: 'spare rebuilding', slot: 1 }, + ], + rebuildPercent: 45.4, + rebuildSpeed: '98000K/sec', +}); + +const LAST_KNOWN_TITLE = 'Last known reading, not current: host agent stopped reporting'; + +describe('RaidCard', () => { + it('shows a current array with live state colour and rebuild progress', () => { + render(() => ); + + const array = screen.getByTestId('raid-card-array'); + expect(array).toHaveAttribute('data-raid-reading', 'current'); + const state = within(array).getByText('clean, degraded, recovering'); + expect(state).toHaveClass('text-amber-600'); + expect( + within(array).getByRole('img', { name: 'RAID state: clean, degraded, recovering' }), + ).toHaveClass('bg-amber-500'); + expect(within(array).getByTestId('raid-card-rebuild')).toHaveTextContent( + 'Rebuild: 45% · 98000K/sec', + ); + const [healthy, rebuilding] = within(array).getAllByTestId('raid-card-device'); + expect(healthy).toHaveTextContent('/dev/sda1'); + expect(healthy.className).toContain('emerald'); + expect(rebuilding).toHaveTextContent('/dev/sdb1'); + expect(rebuilding.className).toContain('amber'); + }); + + it("keeps a silent agent's array state as last known evidence without live colour", () => { + render(() => ( + + )); + + const array = screen.getByTestId('raid-card-array'); + expect(array).toHaveAttribute('data-raid-reading', 'last-known'); + expect(array.innerHTML).not.toMatch(/emerald|amber|text-red|bg-red/); + + const state = within(array).getByTestId('raid-card-state'); + expect(state).toHaveAttribute('title', LAST_KNOWN_TITLE); + expect(state).toHaveTextContent('clean, degraded, recovering (last known)'); + expect( + within(state).getByRole('img', { + name: 'Last known RAID state: clean, degraded, recovering', + }), + ).toHaveClass('bg-slate-400'); + + // A retained rebuild figure is not progress: past tense, no speed. + const rebuild = within(array).getByTestId('raid-card-rebuild'); + expect(rebuild).toHaveTextContent('Rebuild was at 45%'); + expect(rebuild).toHaveAttribute('title', LAST_KNOWN_TITLE); + expect(array).not.toHaveTextContent('98000K/sec'); + + const [healthy, rebuilding] = within(array).getAllByTestId('raid-card-device'); + expect(healthy).toHaveTextContent(/^\/dev\/sda1$/); + expect(rebuilding).toHaveTextContent('/dev/sdb1 · spare rebuilding'); + expect(rebuilding).toHaveAttribute('title', 'slot 1 • spare rebuilding'); + }); + + it('treats a blank reason as current', () => { + render(() => ); + + expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current'); + }); + + it('follows the reading between current and last known', () => { + const [reason, setReason] = createSignal(); + render(() => ); + + const array = screen.getByTestId('raid-card-array'); + expect(array).toHaveAttribute('data-raid-reading', 'current'); + + setReason('host agent stopped reporting'); + expect(array).toHaveAttribute('data-raid-reading', 'last-known'); + expect(within(array).getByTestId('raid-card-rebuild')).toHaveTextContent('Rebuild was at 45%'); + expect(array.innerHTML).not.toMatch(/emerald|amber|text-red|bg-red/); + + setReason(undefined); + expect(array).toHaveAttribute('data-raid-reading', 'current'); + expect(within(array).getByTestId('raid-card-rebuild')).toHaveTextContent('98000K/sec'); + }); + + it('appears when the first RAID report arrives after mount', () => { + const [arrays, setArrays] = createSignal(); + render(() => ); + expect(screen.queryByTestId('raid-card-array')).not.toBeInTheDocument(); + + setArrays([degradedArray()]); + expect(screen.getByTestId('raid-card-array')).toHaveTextContent('md0'); + + setArrays([]); + expect(screen.queryByTestId('raid-card-array')).not.toBeInTheDocument(); + }); + + it('renders nothing without arrays', () => { + const { container } = render(() => ( + + )); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx index fb4ee7772f..9285d7a9fa 100644 --- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx +++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx @@ -14,6 +14,7 @@ import { import { Portal } from 'solid-js/web'; import { AgentMetadataAPI, type AgentMetadata } from '@/api/agentMetadata'; import { MonitoringAPI } from '@/api/monitoring'; +import { HOST_AGENT_STOPPED_REPORTING_REASON } from '@/components/Infrastructure/resourceDetailMappers'; import { EnhancedCPUBar } from '@/components/Workloads/EnhancedCPUBar'; import { MetricBar } from '@/components/Workloads/MetricBar'; import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar'; @@ -70,7 +71,9 @@ import { useAlertsActivation } from '@/stores/alertsActivation'; import { notificationStore } from '@/stores/notifications'; import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { + RAID_LAST_KNOWN_DEVICE_BADGE_CLASS, getRaidDeviceBadgeClass, + getRaidLastKnownDeviceLabel, getRaidStateTextClass, getRaidStateVariant, } from '@/utils/raidPresentation'; @@ -593,8 +596,16 @@ const AgentMachineDiskIOCell: Component<{ const AgentMachineRaidCell: Component<{ arrays: AgentMachineRaidArrayDetail[]; summary: string; + /** Why the array states are retained rather than current. */ + lastKnownReason?: string; }> = (props) => { const hasDetails = () => props.arrays.length > 0; + // A silent agent's arrays are its last report: keep the states as evidence + // without live status colour or rebuild progress. + const lastKnownTitle = () => { + const reason = props.lastKnownReason?.trim(); + return reason ? `Last known reading, not current: ${reason}` : undefined; + }; const shownArrays = () => props.arrays.slice(0, 6); const hiddenArrayCount = () => Math.max(0, props.arrays.length - shownArrays().length); const triggerLabel = () => props.summary || '—'; @@ -610,23 +621,33 @@ const AgentMachineRaidCell: Component<{ triggerClass="inline-flex max-w-full justify-start" tooltipClass="min-w-[250px] max-w-[380px] space-y-2" enabled={hasDetails()} - ariaLabel={hasDetails() ? `RAID details: ${triggerLabel()}` : 'RAID unavailable'} - title={triggerLabel()} + ariaLabel={ + hasDetails() + ? `RAID details: ${triggerLabel()}${lastKnownTitle() ? ', last known' : ''}` + : 'RAID unavailable' + } + title={lastKnownTitle() ? `${triggerLabel()}. ${lastKnownTitle()}` : triggerLabel()} maxWidth={400} trigger={ {triggerLabel()} + + , last known + } >
-
RAID Arrays
+
+ {lastKnownTitle() ? 'RAID Arrays (last known)' : 'RAID Arrays'} +
{(array, index) => { @@ -651,9 +672,20 @@ const AgentMachineRaidCell: Component<{
-
- - +
+ + {stateLabel(array.state)}
@@ -676,7 +708,13 @@ const AgentMachineRaidCell: Component<{ 0}> Failed{' '} - + {array.failedDevices} @@ -684,23 +722,32 @@ const AgentMachineRaidCell: Component<{
-
-
- Rebuilding - - {Math.round(rebuildPercent())}% - + + Rebuild was at {Math.round(rebuildPercent())}% +
+ } + > +
+
+ Rebuilding + + {Math.round(rebuildPercent())}% + +
+
+
+
+ + {(speed) =>
{speed()}
} +
-
-
-
- - {(speed) =>
{speed()}
} -
-
+ 0}> @@ -708,10 +755,14 @@ const AgentMachineRaidCell: Component<{ {(device) => ( - {device.device} + {lastKnownTitle() ? getRaidLastKnownDeviceLabel(device) : device.device} )} @@ -1854,7 +1905,13 @@ export const AgentsMachinesTable: Component<{ - + diff --git a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx index 3c7f0a3506..128daa6740 100644 --- a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx @@ -1015,6 +1015,90 @@ describe('AgentsMachinesTable', () => { expect(screen.getByText('120 MB/s')).toBeInTheDocument(); }); + it("reads a silent agent's RAID arrays as last known, without live colour or rebuild progress", async () => { + const { container } = render(() => ( + + )); + + await openMachineColumnPicker(); + await fireEvent.click(screen.getByLabelText('RAID')); + + const trigger = container.querySelector('[data-agent-machine-raid-trigger="true"]'); + expect(trigger).not.toBeNull(); + if (!trigger) return; + // The summary keeps the evidence but drops to muted text. + const summary = trigger.querySelector('[data-raid-reading]'); + expect(summary).toHaveAttribute('data-raid-reading', 'last-known'); + expect(summary).toHaveClass('text-muted'); + expect(summary).not.toHaveClass('text-base-content'); + expect(summary).toHaveTextContent('1/1 degraded, last known'); + + await fireEvent.mouseEnter(trigger); + + expect(await screen.findByText('RAID Arrays (last known)')).toBeInTheDocument(); + const state = screen.getByText('recovering'); + expect(state).toHaveClass('text-muted'); + expect(state.className).not.toMatch(/amber|emerald|red/); + expect(state.parentElement).toHaveAttribute( + 'title', + 'Last known reading, not current: host agent stopped reporting', + ); + expect(state.parentElement?.querySelector('span[aria-hidden="true"]')).toHaveClass( + 'bg-slate-400', + ); + expect(screen.getByText('Rebuild was at 37%')).toBeInTheDocument(); + expect(screen.queryByText('Rebuilding')).not.toBeInTheDocument(); + const tooltip = document.querySelector('[data-agent-machine-raid-tooltip="true"]'); + expect(tooltip?.innerHTML).not.toMatch(/(text|bg|border)-(emerald|amber|red)-\d/); + expect(tooltip?.querySelector('[style*="width"]')).toBeNull(); + expect(screen.queryByText('120 MB/s')).not.toBeInTheDocument(); + // Badges lose their colour, so a member that was not healthy names its state. + for (const label of ['/dev/sda', '/dev/sdb · faulty', '/dev/sdc · spare']) { + const badge = screen.getByText(label); + expect(badge.className).not.toMatch(/amber|emerald|red/); + expect(badge).toHaveClass('text-muted'); + } + const failedCount = screen + .getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === 'Failed 1') + .querySelector('.font-mono'); + expect(failedCount).toHaveClass('text-base-content'); + expect(failedCount?.className).not.toMatch(/red/); + }); + it('sorts machines by operational metrics from the column headers', async () => { const { container } = render(() => ( { ); }); + it('reads the mdadm detail state of a healthy member as healthy', () => { + // The agent reports mdadm --detail member states verbatim. + for (const state of ['active sync', 'Active Sync', 'active sync writemostly']) { + expect(getRaidDeviceStateVariant({ device: 'sda', state, slot: 0 })).toBe('success'); + expect(getRaidDeviceBadgeClass({ device: 'sda', state, slot: 0 })).toContain('emerald'); + } + expect(getRaidDeviceStateVariant({ device: 'sdc', state: 'spare rebuilding', slot: 2 })).toBe( + 'warning', + ); + }); + it('maps failed RAID devices to red badges', () => { expect(getRaidDeviceBadgeClass({ device: 'sdb', state: 'faulty', slot: 1 })).toContain('red'); }); + + it('names the state of a retained member that was not healthy', () => { + expect(getRaidLastKnownDeviceLabel({ device: 'sda', state: 'active sync', slot: 0 })).toBe( + 'sda', + ); + expect(getRaidLastKnownDeviceLabel({ device: 'sdb', state: 'faulty', slot: 1 })).toBe( + 'sdb · faulty', + ); + expect(getRaidLastKnownDeviceLabel({ device: 'sdc', state: ' spare ', slot: 2 })).toBe( + 'sdc · spare', + ); + expect(getRaidLastKnownDeviceLabel({ device: 'sdd', state: '', slot: 3 })).toBe('sdd'); + expect(RAID_LAST_KNOWN_DEVICE_BADGE_CLASS).not.toMatch(/emerald|amber|red/); + }); }); diff --git a/frontend-modern/src/utils/raidPresentation.ts b/frontend-modern/src/utils/raidPresentation.ts index 7c085f97bc..360f4c6a00 100644 --- a/frontend-modern/src/utils/raidPresentation.ts +++ b/frontend-modern/src/utils/raidPresentation.ts @@ -24,18 +24,50 @@ export function getRaidStateTextClass(state?: string | null): string { return 'text-amber-600 dark:text-amber-400'; } -export function getRaidDeviceBadgeClass(device: HostRAIDDevice): string { +export function getRaidDeviceStateVariant(device: HostRAIDDevice): StatusIndicatorVariant { const normalized = normalize(device.state); - if (normalized === 'active' || normalized === 'in_sync' || normalized === 'online') { - return 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/25 dark:text-emerald-200 dark:border-emerald-800'; - } if ( normalized.includes('fail') || normalized.includes('fault') || normalized.includes('offline') || normalized.includes('removed') ) { + return 'danger'; + } + // mdadm --detail names a healthy member "active sync", with optional flags + // such as writemostly after it. + if ( + normalized === 'active' || + normalized === 'in_sync' || + normalized === 'online' || + normalized.startsWith('active sync') + ) { + return 'success'; + } + return 'warning'; +} + +export function getRaidDeviceBadgeClass(device: HostRAIDDevice): string { + const variant = getRaidDeviceStateVariant(device); + if (variant === 'success') { + return 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/25 dark:text-emerald-200 dark:border-emerald-800'; + } + if (variant === 'danger') { return 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/25 dark:text-red-200 dark:border-red-800'; } return 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/25 dark:text-amber-200 dark:border-amber-800'; } + +// A retained array, such as a silent agent's last report, records what the +// array was, not what it is, so its members take no live status colour. +export const RAID_LAST_KNOWN_DEVICE_BADGE_CLASS = 'border-border bg-surface-alt text-muted'; + +/** + * A retained member badge has no colour to carry its state, so a member that + * was not healthy names its state in the badge. + */ +export function getRaidLastKnownDeviceLabel(device: HostRAIDDevice): string { + const state = (device.state || '').trim(); + if (!state || getRaidDeviceStateVariant(device) === 'success') return device.device; + return `${device.device} · ${state}`; +}