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={
+
+ }
+ >
+
+
+
+
+
+
+
+
+ {(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)}
+
+
+ {stateLabel(row)}
+
+
+
+
+
+ {shareCountLabel(row)}
+
+
+ {diskCountLabel(row)}
+
+
+ {temperatureLabel(row)}
+
+
+
+
+
+ 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;