mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 08:33:03 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user