diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx
index 41c3dbca3d..1b13a31201 100644
--- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx
+++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx
@@ -238,4 +238,57 @@ describe('ResourceDetailDrawer TrueNAS details', () => {
expect(section.getByText('Read/write')).toBeInTheDocument();
expect(section.getByText('media')).toBeInTheDocument();
});
+
+ it('opens native TrueNAS storage detail immediately for inline storage rows', () => {
+ const resource = baseResource({
+ id: 'truenas-pool-1',
+ type: 'storage',
+ displayName: 'archive',
+ status: 'degraded',
+ platformScopes: ['truenas'],
+ disk: {
+ current: 35.2,
+ used: 25.3 * 1024 ** 4,
+ total: 72 * 1024 ** 4,
+ },
+ childCount: 4,
+ storage: {
+ type: 'zfs-pool',
+ topology: 'pool',
+ platform: 'truenas',
+ protection: 'zfs',
+ zfsPoolState: 'DEGRADED',
+ risk: {
+ level: 'warning',
+ reasons: [
+ {
+ code: 'zfs_pool_state',
+ severity: 'warning',
+ summary: 'ZFS pool archive is DEGRADED',
+ },
+ ],
+ },
+ riskSummary: 'ZFS pool archive is DEGRADED',
+ protectionReduced: true,
+ protectionSummary: 'ZFS pool archive is DEGRADED',
+ },
+ });
+
+ const { getByRole, getByTestId } = render(() => (
+
+ ));
+
+ expect(getByRole('button', { name: 'Hide TrueNAS' })).toBeInTheDocument();
+ const section = within(getByTestId('resource-truenas-details-section'));
+ expect(getByTestId('resource-truenas-details-section').querySelector('table')).toBeTruthy();
+ expect(section.getByText('Storage')).toBeInTheDocument();
+ expect(section.getByText('Capacity')).toBeInTheDocument();
+ expect(section.getByText('Health')).toBeInTheDocument();
+ expect(section.getByText('ZFS')).toBeInTheDocument();
+ expect(section.getAllByText('ZFS pool archive is DEGRADED')).toHaveLength(3);
+ });
});
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts
index a6229b68d1..b5662d0df3 100644
--- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts
+++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts
@@ -111,4 +111,81 @@ describe('resourceDetailDrawerTrueNASModel', () => {
'Clients',
]);
});
+
+ it('summarizes native TrueNAS storage pool metadata for the detail drawer', () => {
+ const resource = baseResource({
+ type: 'storage',
+ displayName: 'archive',
+ status: 'degraded',
+ platformScopes: ['truenas'],
+ disk: {
+ current: 35.2,
+ used: 25.3 * 1024 ** 4,
+ total: 72 * 1024 ** 4,
+ },
+ childCount: 4,
+ storage: {
+ type: 'zfs-pool',
+ topology: 'pool',
+ platform: 'truenas',
+ protection: 'zfs',
+ zfsPoolState: 'DEGRADED',
+ risk: {
+ level: 'warning',
+ reasons: [
+ {
+ code: 'zfs_pool_state',
+ severity: 'warning',
+ summary: 'ZFS pool archive is DEGRADED',
+ },
+ ],
+ },
+ riskSummary: 'ZFS pool archive is DEGRADED',
+ protectionReduced: true,
+ protectionSummary: 'ZFS pool archive is DEGRADED',
+ },
+ });
+
+ expect(buildTrueNASDetailsSummary(resource)).toBe(
+ 'Pool, DEGRADED, 25.3 TB / 72.0 TB, ZFS pool archive is DEGRADED',
+ );
+ expect(buildTrueNASDetailSections(resource).map((section) => section.label)).toEqual([
+ 'Storage',
+ 'Capacity',
+ 'Health',
+ ]);
+ expect(buildTrueNASDetailSections(resource)[0]?.rows).toEqual(
+ expect.arrayContaining([expect.objectContaining({ label: 'Protection', value: 'ZFS' })]),
+ );
+ });
+
+ it('summarizes native TrueNAS physical disk metadata for the detail drawer', () => {
+ const resource = baseResource({
+ type: 'physical_disk',
+ displayName: 'sdc',
+ platformScopes: ['truenas'],
+ physicalDisk: {
+ devPath: '/dev/sdc',
+ model: 'WD Red Pro',
+ serial: 'WD-WX12A3456',
+ diskType: 'sata',
+ sizeBytes: 24 * 1024 ** 4,
+ health: 'DEGRADED',
+ temperature: 39,
+ rpm: 7200,
+ smart: {
+ powerOnHours: 10_240,
+ reallocatedSectors: 4,
+ pendingSectors: 1,
+ },
+ },
+ });
+
+ expect(buildTrueNASDetailsSummary(resource)).toBe('SATA, Degraded, 24.0 TB, 39°C');
+ expect(buildTrueNASDetailSections(resource).map((section) => section.label)).toEqual([
+ 'Disk',
+ 'Health',
+ 'SMART',
+ ]);
+ });
});
diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts
index 31a67502bc..cfad172b87 100644
--- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts
+++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts
@@ -1,5 +1,7 @@
import type {
Resource,
+ ResourcePhysicalDiskMeta,
+ ResourceStorageMeta,
ResourceTrueNASAppMeta,
ResourceTrueNASAppPort,
ResourceTrueNASShareMeta,
@@ -56,6 +58,16 @@ const formatBytes = (bytes?: number): string | null => {
return `${scaled.toFixed(scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2)} ${units[unitIndex]}`;
};
+const formatPercent = (percent?: number): string | null => {
+ if (typeof percent !== 'number' || !Number.isFinite(percent)) return null;
+ return `${percent.toFixed(percent >= 10 ? 1 : 2)}%`;
+};
+
+const formatTemperature = (celsius?: number): string | null => {
+ if (typeof celsius !== 'number' || !Number.isFinite(celsius) || celsius <= 0) return null;
+ return `${celsius.toFixed(0)}°C`;
+};
+
const formatCount = (value: number, singular: string, plural = `${singular}s`): string =>
`${new Intl.NumberFormat().format(value)} ${value === 1 ? singular : plural}`;
@@ -102,6 +114,199 @@ const compactSections = (
Boolean(section && section.rows.length > 0),
);
+const isTrueNASScopedResource = (resource: Resource): boolean =>
+ resource.platformType === 'truenas' ||
+ resource.platformScopes?.includes('truenas') === true ||
+ resource.sources?.includes('truenas') === true ||
+ resource.storage?.platform === 'truenas' ||
+ resource.tags?.includes('truenas') === true;
+
+const storageKindLabel = (storage: ResourceStorageMeta): string | null => {
+ const topology = asString(storage.topology);
+ if (topology) return normalizeDelimitedLabel(topology);
+ return normalizeDelimitedLabel(storage.type);
+};
+
+const storageStateLabel = (resource: Resource, storage: ResourceStorageMeta): string | null =>
+ asString(storage.zfsPoolState) ??
+ normalizeDelimitedLabel(storage.arrayState) ??
+ normalizeDelimitedLabel(resource.status);
+
+const storageStateTone = (
+ resource: Resource,
+ storage: ResourceStorageMeta,
+): ResourceDetailDrawerTrueNASRowTone => {
+ const state = (storage.zfsPoolState ?? storage.arrayState ?? resource.status).toLowerCase();
+ if (state === 'online' || state === 'healthy' || state === 'mounted') return 'success';
+ if (state === 'degraded' || state === 'warning' || state === 'offline') return 'warning';
+ return 'default';
+};
+
+const storageUsageLabel = (resource: Resource): string | null => {
+ const used = formatBytes(resource.disk?.used);
+ const total = formatBytes(resource.disk?.total);
+ if (used && total) return `${used} / ${total}`;
+ return formatPercent(resource.disk?.current);
+};
+
+const storageProtectionLabel = (value?: string): string | null => {
+ const protection = asString(value);
+ if (!protection) return null;
+ const normalized = protection.toLowerCase();
+ if (normalized === 'zfs' || normalized.startsWith('raidz')) return normalized.toUpperCase();
+ return normalizeDelimitedLabel(protection);
+};
+
+const buildTrueNASStorageSections = (
+ resource: Resource,
+ storage: ResourceStorageMeta,
+): ResourceDetailDrawerTrueNASSection[] => {
+ const riskReasons = (storage.risk?.reasons ?? [])
+ .map((reason) => asString(reason.summary))
+ .filter((value): value is string => Boolean(value));
+ const storageRows = compactRows([
+ row('Kind', storageKindLabel(storage)),
+ row('State', storageStateLabel(resource, storage), {
+ tone: storageStateTone(resource, storage),
+ }),
+ row('Pool', asString(storage.pool) ?? asString(resource.parentName)),
+ row('Path', asString(storage.path)),
+ row('Protection', storageProtectionLabel(storage.protection)),
+ row('Shared', yesNoValue(storage.shared)),
+ ]);
+
+ const capacityRows = compactRows([
+ row('Usage', storageUsageLabel(resource)),
+ row('Used', formatBytes(resource.disk?.used)),
+ row('Total', formatBytes(resource.disk?.total)),
+ row('Percent', formatPercent(resource.disk?.current)),
+ row('Children', formatInteger(resource.childCount)),
+ row('Consumers', formatInteger(storage.consumerCount)),
+ ]);
+
+ const healthRows = compactRows([
+ row('Risk', normalizeDelimitedLabel(storage.risk?.level), {
+ tone: storage.risk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
+ }),
+ row('Risk summary', asString(storage.riskSummary), { tone: 'warning' }),
+ row('Posture', asString(storage.postureSummary), {
+ tone: storage.postureSummary ? 'warning' : 'default',
+ }),
+ row('Protection reduced', booleanValue(storage.protectionReduced), {
+ tone: storage.protectionReduced ? 'warning' : 'success',
+ }),
+ row('Protection summary', asString(storage.protectionSummary), {
+ tone: storage.protectionSummary ? 'warning' : 'default',
+ }),
+ row('Rebuild', asString(storage.rebuildSummary)),
+ row('Reasons', summarizeList(riskReasons, 2), {
+ title: riskReasons.join(', '),
+ tone: 'warning',
+ }),
+ ]);
+
+ return compactSections([
+ { label: 'Storage', rows: storageRows },
+ { label: 'Capacity', rows: capacityRows },
+ { label: 'Health', rows: healthRows },
+ ]);
+};
+
+const diskStateTone = (disk: ResourcePhysicalDiskMeta): ResourceDetailDrawerTrueNASRowTone => {
+ const health = disk.health?.toLowerCase();
+ if (health === 'passed' || health === 'healthy' || health === 'ok') return 'success';
+ if (health === 'degraded' || health === 'failed' || health === 'warning') return 'warning';
+ return 'default';
+};
+
+const diskTypeLabel = (value?: string): string | null => {
+ const diskType = asString(value);
+ if (!diskType) return null;
+ const normalized = diskType.toLowerCase();
+ if (normalized === 'nvme') return 'NVMe';
+ if (['sata', 'sas', 'ssd', 'hdd'].includes(normalized)) return normalized.toUpperCase();
+ return normalizeDelimitedLabel(diskType);
+};
+
+const formatDiskHours = (hours?: number): string | null => {
+ const value = asPositiveNumber(hours);
+ if (!value) return null;
+ return `${formatInteger(value) ?? value.toFixed(0)}h`;
+};
+
+const buildTrueNASDiskSections = (
+ resource: Resource,
+ disk: ResourcePhysicalDiskMeta,
+): ResourceDetailDrawerTrueNASSection[] => {
+ const riskReasons = (disk.risk?.reasons ?? [])
+ .map((reason) => asString(reason.summary))
+ .filter((value): value is string => Boolean(value));
+ const identityRows = compactRows([
+ row('Device', asString(disk.devPath) ?? asString(resource.name)),
+ row('Model', asString(disk.model)),
+ row('Serial', asString(disk.serial)),
+ row('WWN', asString(disk.wwn)),
+ row('Type', diskTypeLabel(disk.diskType)),
+ row('Size', formatBytes(disk.sizeBytes)),
+ ]);
+
+ const healthRows = compactRows([
+ row('Health', normalizeDelimitedLabel(disk.health), { tone: diskStateTone(disk) }),
+ row('Temperature', formatTemperature(disk.temperature), {
+ tone: disk.temperature && disk.temperature >= 55 ? 'warning' : 'default',
+ }),
+ row(
+ 'Wearout',
+ disk.wearout === undefined || disk.wearout < 0 ? null : formatPercent(disk.wearout),
+ ),
+ row('RPM', formatInteger(disk.rpm)),
+ row('Role', normalizeDelimitedLabel(disk.storageRole)),
+ row('Group', asString(disk.storageGroup) ?? asString(resource.parentName)),
+ row('State', normalizeDelimitedLabel(disk.storageState)),
+ row('Spun down', yesNoValue(disk.spunDown)),
+ ]);
+
+ const smartRows = compactRows([
+ row('Power on', formatDiskHours(disk.smart?.powerOnHours)),
+ row('Power cycles', formatInteger(disk.smart?.powerCycles)),
+ row('Reallocated', formatInteger(disk.smart?.reallocatedSectors), {
+ tone: disk.smart?.reallocatedSectors ? 'warning' : 'default',
+ }),
+ row('Pending sectors', formatInteger(disk.smart?.pendingSectors), {
+ tone: disk.smart?.pendingSectors ? 'warning' : 'default',
+ }),
+ row('Offline uncorrectable', formatInteger(disk.smart?.offlineUncorrectable), {
+ tone: disk.smart?.offlineUncorrectable ? 'warning' : 'default',
+ }),
+ row('CRC errors', formatInteger(disk.smart?.udmaCrcErrors), {
+ tone: disk.smart?.udmaCrcErrors ? 'warning' : 'default',
+ }),
+ row('Media errors', formatInteger(disk.smart?.mediaErrors), {
+ tone: disk.smart?.mediaErrors ? 'warning' : 'default',
+ }),
+ row('Unsafe shutdowns', formatInteger(disk.smart?.unsafeShutdowns)),
+ row('Available spare', formatPercent(disk.smart?.availableSpare)),
+ row('Percentage used', formatPercent(disk.smart?.percentageUsed)),
+ ]);
+
+ const riskRows = compactRows([
+ row('Risk', normalizeDelimitedLabel(disk.risk?.level), {
+ tone: disk.risk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
+ }),
+ row('Reasons', summarizeList(riskReasons, 2), {
+ title: riskReasons.join(', '),
+ tone: 'warning',
+ }),
+ ]);
+
+ return compactSections([
+ { label: 'Disk', rows: identityRows },
+ { label: 'Health', rows: healthRows },
+ { label: 'SMART', rows: smartRows },
+ { label: 'Risk', rows: riskRows },
+ ]);
+};
+
const portLabel = (port: ResourceTrueNASAppPort): string | null => {
const protocol = asString(port.protocol)?.toLowerCase();
const container =
@@ -341,6 +546,12 @@ export const buildTrueNASDetailSections = (
if (resource.truenas?.share) return buildTrueNASShareSections(resource.truenas.share);
if (resource.truenas?.vm) return buildTrueNASVMSections(resource.truenas.vm);
if (resource.truenas?.app) return buildTrueNASAppSections(resource.truenas.app);
+ if (isTrueNASScopedResource(resource) && resource.storage) {
+ return buildTrueNASStorageSections(resource, resource.storage);
+ }
+ if (isTrueNASScopedResource(resource) && resource.physicalDisk) {
+ return buildTrueNASDiskSections(resource, resource.physicalDisk);
+ }
return [];
};
@@ -381,5 +592,30 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
return summary.length > 0 ? summary.join(', ') : null;
}
+ if (isTrueNASScopedResource(resource) && resource.storage) {
+ const storage = resource.storage;
+ const summary = [
+ storageKindLabel(storage),
+ storageStateLabel(resource, storage),
+ storageUsageLabel(resource),
+ asString(storage.riskSummary),
+ ].filter((value): value is string => Boolean(value));
+ return summary.length > 0 ? summary.join(', ') : null;
+ }
+
+ if (isTrueNASScopedResource(resource) && resource.physicalDisk) {
+ const disk = resource.physicalDisk;
+ const summary = [
+ diskTypeLabel(disk.diskType),
+ normalizeDelimitedLabel(disk.health),
+ formatBytes(disk.sizeBytes),
+ formatTemperature(disk.temperature),
+ ].filter((value): value is string => Boolean(value));
+ return summary.length > 0 ? summary.join(', ') : null;
+ }
+
return null;
};
+
+export const hasTrueNASDetailSections = (resource: Resource): boolean =>
+ buildTrueNASDetailSections(resource).length > 0;
diff --git a/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx b/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx
index 8589b41544..c9bb6b1a22 100644
--- a/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx
+++ b/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx
@@ -1,5 +1,6 @@
import { Show, createSignal, type Component, type JSX } from 'solid-js';
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
+import { hasTrueNASDetailSections } from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel';
import { TableCell, TableRow } from '@/components/shared/Table';
import type { Resource } from '@/types/resource';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
@@ -63,9 +64,6 @@ export function createPlatformResourceLabelResolver(resources: () => readonly Re
};
}
-const hasNativeTrueNASDetails = (resource: Resource): boolean =>
- Boolean(resource.truenas?.app || resource.truenas?.share || resource.truenas?.vm);
-
export const PlatformResourceDetailTableRow: Component<{
resource: Resource;
open: boolean;
@@ -76,7 +74,7 @@ export const PlatformResourceDetailTableRow: Component<{
onClose?: () => void;
}> = (props) => {
const initialShowTrueNASDetails = () =>
- props.initialShowTrueNASDetails ?? hasNativeTrueNASDetails(props.resource);
+ props.initialShowTrueNASDetails ?? hasTrueNASDetailSections(props.resource);
return (
diff --git a/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx b/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx
index ab0da1bb29..d1abad270e 100644
--- a/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx
+++ b/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { KubernetesClustersTable } from '@/features/kubernetes/KubernetesClustersTable';
import { KubernetesNodesTable } from '@/features/kubernetes/KubernetesNodesTable';
+import { TrueNASStorageTopologyTable } from '@/features/truenas/TrueNASStorageTopologyTable';
import { TrueNASSystemsTable } from '@/features/truenas/TrueNASSystemsTable';
import { VsphereHostsTable } from '@/features/vmware/VsphereHostsTable';
@@ -11,12 +12,14 @@ vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({
ResourceDetailDrawer: (props: {
resource: { id: string };
presentation?: string;
+ initialShowTrueNASDetails?: boolean;
onClose?: () => void;
}) => (
@@ -103,6 +106,47 @@ describe('platform resource table drawers', () => {
await expectRowOpensResourceDrawer(row!, system.id);
});
+ it('opens native TrueNAS detail by default from TrueNAS storage topology rows', async () => {
+ const pool = makeResource({
+ id: 'storage:tank',
+ type: 'storage',
+ name: 'tank',
+ displayName: 'tank',
+ platformType: 'truenas',
+ platformScopes: ['truenas'],
+ storage: {
+ topology: 'pool',
+ platform: 'truenas',
+ zfsPoolState: 'ONLINE',
+ },
+ });
+
+ render(() => (
+ }
+ emptyTitle="No storage"
+ emptyDescription="No storage"
+ showToolbar={false}
+ />
+ ));
+
+ const row = screen.getByText('tank').closest('tr');
+ expect(row).toBeTruthy();
+
+ await fireEvent.click(row!);
+
+ expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
+ 'data-resource-id',
+ pool.id,
+ );
+ expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
+ 'data-initial-show-truenas-details',
+ 'true',
+ );
+ });
+
it('opens canonical resource details from vSphere host rows', async () => {
const host = makeResource({
id: 'vmware-host:esxi-01',
diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts
index e25adf114e..b3b5a75bca 100644
--- a/frontend-modern/src/types/resource.ts
+++ b/frontend-modern/src/types/resource.ts
@@ -909,6 +909,7 @@ export interface Resource {
// Hierarchy
parentId?: string; // Parent resource (e.g., VM -> Node)
parentName?: string; // Parent display identifier when available
+ childCount?: number; // Backend-derived count of direct child resources
clusterId?: string; // Cluster membership
// Universal Metrics