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.
This commit is contained in:
rcourtman
2026-05-21 08:48:19 +01:00
parent 497390ff95
commit f453d56607
4 changed files with 783 additions and 14 deletions
@@ -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() {
<TrueNASOverview model={model} />
</Show>
<Show when={activeTab() === 'storage'}>
<StorageSurface
embedded
tableOnly
showFilterToolbar
forcedSourceFilter={TRUENAS_PLATFORM_FILTER}
suppressNodeFilter
filterAriaLabel="TrueNAS storage filters"
filterSearchPlaceholder="Search TrueNAS pools, datasets, disks, or nodes"
filterSearchEmptyMessage="Recent TrueNAS storage searches appear here."
/>
<TrueNASStorage model={model} />
</Show>
<Show when={activeTab() === 'protection'}>
<RecoverySurface
@@ -118,6 +109,18 @@ interface TrueNASOverviewProps {
model: Accessor<TrueNASPageModel>;
}
function TrueNASStorage(props: TrueNASOverviewProps) {
return (
<TrueNASStorageTopologyTable
resources={props.model().resources}
scope={props.model().resources}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS storage inventory"
emptyDescription="Pools, datasets, and physical disks appear here once the TrueNAS API reports storage inventory."
/>
);
}
function TrueNASOverview(props: TrueNASOverviewProps) {
return (
<div class="space-y-4">
@@ -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<TrueNASStorageStatusFilter>[] = [
{ 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 (
<div class="min-w-0">
<div class="truncate text-base-content" title={capacityLabel(props.row)}>
{capacityLabel(props.row)}
</div>
<Show when={percent() !== undefined}>
<div class="mt-1 h-1.5 overflow-hidden rounded bg-surface-alt">
<div
class="h-full rounded bg-emerald-500"
style={{ width: `${Math.max(0, Math.min(100, percent() ?? 0))}%` }}
/>
</div>
</Show>
</div>
);
};
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) => (
<span
class={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${riskPillClass(
props.row,
)}`}
>
{riskLabel(props.row)}
</span>
);
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 (
<div class={`flex min-w-0 items-center gap-2 ${rowIndentClass(props.row.depth)}`}>
<StatusDot size="sm" variant={indicator().variant} title={indicator().label} />
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
{name()}
</div>
<div class="truncate text-[10px] text-muted" title={subtitle(props.row)}>
{subtitle(props.row)}
</div>
</div>
</div>
);
};
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 (
<Show
when={rows().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS pools, datasets, or disks"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_STORAGE_STATUS_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="items"
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No storage items match current filters"
description="Adjust the search or status filter to see more TrueNAS storage."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title="Storage Topology" />
<Table class="min-w-full table-fixed text-xs md:min-w-[1180px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[28%]`}>
Resource
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>
Kind
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden sm:table-cell md:w-[11%]`}
>
State
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[21%]`}>
Usage / Size
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
>
Shares
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
>
Disks
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell md:w-[7%]`}
>
Temp
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[7%]`}>
Health
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(row) => {
const resource = () => row.resource;
const detailRowId = () => drawer.detailRowId(resource());
const isExpanded = () => drawer.isExpanded(resource());
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-truenas-storage-row={row.id}
data-truenas-storage-kind={row.kind}
data-truenas-storage-resource={resource().id}
onClick={() => drawer.toggle(resource())}
onKeyDown={drawer.handleActivationKey(resource())}
tabIndex={0}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<ResourceCell row={row} />
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="text-base-content">{kindLabel(row.kind)}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content sm:table-cell`}
>
<span class="font-mono text-[11px]">{stateLabel(row)}</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('metric-bar')}>
<CapacityCell row={row} />
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content tabular-nums md:table-cell`}
>
{shareCountLabel(row)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content tabular-nums md:table-cell`}
>
{diskCountLabel(row)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content tabular-nums lg:table-cell`}
>
{temperatureLabel(row)}
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<RiskPill row={row} />
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={resource()}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={8}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(resource())}
/>
</>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default TrueNASStorageTopologyTable;
@@ -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',
@@ -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<string, TrueNASStorageChildCounts> {
const countsByStorage = new Map<string, TrueNASStorageChildCounts>();
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<string>();
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<string>();
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<string>();
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<string>();
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<TrueNASStorageStatusFilter, 'all'> | '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;