Render Machines RAID detail tooltip

This commit is contained in:
rcourtman
2026-05-26 21:57:44 +01:00
parent 6d2f6e8e28
commit d1a7e018b3
4 changed files with 347 additions and 4 deletions
@@ -38,6 +38,11 @@ import type { Disk } from '@/types/api';
import type { Resource, ResourceAvailabilityMeta } from '@/types/resource';
import { formatSpeed, normalizeDiskArray } from '@/utils/format';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
getRaidDeviceBadgeClass,
getRaidStateTextClass,
getRaidStateVariant,
} from '@/utils/raidPresentation';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import { formatTemperature as formatTemperatureValue } from '@/utils/temperature';
@@ -50,6 +55,7 @@ import {
getAgentMachineNetworkInterfaceDetails,
getAgentMachineNetworkTotal,
getAgentMachinePrimaryIp,
getAgentMachineRaidArrayDetails,
getAgentMachineRaidSummary,
getAgentMachineTemperatureCelsius,
getAgentMachineTemperatureDetailSections,
@@ -60,6 +66,7 @@ import {
type AgentMachineColumn,
type AgentMachineColumnId,
type AgentMachineNetworkInterfaceDetail,
type AgentMachineRaidArrayDetail,
type AgentMachineSortKey,
type AgentMachineTemperatureDetailSection,
} from './agentMachineTableModel';
@@ -333,6 +340,152 @@ const AgentMachineNetworkCell: Component<{
);
};
const AgentMachineRaidCell: Component<{
arrays: AgentMachineRaidArrayDetail[];
summary: string;
}> = (props) => {
const hasDetails = () => props.arrays.length > 0;
const shownArrays = () => props.arrays.slice(0, 6);
const hiddenArrayCount = () => Math.max(0, props.arrays.length - shownArrays().length);
const triggerLabel = () => props.summary || '—';
const stateLabel = (state: string) => asTrimmedString(state) ?? 'unknown';
const arrayLabel = (array: AgentMachineRaidArrayDetail) =>
asTrimmedString(array.name) ?? asTrimmedString(array.device) ?? 'RAID array';
const rebuildWidth = (percent: number) => `${Math.min(100, Math.max(0, percent))}%`;
return (
<AgentMachineMetricTooltip
triggerDataAttribute="data-agent-machine-raid-trigger"
tooltipDataAttribute="data-agent-machine-raid-tooltip"
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()}
maxWidth={400}
trigger={
<span
class="max-w-full truncate text-[11px] font-medium"
classList={{
'text-base-content': hasDetails(),
'text-muted': !hasDetails(),
}}
>
{triggerLabel()}
</span>
}
>
<section>
<div class="mb-1 border-b border-border pb-1 font-semibold text-muted">RAID Arrays</div>
<div class="max-h-[300px] space-y-2 overflow-y-auto pr-1">
<For each={shownArrays()}>
{(array, index) => {
const rebuilding = () =>
Number.isFinite(array.rebuildPercent) && array.rebuildPercent > 0;
return (
<div class="min-w-0" classList={{ 'border-t border-border pt-2': index() > 0 }}>
<div class="flex min-w-0 items-start justify-between gap-2">
<div class="min-w-0">
<div
class="truncate font-semibold text-base-content"
title={arrayLabel(array)}
>
{arrayLabel(array)}
</div>
<div class="mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[9px] text-muted">
<span class="font-mono uppercase">{array.level}</span>
<Show when={array.device && array.device !== arrayLabel(array)}>
<span class="font-mono">{array.device}</span>
</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)}`}>
{stateLabel(array.state)}
</span>
</div>
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[9px] text-muted">
<span>
Active <span class="font-mono text-base-content">{array.activeDevices}</span>/
<span class="font-mono text-base-content">{array.totalDevices}</span>
</span>
<span>
Working{' '}
<span class="font-mono text-base-content">{array.workingDevices}</span>
</span>
<Show when={array.spareDevices > 0}>
<span>
Spare <span class="font-mono text-base-content">{array.spareDevices}</span>
</span>
</Show>
<Show when={array.failedDevices > 0}>
<span>
Failed{' '}
<span class="font-mono text-red-600 dark:text-red-400">
{array.failedDevices}
</span>
</span>
</Show>
</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(array.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(array.rebuildPercent) }}
/>
</div>
<Show when={array.rebuildSpeed}>
{(speed) => <div class="mt-0.5 text-[9px] text-muted">{speed()}</div>}
</Show>
</div>
</Show>
<Show when={array.devices.length > 0}>
<div class="mt-1.5 flex flex-wrap gap-1">
<For each={array.devices.slice(0, 12)}>
{(device) => (
<span
class={`inline-flex max-w-full items-center truncate rounded border px-1.5 py-0.5 text-[9px] font-medium ${getRaidDeviceBadgeClass(device)}`}
title={`slot ${device.slot} - ${device.state}`}
>
{device.device}
</span>
)}
</For>
<Show when={array.devices.length > 12}>
<span class="rounded border border-border px-1.5 py-0.5 text-[9px] text-muted">
+{array.devices.length - 12}
</span>
</Show>
</div>
</Show>
</div>
);
}}
</For>
<Show when={hiddenArrayCount() > 0}>
<div class="border-t border-border pt-1 text-[9px] text-muted">
+{hiddenArrayCount()} more arrays
</div>
</Show>
</div>
</section>
</AgentMachineMetricTooltip>
);
};
const availabilityFor = (machine: Resource): ResourceAvailabilityMeta | undefined =>
machine.availability ??
(machine.platformData?.availability as ResourceAvailabilityMeta | undefined);
@@ -660,6 +813,7 @@ export const AgentsMachinesTable: Component<{
const diskIOTotal = () => getAgentMachineDiskIOTotal(machine);
const primaryIp = () => getAgentMachinePrimaryIp(machine);
const ipValues = () => getAgentMachineIpValues(machine);
const raidArrays = () => getAgentMachineRaidArrayDetails(machine);
const raidSummary = () => getAgentMachineRaidSummary(machine);
const temperature = () => getAgentMachineTemperatureCelsius(machine);
const temperatureSections = () =>
@@ -851,9 +1005,7 @@ export const AgentsMachinesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} ${machineColumnWidthClass('raid')} text-base-content`}
>
<span class="truncate" title={raidSummary()}>
{raidSummary() || '—'}
</span>
<AgentMachineRaidCell arrays={raidArrays()} summary={raidSummary()} />
</TableCell>
</Show>
<Show when={columnVisibility.isColumnVisible('arch')}>
@@ -196,6 +196,64 @@ describe('AgentsMachinesTable', () => {
expect(screen.getByText('1000 Mbps')).toBeInTheDocument();
});
it('shows structured agent RAID array details from the RAID value', async () => {
const { container } = render(() => (
<AgentsMachinesTable
resources={[
resource({
id: 'raid-host',
name: 'RAID Host',
agent: {
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', 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 fireEvent.click(screen.getByTitle('Choose which columns to display'));
await fireEvent.click(screen.getByLabelText('RAID'));
const trigger = container.querySelector('[data-agent-machine-raid-trigger="true"]');
expect(trigger).not.toBeNull();
if (!trigger) return;
await fireEvent.mouseEnter(trigger);
expect(await screen.findByText('RAID Arrays')).toBeInTheDocument();
expect(screen.getByText('media')).toBeInTheDocument();
expect(screen.getByText('raid6')).toBeInTheDocument();
expect(screen.getByText('recovering')).toBeInTheDocument();
expect(screen.getByText('/dev/md0')).toBeInTheDocument();
expect(screen.getByText('/dev/sda')).toBeInTheDocument();
expect(screen.getByText('/dev/sdb')).toBeInTheDocument();
expect(screen.getByText('/dev/sdc')).toBeInTheDocument();
expect(screen.getByText('37%')).toBeInTheDocument();
expect(screen.getByText('120 MB/s')).toBeInTheDocument();
});
it('sorts machines by operational metrics from the column headers', async () => {
const { container } = render(() => (
<AgentsMachinesTable
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
getAgentMachineNetworkInterfaceDetails,
getAgentMachineRaidArrayDetails,
getAgentMachineTemperatureCelsius,
getAgentMachineTemperatureTitle,
sortAgentMachines,
@@ -22,6 +23,66 @@ const resource = (overrides: Partial<Resource>): Resource =>
}) as Resource;
describe('agentMachineTableModel', () => {
it('normalizes agent RAID array details for table inspection', () => {
const details = getAgentMachineRaidArrayDetails(
resource({
agent: {
raid: [
{
device: ' /dev/md0 ',
name: ' media ',
level: ' raid6 ',
state: ' clean ',
totalDevices: 6,
activeDevices: 6,
workingDevices: 6,
failedDevices: 0,
spareDevices: 1,
devices: [
{ device: ' /dev/sda ', state: ' active ', slot: 0 },
{ device: '', state: ' spare ', slot: 7 },
],
rebuildPercent: 12.4,
rebuildSpeed: ' 120 MB/s ',
},
{
device: '',
level: '',
state: '',
totalDevices: 0,
activeDevices: 0,
workingDevices: 0,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
},
],
},
}),
);
expect(details).toEqual([
{
device: '/dev/md0',
name: 'media',
level: 'raid6',
state: 'clean',
totalDevices: 6,
activeDevices: 6,
workingDevices: 6,
failedDevices: 0,
spareDevices: 1,
devices: [
{ device: '/dev/sda', state: 'active', slot: 0 },
{ device: 'disk-2', state: 'spare', slot: 7 },
],
rebuildPercent: 12.4,
rebuildSpeed: '120 MB/s',
},
]);
});
it('normalizes agent network interface details for table inspection', () => {
const details = getAgentMachineNetworkInterfaceDetails(
resource({
@@ -1,4 +1,5 @@
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import type { HostRAIDArray, HostRAIDDevice } from '@/types/api';
import type { Resource } from '@/types/resource';
import { asTrimmedString } from '@/utils/stringUtils';
@@ -138,6 +139,8 @@ export type AgentMachineNetworkInterfaceDetail = {
speedMbps?: number;
};
export type AgentMachineRaidArrayDetail = HostRAIDArray;
const positiveTemperature = (value: number | undefined): number | undefined => {
const metric = finiteMetric(value);
return metric !== undefined && metric > 0 ? metric : undefined;
@@ -243,6 +246,11 @@ const positiveMetric = (value: number | undefined): number | undefined => {
return metric !== undefined && metric > 0 ? metric : undefined;
};
const nonNegativeMetric = (value: number | undefined): number => {
const metric = finiteMetric(value);
return metric !== undefined && metric > 0 ? metric : 0;
};
const uniqueTrimmedValues = (values: readonly string[] | undefined): string[] => {
const seen = new Set<string>();
const result: string[] = [];
@@ -387,8 +395,72 @@ export const getAgentMachineIpValues = (machine: Resource): string[] => {
export const getAgentMachinePrimaryIp = (machine: Resource): string =>
getAgentMachineIpValues(machine)[0] ?? '';
const getAgentMachineRaidDeviceDetails = (
devices: readonly HostRAIDDevice[] | undefined,
): HostRAIDDevice[] =>
(devices ?? []).reduce<HostRAIDDevice[]>((details, device, index) => {
const deviceName = asTrimmedString(device.device);
const state = asTrimmedString(device.state);
const slot = finiteMetric(device.slot);
if (!deviceName && !state && slot === undefined) return details;
details.push({
device: deviceName ?? `disk-${index + 1}`,
state: state ?? 'unknown',
slot: slot !== undefined ? Math.round(slot) : index,
});
return details;
}, []);
export const getAgentMachineRaidArrayDetails = (machine: Resource): AgentMachineRaidArrayDetail[] =>
(machine.agent?.raid ?? []).reduce<AgentMachineRaidArrayDetail[]>((details, array, index) => {
const device = asTrimmedString(array.device);
const name = asTrimmedString(array.name);
const level = asTrimmedString(array.level);
const state = asTrimmedString(array.state);
const devices = getAgentMachineRaidDeviceDetails(array.devices);
const counts = [
array.totalDevices,
array.activeDevices,
array.workingDevices,
array.failedDevices,
array.spareDevices,
].map(nonNegativeMetric);
const rebuildPercent = nonNegativeMetric(array.rebuildPercent);
const rebuildSpeed = asTrimmedString(array.rebuildSpeed);
if (
!device &&
!name &&
!level &&
!state &&
devices.length === 0 &&
counts.every((count) => count === 0) &&
rebuildPercent === 0
) {
return details;
}
details.push({
device: device ?? name ?? `array-${index + 1}`,
...(name ? { name } : {}),
level: level ?? 'unknown',
state: state ?? 'unknown',
totalDevices: counts[0],
activeDevices: counts[1],
workingDevices: counts[2],
failedDevices: counts[3],
spareDevices: counts[4],
devices,
rebuildPercent,
...(rebuildSpeed ? { rebuildSpeed } : {}),
});
return details;
}, []);
export const getAgentMachineRaidSummary = (machine: Resource): string => {
const arrays = machine.agent?.raid ?? [];
const arrays = getAgentMachineRaidArrayDetails(machine);
if (arrays.length === 0) return '';
const failed = arrays.filter((array) => (array.failedDevices ?? 0) > 0).length;