mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 18:55:40 +00:00
Add native TrueNAS system details
This commit is contained in:
+43
@@ -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',
|
||||
|
||||
+39
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user