diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx index 65efe3248..003026799 100644 --- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx +++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx @@ -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 ( + + {triggerLabel()} + + } + > +
+
RAID Arrays
+
+ + {(array, index) => { + const rebuilding = () => + Number.isFinite(array.rebuildPercent) && array.rebuildPercent > 0; + + return ( +
0 }}> +
+
+
+ {arrayLabel(array)} +
+
+ {array.level} + + {array.device} + +
+
+
+ + + {stateLabel(array.state)} + +
+
+ +
+ + Active {array.activeDevices}/ + {array.totalDevices} + + + Working{' '} + {array.workingDevices} + + 0}> + + Spare {array.spareDevices} + + + 0}> + + Failed{' '} + + {array.failedDevices} + + + +
+ + +
+
+ Rebuilding + + {Math.round(array.rebuildPercent)}% + +
+
+
+
+ + {(speed) =>
{speed()}
} +
+
+ + + 0}> +
+ + {(device) => ( + + {device.device} + + )} + + 12}> + + +{array.devices.length - 12} + + +
+
+
+ ); + }} + + 0}> +
+ +{hiddenArrayCount()} more arrays +
+
+
+
+
+ ); +}; + 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<{ - - {raidSummary() || '—'} - + diff --git a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx index 433bf4155..292b197fb 100644 --- a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx @@ -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(() => ( + + )); + + 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(() => ( ): 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({ diff --git a/frontend-modern/src/features/standalone/agentMachineTableModel.ts b/frontend-modern/src/features/standalone/agentMachineTableModel.ts index a48aa9922..4615ec58b 100644 --- a/frontend-modern/src/features/standalone/agentMachineTableModel.ts +++ b/frontend-modern/src/features/standalone/agentMachineTableModel.ts @@ -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(); 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((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((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;