mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Render Machines RAID detail tooltip
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user