Show a silent agent's retained RAID state as last known (#2652)

Since #2644 the machine drawer reads a silent host agent's Thermals and
Disks as last known, but the RAID card between them kept presenting the
agent's last report as current: a green "clean" array, an amber "clean,
degraded, recovering" array with "Rebuild: 45% · 98000K/sec", and
coloured member badges, as if the array were still rebuilding. The
Machines table's RAID tooltip did the same with an amber rebuild bar.

What changes
- `RaidCard` takes the drawer's `lastKnownReason`
(`agentReadingsLastKnownReason`, from `agent.stale`). Every array and
member state stays as evidence, but reads "<state> (last known)" in
muted text beside a muted dot, titled "Last known reading, not current:
host agent stopped reporting". A rebuild reads "Rebuild was at N%"
without its speed. Member badges lose their status colour, so a member
that was not healthy names its state ("/dev/sdb2 · faulty").
- The Machines RAID column (hidden by default) and its tooltip apply the
same rule from `agent.stale`: a muted summary with ", last known" for
screen readers, and a tooltip headed "RAID Arrays (last known)" without
the rebuild bar or speed.
- mdadm reports healthy members as "active sync", which the badge helper
only matched as an exact "active", so every healthy software RAID member
wore the amber warning badge. `raidPresentation.ts` now reads it as
healthy (failure states still win).
- RaidCard's empty guard ran once at mount, so a drawer opened before
the first RAID report never showed the card. It is now tracked.
- The array header row wraps, so at 390 wide the longer last-known state
takes its own line instead of truncating the array name to "m..".

Not changed: the guest drawer's Guest RAID card. The workload model maps
`agentRaid` but not the in-guest agent's staleness, so wiring it needs a
separate change across `useWorkloads.ts` and the other agent-sourced
guest rows.

Verification
- Private mock stack with a local-only overlay holding Apollo-114
offline past its lease, seeded with a clean md0 and a degraded,
rebuilding md1. The live control host Spectrum-709 carries the same
arrays. Checked the drawer card at 1920, 1440 and 390, the Machines RAID
cell and tooltip at 1920 (the column is container-hidden below that),
and the drawer in dark mode. Silent host: no emerald, amber or red
classes anywhere in the card or tooltip. Live host: unchanged colours
and rebuild bar, with healthy members now green. No console errors and
no horizontal scroll. Receipt:
`frontend-modern/browser-verification/05a685ccb29f5883.json`.
- New tests: `RaidCard.test.tsx` (current, last known, transitions both
ways, empty to populated), `raidPresentation.test.ts` (active sync and
last-known labels), an `AgentsMachinesTable.test.tsx` case (muted
summary, dot, no colour classes, no rebuild bar), and a
`SharedPrimitives.guardrails.test.ts` pin. All five fail against the
base source.
- `vitest` over `components/shared`, `components/Infrastructure`,
`components/Workloads`, `features/standalone`, `features/platformPage`
and `utils` passes, apart from the PTY tests in
`agentInstallCommand.test.ts`, which fail on macOS regardless of this
change. The pre-commit battery passes.
This commit is contained in:
rcourtman
2026-10-07 13:38:02 +01:00
committed by GitHub
11 changed files with 598 additions and 103 deletions
@@ -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 "<state> (last known)" in muted text beside a muted
dot, titled "Last known reading, not current: <reason>". 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.
@@ -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
@@ -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"
]
}
@@ -183,7 +183,10 @@ const HostDetailsDisclosure: Component<{
disks={agent().disks}
lastKnownReason={props.drawer.agentReadingsLastKnownReason()}
/>
<RaidCard arrays={props.drawer.agentMeta()?.raid} />
<RaidCard
arrays={props.drawer.agentMeta()?.raid}
lastKnownReason={props.drawer.agentReadingsLastKnownReason()}
/>
<TemperaturesCard rows={props.drawer.temperatureRows()} title="Thermals" />
<TemperaturesCard rows={props.drawer.customSensorRows()} title="Custom Metrics" />
</>
@@ -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(/<Show\s+when=\{!lastKnownTitle\(\)\}[\s\S]*?getRaidStateTextClass/);
expect(source).toContain('Rebuild was at {Math.round(rebuildPercent())}%');
expect(source).toContain('RAID_LAST_KNOWN_DEVICE_BADGE_CLASS');
expect(
readFrontendSource('src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx'),
).toMatch(
/<RaidCard\s+arrays=\{props\.drawer\.agentMeta\(\)\?\.raid\}\s+lastKnownReason=\{props\.drawer\.agentReadingsLastKnownReason\(\)\}/,
);
expect(readFrontendSource('src/features/standalone/AgentsMachinesTable.tsx')).toMatch(
/lastKnownReason=\{\s*agentStale\(\) \? HOST_AGENT_STOPPED_REPORTING_REASON : undefined\s*\}/,
);
});
it('keeps one canonical agent-host metric history group catalog', () => {
expect(HOST_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([
'utilization',
@@ -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<RaidCardProps> = (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 (
<InfoCardFrame>
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
{props.title || 'RAID'}
</div>
<Show when={props.arrays?.length}>
<InfoCardFrame>
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
{props.title || 'RAID'}
</div>
<div class="max-h-[160px] overflow-y-auto custom-scrollbar space-y-2">
<For each={props.arrays}>
{(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;
<div class="max-h-[160px] overflow-y-auto custom-scrollbar space-y-2">
<For each={props.arrays}>
{(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 (
<div class="rounded-sm border border-dashed border-border p-2 overflow-hidden">
<div class="flex items-start justify-between gap-2 min-w-0">
<div class="min-w-0">
<div
class="text-[11px] font-semibold text-base-content truncate"
title={label()}
return (
<div
class="rounded-sm border border-dashed border-border p-2 overflow-hidden"
data-testid="raid-card-array"
data-raid-reading={lastKnownTitle() ? 'last-known' : 'current'}
>
{/* The state drops to its own line rather than squeezing the
array name when both do not fit, as on a phone. */}
<div class="flex flex-wrap items-start justify-between gap-x-2 gap-y-1 min-w-0">
<div class="min-w-0">
<div
class="text-[11px] font-semibold text-base-content truncate"
title={label()}
>
{label()}
</div>
<div class="mt-0.5 text-[10px] text-muted truncate" title={levelText()}>
{levelText()}
</div>
</div>
<Show
when={!lastKnownTitle()}
fallback={
<div
class="ml-auto flex min-w-0 items-center gap-1.5"
title={lastKnownTitle()}
data-testid="raid-card-state"
>
<StatusDot
variant="muted"
size="xs"
ariaLabel={`Last known RAID state: ${stateText()}`}
/>
<span class="text-[10px] font-medium text-muted">
{stateText()} (last known)
</span>
</div>
}
>
{label()}
</div>
<div class="mt-0.5 text-[10px] text-muted truncate" title={levelText()}>
{levelText()}
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0" title={stateText()}>
<StatusDot
variant={variant()}
size="xs"
ariaLabel={`RAID state: ${stateText()}`}
/>
<span class={`text-[10px] font-medium ${getRaidStateTextClass(array.state)}`}>
{stateText()}
</span>
</div>
</div>
<Show when={rebuilding()}>
<div class="mt-2 text-[10px] text-muted">
Rebuild:{' '}
<span class="font-medium text-base-content">
{Math.round(rebuildPercent())}%
</span>
<Show when={array.rebuildSpeed}>
<span class="text-muted"> · </span>
<span class="font-medium text-base-content">{array.rebuildSpeed}</span>
<div
class="ml-auto flex min-w-0 items-center gap-1.5"
title={stateText()}
data-testid="raid-card-state"
>
<StatusDot
variant={variant()}
size="xs"
ariaLabel={`RAID state: ${stateText()}`}
/>
<span
class={`text-[10px] font-medium ${getRaidStateTextClass(array.state)}`}
>
{stateText()}
</span>
</div>
</Show>
</div>
</Show>
<Show when={array.devices && array.devices.length > 0}>
<div class="mt-2 flex flex-wrap gap-1">
<For each={array.devices}>
{(device) => (
<span
class={`inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-medium ${getRaidDeviceBadgeClass(device)}`}
title={`slot ${device.slot} • ${device.state}`}
<Show when={rebuilding()}>
<Show
when={!lastKnownTitle()}
fallback={
// A rebuild figure from the last report is not progress,
// so it reads in the past tense and drops the speed.
<div
class="mt-2 text-[10px] text-muted"
title={lastKnownTitle()}
data-testid="raid-card-rebuild"
>
{device.device}
Rebuild was at {Math.round(rebuildPercent())}%
</div>
}
>
<div class="mt-2 text-[10px] text-muted" data-testid="raid-card-rebuild">
Rebuild:{' '}
<span class="font-medium text-base-content">
{Math.round(rebuildPercent())}%
</span>
)}
</For>
</div>
</Show>
</div>
);
}}
</For>
</div>
</InfoCardFrame>
<Show when={array.rebuildSpeed}>
<span class="text-muted"> · </span>
<span class="font-medium text-base-content">{array.rebuildSpeed}</span>
</Show>
</div>
</Show>
</Show>
<Show when={array.devices && array.devices.length > 0}>
<div class="mt-2 flex flex-wrap gap-1">
<For each={array.devices}>
{(device) => (
<span
class={`inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-medium ${
lastKnownTitle()
? RAID_LAST_KNOWN_DEVICE_BADGE_CLASS
: getRaidDeviceBadgeClass(device)
}`}
title={`slot ${device.slot} • ${device.state}`}
data-testid="raid-card-device"
>
{lastKnownTitle() ? getRaidLastKnownDeviceLabel(device) : device.device}
</span>
)}
</For>
</div>
</Show>
</div>
);
}}
</For>
</div>
</InfoCardFrame>
</Show>
);
};
@@ -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(() => <RaidCard arrays={[degradedArray()]} />);
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(() => (
<RaidCard arrays={[degradedArray()]} lastKnownReason="host agent stopped reporting" />
));
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(() => <RaidCard arrays={[degradedArray()]} lastKnownReason=" " />);
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
});
it('follows the reading between current and last known', () => {
const [reason, setReason] = createSignal<string | undefined>();
render(() => <RaidCard arrays={[degradedArray()]} lastKnownReason={reason()} />);
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<HostRAIDArray[] | undefined>();
render(() => <RaidCard arrays={arrays()} />);
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(() => (
<RaidCard arrays={[]} lastKnownReason="host agent stopped reporting" />
));
expect(container).toBeEmptyDOMElement();
});
});
@@ -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={
<span
class="max-w-full truncate text-[11px] font-medium"
classList={{
'text-base-content': hasDetails(),
'text-muted': !hasDetails(),
'text-base-content': hasDetails() && !lastKnownTitle(),
'text-muted': !hasDetails() || Boolean(lastKnownTitle()),
}}
data-raid-reading={lastKnownTitle() ? 'last-known' : 'current'}
>
{triggerLabel()}
<Show when={hasDetails() && lastKnownTitle()}>
<span class="sr-only">, last known</span>
</Show>
</span>
}
>
<section>
<div class="mb-1 border-b border-border pb-1 font-semibold text-muted">RAID Arrays</div>
<div class="mb-1 border-b border-border pb-1 font-semibold text-muted">
{lastKnownTitle() ? 'RAID Arrays (last known)' : 'RAID Arrays'}
</div>
<div class="max-h-[300px] space-y-2 overflow-y-auto pr-1">
<For each={shownArrays()}>
{(array, index) => {
@@ -651,9 +672,20 @@ const AgentMachineRaidCell: Component<{
</Show>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5" title={stateLabel(array.state)}>
<StatusDot variant={getRaidStateVariant(array.state)} size="xs" ariaHidden />
<span class={`text-[10px] font-medium ${getRaidStateTextClass(array.state)}`}>
<div
class="flex shrink-0 items-center gap-1.5"
title={lastKnownTitle() ?? stateLabel(array.state)}
>
<StatusDot
variant={lastKnownTitle() ? 'muted' : getRaidStateVariant(array.state)}
size="xs"
ariaHidden
/>
<span
class={`text-[10px] font-medium ${
lastKnownTitle() ? 'text-muted' : getRaidStateTextClass(array.state)
}`}
>
{stateLabel(array.state)}
</span>
</div>
@@ -676,7 +708,13 @@ const AgentMachineRaidCell: Component<{
<Show when={array.failedDevices > 0}>
<span>
Failed{' '}
<span class="font-mono text-red-600 dark:text-red-400">
<span
class="font-mono"
classList={{
'text-red-600 dark:text-red-400': !lastKnownTitle(),
'text-base-content': Boolean(lastKnownTitle()),
}}
>
{array.failedDevices}
</span>
</span>
@@ -684,23 +722,32 @@ const AgentMachineRaidCell: Component<{
</div>
<Show when={rebuilding()}>
<div class="mt-1.5">
<div class="mb-0.5 flex items-center justify-between gap-3 text-[9px]">
<span class="text-amber-600 dark:text-amber-400">Rebuilding</span>
<span class="font-mono text-base-content">
{Math.round(rebuildPercent())}%
</span>
<Show
when={!lastKnownTitle()}
fallback={
<div class="mt-1.5 text-[9px] text-muted">
Rebuild was at {Math.round(rebuildPercent())}%
</div>
}
>
<div class="mt-1.5">
<div class="mb-0.5 flex items-center justify-between gap-3 text-[9px]">
<span class="text-amber-600 dark:text-amber-400">Rebuilding</span>
<span class="font-mono text-base-content">
{Math.round(rebuildPercent())}%
</span>
</div>
<div class="h-1 overflow-hidden rounded-full bg-surface-alt">
<div
class="h-full rounded-full bg-amber-500"
style={{ width: rebuildWidth(rebuildPercent()) }}
/>
</div>
<Show when={array.rebuildSpeed}>
{(speed) => <div class="mt-0.5 text-[9px] text-muted">{speed()}</div>}
</Show>
</div>
<div class="h-1 overflow-hidden rounded-full bg-surface-alt">
<div
class="h-full rounded-full bg-amber-500"
style={{ width: rebuildWidth(rebuildPercent()) }}
/>
</div>
<Show when={array.rebuildSpeed}>
{(speed) => <div class="mt-0.5 text-[9px] text-muted">{speed()}</div>}
</Show>
</div>
</Show>
</Show>
<Show when={devices().length > 0}>
@@ -708,10 +755,14 @@ const AgentMachineRaidCell: Component<{
<For each={devices().slice(0, 12)}>
{(device) => (
<span
class={`inline-flex max-w-full items-center truncate rounded-sm border px-1.5 py-0.5 text-[9px] font-medium ${getRaidDeviceBadgeClass(device)}`}
class={`inline-flex max-w-full items-center truncate rounded-sm border px-1.5 py-0.5 text-[9px] font-medium ${
lastKnownTitle()
? RAID_LAST_KNOWN_DEVICE_BADGE_CLASS
: getRaidDeviceBadgeClass(device)
}`}
title={`slot ${device.slot} - ${device.state}`}
>
{device.device}
{lastKnownTitle() ? getRaidLastKnownDeviceLabel(device) : device.device}
</span>
)}
</For>
@@ -1854,7 +1905,13 @@ export const AgentsMachinesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} ${machineColumnWidthClass('raid')} text-base-content`}
>
<AgentMachineRaidCell arrays={raidArrays()} summary={raidSummary()} />
<AgentMachineRaidCell
arrays={raidArrays()}
summary={raidSummary()}
lastKnownReason={
agentStale() ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined
}
/>
</TableCell>
</Show>
<Show when={columnVisibility.isColumnVisible('arch')}>
@@ -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(() => (
<AgentsMachinesTable
resources={[
resource({
id: 'silent-raid-host',
name: 'Silent RAID Host',
status: 'offline',
agent: {
stale: true,
raid: [
{
device: '/dev/md0',
name: 'media',
level: 'raid6',
state: 'recovering',
totalDevices: 6,
activeDevices: 5,
workingDevices: 5,
failedDevices: 1,
spareDevices: 1,
devices: [
{ device: '/dev/sda', state: 'active sync', slot: 0 },
{ device: '/dev/sdb', state: 'faulty', slot: 1 },
{ device: '/dev/sdc', state: 'spare', slot: 2 },
],
rebuildPercent: 37.2,
rebuildSpeed: '120 MB/s',
},
],
},
}),
]}
emptyIcon={emptyIcon}
emptyTitle="No machines"
emptyDescription="Install Pulse Agent."
/>
));
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(() => (
<AgentsMachinesTable
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
RAID_LAST_KNOWN_DEVICE_BADGE_CLASS,
getRaidDeviceBadgeClass,
getRaidDeviceStateVariant,
getRaidLastKnownDeviceLabel,
getRaidStateTextClass,
getRaidStateVariant,
} from '@/utils/raidPresentation';
@@ -27,7 +30,32 @@ describe('raidPresentation', () => {
);
});
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/);
});
});
+36 -4
View File
@@ -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}`;
}