From f453d56607be3ee5101afaa5f7d564ed7a4eb790 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 21 May 2026 08:48:19 +0100 Subject: [PATCH] Show native TrueNAS storage topology Replace the generic embedded storage surface with a TrueNAS-owned topology table for pools, datasets, and physical disks. Keep pool child inventory visible by default and route rows through the shared platform detail drawer. --- .../features/truenas/TrueNASPageSurface.tsx | 25 +- .../truenas/TrueNASStorageTopologyTable.tsx | 372 ++++++++++++++++++ .../__tests__/truenasPageModel.test.ts | 114 +++++- .../src/features/truenas/truenasPageModel.ts | 286 +++++++++++++- 4 files changed, 783 insertions(+), 14 deletions(-) create mode 100644 frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx diff --git a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx index 1f40f56fc6..e9c8a34c30 100644 --- a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx +++ b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx @@ -2,7 +2,6 @@ import { useLocation } from '@solidjs/router'; import DatabaseIcon from 'lucide-solid/icons/database'; import { Show, createMemo, type Accessor } from 'solid-js'; import RecoverySurface from '@/components/Recovery/Recovery'; -import StorageSurface from '@/components/Storage/Storage'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, @@ -13,6 +12,7 @@ import { import { TrueNASAppsTable } from './TrueNASAppsTable'; import { TrueNASAlertsTable } from './TrueNASAlertsTable'; import { TrueNASNetworkSharesTable } from './TrueNASNetworkSharesTable'; +import { TrueNASStorageTopologyTable } from './TrueNASStorageTopologyTable'; import { TrueNASSystemsTable } from './TrueNASSystemsTable'; import { TrueNASVirtualMachinesTable } from './TrueNASVirtualMachinesTable'; import { @@ -87,16 +87,7 @@ export function TrueNASPageSurface() { - + ; } +function TrueNASStorage(props: TrueNASOverviewProps) { + return ( + + ); +} + function TrueNASOverview(props: TrueNASOverviewProps) { return (
diff --git a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx new file mode 100644 index 0000000000..8df86e527c --- /dev/null +++ b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx @@ -0,0 +1,372 @@ +import { For, Show, createMemo, type Component, type JSX } from 'solid-js'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { TableCard } from '@/components/shared/TableCard'; +import { TableCardHeader } from '@/components/shared/TableCardHeader'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/shared/Table'; +import { formatBytes, formatPercent } from '@/utils/format'; +import { getSimpleStatusIndicator } from '@/utils/status'; +import { asTrimmedString } from '@/utils/stringUtils'; +import { + PLATFORM_TABLE_BODY_CLASS, + PLATFORM_TABLE_CARD_CLASS, + PLATFORM_TABLE_HEADER_ROW_CLASS, + PlatformTableEmptyState, + PlatformTableToolbar, + createPlatformTableFilterState, + getPlatformTableCellClassForKind, + getPlatformTableHeadClassForKind, + type PlatformTableFilterOption, +} from '@/features/platformPage/sharedPlatformPage'; +import { + PlatformResourceDetailTableRow, + createPlatformResourceDetailState, + createPlatformResourceLabelResolver, + getPlatformResourceDetailRowClass, +} from '@/features/platformPage/PlatformResourceDetailTableRow'; +import type { Resource } from '@/types/resource'; +import { + buildTrueNASStorageTopologyRows, + filterTrueNASStorageTopologyRows, + mapTrueNASStorageStatus, + type TrueNASStorageStatusFilter, + type TrueNASStorageTopologyKind, + type TrueNASStorageTopologyRow, +} from './truenasPageModel'; + +const TRUENAS_STORAGE_STATUS_OPTIONS: PlatformTableFilterOption[] = [ + { value: 'all', label: 'All' }, + { value: 'healthy', label: 'Healthy', tone: 'success' }, + { value: 'attention', label: 'Attention', tone: 'warning' }, + { value: 'offline', label: 'Offline', tone: 'danger' }, +]; + +const titleCase = (value: string | undefined): string => { + const normalized = asTrimmedString(value); + if (!normalized) return 'Unknown'; + return normalized.charAt(0).toUpperCase() + normalized.slice(1).toLowerCase(); +}; + +const resourceName = (resource: Resource): string => + asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id; + +const kindLabel = (kind: TrueNASStorageTopologyKind): string => { + if (kind === 'pool') return 'Pool'; + if (kind === 'dataset') return 'Dataset'; + return 'Disk'; +}; + +const stateLabel = (row: TrueNASStorageTopologyRow): string => { + if (row.kind === 'pool') { + return ( + asTrimmedString(row.resource.storage?.zfsPoolState)?.toUpperCase() || + titleCase(row.resource.status) + ); + } + if (row.kind === 'disk') { + return ( + asTrimmedString(row.resource.physicalDisk?.health)?.toUpperCase() || + titleCase(row.resource.status) + ); + } + return titleCase(row.resource.status); +}; + +const subtitle = (row: TrueNASStorageTopologyRow): string => { + if (row.kind === 'pool') return row.resource.parentName || 'TrueNAS pool'; + if (row.kind === 'dataset') return row.resource.parentName || 'Dataset'; + return row.resource.physicalDisk?.serial || row.resource.physicalDisk?.devPath || 'Physical disk'; +}; + +const capacityPercent = (resource: Resource): number | undefined => { + if (typeof resource.disk?.current === 'number' && Number.isFinite(resource.disk.current)) { + return resource.disk.current; + } + if ( + typeof resource.disk?.used === 'number' && + typeof resource.disk?.total === 'number' && + resource.disk.total > 0 + ) { + return (resource.disk.used / resource.disk.total) * 100; + } + return undefined; +}; + +const capacityLabel = (row: TrueNASStorageTopologyRow): string => { + if (row.kind === 'disk') { + const size = row.resource.physicalDisk?.sizeBytes; + return typeof size === 'number' && size > 0 ? formatBytes(size) : '-'; + } + if (typeof row.resource.disk?.used === 'number' && typeof row.resource.disk?.total === 'number') { + return `${formatBytes(row.resource.disk.used)} / ${formatBytes(row.resource.disk.total)}`; + } + const percent = capacityPercent(row.resource); + return percent === undefined ? '-' : formatPercent(percent); +}; + +const CapacityCell: Component<{ row: TrueNASStorageTopologyRow }> = (props) => { + const percent = () => + props.row.kind === 'disk' ? undefined : capacityPercent(props.row.resource); + return ( +
+
+ {capacityLabel(props.row)} +
+ +
+
+
+ +
+ ); +}; + +const riskLabel = (row: TrueNASStorageTopologyRow): string => { + const risk = + asTrimmedString(row.resource.storage?.risk?.level) || + asTrimmedString(row.resource.physicalDisk?.risk?.level); + if (risk) return titleCase(risk); + const incidentCount = row.resource.incidentCount ?? row.resource.incidents?.length ?? 0; + if (incidentCount > 0) return `${incidentCount} alert${incidentCount === 1 ? '' : 's'}`; + const mapped = mapTrueNASStorageStatus(row.resource); + if (mapped === 'attention') return 'Attention'; + if (mapped === 'offline') return 'Offline'; + if (mapped === 'healthy') return 'Healthy'; + return 'Unknown'; +}; + +const riskPillClass = (row: TrueNASStorageTopologyRow): string => { + const mapped = mapTrueNASStorageStatus(row.resource); + if (mapped === 'attention') { + return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'; + } + if (mapped === 'offline') { + return 'border-red-300/50 bg-red-500/10 text-red-700 dark:text-red-300'; + } + if (mapped === 'healthy') { + return 'border-emerald-300/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; + } + return 'border-border bg-surface-alt text-muted'; +}; + +const RiskPill: Component<{ row: TrueNASStorageTopologyRow }> = (props) => ( + + {riskLabel(props.row)} + +); + +const temperatureLabel = (row: TrueNASStorageTopologyRow): string => { + if (row.kind !== 'disk') return '-'; + const value = row.resource.physicalDisk?.temperature; + return typeof value === 'number' && Number.isFinite(value) ? `${Math.round(value)}C` : '-'; +}; + +const diskCountLabel = (row: TrueNASStorageTopologyRow): string => { + if (row.kind === 'pool') return String(row.counts.disks); + return '-'; +}; + +const shareCountLabel = (row: TrueNASStorageTopologyRow): string => { + if (row.kind === 'disk') return '-'; + return String(row.counts.shares); +}; + +const rowIndentClass = (depth: number): string => (depth > 0 ? 'pl-5 sm:pl-7' : ''); + +const ResourceCell: Component<{ row: TrueNASStorageTopologyRow }> = (props) => { + const indicator = () => getSimpleStatusIndicator(props.row.resource.status); + const name = () => resourceName(props.row.resource); + return ( +
+ +
+
+ {name()} +
+
+ {subtitle(props.row)} +
+
+
+ ); +}; + +export const TrueNASStorageTopologyTable: Component<{ + resources: Resource[]; + scope: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; + showToolbar?: boolean; +}> = (props) => { + const rows = createMemo(() => buildTrueNASStorageTopologyRows(props.resources)); + const tableState = createPlatformTableFilterState({ + resources: rows, + initialStatus: 'all' as TrueNASStorageStatusFilter, + filter: filterTrueNASStorageTopologyRows, + }); + const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-storage-drawer' }); + const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope); + + return ( + 0} + fallback={ + + } + > +
+ + + + + 0} + fallback={ + + } + > + + + + + + + Resource + + + Kind + + + + Usage / Size + + + + + + Health + + + + + + {(row) => { + const resource = () => row.resource; + const detailRowId = () => drawer.detailRowId(resource()); + const isExpanded = () => drawer.isExpanded(resource()); + return ( + <> + drawer.toggle(resource())} + onKeyDown={drawer.handleActivationKey(resource())} + tabIndex={0} + > + + + + + {kindLabel(row.kind)} + + + + + + + + + + + + + drawer.close(resource())} + /> + + ); + }} + + +
+
+
+
+
+ ); +}; + +export default TrueNASStorageTopologyTable; diff --git a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts index e490694a00..4495d5a8f3 100644 --- a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts +++ b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts @@ -3,14 +3,18 @@ import type { Resource } from '@/types/resource'; import { TRUENAS_TAB_SPECS, buildTrueNASPageModel, + buildTrueNASStorageChildCounts, + buildTrueNASStorageTopologyRows, buildTrueNASSystemChildCounts, filterTrueNASApps, filterTrueNASIncidents, + filterTrueNASStorageTopologyRows, filterTrueNASShares, filterTrueNASVMs, mapTrueNASAppStatus, mapTrueNASIncidentSeverity, mapTrueNASShareStatus, + mapTrueNASStorageStatus, mapTrueNASVMStatus, } from '../truenasPageModel'; @@ -30,13 +34,22 @@ describe('truenasPageModel', () => { expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview', 'storage', 'protection']); }); - it('buckets systems and apps while keeping storage inventory in scope for shared surfaces', () => { + it('buckets systems, workloads, and native storage inventory by TrueNAS API facet', () => { const model = buildTrueNASPageModel([ makeResource({ id: 'truenas-system', type: 'agent' }), makeResource({ id: 'truenas-vm', type: 'vm' }), makeResource({ id: 'truenas-app', type: 'app-container' }), makeResource({ id: 'truenas-share', type: 'network-share' }), - makeResource({ id: 'truenas-pool', type: 'pool' }), + makeResource({ + id: 'truenas-pool', + type: 'storage', + storage: { topology: 'pool', platform: 'truenas' }, + }), + makeResource({ + id: 'truenas-dataset', + type: 'storage', + storage: { topology: 'dataset', platform: 'truenas' }, + }), makeResource({ id: 'truenas-disk', type: 'physical_disk' }), makeResource({ id: 'docker-host', type: 'agent', platformType: 'docker' }), makeResource({ id: 'pve-node', type: 'agent', platformType: 'proxmox-pve' }), @@ -46,9 +59,13 @@ describe('truenasPageModel', () => { expect(model.shares.map((r) => r.id)).toEqual(['truenas-share']); expect(model.vms.map((r) => r.id)).toEqual(['truenas-vm']); expect(model.apps.map((r) => r.id)).toEqual(['truenas-app']); + expect(model.pools.map((r) => r.id)).toEqual(['truenas-pool']); + expect(model.datasets.map((r) => r.id)).toEqual(['truenas-dataset']); + expect(model.disks.map((r) => r.id)).toEqual(['truenas-disk']); expect(model.resources.map((r) => r.id).sort()).toEqual( [ 'truenas-app', + 'truenas-dataset', 'truenas-disk', 'truenas-pool', 'truenas-share', @@ -147,6 +164,99 @@ describe('truenasPageModel', () => { }); }); + it('builds a native TrueNAS storage topology with pool child counts', () => { + const system = makeResource({ id: 'system-primary', type: 'agent' }); + const pool = makeResource({ + id: 'pool-tank', + type: 'storage', + name: 'tank', + parentId: system.id, + storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'ONLINE' }, + }); + const dataset = makeResource({ + id: 'dataset-media', + type: 'storage', + name: 'tank/media', + parentId: pool.id, + storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/tank/media' }, + }); + const share = makeResource({ + id: 'share-media', + type: 'network-share', + parentId: dataset.id, + truenas: { + share: { + id: 'smb-1', + name: 'Media', + protocol: 'SMB', + path: '/mnt/tank/media', + dataset: 'tank/media', + }, + }, + }); + const disk = makeResource({ + id: 'disk-sda', + type: 'physical_disk', + name: 'sda', + parentId: pool.id, + physicalDisk: { + devPath: '/dev/sda', + serial: 'serial-123', + sizeBytes: 2_000_000_000_000, + health: 'PASSED', + }, + }); + + const counts = buildTrueNASStorageChildCounts([system, pool, dataset, share, disk]); + const rows = buildTrueNASStorageTopologyRows([system, pool, dataset, share, disk]); + + expect(counts.get(pool.id)).toEqual({ datasets: 1, shares: 1, disks: 1 }); + expect(counts.get(dataset.id)).toEqual({ datasets: 0, shares: 1, disks: 0 }); + expect(rows.map((row) => row.id)).toEqual([ + 'pool:pool-tank', + 'dataset:dataset-media', + 'disk:disk-sda', + ]); + expect(rows.map((row) => row.depth)).toEqual([0, 1, 1]); + expect(rows[1]?.parentRowId).toBe('pool:pool-tank'); + expect(rows[2]?.parentRowId).toBe('pool:pool-tank'); + }); + + it('keeps pool ancestors visible when filtering matching storage children', () => { + const pool = makeResource({ + id: 'pool-tank', + type: 'storage', + name: 'tank', + storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'ONLINE' }, + }); + const degradedPool = makeResource({ + id: 'pool-archive', + type: 'storage', + name: 'archive', + status: 'online', + storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'DEGRADED' }, + }); + const disk = makeResource({ + id: 'disk-sdb', + type: 'physical_disk', + name: 'sdb', + parentId: pool.id, + physicalDisk: { + devPath: '/dev/sdb', + serial: 'serial-degraded', + health: 'DEGRADED', + }, + }); + + expect(mapTrueNASStorageStatus(degradedPool)).toBe('attention'); + expect(mapTrueNASStorageStatus(disk)).toBe('attention'); + + const rows = buildTrueNASStorageTopologyRows([pool, degradedPool, disk]); + expect( + filterTrueNASStorageTopologyRows(rows, 'serial-degraded', 'attention').map((row) => row.id), + ).toEqual(['pool:pool-tank', 'disk:disk-sdb']); + }); + it('filters apps using native TrueNAS app.query metadata', () => { const nextcloud = makeResource({ id: 'app-nextcloud', diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts index c6f45d0e8c..ef0028bbec 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -7,6 +7,7 @@ export type TrueNASAppStatusFilter = 'all' | 'running' | 'attention' | 'stopped' export type TrueNASVMStatusFilter = 'all' | 'running' | 'attention' | 'stopped'; export type TrueNASShareStatusFilter = 'all' | 'active' | 'attention' | 'disabled'; export type TrueNASIncidentSeverityFilter = 'all' | 'critical' | 'warning' | 'info'; +export type TrueNASStorageStatusFilter = 'all' | 'healthy' | 'attention' | 'offline'; export type TrueNASTabSpec = { id: TrueNASPageTabId; @@ -41,6 +42,9 @@ const isTrueNASPlatform = (resource: Resource): boolean => export type TrueNASPageModel = { resources: Resource[]; systems: Resource[]; + pools: Resource[]; + datasets: Resource[]; + disks: Resource[]; shares: Resource[]; vms: Resource[]; apps: Resource[]; @@ -73,6 +77,23 @@ export type TrueNASSystemChildCounts = { disks: number; }; +export type TrueNASStorageChildCounts = { + datasets: number; + shares: number; + disks: number; +}; + +export type TrueNASStorageTopologyKind = 'pool' | 'dataset' | 'disk'; + +export type TrueNASStorageTopologyRow = { + id: string; + resource: Resource; + kind: TrueNASStorageTopologyKind; + depth: number; + parentRowId?: string; + counts: TrueNASStorageChildCounts; +}; + const emptyTrueNASSystemChildCounts = (): TrueNASSystemChildCounts => ({ pools: 0, datasets: 0, @@ -81,18 +102,30 @@ const emptyTrueNASSystemChildCounts = (): TrueNASSystemChildCounts => ({ disks: 0, }); +const emptyTrueNASStorageChildCounts = (): TrueNASStorageChildCounts => ({ + datasets: 0, + shares: 0, + disks: 0, +}); + export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel { const trueNasResources = resources.filter( (resource) => isTrueNASPlatform(resource) && TRUENAS_RESOURCE_TYPES.has(resource.type), ); const systems = trueNasResources.filter((resource) => resource.type === 'agent'); + const pools = trueNasResources.filter(isTrueNASPoolResource); + const datasets = trueNasResources.filter(isTrueNASDatasetResource); + const disks = trueNasResources.filter((resource) => resource.type === 'physical_disk'); const shares = trueNasResources.filter((resource) => resource.type === 'network-share'); const vms = trueNasResources.filter((resource) => resource.type === 'vm'); const apps = trueNasResources.filter((resource) => resource.type === 'app-container'); return { resources: trueNasResources, systems, + pools, + datasets, + disks, shares, vms, apps, @@ -100,6 +133,12 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel { }; } +const isTrueNASPoolResource = (resource: Resource): boolean => + resource.type === 'pool' || resource.storage?.topology === 'pool'; + +const isTrueNASDatasetResource = (resource: Resource): boolean => + resource.type === 'dataset' || resource.storage?.topology === 'dataset'; + export function buildTrueNASSystemChildCounts( resources: Resource[], systems: Resource[], @@ -112,7 +151,9 @@ export function buildTrueNASSystemChildCounts( const systemIds = new Set(systems.map((system) => system.id)); const resourceById = new Map(resources.map((resource) => [resource.id, resource])); - const hasParentSignals = resources.some((resource) => Boolean(asTrimmedString(resource.parentId))); + const hasParentSignals = resources.some((resource) => + Boolean(asTrimmedString(resource.parentId)), + ); const fallbackSystemId = systems.length === 1 && !hasParentSignals ? asTrimmedString(systems[0]?.id) : ''; @@ -153,6 +194,155 @@ export function buildTrueNASSystemChildCounts( return countsBySystem; } +export function buildTrueNASStorageChildCounts( + resources: Resource[], +): Map { + const countsByStorage = new Map(); + const trueNasResources = resources.filter(isTrueNASPlatform); + const resourceById = new Map(trueNasResources.map((resource) => [resource.id, resource])); + const storageResources = trueNasResources.filter( + (resource) => isTrueNASPoolResource(resource) || isTrueNASDatasetResource(resource), + ); + + for (const storage of storageResources) { + countsByStorage.set(storage.id, emptyTrueNASStorageChildCounts()); + } + + const hasAncestor = (resource: Resource, ancestorId: string): boolean => { + let parentId = asTrimmedString(resource.parentId); + const visited = new Set(); + while (parentId && !visited.has(parentId)) { + if (parentId === ancestorId) return true; + visited.add(parentId); + parentId = asTrimmedString(resourceById.get(parentId)?.parentId); + } + return false; + }; + + for (const storage of storageResources) { + const counts = countsByStorage.get(storage.id); + if (!counts) continue; + for (const resource of trueNasResources) { + if (resource.id === storage.id || !hasAncestor(resource, storage.id)) continue; + if (isTrueNASDatasetResource(resource)) { + counts.datasets += 1; + } else if (resource.type === 'network-share') { + counts.shares += 1; + } else if (resource.type === 'physical_disk') { + counts.disks += 1; + } + } + } + + return countsByStorage; +} + +export function buildTrueNASStorageTopologyRows( + resources: Resource[], +): TrueNASStorageTopologyRow[] { + const storageResources = resources.filter( + (resource) => + isTrueNASPlatform(resource) && + (isTrueNASPoolResource(resource) || + isTrueNASDatasetResource(resource) || + resource.type === 'physical_disk'), + ); + const resourceById = new Map(storageResources.map((resource) => [resource.id, resource])); + const countsByStorage = buildTrueNASStorageChildCounts(resources); + const pools = storageResources.filter(isTrueNASPoolResource).sort(compareStorageResources); + const datasets = storageResources.filter(isTrueNASDatasetResource).sort(compareStorageResources); + const disks = storageResources + .filter((resource) => resource.type === 'physical_disk') + .sort(compareStorageResources); + + const owningPoolId = (resource: Resource): string => { + let parentId = asTrimmedString(resource.parentId); + const visited = new Set(); + while (parentId && !visited.has(parentId)) { + const parent = resourceById.get(parentId); + if (parent && isTrueNASPoolResource(parent)) return parent.id; + visited.add(parentId); + parentId = asTrimmedString(parent?.parentId); + } + return ''; + }; + + const rows: TrueNASStorageTopologyRow[] = []; + const emitted = new Set(); + const appendRow = ( + resource: Resource, + kind: TrueNASStorageTopologyKind, + depth: number, + parentRowId?: string, + ) => { + emitted.add(resource.id); + rows.push({ + id: `${kind}:${resource.id}`, + resource, + kind, + depth, + parentRowId, + counts: countsByStorage.get(resource.id) ?? emptyTrueNASStorageChildCounts(), + }); + }; + + for (const pool of pools) { + const poolRowId = `pool:${pool.id}`; + appendRow(pool, 'pool', 0); + for (const dataset of datasets.filter((candidate) => owningPoolId(candidate) === pool.id)) { + appendRow(dataset, 'dataset', 1, poolRowId); + } + for (const disk of disks.filter((candidate) => owningPoolId(candidate) === pool.id)) { + appendRow(disk, 'disk', 1, poolRowId); + } + } + + for (const dataset of datasets.filter((resource) => !emitted.has(resource.id))) { + appendRow(dataset, 'dataset', 0); + } + for (const disk of disks.filter((resource) => !emitted.has(resource.id))) { + appendRow(disk, 'disk', 0); + } + + return rows; +} + +function filterTrueNASStorageTopologyRow( + row: TrueNASStorageTopologyRow, + search: string, + status: TrueNASStorageStatusFilter, +): boolean { + if (status !== 'all' && mapTrueNASStorageStatus(row.resource) !== status) return false; + if (!search.trim()) return true; + if (row.kind.includes(search.trim().toLowerCase())) return true; + return matchesTrueNASStorageSearch(row.resource, search); +} + +export function filterTrueNASStorageTopologyRows( + rows: TrueNASStorageTopologyRow[], + search: string, + status: TrueNASStorageStatusFilter, +): TrueNASStorageTopologyRow[] { + const directMatches = new Set( + rows.filter((row) => filterTrueNASStorageTopologyRow(row, search, status)).map((row) => row.id), + ); + if (directMatches.size === rows.length) return rows; + + const rowById = new Map(rows.map((row) => [row.id, row])); + for (const row of rows) { + if (!directMatches.has(row.id)) continue; + let parentRowId = row.parentRowId; + const visited = new Set(); + while (parentRowId && !visited.has(parentRowId)) { + directMatches.add(parentRowId); + visited.add(parentRowId); + parentRowId = rowById.get(parentRowId)?.parentRowId; + } + } + + return rows.filter((row) => directMatches.has(row.id)); +} + const normalize = (value: unknown): string => typeof value === 'string' ? value.trim().toLowerCase() : ''; @@ -163,6 +353,25 @@ const titleize = (value: string): string => .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) .join(' '); +const storageStatusRank = (resource: Resource): number => { + switch (mapTrueNASStorageStatus(resource)) { + case 'attention': + return 0; + case 'offline': + return 1; + case 'unknown': + return 2; + case 'healthy': + return 3; + } +}; + +const compareStorageResources = (left: Resource, right: Resource): number => { + const rankDelta = storageStatusRank(left) - storageStatusRank(right); + if (rankDelta !== 0) return rankDelta; + return resourceDisplayName(left).localeCompare(resourceDisplayName(right)); +}; + const incidentSeverityRank = (severity: string): number => { switch (mapTrueNASIncidentSeverity(severity)) { case 'critical': @@ -185,6 +394,81 @@ export function mapTrueNASIncidentSeverity( return 'info'; } +export function mapTrueNASStorageStatus( + resource: Resource, +): Exclude | 'unknown' { + const status = normalize(resource.status); + const zfsState = normalize(resource.storage?.zfsPoolState); + const diskHealth = normalize(resource.physicalDisk?.health); + + if ( + ['warning', 'degraded', 'faulted', 'critical', 'failed', 'failure', 'paused'].includes(status) + ) { + return 'attention'; + } + if (['offline', 'stopped'].includes(status)) return 'offline'; + if (zfsState && zfsState !== 'online' && zfsState !== 'healthy') return 'attention'; + if (diskHealth && diskHealth !== 'passed' && diskHealth !== 'healthy' && diskHealth !== 'ok') { + return 'attention'; + } + if (['online', 'running', 'healthy'].includes(status)) return 'healthy'; + return 'unknown'; +} + +const matchesTrueNASStorageSearch = (resource: Resource, search: string): boolean => { + const needle = search.trim().toLowerCase(); + if (!needle) return true; + const storage = resource.storage; + const disk = resource.physicalDisk; + const incidents = resource.incidents ?? []; + const haystack = [ + resource.name, + resource.displayName, + resource.id, + resource.parentName, + resource.status, + resource.metricsTarget?.resourceId, + storage?.type, + storage?.topology, + storage?.platform, + storage?.protection, + storage?.pool, + storage?.path, + storage?.zfsPoolState, + storage?.risk?.level, + storage?.riskSummary, + storage?.protectionSummary, + disk?.devPath, + disk?.model, + disk?.serial, + disk?.wwn, + disk?.diskType, + disk?.health, + disk?.storageGroup, + disk?.storageRole, + disk?.storageState, + ...(resource.tags ?? []), + ...incidents.map((incident) => incident.summary), + ...incidents.map((incident) => incident.code), + ] + .filter((value): value is string => typeof value === 'string') + .join(' ') + .toLowerCase(); + return haystack.includes(needle); +}; + +export function filterTrueNASStorageResources( + resources: Resource[], + search: string, + status: TrueNASStorageStatusFilter, +): Resource[] { + return resources.filter((resource) => { + if (!matchesTrueNASStorageSearch(resource, search)) return false; + if (status === 'all') return true; + return mapTrueNASStorageStatus(resource) === status; + }); +} + const resourceIncidentLabel = (resource: Resource, incident: ResourceIncident): string => { const label = asTrimmedString(resource.incidentLabel); if (label) return label;