Add native TrueNAS system details

This commit is contained in:
rcourtman
2026-05-21 20:19:41 +01:00
parent 417b459c3d
commit 85364cd01c
3 changed files with 213 additions and 0 deletions
@@ -65,6 +65,49 @@ const baseResource = (overrides: Partial<Resource>): Resource =>
}) as Resource;
describe('ResourceDetailDrawer TrueNAS details', () => {
it('opens native TrueNAS system detail immediately for inline platform rows', () => {
const resource = baseResource({
id: 'truenas-system-1',
type: 'agent',
displayName: 'truenas-main',
uptime: 42 * 86_400,
truenas: {
hostname: 'truenas-main',
version: 'TrueNAS-SCALE-24.10.2',
storageRisk: { level: 'warning' },
storageRiskSummary: 'ZFS pool archive is DEGRADED',
storagePostureSummary: 'One pool needs attention',
protectionReduced: true,
protectionSummary: 'Snapshots are current but replication is degraded',
services: [
{ id: '1', service: 'smb', enabled: true, state: 'RUNNING', pids: [2418, 2420] },
{ id: '2', service: 'nfs', enabled: true, state: 'RUNNING', pids: [2501] },
{ id: '3', service: 'ssh', enabled: false, state: 'STOPPED' },
{ id: '4', service: 'smartd', enabled: true, state: 'STOPPED' },
],
},
});
const { getByRole, getByTestId } = render(() => (
<ResourceDetailDrawer
resource={resource}
presentation="table-row"
initialShowTrueNASDetails
/>
));
expect(getByRole('button', { name: 'Hide TrueNAS' })).toBeInTheDocument();
const section = within(getByTestId('resource-truenas-details-section'));
expect(section.getByText('System')).toBeInTheDocument();
expect(section.getByText('Storage Health')).toBeInTheDocument();
expect(section.getAllByText('Services').length).toBeGreaterThan(1);
expect(section.getByText('TrueNAS-SCALE-24.10.2')).toBeInTheDocument();
expect(section.getByText('SMB, NFS, SSH, SMART')).toBeInTheDocument();
expect(
section.getByText('Snapshots are current but replication is degraded'),
).toBeInTheDocument();
});
it('renders native TrueNAS VM detail only after the TrueNAS section is expanded', () => {
const resource = baseResource({
id: 'truenas-vm-1',
@@ -19,6 +19,45 @@ const baseResource = (overrides: Partial<Resource>): Resource =>
}) as Resource;
describe('resourceDetailDrawerTrueNASModel', () => {
it('summarizes native TrueNAS system metadata for the detail drawer', () => {
const resource = baseResource({
type: 'agent',
displayName: 'truenas-main',
uptime: 42 * 86_400,
truenas: {
hostname: 'truenas-main',
version: 'TrueNAS-SCALE-24.10.2',
storageRisk: { level: 'warning' },
storageRiskSummary: 'ZFS pool archive is DEGRADED',
storagePostureSummary: 'One pool needs attention',
protectionReduced: true,
protectionSummary: 'Snapshots are current but replication is degraded',
services: [
{ id: '1', service: 'smb', enabled: true, state: 'RUNNING', pids: [2418, 2420] },
{ id: '2', service: 'nfs', enabled: true, state: 'RUNNING', pids: [2501] },
{ id: '3', service: 'ssh', enabled: false, state: 'STOPPED' },
{ id: '4', service: 'smartd', enabled: true, state: 'STOPPED' },
],
},
});
expect(buildTrueNASDetailsSummary(resource)).toBe(
'TrueNAS-SCALE-24.10.2, 42d, 4 services, ZFS pool archive is DEGRADED',
);
expect(buildTrueNASDetailSections(resource).map((section) => section.label)).toEqual([
'System',
'Storage Health',
'Services',
]);
expect(buildTrueNASDetailSections(resource)[2]?.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: 'Running', value: '2' }),
expect.objectContaining({ label: 'Disabled', value: '1' }),
expect.objectContaining({ label: 'Names', value: 'SMB, NFS, SSH, SMART' }),
]),
);
});
it('summarizes native vm.query metadata for the detail drawer', () => {
const resource = baseResource({
type: 'vm',
@@ -4,6 +4,8 @@ import type {
ResourceStorageMeta,
ResourceTrueNASAppMeta,
ResourceTrueNASAppPort,
ResourceTrueNASMeta,
ResourceTrueNASServiceMeta,
ResourceTrueNASShareMeta,
ResourceTrueNASVMMeta,
} from '@/types/resource';
@@ -68,6 +70,17 @@ const formatTemperature = (celsius?: number): string | null => {
return `${celsius.toFixed(0)}°C`;
};
const formatDurationSeconds = (seconds?: number): string | null => {
const value = asPositiveNumber(seconds);
if (!value) return null;
const days = Math.floor(value / 86_400);
if (days > 0) return `${days}d`;
const hours = Math.floor(value / 3_600);
if (hours > 0) return `${hours}h`;
const minutes = Math.floor(value / 60);
return minutes > 0 ? `${minutes}m` : '<1m';
};
const formatCount = (value: number, singular: string, plural = `${singular}s`): string =>
`${new Intl.NumberFormat().format(value)} ${value === 1 ? singular : plural}`;
@@ -121,6 +134,109 @@ const isTrueNASScopedResource = (resource: Resource): boolean =>
resource.storage?.platform === 'truenas' ||
resource.tags?.includes('truenas') === true;
type TrueNASServiceStatus = 'running' | 'attention' | 'stopped' | 'disabled';
const truenasServiceStatus = (service: ResourceTrueNASServiceMeta): TrueNASServiceStatus => {
const state = asString(service.state)?.toLowerCase() ?? '';
if (['running', 'started', 'active'].includes(state)) return 'running';
if (['failed', 'error', 'crashed', 'degraded', 'unknown'].includes(state)) return 'attention';
if (['stopped', 'stop', 'inactive'].includes(state)) {
return service.enabled === false ? 'disabled' : 'stopped';
}
return service.enabled === false ? 'disabled' : 'attention';
};
const serviceStatusLabel = (status: TrueNASServiceStatus): string => {
if (status === 'running') return 'Running';
if (status === 'attention') return 'Attention';
if (status === 'stopped') return 'Stopped';
return 'Disabled';
};
const serviceStatusTone = (status: TrueNASServiceStatus): ResourceDetailDrawerTrueNASRowTone => {
if (status === 'running') return 'success';
if (status === 'attention' || status === 'stopped') return 'warning';
return 'default';
};
const serviceNameLabel = (service: ResourceTrueNASServiceMeta): string | null => {
const value = asString(service.service) ?? asString(service.id);
if (!value) return null;
const normalized = value.toLowerCase();
if (['ftp', 'nfs', 's3', 'smb', 'snmp', 'ssh', 'ups'].includes(normalized)) {
return normalized.toUpperCase();
}
if (normalized === 'smartd') return 'SMART';
return normalizeDelimitedLabel(value);
};
const buildTrueNASSystemSections = (
resource: Resource,
truenas: ResourceTrueNASMeta,
): ResourceDetailDrawerTrueNASSection[] => {
const services = truenas.services ?? [];
const serviceCounts = services.reduce<Record<TrueNASServiceStatus, number>>(
(counts, service) => {
counts[truenasServiceStatus(service)] += 1;
return counts;
},
{ running: 0, attention: 0, stopped: 0, disabled: 0 },
);
const serviceLabels = services
.map((service) => serviceNameLabel(service))
.filter((value): value is string => Boolean(value));
const pids = services.flatMap((service) => service.pids ?? []).filter((pid) => pid > 0);
const systemRows = compactRows([
row('Hostname', asString(truenas.hostname) ?? asString(resource.name)),
row('Version', asString(truenas.version)),
row('Uptime', formatDurationSeconds(truenas.uptimeSeconds ?? resource.uptime)),
row('Status', normalizeDelimitedLabel(resource.status)),
]);
const healthRows = compactRows([
row('Storage risk', normalizeDelimitedLabel(truenas.storageRisk?.level), {
tone: truenas.storageRisk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
}),
row('Storage summary', asString(truenas.storageRiskSummary), { tone: 'warning' }),
row('Storage posture', asString(truenas.storagePostureSummary), {
tone: truenas.storagePostureSummary ? 'warning' : 'default',
}),
row('Protection reduced', yesNoValue(truenas.protectionReduced), {
tone: truenas.protectionReduced ? 'warning' : 'success',
}),
row('Protection summary', asString(truenas.protectionSummary), {
tone: truenas.protectionSummary ? 'warning' : 'default',
}),
row('Rebuild active', yesNoValue(truenas.rebuildInProgress), {
tone: truenas.rebuildInProgress ? 'warning' : 'success',
}),
row('Rebuild', asString(truenas.rebuildSummary)),
]);
const serviceRows = compactRows([
row('Services', services.length > 0 ? formatCount(services.length, 'service') : null),
...(['running', 'attention', 'stopped', 'disabled'] as TrueNASServiceStatus[]).map((status) =>
row(
serviceStatusLabel(status),
serviceCounts[status] > 0 ? `${serviceCounts[status]}` : null,
{
tone: serviceStatusTone(status),
},
),
),
row('PIDs', pids.length > 0 ? summarizeList(pids.map(String), 4) : null, {
title: pids.join(', '),
}),
row('Names', summarizeList(serviceLabels, 6), { title: serviceLabels.join(', ') }),
]);
return compactSections([
{ label: 'System', rows: systemRows },
{ label: 'Storage Health', rows: healthRows },
{ label: 'Services', rows: serviceRows },
]);
};
const storageKindLabel = (storage: ResourceStorageMeta): string | null => {
const topology = asString(storage.topology);
if (topology) return normalizeDelimitedLabel(topology);
@@ -552,6 +668,9 @@ export const buildTrueNASDetailSections = (
if (isTrueNASScopedResource(resource) && resource.physicalDisk) {
return buildTrueNASDiskSections(resource, resource.physicalDisk);
}
if (isTrueNASScopedResource(resource) && resource.truenas) {
return buildTrueNASSystemSections(resource, resource.truenas);
}
return [];
};
@@ -614,6 +733,18 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
return summary.length > 0 ? summary.join(', ') : null;
}
if (isTrueNASScopedResource(resource) && resource.truenas) {
const truenas = resource.truenas;
const serviceCount = truenas.services?.length;
const summary = [
asString(truenas.version),
formatDurationSeconds(truenas.uptimeSeconds ?? resource.uptime),
serviceCount !== undefined ? formatCount(serviceCount, 'service') : null,
asString(truenas.storageRiskSummary) ?? asString(truenas.protectionSummary),
].filter((value): value is string => Boolean(value));
return summary.length > 0 ? summary.join(', ') : null;
}
return null;
};