diff --git a/frontend-modern/src/features/truenas/TrueNASDisksTable.tsx b/frontend-modern/src/features/truenas/TrueNASDisksTable.tsx new file mode 100644 index 000000000..6d666e48c --- /dev/null +++ b/frontend-modern/src/features/truenas/TrueNASDisksTable.tsx @@ -0,0 +1,201 @@ +import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; +import { Card } from '@/components/shared/Card'; +import { EmptyState } from '@/components/shared/EmptyState'; +import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; +import { SearchInput } from '@/components/shared/SearchInput'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/shared/Table'; +import { asTrimmedString } from '@/utils/stringUtils'; +import { + filterPlatformResources, + type PlatformResourceStatusFilter, +} from '@/features/platformPage/sharedPlatformPage'; +import type { Resource } from '@/types/resource'; + +// TrueNAS physical disks are SMART-instrumented storage devices, not +// compute hosts. The canonical infrastructure table's CPU / Memory / +// Disk I/O / Uptime columns are conceptually N/A; the operator columns +// are model, serial, type, size, health, temperature, and wearout. +// This bespoke table surfaces those from the canonical `physicalDisk` +// payload already attached to each row. + +const STATUS_FILTER_OPTIONS: FilterOption[] = [ + { value: 'all', label: 'All' }, + { value: 'online', label: 'Healthy' }, + { value: 'degraded', label: 'Degraded' }, + { value: 'offline', label: 'Offline' }, +]; + +const formatBytes = (bytes: number | undefined): string => { + if (!bytes || bytes <= 0) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let value = bytes; + let unitIdx = 0; + while (value >= 1024 && unitIdx < units.length - 1) { + value /= 1024; + unitIdx += 1; + } + return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`; +}; + +const formatTemperature = (celsius: number | undefined): JSX.Element => { + if (typeof celsius !== 'number' || celsius <= 0) return ; + return {celsius.toFixed(1)}°C; +}; + +const formatWearout = (wearout: number | undefined): JSX.Element => { + if (typeof wearout !== 'number' || wearout < 0) return ; + return {wearout.toFixed(0)}%; +}; + +const healthVariant = ( + health: string | undefined, +): 'success' | 'warning' | 'danger' | 'muted' => { + const normalized = (health || '').trim().toLowerCase(); + if (normalized === 'healthy' || normalized === 'passed' || normalized === 'pass') return 'success'; + if (normalized === 'warning' || normalized === 'degraded') return 'warning'; + if (normalized === 'failed' || normalized === 'fail' || normalized === 'critical') return 'danger'; + return 'muted'; +}; + +export const TrueNASDisksTable: Component<{ + resources: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; +}> = (props) => { + const [search, setSearch] = createSignal(''); + const [status, setStatus] = createSignal('all'); + + const filtered = createMemo(() => filterPlatformResources(props.resources, search(), status())); + const visible = createMemo(() => filtered().length); + const total = createMemo(() => props.resources.length); + + return ( + 0} + fallback={ + + + + } + > +
+
+
+ +
+ + + {total()} disks}> + {visible()} of {total()} disks + + +
+ + 0} + fallback={ + + + + } + > + + + + + Disk + Model + Type + Size + Health + Temp + Wearout + Serial + + + + + {(disk) => { + const meta = () => disk.physicalDisk; + const name = () => asTrimmedString(disk.name) || disk.id; + const model = () => asTrimmedString(meta()?.model) || '—'; + const type = () => asTrimmedString(meta()?.diskType) || '—'; + const health = () => asTrimmedString(meta()?.health) || '—'; + const serial = () => asTrimmedString(meta()?.serial) || '—'; + return ( + + + + {name()} + + + + + {model()} + + + {type()} + + {formatBytes(meta()?.sizeBytes)} + + +
+ + {health()} +
+
+ + {formatTemperature(meta()?.temperature)} + + + {formatWearout(meta()?.wearout)} + + + + {serial()} + + +
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default TrueNASDisksTable; diff --git a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx index fd1144f5e..4fb7baf1c 100644 --- a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx +++ b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx @@ -6,10 +6,11 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, - PlatformResourceTable, PlatformSectionTabs, PlatformTableEmptyState, } from '@/features/platformPage/sharedPlatformPage'; +import { TrueNASDisksTable } from './TrueNASDisksTable'; +import { TrueNASSystemsTable } from './TrueNASSystemsTable'; import { TRUENAS_TAB_SPECS, buildTrueNASPageModel, @@ -75,8 +76,9 @@ export function TrueNASPageSurface() { } > - + + + [] = [ + { value: 'all', label: 'All' }, + { value: 'online', label: 'Healthy' }, + { value: 'degraded', label: 'Degraded' }, + { value: 'offline', label: 'Offline' }, +]; + +const formatUptime = (seconds: number | undefined): string => { + if (!seconds || seconds <= 0) return '—'; + const days = Math.floor(seconds / 86_400); + if (days > 0) return `${days}d`; + const hours = Math.floor(seconds / 3_600); + if (hours > 0) return `${hours}h`; + const mins = Math.floor(seconds / 60); + return `${mins}m`; +}; + +const formatBytes = (bytes: number | undefined): string => { + if (!bytes || bytes <= 0) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let value = bytes; + let unitIdx = 0; + while (value >= 1024 && unitIdx < units.length - 1) { + value /= 1024; + unitIdx += 1; + } + return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`; +}; + +const formatPercent = (percent?: number): JSX.Element => { + if (typeof percent !== 'number' || Number.isNaN(percent)) return ; + return {percent.toFixed(1)}%; +}; + +const formatTemperature = (celsius: number | undefined): JSX.Element => { + if (typeof celsius !== 'number' || celsius <= 0) return ; + return {celsius.toFixed(1)}°C; +}; + +export const TrueNASSystemsTable: Component<{ + systems: Resource[]; + // Full TrueNAS resource scope so we can count pools / datasets / apps + // per system without spawning additional fetches. + scope: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; +}> = (props) => { + const [search, setSearch] = createSignal(''); + const [status, setStatus] = createSignal('all'); + + const filtered = createMemo(() => filterPlatformResources(props.systems, search(), status())); + const visible = createMemo(() => filtered().length); + const total = createMemo(() => props.systems.length); + + // Single-system canonical mock is the common case today; counts span + // the full TrueNAS resource scope per row. When multi-system support + // arrives, the canonical adapter should attach a parent system id to + // child resources and we can filter by it. + const counts = createMemo(() => { + const pools = props.scope.filter( + (r) => r.type === 'pool' || (r.type === 'storage' && r.storage?.topology === 'pool'), + ).length; + const datasets = props.scope.filter( + (r) => r.type === 'dataset' || (r.type === 'storage' && r.storage?.topology === 'dataset'), + ).length; + const apps = props.scope.filter((r) => r.type === 'app-container').length; + const disks = props.scope.filter((r) => r.type === 'physical_disk').length; + return { pools, datasets, apps, disks }; + }); + + return ( + 0} + fallback={ + + + + } + > +
+
+
+ +
+ + + {total()} systems}> + {visible()} of {total()} systems + + +
+ + 0} + fallback={ + + + + } + > + + + + + System + Version + Uptime + CPU + Memory + Storage + Temp + Pools + Datasets + Disks + Apps + + + + + {(system) => { + const name = () => asTrimmedString(system.name) || system.id; + const version = () => asTrimmedString(system.agent?.osVersion) || '—'; + const indicator = () => getSimpleStatusIndicator(system.status); + const c = counts(); + return ( + + +
+ + + {name()} + +
+
+ + {version()} + + + {formatUptime(system.uptime)} + + + {formatPercent(system.cpu?.current)} + + + {formatPercent(system.memory?.current)} + + + {typeof system.disk?.used === 'number' && typeof system.disk?.total === 'number' + ? `${formatBytes(system.disk.used)} / ${formatBytes(system.disk.total)}` + : formatPercent(system.disk?.current)} + + + {formatTemperature(system.temperature)} + + + {c.pools} + + + {c.datasets} + + + {c.disks} + + + {c.apps} + +
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default TrueNASSystemsTable; diff --git a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts index a7d70a337..28a4ce3ef 100644 --- a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts +++ b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts @@ -14,11 +14,16 @@ const makeResource = (resource: Partial & Pick { - it('declares the TrueNAS section set with Systems, Storage, and Apps', () => { - expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview', 'storage', 'apps']); + it('declares the TrueNAS section set with Systems, Storage, Disks, and Apps', () => { + expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual([ + 'overview', + 'storage', + 'disks', + 'apps', + ]); }); - it('buckets systems, apps, storage, and disks while ignoring non-TrueNAS resources', () => { + it('buckets systems, disks, apps, storage while ignoring non-TrueNAS resources', () => { const model = buildTrueNASPageModel([ makeResource({ id: 'truenas-system', type: 'agent' }), makeResource({ id: 'truenas-app', type: 'app-container' }), @@ -29,6 +34,7 @@ describe('truenasPageModel', () => { ]); expect(model.systems.map((r) => r.id)).toEqual(['truenas-system']); + expect(model.disks.map((r) => r.id)).toEqual(['truenas-disk']); expect(model.apps.map((r) => r.id)).toEqual(['truenas-app']); expect(model.resources.map((r) => r.id).sort()).toEqual( ['truenas-app', 'truenas-disk', 'truenas-pool', 'truenas-system'].sort(), diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts index d61902ba1..e75ac64c2 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -1,7 +1,7 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import type { Resource, ResourceType } from '@/types/resource'; -export type TrueNASPageTabId = 'overview' | 'storage' | 'apps'; +export type TrueNASPageTabId = 'overview' | 'storage' | 'disks' | 'apps'; export type TrueNASTabSpec = { id: TrueNASPageTabId; @@ -12,6 +12,7 @@ export type TrueNASTabSpec = { export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [ { id: 'overview', label: 'Systems', path: '/truenas/overview' }, { id: 'storage', label: 'Storage', path: '/truenas/storage' }, + { id: 'disks', label: 'Disks', path: '/truenas/disks' }, { id: 'apps', label: 'Apps', path: '/truenas/apps' }, ] as const; @@ -30,6 +31,7 @@ const isTrueNASPlatform = (resource: Resource): boolean => export type TrueNASPageModel = { resources: Resource[]; systems: Resource[]; + disks: Resource[]; apps: Resource[]; }; @@ -40,10 +42,12 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel { const systems = trueNasResources.filter((resource) => resource.type === 'agent'); const apps = trueNasResources.filter((resource) => resource.type === 'app-container'); + const disks = trueNasResources.filter((resource) => resource.type === 'physical_disk'); return { resources: trueNasResources, systems, + disks, apps, }; } diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index 8db41f9ed..e6b5280bb 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -460,16 +460,19 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time) records := make([]unifiedresources.IngestRecord, 0, 1+len(snapshot.Pools)+len(snapshot.Datasets)+len(snapshot.Apps)+len(snapshot.Disks)) totalCapacity, totalUsed := aggregatePoolUsage(snapshot.Pools) + systemAgent := agentDataFromTrueNASSystem(snapshot.System, systemRisk, protectionReduced, protectionSummary, rebuildInProgress, rebuildSummary) records = append(records, unifiedresources.IngestRecord{ SourceID: systemSourceID, Resource: unifiedresources.Resource{ - Type: unifiedresources.ResourceTypeAgent, - Name: strings.TrimSpace(snapshot.System.Hostname), - Status: systemStatus(snapshot.System, systemRisk, systemIncidents), - LastSeen: collectedAt, - UpdatedAt: collectedAt, - Metrics: metricsFromTrueNASSystem(snapshot.System, totalCapacity, totalUsed), - Agent: agentDataFromTrueNASSystem(snapshot.System, systemRisk, protectionReduced, protectionSummary, rebuildInProgress, rebuildSummary), + Type: unifiedresources.ResourceTypeAgent, + Name: strings.TrimSpace(snapshot.System.Hostname), + Status: systemStatus(snapshot.System, systemRisk, systemIncidents), + LastSeen: collectedAt, + UpdatedAt: collectedAt, + Metrics: metricsFromTrueNASSystem(snapshot.System, totalCapacity, totalUsed), + Uptime: snapshot.System.UptimeSeconds, + Temperature: systemAgent.Temperature, + Agent: systemAgent, TrueNAS: &unifiedresources.TrueNASData{ Hostname: strings.TrimSpace(snapshot.System.Hostname), Version: snapshot.System.Version, diff --git a/tests/integration/tests/68-platform-pages-shell.spec.ts b/tests/integration/tests/68-platform-pages-shell.spec.ts index bad2e1900..6e4a057f9 100644 --- a/tests/integration/tests/68-platform-pages-shell.spec.ts +++ b/tests/integration/tests/68-platform-pages-shell.spec.ts @@ -82,8 +82,13 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [ rootPath: '/truenas', testId: 'truenas-page', ariaLabel: 'TrueNAS sections', - tabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'], - populatedTabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'], + tabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/disks', '/truenas/apps'], + populatedTabPaths: [ + '/truenas/overview', + '/truenas/storage', + '/truenas/disks', + '/truenas/apps', + ], }, { id: 'vmware', @@ -181,6 +186,7 @@ test.describe('Platform pages shell', () => { { path: '/kubernetes/deployments', testId: 'kubernetes-page' }, { path: '/truenas/overview', testId: 'truenas-page' }, { path: '/truenas/storage', testId: 'truenas-page' }, + { path: '/truenas/disks', testId: 'truenas-page' }, { path: '/truenas/apps', testId: 'truenas-page' }, { path: '/vmware/overview', testId: 'vmware-page' }, { path: '/vmware/vms', testId: 'vmware-page' },