From f9bd009a1c4cc3752d1d35f0dbfe4a3cf9c23181 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 21 May 2026 21:47:10 +0100 Subject: [PATCH] Add TrueNAS service detail rows --- .../features/truenas/TrueNASServicesTable.tsx | 282 +++++++++++++++--- .../__tests__/TrueNASServicesTable.test.tsx | 68 +++++ 2 files changed, 309 insertions(+), 41 deletions(-) create mode 100644 frontend-modern/src/features/truenas/__tests__/TrueNASServicesTable.test.tsx diff --git a/frontend-modern/src/features/truenas/TrueNASServicesTable.tsx b/frontend-modern/src/features/truenas/TrueNASServicesTable.tsx index d6d2d49e8c..c6ccc8c096 100644 --- a/frontend-modern/src/features/truenas/TrueNASServicesTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASServicesTable.tsx @@ -22,6 +22,10 @@ import { getPlatformTableHeadClassForKind, type PlatformTableFilterOption, } from '@/features/platformPage/sharedPlatformPage'; +import { + createPlatformResourceDetailState, + getPlatformResourceDetailRowClass, +} from '@/features/platformPage/PlatformResourceDetailTableRow'; import { filterTrueNASServices, mapTrueNASServiceStatus, @@ -78,6 +82,170 @@ const serviceStatusVariant = ( return 'muted'; }; +type ServiceDetailTone = 'default' | 'success' | 'warning' | 'danger' | 'muted'; + +type ServiceDetailRow = { + label: string; + value: string; + title?: string; + tone?: ServiceDetailTone; +}; + +type ServiceDetailSection = { + label: string; + rows: ServiceDetailRow[]; +}; + +const detailBool = (value?: boolean): string | null => { + if (value === undefined) return null; + return value ? 'Enabled' : 'Disabled'; +}; + +const detailRow = ( + label: string, + value?: string | null, + options: Pick = {}, +): ServiceDetailRow | null => { + const trimmed = value?.trim(); + if (!trimmed || trimmed === '-') return null; + return { label, value: trimmed, ...options }; +}; + +const compactDetailRows = (rows: Array): ServiceDetailRow[] => + rows.filter((row): row is ServiceDetailRow => Boolean(row)); + +const compactDetailSections = ( + sections: Array, +): ServiceDetailSection[] => + sections.filter((section): section is ServiceDetailSection => + Boolean(section && section.rows.length > 0), + ); + +const serviceTone = (row: TrueNASServiceRow): ServiceDetailTone => { + const status = mapTrueNASServiceStatus(row); + if (status === 'running') return 'success'; + if (status === 'attention' || status === 'stopped') return 'warning'; + if (status === 'disabled') return 'muted'; + return 'default'; +}; + +const detailValueToneClass = (tone: ServiceDetailTone | undefined): string => { + if (tone === 'success') return 'text-emerald-700 dark:text-emerald-300'; + if (tone === 'warning') return 'text-amber-700 dark:text-amber-300'; + if (tone === 'danger') return 'text-rose-700 dark:text-rose-300'; + if (tone === 'muted') return 'text-muted'; + return 'text-base-content'; +}; + +const formatAllPIDs = (pids: number[] | undefined): { label: string | null; title?: string } => { + const values = (pids ?? []).filter((pid) => Number.isFinite(pid) && pid > 0); + if (values.length === 0) return { label: null }; + const label = values.join(', '); + return { label, title: label }; +}; + +const buildServiceDetailSections = (row: TrueNASServiceRow): ServiceDetailSection[] => { + const pids = formatAllPIDs(row.service.pids); + const hostName = asTrimmedString(row.system.truenas?.hostname) || row.systemName; + return compactDetailSections([ + { + label: 'Service', + rows: compactDetailRows([ + detailRow('Name', formatServiceName(row.service.service)), + detailRow('TrueNAS ID', asTrimmedString(row.service.id)), + detailRow('System', row.systemName), + detailRow('System ID', row.systemId), + ]), + }, + { + label: 'Runtime', + rows: compactDetailRows([ + detailRow('State', titleCase(asTrimmedString(row.service.state) || undefined), { + tone: serviceTone(row), + }), + detailRow('Boot', detailBool(row.service.enabled), { + tone: row.service.enabled ? 'success' : 'muted', + }), + detailRow('PIDs', pids.label, { title: pids.title }), + detailRow('PID count', row.service.pids?.length ? String(row.service.pids.length) : null), + ]), + }, + { + label: 'Host', + rows: compactDetailRows([ + detailRow('Hostname', hostName), + detailRow('Version', asTrimmedString(row.system.truenas?.version)), + ]), + }, + ]); +}; + +const ServiceDetailTable: Component<{ row: TrueNASServiceRow; onClose: () => void }> = (props) => { + const sections = createMemo(() => buildServiceDetailSections(props.row)); + + return ( +
+
+
+
+ Service detail +
+
+ {formatServiceName(props.row.service.service)} ยท{' '} + {titleCase(mapTrueNASServiceStatus(props.row))} +
+
+ +
+
+ + + + {(section) => ( + <> + + + {section.label} + + + + {(row) => ( + + + {row.label} + + + {row.value} + + + )} + + + )} + + +
+
+
+ ); +}; + export const TrueNASServicesTable: Component<{ services: TrueNASServiceRow[]; emptyIcon: JSX.Element; @@ -90,6 +258,7 @@ export const TrueNASServicesTable: Component<{ initialStatus: 'all' as TrueNASServiceStatusFilter, filter: filterTrueNASServices, }); + const detail = createPlatformResourceDetailState({ idPrefix: 'truenas-service-detail' }); return ( mapTrueNASServiceStatus(row); const pids = createMemo(() => formatPIDs(row.service.pids)); const rawState = () => asTrimmedString(row.service.state) || '-'; + const detailRowId = () => detail.detailRowId(row); + const isExpanded = () => detail.isExpanded(row); return ( - - -
- -
- {formatServiceName(row.service.service)} -
-
-
- - - {titleCase(rawState())} - - - - - {row.service.enabled ? 'Enabled' : 'Disabled'} - - - - -
{row.systemName}
-
-
+ +
+ +
+ {formatServiceName(row.service.service)} +
+
+
+ + + {titleCase(rawState())} + + + + + {row.service.enabled ? 'Enabled' : 'Disabled'} + + + + +
{row.systemName}
+
+ + + + +
event.stopPropagation()} + > + detail.close(row)} + /> +
+
+
+
+ ); }} diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASServicesTable.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASServicesTable.test.tsx new file mode 100644 index 0000000000..e78e171f6c --- /dev/null +++ b/frontend-modern/src/features/truenas/__tests__/TrueNASServicesTable.test.tsx @@ -0,0 +1,68 @@ +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { TrueNASServicesTable } from '@/features/truenas/TrueNASServicesTable'; +import { buildTrueNASServiceRows } from '@/features/truenas/truenasPageModel'; +import type { Resource } from '@/types/resource'; + +const makeSystem = (overrides: Partial = {}): Resource => + ({ + id: 'system-primary', + type: 'agent', + name: 'nas-primary', + displayName: 'nas-primary', + status: 'online', + platformType: 'truenas', + platformScopes: ['truenas'], + truenas: { + hostname: 'nas-primary', + version: 'TrueNAS-SCALE-24.10.2', + services: [ + { id: '1', service: 'smb', enabled: true, state: 'RUNNING', pids: [2418, 2420] }, + ], + }, + ...overrides, + }) as Resource; + +afterEach(() => { + cleanup(); +}); + +describe('TrueNASServicesTable', () => { + it('opens inline native service details for TrueNAS service rows', async () => { + const rows = buildTrueNASServiceRows([makeSystem()]); + + render(() => ( + } + emptyTitle="No services" + emptyDescription="No services" + showToolbar={false} + /> + )); + + const row = screen.getByText('SMB').closest('tr'); + expect(row).toBeTruthy(); + expect(row).toHaveAttribute('aria-expanded', 'false'); + + await fireEvent.click(row!); + + expect(row).toHaveAttribute('aria-expanded', 'true'); + const detail = within(screen.getByTestId('truenas-service-detail')); + expect(detail.getByText('Service detail')).toBeInTheDocument(); + expect(detail.getByText('Service')).toBeInTheDocument(); + expect(detail.getByText('Runtime')).toBeInTheDocument(); + expect(detail.getByText('Host')).toBeInTheDocument(); + expect(detail.getByText('TrueNAS ID')).toBeInTheDocument(); + expect(detail.getByText('1')).toBeInTheDocument(); + expect(detail.getByText('PIDs')).toBeInTheDocument(); + expect(detail.getByText('2418, 2420')).toBeInTheDocument(); + expect(detail.getByText('TrueNAS-SCALE-24.10.2')).toBeInTheDocument(); + + await fireEvent.click(detail.getByRole('button', { name: 'Close' })); + + expect(screen.queryByTestId('truenas-service-detail')).not.toBeInTheDocument(); + expect(row).toHaveAttribute('aria-expanded', 'false'); + }); +});