Fix storage surface filters

This commit is contained in:
rcourtman 2026-04-23 23:06:04 +01:00
parent 16efcba31f
commit 2a85408a7f
26 changed files with 510 additions and 188 deletions

View file

@ -69,6 +69,7 @@ import {
getPhysicalDiskSourceBadgePresentation,
} from '@/features/storageBackups/diskPresentation';
import type { Resource } from '@/types/resource';
import type { StorageHealthFilter } from '@/features/storageBackups/models';
import { DiskDetail } from './DiskDetail';
import { useDiskListModel } from './useDiskListModel';
@ -76,6 +77,8 @@ interface DiskListProps {
disks: Resource[];
nodes: Resource[];
selectedNode: string | null;
sourceFilter?: string;
healthFilter?: StorageHealthFilter;
searchTerm: string;
selectedDiskId: string | null;
highlightedSummarySeriesId?: string | null;
@ -88,6 +91,8 @@ export const DiskList: Component<DiskListProps> = (props) => {
disks: () => props.disks,
nodes: () => props.nodes,
selectedNode: () => props.selectedNode,
sourceFilter: () => props.sourceFilter ?? 'all',
healthFilter: () => props.healthFilter ?? 'all',
searchTerm: () => props.searchTerm,
selectedDiskId: () => props.selectedDiskId,
setSelectedDiskId: props.onSelectedDiskChange,
@ -303,7 +308,6 @@ export const DiskList: Component<DiskListProps> = (props) => {
</span>
</Show>
</TableCell>
</TableRow>
<Show when={isSelected()}>
<TableRow data-inline-detail-for={summarySeriesId}>

View file

@ -28,6 +28,7 @@ const Storage: Component = () => {
setSearch,
sourceFilter,
setSourceFilter,
healthFilter,
sortKey,
setSortKey,
sortDirection,
@ -76,11 +77,7 @@ const Storage: Component = () => {
} = useStoragePageModel();
return (
<div
ref={setClearSurfaceRootRef}
class="space-y-4"
data-testid="storage-page"
>
<div ref={setClearSurfaceRootRef} class="space-y-4" data-testid="storage-page">
<PageHeader
title="Storage"
description="Review capacity, node health, pools, and storage pressure across connected clusters and devices."
@ -89,6 +86,9 @@ const Storage: Component = () => {
<StickySummarySection desktopOnly={false}>
<StoragePageSummary
filteredRecords={filteredRecords}
search={search}
sourceFilter={sourceFilter}
healthFilter={healthFilter}
selectedNodeId={selectedNodeId}
nodeOptions={nodeOptions}
physicalDisks={physicalDisks}
@ -107,10 +107,11 @@ const Storage: Component = () => {
onJumpToActiveRow={jumpToActiveStorageRow}
onScopeToDegradedPools={() => {
setView('pools');
setStorageFilterStatus('warning');
setStorageFilterStatus('attention');
}}
onScopeToFailingDisks={() => {
setView('disks');
setStorageFilterStatus('attention');
}}
/>
</StickySummarySection>
@ -154,6 +155,8 @@ const Storage: Component = () => {
view={view}
physicalDisks={physicalDisks}
nodes={nodes}
sourceFilter={sourceFilter}
healthFilter={healthFilter}
selectedNodeId={selectedNodeId}
search={search}
groupedRecords={groupedRecords}

View file

@ -3,11 +3,10 @@ import { Card } from '@/components/shared/Card';
import { SummaryTableCardHeader } from '@/components/shared/SummaryTableCardHeader';
import { DiskList } from '@/components/Storage/DiskList';
import StoragePoolsTable from '@/components/Storage/StoragePoolsTable';
import {
STORAGE_CONTENT_CARD_BODY_CLASS,
} from '@/features/storageBackups/storagePagePresentation';
import { STORAGE_CONTENT_CARD_BODY_CLASS } from '@/features/storageBackups/storagePagePresentation';
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
import type { Resource } from '@/types/resource';
import type { StorageHealthFilter } from '@/features/storageBackups/models';
import type { StorageGroupKey, StorageGroupedRecords } from './useStorageModel';
import type { StorageAlertRowState } from '@/features/storageBackups/storageAlertState';
import type { StorageView } from './storagePageState';
@ -18,6 +17,8 @@ type StorageContentCardProps = {
view: () => StorageView;
physicalDisks: () => Resource[];
nodes: () => Resource[];
sourceFilter: () => string;
healthFilter: () => StorageHealthFilter;
selectedNodeId: () => string;
search: () => string;
groupedRecords: () => StorageGroupedRecords[];
@ -74,6 +75,8 @@ export const StorageContentCard: Component<StorageContentCardProps> = (props) =>
<DiskList
disks={props.physicalDisks()}
nodes={props.nodes()}
sourceFilter={props.sourceFilter()}
healthFilter={props.healthFilter()}
selectedNode={model.selectedDiskNodeId()}
searchTerm={props.search()}
selectedDiskId={props.selectedDiskId()}

View file

@ -89,6 +89,8 @@ export const StorageControls: Component<StorageControlsProps> = (props) => {
sourceFilter={props.sourceFilter}
setSourceFilter={props.setSourceFilter}
sourceOptions={props.sourceOptions}
selectedNodeId={props.selectedNodeId}
setSelectedNodeId={props.setSelectedNodeId}
leadingFilters={leadingFilters()}
mobileTrailing={props.mobileTrailing}
utilityActions={props.utilityActions}

View file

@ -29,6 +29,7 @@ import { useStorageFilterToolbarModel } from './useStorageFilterToolbarModel';
export type StorageStatusFilter =
| 'all'
| 'attention'
| 'available'
| 'warning'
| 'critical'
@ -53,6 +54,8 @@ interface StorageFilterProps {
sourceFilter?: () => string;
setSourceFilter?: (value: string) => void;
sourceOptions?: () => StorageSourceOption[];
selectedNodeId?: () => string;
setSelectedNodeId?: (value: string) => void;
// Slot for page-specific filters (e.g., view toggle, node selector).
leadingFilters?: JSX.Element;
// Column visibility (optional)
@ -92,6 +95,8 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
setStatusFilter: props.setStatusFilter,
sourceFilter: props.sourceFilter,
setSourceFilter: props.setSourceFilter,
selectedNodeId: props.selectedNodeId,
setSelectedNodeId: props.setSelectedNodeId,
sortOptions: props.sortOptions ?? DEFAULT_STORAGE_SORT_OPTIONS,
sourceOptions: props.sourceOptions,
});

View file

@ -3,7 +3,7 @@ import StorageSummary from '@/components/Storage/StorageSummary';
import type { StorageSummaryChartsResponse } from '@/api/charts';
import type { SummaryTimeRange } from '@/components/shared/summaryTimeRange';
import type { Resource } from '@/types/resource';
import type { StorageRecord } from '@/features/storageBackups/models';
import type { StorageHealthFilter, StorageRecord } from '@/features/storageBackups/models';
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
import type { StoragePageNodeOption } from './storagePageState';
import { useStoragePageSummary } from './useStoragePageSummary';
@ -11,6 +11,9 @@ import type { SummaryChartHoverSync } from '@/components/shared/contextualFocus'
type StoragePageSummaryProps = {
filteredRecords: () => StorageRecord[];
search: () => string;
sourceFilter: () => string;
healthFilter: () => StorageHealthFilter;
selectedNodeId: () => string;
nodeOptions: () => StoragePageNodeOption[];
physicalDisks: () => Resource[];
@ -32,13 +35,11 @@ type StoragePageSummaryProps = {
};
export const StoragePageSummary: Component<StoragePageSummaryProps> = (props) => {
const {
poolCount,
diskCount,
poolsDegraded,
disksFailing,
} = useStoragePageSummary({
const { poolCount, diskCount, poolsDegraded, disksFailing } = useStoragePageSummary({
filteredRecords: props.filteredRecords,
search: props.search,
sourceFilter: props.sourceFilter,
healthFilter: props.healthFilter,
selectedNodeId: props.selectedNodeId,
nodeOptions: props.nodeOptions,
physicalDisks: props.physicalDisks,

View file

@ -1,7 +1,7 @@
import { createSignal } from 'solid-js';
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import type { NormalizedHealth, StorageRecord } from '@/features/storageBackups/models';
import type { StorageHealthFilter, StorageRecord } from '@/features/storageBackups/models';
import type { StorageGroupKey, StorageSortKey } from '@/components/Storage/useStorageModel';
import {
buildStorageNodeFilterOptions,
@ -75,29 +75,29 @@ const makeDisk = (overrides: Partial<Resource> = {}): Resource =>
...overrides,
}) as Resource;
const makeStorageRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord =>
({
id: 'storage-1',
name: 'ceph-pool',
category: 'pool',
health: 'healthy',
location: { label: 'pve1', scope: 'node' },
capacity: { totalBytes: 100, usedBytes: 40, freeBytes: 60, usagePercent: 40 },
capabilities: ['capacity', 'replication'],
source: {
platform: 'proxmox-pve',
family: 'virtualization',
origin: 'resource',
adapterId: 'resource-storage',
},
observedAt: Date.now(),
details: { type: 'rbd' },
...overrides,
});
const makeStorageRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord => ({
id: 'storage-1',
name: 'ceph-pool',
category: 'pool',
health: 'healthy',
location: { label: 'pve1', scope: 'node' },
capacity: { totalBytes: 100, usedBytes: 40, freeBytes: 60, usagePercent: 40 },
capabilities: ['capacity', 'replication'],
source: {
platform: 'proxmox-pve',
family: 'virtualization',
origin: 'resource',
adapterId: 'resource-storage',
},
observedAt: Date.now(),
details: { type: 'rbd' },
...overrides,
});
describe('storagePageState', () => {
it('normalizes storage query state canonically', () => {
expect(normalizeStorageHealthFilter('available')).toBe('healthy');
expect(normalizeStorageHealthFilter('needs-attention')).toBe('attention');
expect(normalizeStorageSortKey(' usage ')).toBe('usage');
expect(normalizeStorageSortKey('usage')).toBe('usage');
expect(normalizeStorageSortKey('weird')).toBe('priority');
@ -112,7 +112,9 @@ describe('storagePageState', () => {
expect(normalizeStorageSortDirection('bad')).toBe('desc');
expect(getStorageFilterGroupBy('node')).toBe('node');
expect(getStorageStatusFilterValue('healthy')).toBe('available');
expect(getStorageStatusFilterValue('attention')).toBe('attention');
expect(toStorageHealthFilterValue('available')).toBe('healthy');
expect(toStorageHealthFilterValue('attention')).toBe('attention');
expect(getStorageNodeFilterLabel('pools')).toBe('All Nodes');
expect(getStorageNodeFilterLabel('disks')).toBe('All Disk Hosts');
expect(DEFAULT_STORAGE_VIEW).toBe('pools');
@ -129,6 +131,7 @@ describe('storagePageState', () => {
]);
expect(STORAGE_STATUS_FILTER_OPTIONS.map((option) => option.value)).toEqual([
'all',
'attention',
'available',
'warning',
'critical',
@ -145,7 +148,10 @@ describe('storagePageState', () => {
it('derives canonical ceph and node state helpers', () => {
const record = makeStorageRecord();
const nodes = [makeNode(), makeNode({ id: 'node-2', name: 'pve2', status: 'offline', uptime: 0 })];
const nodes = [
makeNode(),
makeNode({ id: 'node-2', name: 'pve2', status: 'offline', uptime: 0 }),
];
const nodeOptions = buildStorageNodeOptions(nodes);
const diskNodeOptions = filterStorageDiskNodeOptions(nodeOptions, [makeDisk()]);
@ -219,7 +225,7 @@ describe('storagePageState', () => {
it('builds storage route fields from shared defaults and normalizers', () => {
const [view, setView] = createSignal<'pools' | 'disks'>('pools');
const [sourceFilter, setSourceFilter] = createSignal('all');
const [healthFilter, setHealthFilter] = createSignal<'all' | NormalizedHealth>('all');
const [healthFilter, setHealthFilter] = createSignal<StorageHealthFilter>('all');
const [selectedNodeId, setSelectedNodeId] = createSignal('all');
const [groupBy, setGroupBy] = createSignal<StorageGroupKey>('none');
const [sortKey, setSortKey] = createSignal<StorageSortKey>('priority');
@ -253,7 +259,9 @@ describe('storagePageState', () => {
expect(fields.source?.write?.('all')).toBeNull();
expect(fields.source?.write?.(' PVE ' as any)).toBe('proxmox-pve');
expect(fields.status?.read({ status: 'available' } as any)).toBe('healthy');
expect(fields.status?.read({ status: 'attention' } as any)).toBe('attention');
expect(fields.status?.write?.('healthy')).toBe('available');
expect(fields.status?.write?.('attention')).toBe('attention');
expect(fields.status?.write?.('warning')).toBe('warning');
expect(fields.status?.write?.('all')).toBeNull();
expect(fields.node?.read({ node: ' node-1 ' } as any)).toBe('node-1');

View file

@ -53,6 +53,8 @@ describe('useDiskListModel', () => {
const [nodes] = createSignal<Resource[]>([buildNode('node-tower', 'tower')]);
const [selectedNode] = createSignal<string | null>('node-tower');
const [searchTerm] = createSignal('cache');
const [sourceFilter] = createSignal('proxmox-pve');
const [healthFilter] = createSignal('healthy' as const);
const [selectedDiskId, setSelectedDiskId] = createSignal<string | null>(null);
const { result } = renderHook(() =>
@ -60,6 +62,8 @@ describe('useDiskListModel', () => {
disks,
nodes,
selectedNode,
sourceFilter,
healthFilter,
searchTerm,
selectedDiskId,
setSelectedDiskId,

View file

@ -3,20 +3,17 @@ import { createSignal } from 'solid-js';
import { describe, expect, it } from 'vitest';
import { useStorageFilterState } from '@/components/Storage/useStorageFilterState';
import type { StorageNodeOption, StorageGroupKey } from '@/components/Storage/useStorageModel';
import type { NormalizedHealth } from '@/features/storageBackups/models';
import type { StorageHealthFilter } from '@/features/storageBackups/models';
describe('useStorageFilterState', () => {
it('builds source and node filter options canonically', () => {
const [view] = createSignal<'pools' | 'disks'>('pools');
const [nodeOptions] = createSignal<StorageNodeOption[]>([
{ id: 'node-1', label: 'pve1' },
]);
const [diskNodeOptions] = createSignal<StorageNodeOption[]>([
{ id: 'node-2', label: 'tower' },
]);
const [nodeOptions] = createSignal<StorageNodeOption[]>([{ id: 'node-1', label: 'pve1' }]);
const [diskNodeOptions] = createSignal<StorageNodeOption[]>([{ id: 'node-2', label: 'tower' }]);
const [selectedNodeId, setSelectedNodeId] = createSignal('node-1');
const [sourceOptions] = createSignal(['all', 'truenas', 'proxmox-pve', 'agent']);
const [healthFilter, setHealthFilter] = createSignal<'all' | NormalizedHealth>('all');
const [sourceFilter, setSourceFilter] = createSignal('all');
const [healthFilter, setHealthFilter] = createSignal<StorageHealthFilter>('all');
const [groupBy] = createSignal<StorageGroupKey>('node');
const { result } = renderHook(() =>
@ -27,6 +24,8 @@ describe('useStorageFilterState', () => {
selectedNodeId,
setSelectedNodeId,
sourceOptions,
sourceFilter,
setSourceFilter,
healthFilter,
setHealthFilter,
groupBy,
@ -49,15 +48,14 @@ describe('useStorageFilterState', () => {
it('coerces stale selected nodes and maps status setters', () => {
const [view] = createSignal<'pools' | 'disks'>('disks');
const [nodeOptions] = createSignal<StorageNodeOption[]>([
{ id: 'all', label: 'All Nodes' },
]);
const [nodeOptions] = createSignal<StorageNodeOption[]>([{ id: 'all', label: 'All Nodes' }]);
const [diskNodeOptions] = createSignal<StorageNodeOption[]>([
{ id: 'all', label: 'All Nodes' },
]);
const [selectedNodeId, setSelectedNodeId] = createSignal('missing');
const [sourceOptions] = createSignal(['all']);
const [healthFilter, setHealthFilter] = createSignal<'all' | NormalizedHealth>('all');
const [sourceFilter, setSourceFilter] = createSignal('missing-source');
const [healthFilter, setHealthFilter] = createSignal<StorageHealthFilter>('all');
const [groupBy] = createSignal<StorageGroupKey>('none');
const { result } = renderHook(() =>
@ -68,6 +66,8 @@ describe('useStorageFilterState', () => {
selectedNodeId,
setSelectedNodeId,
sourceOptions,
sourceFilter,
setSourceFilter,
healthFilter,
setHealthFilter,
groupBy,
@ -75,6 +75,7 @@ describe('useStorageFilterState', () => {
);
expect(selectedNodeId()).toBe('all');
expect(sourceFilter()).toBe('all');
result.setStorageFilterStatus('critical');
expect(healthFilter()).toBe('critical');
});

View file

@ -12,6 +12,7 @@ describe('useStorageFilterToolbarModel', () => {
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'warning'>('warning');
const [sourceFilter, setSourceFilter] = createSignal('agent');
const [selectedNodeId, setSelectedNodeId] = createSignal('node-1');
const { result } = renderHook(() =>
useStorageFilterToolbarModel({
@ -27,6 +28,8 @@ describe('useStorageFilterToolbarModel', () => {
setStatusFilter,
sourceFilter,
setSourceFilter,
selectedNodeId,
setSelectedNodeId,
sortOptions: [{ value: 'usage', label: 'Usage' }],
}),
);
@ -48,6 +51,7 @@ describe('useStorageFilterToolbarModel', () => {
expect(sortDirection()).toBe('desc');
expect(statusFilter()).toBe('all');
expect(sourceFilter()).toBe('all');
expect(selectedNodeId()).toBe('all');
});
it('keeps storage source options reactive for restored route state', () => {

View file

@ -13,7 +13,12 @@ const makeRecord = (id: string, health: StorageRecord['health']): StorageRecord
location: { label: 'pve1', scope: 'node' },
capacity: { totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: null },
capabilities: [],
source: { platform: 'proxmox-pve', family: 'virtualization', origin: 'resource', adapterId: 'test' },
source: {
platform: 'proxmox-pve',
family: 'virtualization',
origin: 'resource',
adapterId: 'test',
},
observedAt: Date.now(),
});
@ -26,6 +31,9 @@ describe('useStoragePageSummary', () => {
makeRecord('pool-d', 'healthy'),
]);
const [selectedNodeId] = createSignal('node-1');
const [search] = createSignal('');
const [sourceFilter] = createSignal('all');
const [healthFilter] = createSignal('all' as const);
const [nodeOptions] = createSignal([
{ id: 'node-1', label: 'pve1', aliases: ['pve1.local'] },
{ id: 'node-2', label: 'pve2' },
@ -63,6 +71,9 @@ describe('useStoragePageSummary', () => {
const { result } = renderHook(() =>
useStoragePageSummary({
filteredRecords,
search,
sourceFilter,
healthFilter,
selectedNodeId,
nodeOptions,
physicalDisks,

View file

@ -1,5 +1,5 @@
import type { Accessor } from 'solid-js';
import type { NormalizedHealth, StorageRecord } from '@/features/storageBackups/models';
import type { StorageHealthFilter, StorageRecord } from '@/features/storageBackups/models';
import { isCephStorageRecord } from '@/features/storageBackups/cephRecordPresentation';
import type { Resource } from '@/types/resource';
import { getProxmoxData } from '@/utils/resourcePlatformData';
@ -12,6 +12,7 @@ import type { StorageRouteStateFields } from './useStorageRouteState';
export type StorageView = 'pools' | 'disks';
export type StorageStatusFilterValue =
| 'all'
| 'attention'
| 'available'
| 'warning'
| 'critical'
@ -56,6 +57,7 @@ export const DEFAULT_STORAGE_SORT_OPTIONS: Array<{ value: StorageSortKey; label:
export const STORAGE_STATUS_FILTER_OPTIONS: StorageOption[] = [
{ value: 'all', label: 'All' },
{ value: 'attention', label: 'Needs attention' },
{ value: 'available', label: 'Healthy' },
{ value: 'warning', label: 'Warning' },
{ value: 'critical', label: 'Critical' },
@ -70,9 +72,18 @@ export const STORAGE_GROUP_BY_OPTIONS: StorageOption[] = [
{ value: 'status', label: 'By Status' },
];
export const normalizeStorageHealthFilter = (value: string): 'all' | NormalizedHealth => {
export const normalizeStorageHealthFilter = (value: string): StorageHealthFilter => {
const normalized = (value || '').trim().toLowerCase();
if (!normalized || normalized === 'all') return 'all';
if (
normalized === 'attention' ||
normalized === 'needs-attention' ||
normalized === 'issue' ||
normalized === 'issues' ||
normalized === 'unhealthy'
) {
return 'attention';
}
if (normalized === 'available' || normalized === 'online' || normalized === 'healthy') {
return 'healthy';
}
@ -130,17 +141,19 @@ export const getStorageFilterGroupBy = (
value === 'type' || value === 'status' || value === 'none' ? value : 'node';
export const getStorageStatusFilterValue = (
value: 'all' | NormalizedHealth,
value: StorageHealthFilter,
): StorageStatusFilterValue => {
if (value === 'all') return 'all';
if (value === 'attention') return 'attention';
if (value === 'healthy') return 'available';
return value;
};
export const toStorageHealthFilterValue = (
value: StorageStatusFilterValue,
): 'all' | NormalizedHealth => {
): StorageHealthFilter => {
if (value === 'all') return 'all';
if (value === 'attention') return 'attention';
if (value === 'available') return 'healthy';
return value;
};
@ -165,26 +178,21 @@ export const hasActiveStorageFilters = (state: StorageFilterActivityState): bool
export const getStorageNodeFilterLabel = (view: StorageView): string =>
view === 'disks' ? 'All Disk Hosts' : 'All Nodes';
export const readStorageRouteValue = (
value: string | undefined,
defaultValue: string,
): string => {
export const readStorageRouteValue = (value: string | undefined, defaultValue: string): string => {
const normalized = (value || '').trim();
return normalized || defaultValue;
};
export const writeStorageRouteValue = (
value: string,
defaultValue: string,
): string | null => (value !== defaultValue ? value : null);
export const writeStorageRouteValue = (value: string, defaultValue: string): string | null =>
value !== defaultValue ? value : null;
type StorageRouteFieldBuilderOptions = {
view: Accessor<StorageView>;
setView: (value: StorageView) => void;
sourceFilter: Accessor<string>;
setSourceFilter: (value: string) => void;
healthFilter: Accessor<'all' | NormalizedHealth>;
setHealthFilter: (value: 'all' | NormalizedHealth) => void;
healthFilter: Accessor<StorageHealthFilter>;
setHealthFilter: (value: StorageHealthFilter) => void;
selectedNodeId: Accessor<string>;
setSelectedNodeId: (value: string) => void;
groupBy: Accessor<StorageGroupKey>;
@ -223,12 +231,14 @@ export const buildStorageRouteFields = (
get: options.healthFilter,
set: options.setHealthFilter,
read: (parsed) => normalizeStorageHealthFilter(parsed.status),
write: (value) => writeStorageRouteValue(getStorageStatusFilterValue(value), DEFAULT_STORAGE_STATUS_FILTER),
write: (value) =>
writeStorageRouteValue(getStorageStatusFilterValue(value), DEFAULT_STORAGE_STATUS_FILTER),
},
node: {
get: options.selectedNodeId,
set: options.setSelectedNodeId,
read: (parsed) => normalizeStorageRouteSelection(parsed.node) || DEFAULT_STORAGE_SELECTED_NODE_ID,
read: (parsed) =>
normalizeStorageRouteSelection(parsed.node) || DEFAULT_STORAGE_SELECTED_NODE_ID,
write: (value) =>
writeStorageRouteValue(
normalizeStorageRouteSelection(value) || DEFAULT_STORAGE_SELECTED_NODE_ID,
@ -353,10 +363,7 @@ export const syncExpandedStorageGroups = (
return changed ? next : previous;
};
export const toggleExpandedStorageGroup = (
previous: Set<string>,
key: string,
): Set<string> => {
export const toggleExpandedStorageGroup = (previous: Set<string>, key: string): Set<string> => {
const next = new Set(previous);
if (next.has(key)) next.delete(key);
else next.add(key);

View file

@ -1,5 +1,6 @@
import { createMemo } from 'solid-js';
import type { Resource } from '@/types/resource';
import type { StorageHealthFilter } from '@/features/storageBackups/models';
import {
buildPhysicalDiskPresentationDataMap,
extractPhysicalDiskPresentationData,
@ -12,6 +13,8 @@ type UseDiskListModelOptions = {
disks: () => Resource[];
nodes: () => Resource[];
selectedNode: () => string | null;
sourceFilter?: () => string;
healthFilter?: () => StorageHealthFilter;
searchTerm: () => string;
selectedDiskId: () => string | null;
setSelectedDiskId: (diskId: string | null) => void;
@ -32,6 +35,8 @@ export const useDiskListModel = (options: UseDiskListModelOptions) => {
const filteredDisks = createMemo(() =>
filterAndSortPhysicalDisks(options.disks(), {
selectedNode: selectedNodeResource(),
sourceFilter: options.sourceFilter?.() ?? 'all',
healthFilter: options.healthFilter?.() ?? 'all',
searchTerm: options.searchTerm(),
getDiskData,
matchesNode: matchesPhysicalDiskNode,

View file

@ -1,6 +1,7 @@
import { createEffect, createMemo, type Accessor } from 'solid-js';
import { buildStorageSourceOptionsFromKeys } from '@/utils/storageSources';
import type { NormalizedHealth } from '@/features/storageBackups/models';
import type { StorageHealthFilter } from '@/features/storageBackups/models';
import { normalizeStorageSourceKey } from '@/utils/storageSources';
import {
buildStorageNodeFilterOptions,
coerceSelectedStorageNodeId,
@ -20,8 +21,11 @@ type UseStorageFilterStateOptions = {
selectedNodeId: Accessor<string>;
setSelectedNodeId: (value: string) => void;
sourceOptions: Accessor<string[]>;
healthFilter: Accessor<'all' | NormalizedHealth>;
setHealthFilter: (value: 'all' | NormalizedHealth) => void;
diskSourceOptions?: Accessor<string[]>;
sourceFilter: Accessor<string>;
setSourceFilter: (value: string) => void;
healthFilter: Accessor<StorageHealthFilter>;
setHealthFilter: (value: StorageHealthFilter) => void;
groupBy: Accessor<StorageGroupKey>;
};
@ -32,8 +36,13 @@ export const useStorageFilterState = (options: UseStorageFilterStateOptions) =>
const nodeFilterOptions = createMemo(() =>
buildStorageNodeFilterOptions(options.view(), activeNodeOptions()),
);
const activeSourceKeys = createMemo(() =>
options.view() === 'disks' && options.diskSourceOptions
? options.diskSourceOptions()
: options.sourceOptions(),
);
const sourceFilterOptions = createMemo(() =>
buildStorageSourceOptionsFromKeys(options.sourceOptions()),
buildStorageSourceOptionsFromKeys(activeSourceKeys()),
);
const storageFilterGroupBy = (): StorageGroupByFilter => {
@ -49,15 +58,25 @@ export const useStorageFilterState = (options: UseStorageFilterStateOptions) =>
};
createEffect(() => {
const next = coerceSelectedStorageNodeId(
options.selectedNodeId(),
activeNodeOptions(),
);
const next = coerceSelectedStorageNodeId(options.selectedNodeId(), activeNodeOptions());
if (next !== options.selectedNodeId()) {
options.setSelectedNodeId(next);
}
});
createEffect(() => {
const selectedSource = normalizeStorageSourceKey(options.sourceFilter());
if (selectedSource === 'all') {
return;
}
const availableSources = new Set(
activeSourceKeys().map((key) => normalizeStorageSourceKey(key)),
);
if (!availableSources.has(selectedSource)) {
options.setSourceFilter('all');
}
});
return {
activeNodeOptions,
nodeFilterOptions,

View file

@ -11,6 +11,7 @@ import {
DEFAULT_STORAGE_SORT_DIRECTION,
DEFAULT_STORAGE_SORT_KEY,
DEFAULT_STORAGE_SOURCE_FILTER,
DEFAULT_STORAGE_SELECTED_NODE_ID,
DEFAULT_STORAGE_STATUS_FILTER,
hasActiveStorageFilters,
type StorageOption,
@ -30,6 +31,8 @@ type UseStorageFilterToolbarModelOptions = {
setStatusFilter?: (value: StorageStatusFilter) => void;
sourceFilter?: Accessor<string | undefined>;
setSourceFilter?: (value: string) => void;
selectedNodeId?: Accessor<string | undefined>;
setSelectedNodeId?: (value: string) => void;
sortOptions?: StorageOption[];
sourceOptions?: Accessor<StorageSourceOption[] | undefined>;
};
@ -37,26 +40,34 @@ type UseStorageFilterToolbarModelOptions = {
export const useStorageFilterToolbarModel = (options: UseStorageFilterToolbarModelOptions) => {
const [filtersOpen, setFiltersOpen] = createSignal(false);
const activeFilterCount = createMemo(() =>
countActiveStorageFilters({
search: options.search(),
sortKey: options.sortKey(),
sortDirection: options.sortDirection(),
groupBy: options.groupBy?.(),
statusFilter: options.statusFilter?.(),
sourceFilter: options.sourceFilter?.(),
}),
);
const activeFilterCount = createMemo(() => {
const nodeActive =
(options.selectedNodeId?.() || DEFAULT_STORAGE_SELECTED_NODE_ID) !==
DEFAULT_STORAGE_SELECTED_NODE_ID;
return (
countActiveStorageFilters({
search: options.search(),
sortKey: options.sortKey(),
sortDirection: options.sortDirection(),
groupBy: options.groupBy?.(),
statusFilter: options.statusFilter?.(),
sourceFilter: options.sourceFilter?.(),
}) + (nodeActive ? 1 : 0)
);
});
const showReset = createMemo(() =>
hasActiveStorageFilters({
search: options.search(),
sortKey: options.sortKey(),
sortDirection: options.sortDirection(),
groupBy: options.groupBy?.(),
statusFilter: options.statusFilter?.(),
sourceFilter: options.sourceFilter?.(),
}),
const showReset = createMemo(
() =>
hasActiveStorageFilters({
search: options.search(),
sortKey: options.sortKey(),
sortDirection: options.sortDirection(),
groupBy: options.groupBy?.(),
statusFilter: options.statusFilter?.(),
sourceFilter: options.sourceFilter?.(),
}) ||
(options.selectedNodeId?.() || DEFAULT_STORAGE_SELECTED_NODE_ID) !==
DEFAULT_STORAGE_SELECTED_NODE_ID,
);
const sortOptions = createMemo(() => options.sortOptions ?? []);
@ -81,6 +92,7 @@ export const useStorageFilterToolbarModel = (options: UseStorageFilterToolbarMod
if (options.setGroupBy) options.setGroupBy(DEFAULT_STORAGE_GROUP_KEY);
if (options.setStatusFilter) options.setStatusFilter(DEFAULT_STORAGE_STATUS_FILTER);
if (options.setSourceFilter) options.setSourceFilter(DEFAULT_STORAGE_SOURCE_FILTER);
if (options.setSelectedNodeId) options.setSelectedNodeId(DEFAULT_STORAGE_SELECTED_NODE_ID);
};
return {

View file

@ -1,5 +1,5 @@
import { Accessor, createMemo } from 'solid-js';
import type { NormalizedHealth, StorageRecord } from '@/features/storageBackups/models';
import type { StorageHealthFilter, StorageRecord } from '@/features/storageBackups/models';
import {
buildStorageSourceOptions,
filterStorageRecords,
@ -26,7 +26,7 @@ type UseStorageModelOptions = {
records: Accessor<StorageRecord[]>;
search: Accessor<string>;
sourceFilter: Accessor<string>;
healthFilter: Accessor<'all' | NormalizedHealth>;
healthFilter: Accessor<StorageHealthFilter>;
selectedNodeId: Accessor<string>;
nodeOptions: Accessor<StorageNodeOption[]>;
sortKey: Accessor<StorageSortKey>;

View file

@ -1,6 +1,7 @@
import { Accessor, createMemo } from 'solid-js';
import { buildStorageRecords } from '@/features/storageBackups/storageAdapters';
import type { NormalizedHealth } from '@/features/storageBackups/models';
import type { StorageHealthFilter } from '@/features/storageBackups/models';
import { getPhysicalDiskSourceKey } from '@/features/storageBackups/diskPresentation';
import type { State } from '@/types/api';
import type { Resource } from '@/types/resource';
import { useStorageAlertState } from './useStorageAlertState';
@ -10,11 +11,7 @@ import {
buildStorageNodeOptions,
filterStorageDiskNodeOptions,
} from './storagePageState';
import {
type StorageGroupKey,
type StorageSortKey,
useStorageModel,
} from './useStorageModel';
import { type StorageGroupKey, type StorageSortKey, useStorageModel } from './useStorageModel';
type UseStoragePageDataOptions = {
state: Accessor<State>;
@ -26,7 +23,7 @@ type UseStoragePageDataOptions = {
cephResources: Accessor<Resource[]>;
search: Accessor<string>;
sourceFilter: Accessor<string>;
healthFilter: Accessor<'all' | NormalizedHealth>;
healthFilter: Accessor<StorageHealthFilter>;
selectedNodeId: Accessor<string>;
sortKey: Accessor<StorageSortKey>;
sortDirection: Accessor<'asc' | 'desc'>;
@ -64,6 +61,13 @@ export const useStoragePageData = (options: UseStoragePageDataOptions) => {
groupBy: options.groupBy,
});
const diskSourceOptions = createMemo(() => [
'all',
...Array.from(new Set(options.physicalDisks().map((disk) => getPhysicalDiskSourceKey(disk))))
.filter((key) => key !== 'all')
.sort(),
]);
const { cephSummaryStats } = useStorageCephModel({
records,
cephResources: options.cephResources,
@ -76,6 +80,7 @@ export const useStoragePageData = (options: UseStoragePageDataOptions) => {
diskNodeOptions,
nodeOnlineByLabel,
sourceOptions,
diskSourceOptions,
filteredRecords,
groupedRecords,
cephSummaryStats,

View file

@ -1,6 +1,6 @@
import { createSignal } from 'solid-js';
import { buildStoragePath } from '@/routing/resourceLinks';
import type { NormalizedHealth } from '@/features/storageBackups/models';
import type { StorageHealthFilter } from '@/features/storageBackups/models';
import { useStorageRouteState } from './useStorageRouteState';
import {
buildStorageRouteFields,
@ -25,12 +25,13 @@ type UseStoragePageFiltersOptions = {
export const useStoragePageFilters = (options: UseStoragePageFiltersOptions) => {
const [search, setSearch] = createSignal('');
const [sourceFilter, setSourceFilter] = createSignal(DEFAULT_STORAGE_SOURCE_FILTER);
const [healthFilter, setHealthFilter] = createSignal<'all' | NormalizedHealth>('all');
const [healthFilter, setHealthFilter] = createSignal<StorageHealthFilter>('all');
const [view, setView] = createSignal<StorageView>(DEFAULT_STORAGE_VIEW);
const [selectedNodeId, setSelectedNodeId] = createSignal(DEFAULT_STORAGE_SELECTED_NODE_ID);
const [sortKey, setSortKey] = createSignal<StorageSortKey>(DEFAULT_STORAGE_SORT_KEY);
const [sortDirection, setSortDirection] =
createSignal<'asc' | 'desc'>(DEFAULT_STORAGE_SORT_DIRECTION);
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>(
DEFAULT_STORAGE_SORT_DIRECTION,
);
const [groupBy, setGroupBy] = createSignal<StorageGroupKey>(DEFAULT_STORAGE_GROUP_KEY);
const isActiveStorageRoute = () => options.location.pathname === '/storage';
@ -81,4 +82,3 @@ export const useStoragePageFilters = (options: UseStoragePageFiltersOptions) =>
setGroupBy,
};
};

View file

@ -1,6 +1,9 @@
import { createEffect, createMemo, createSignal, onCleanup, untrack } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import { SUMMARY_TIME_RANGE_LABEL, type SummaryTimeRange } from '@/components/shared/summaryTimeRange';
import {
SUMMARY_TIME_RANGE_LABEL,
type SummaryTimeRange,
} from '@/components/shared/summaryTimeRange';
import { buildStorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
import {
resolvePhysicalDiskMetricResourceId,
@ -86,6 +89,7 @@ export const useStoragePageModel = () => {
diskNodeOptions,
nodeOnlineByLabel,
sourceOptions,
diskSourceOptions,
filteredRecords,
groupedRecords,
cephSummaryStats,
@ -128,11 +132,15 @@ export const useStoragePageModel = () => {
caller: 'useStoragePageModel',
});
const { expandedGroups, expandedPoolId, setExpandedPoolId: setExpandedPoolIdRaw, toggleGroup } =
useStorageExpansionState({
groupedKeys: () => groupedRecords().map((group) => group.key),
view,
});
const {
expandedGroups,
expandedPoolId,
setExpandedPoolId: setExpandedPoolIdRaw,
toggleGroup,
} = useStorageExpansionState({
groupedKeys: () => groupedRecords().map((group) => group.key),
view,
});
const storageRecordMetricIds = createMemo(() => {
const ids = new Map<string, string>();
for (const record of records()) {
@ -150,9 +158,7 @@ export const useStoragePageModel = () => {
const storageGrowthRangeLabel = createMemo(
() => SUMMARY_TIME_RANGE_LABEL[summaryTimeRange()] ?? summaryTimeRange(),
);
const storageGrowthColumnLabel = createMemo(
() => `Growth (${storageGrowthRangeLabel()})`,
);
const storageGrowthColumnLabel = createMemo(() => `Growth (${storageGrowthRangeLabel()})`);
const storageGrowthBySeriesId = createMemo(() => {
const growth = new Map<string, ReturnType<typeof buildStorageCapacityDeltaPresentation>>();
const pools = storageSummaryCharts.data()?.pools ?? {};
@ -255,8 +261,14 @@ export const useStoragePageModel = () => {
const nextValue = typeof value === 'function' ? value(expandedPoolId()) : value;
const focusedScope = focusedStorageGroupScope();
const nextResourceId =
nextValue && storageRecordMetricIds().get(nextValue) ? storageRecordMetricIds().get(nextValue)! : null;
if (focusedScope && nextResourceId && !isSummarySeriesInGroupScope(focusedScope, nextResourceId)) {
nextValue && storageRecordMetricIds().get(nextValue)
? storageRecordMetricIds().get(nextValue)!
: null;
if (
focusedScope &&
nextResourceId &&
!isSummarySeriesInGroupScope(focusedScope, nextResourceId)
) {
clearFocusedStorageGroup();
}
setExpandedPoolIdRaw(nextValue);
@ -343,6 +355,9 @@ export const useStoragePageModel = () => {
selectedNodeId,
setSelectedNodeId,
sourceOptions,
diskSourceOptions,
sourceFilter,
setSourceFilter,
healthFilter,
setHealthFilter,
groupBy,
@ -421,6 +436,7 @@ export const useStoragePageModel = () => {
setSearch,
sourceFilter,
setSourceFilter,
healthFilter,
sortKey,
setSortKey,
sortDirection,

View file

@ -1,11 +1,21 @@
import { Accessor, createMemo } from 'solid-js';
import type { Resource } from '@/types/resource';
import type { StorageRecord } from '@/features/storageBackups/models';
import type { StorageHealthFilter, StorageRecord } from '@/features/storageBackups/models';
import type { StoragePageNodeOption } from './storagePageState';
import { countVisiblePhysicalDisksForNode } from './storagePageState';
import {
buildPhysicalDiskPresentationDataMap,
extractPhysicalDiskPresentationData,
getPhysicalDiskNormalizedHealth,
matchesPhysicalDiskFilterState,
matchesPhysicalDiskHealthFilter,
} from '@/features/storageBackups/diskPresentation';
import { matchesPhysicalDiskNode } from './diskResourceUtils';
type UseStoragePageSummaryOptions = {
filteredRecords: Accessor<StorageRecord[]>;
search: Accessor<string>;
sourceFilter: Accessor<string>;
healthFilter: Accessor<StorageHealthFilter>;
selectedNodeId: Accessor<string>;
nodeOptions: Accessor<StoragePageNodeOption[]>;
physicalDisks: Accessor<Resource[]>;
@ -15,33 +25,51 @@ const POOL_DEGRADED_HEALTHS = new Set(['warning', 'critical', 'offline']);
export const useStoragePageSummary = (options: UseStoragePageSummaryOptions) => {
const poolCount = createMemo(() => options.filteredRecords().length);
const diskCount = createMemo(() =>
countVisiblePhysicalDisksForNode(
options.selectedNodeId(),
options.nodeOptions(),
options.physicalDisks(),
),
const diskDataById = createMemo(() =>
buildPhysicalDiskPresentationDataMap(options.physicalDisks()),
);
const getDiskData = (disk: Resource) =>
diskDataById().get(disk.id) ?? extractPhysicalDiskPresentationData(disk);
const filteredPhysicalDisks = createMemo(() => {
const nodeId = options.selectedNodeId();
const selectedNode =
nodeId && nodeId !== 'all'
? (options.nodeOptions().find((option) => option.id === nodeId) ?? null)
: null;
return options.physicalDisks().filter((disk) => {
if (
selectedNode &&
!matchesPhysicalDiskNode(disk, {
id: selectedNode.id,
name: selectedNode.label,
instance: selectedNode.instance,
})
) {
return false;
}
return matchesPhysicalDiskFilterState(disk, getDiskData(disk), {
sourceFilter: options.sourceFilter(),
healthFilter: options.healthFilter(),
searchTerm: options.search(),
});
});
});
const diskCount = createMemo(() => filteredPhysicalDisks().length);
const poolsDegraded = createMemo(
() => options.filteredRecords().filter((record) => POOL_DEGRADED_HEALTHS.has(record.health)).length,
() =>
options.filteredRecords().filter((record) => POOL_DEGRADED_HEALTHS.has(record.health)).length,
);
const disksFailing = createMemo(() => {
const nodeId = options.selectedNodeId();
const isForSelectedNode = (disk: Resource): boolean => {
if (!nodeId || nodeId === 'all') return true;
const nodeOption = options.nodeOptions().find((option) => option.id === nodeId);
if (!nodeOption) return true;
const hostname = disk.identity?.hostname ?? disk.canonicalIdentity?.hostname;
if (!hostname) return false;
if (nodeOption.label === hostname) return true;
return (nodeOption.aliases ?? []).includes(hostname);
};
return options.physicalDisks().filter((disk) => {
if (!isForSelectedNode(disk)) return false;
if (disk.status === 'degraded' || disk.status === 'offline') return true;
return (disk.alerts?.length ?? 0) > 0;
}).length;
return filteredPhysicalDisks().filter((disk) =>
matchesPhysicalDiskHealthFilter(
getPhysicalDiskNormalizedHealth(disk, getDiskData(disk)),
'attention',
),
).length;
});
return {

View file

@ -16,9 +16,11 @@ import {
getPhysicalDiskHealthStatus,
getPhysicalDiskHealthSummary,
getPhysicalDiskHostLabel,
getPhysicalDiskNormalizedHealth,
getPhysicalDiskParentLabel,
getPhysicalDiskPlatformLabel,
getPhysicalDiskRoleLabel,
getPhysicalDiskSourceKey,
getPhysicalDiskSourceBadgePresentation,
hasPhysicalDiskSmartWarning,
matchesPhysicalDiskSearch,
@ -116,6 +118,18 @@ describe('diskPresentation', () => {
className:
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400 inline-flex min-w-[3.25rem] justify-center px-1.5 py-px text-[9px] font-medium',
});
expect(
getPhysicalDiskSourceKey({
platformType: 'agent',
platformData: { sources: ['agent', 'proxmox'] },
} as unknown as Resource),
).toBe('proxmox-pve');
expect(
getPhysicalDiskSourceBadgePresentation({
platformType: 'agent',
platformData: { sources: ['agent', 'proxmox'] },
} as unknown as Resource).label,
).toBe('PVE');
});
it('returns host, health summary, and empty-state presentation canonically', () => {
@ -209,15 +223,33 @@ describe('diskPresentation', () => {
expect(warningData.riskReasons).toEqual(['Pending sectors detected.']);
expect(matchesPhysicalDiskSearch(warningDisk, warningData, 'cache')).toBe(true);
expect(matchesPhysicalDiskSearch(warningDisk, warningData, 'tank')).toBe(true);
expect(comparePhysicalDiskPresentation(warningDisk, warningData, healthyDisk, healthyData)).toBeLessThan(0);
expect(matchesPhysicalDiskSearch(warningDisk, warningData, 'node:tower')).toBe(true);
expect(matchesPhysicalDiskSearch(warningDisk, warningData, 'node:pve1')).toBe(false);
expect(getPhysicalDiskSourceKey(warningDisk)).toBe('proxmox-pve');
expect(getPhysicalDiskNormalizedHealth(warningDisk, warningData)).toBe('warning');
expect(
comparePhysicalDiskPresentation(warningDisk, warningData, healthyDisk, healthyData),
).toBeLessThan(0);
expect(buildPhysicalDiskPresentationDataMap([warningDisk, healthyDisk]).size).toBe(2);
expect(
filterAndSortPhysicalDisks([warningDisk, healthyDisk], {
selectedNode: null,
searchTerm: 'cache',
sourceFilter: 'proxmox-pve',
healthFilter: 'attention',
getDiskData: (disk) => (disk.id === warningDisk.id ? warningData : healthyData),
matchesNode: () => true,
}).map((disk) => disk.id),
).toEqual(['disk-warning']);
expect(
filterAndSortPhysicalDisks([warningDisk, healthyDisk], {
selectedNode: null,
searchTerm: '',
sourceFilter: 'agent',
healthFilter: 'all',
getDiskData: (disk) => (disk.id === warningDisk.id ? warningData : healthyData),
matchesNode: () => true,
}).map((disk) => disk.id),
).toEqual([]);
});
});

View file

@ -80,6 +80,9 @@ describe('storageModelCore', () => {
'truenas',
]);
expect(matchesStorageRecordSearch(makeRecord(), 'tank')).toBe(true);
expect(matchesStorageRecordSearch(makeRecord(), 'node:pve1')).toBe(true);
expect(matchesStorageRecordSearch(makeRecord(), 'node:pve1 tank')).toBe(true);
expect(matchesStorageRecordSearch(makeRecord(), 'node:pve2')).toBe(false);
expect(matchesStorageRecordSearch(makeRecord(), 'missing')).toBe(false);
});
@ -101,22 +104,18 @@ describe('storageModelCore', () => {
const records = filterStorageRecords([makeRecord(), warning], {
search: '',
sourceFilter: 'all',
healthFilter: 'all',
healthFilter: 'attention',
selectedNode: node,
});
expect(sortStorageRecords(records, 'priority', 'desc').map((record) => record.id)).toEqual([
'storage-2',
'storage-1',
]);
expect(groupStorageRecords(records, 'status').map((group) => group.key)).toEqual([
'available',
'degraded',
]);
expect(groupStorageRecords(records, 'status').map((group) => group.key)).toEqual(['degraded']);
expect(summarizeStorageRecords(records)).toMatchObject({
count: 2,
totalBytes: 200,
usedBytes: 80,
count: 1,
totalBytes: 100,
usedBytes: 40,
usagePercent: 40,
});
});

View file

@ -1,6 +1,13 @@
import type { Resource } from '@/types/resource';
import { getSourcePlatformLabel, getSourcePlatformPresentation } from '@/utils/sourcePlatforms';
import {
getSourcePlatformLabel,
getSourcePlatformPresentation,
resolvePlatformTypeFromSources,
} from '@/utils/sourcePlatforms';
import { getPhysicalDiskNodeIdentity } from '@/components/Storage/diskResourceUtils';
import { normalizeStorageSourceKey } from '@/utils/storageSources';
import type { NormalizedHealth, StorageHealthFilter } from './models';
import { matchesStorageNodeTerms, parseStorageSearchQuery } from './storageSearchQuery';
export interface DiskHealthStatusPresentation {
label: string;
@ -68,7 +75,8 @@ export const PHYSICAL_DISK_EMPTY_REQUIREMENTS_NOTE_CLASS =
export const PHYSICAL_DISK_TABLE_SCROLL_CLASS = 'overflow-x-auto';
export const PHYSICAL_DISK_TABLE_CLASS = 'w-full text-xs';
export const PHYSICAL_DISK_TABLE_HEADER_ROW_CLASS = 'border-b border-border bg-surface-alt text-muted';
export const PHYSICAL_DISK_TABLE_HEADER_ROW_CLASS =
'border-b border-border bg-surface-alt text-muted';
export const PHYSICAL_DISK_TABLE_BODY_CLASS = 'divide-y divide-border';
export const PHYSICAL_DISK_TABLE_ROW_CLASS = 'cursor-pointer transition-colors';
export const PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS = 'bg-blue-50 dark:bg-blue-900';
@ -91,23 +99,29 @@ export const PHYSICAL_DISK_HEADER_TEMP_CLASS =
'hidden md:table-cell px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[72px]';
export const PHYSICAL_DISK_HEADER_SIZE_CLASS =
'px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[96px]';
export const PHYSICAL_DISK_CELL_DISK_CLASS = 'px-1.5 sm:px-2 py-1 align-middle text-xs md:min-w-[220px]';
export const PHYSICAL_DISK_CELL_DISK_CLASS =
'px-1.5 sm:px-2 py-1 align-middle text-xs md:min-w-[220px]';
export const PHYSICAL_DISK_CELL_SOURCE_CLASS = 'px-1.5 sm:px-2 py-1 align-middle text-xs w-[72px]';
export const PHYSICAL_DISK_CELL_HOST_CLASS = 'px-1.5 sm:px-2 py-1 align-middle text-xs md:min-w-[120px]';
export const PHYSICAL_DISK_CELL_ROLE_CLASS = 'hidden xl:table-cell px-1.5 sm:px-2 py-1 align-middle text-xs';
export const PHYSICAL_DISK_CELL_HOST_CLASS =
'px-1.5 sm:px-2 py-1 align-middle text-xs md:min-w-[120px]';
export const PHYSICAL_DISK_CELL_ROLE_CLASS =
'hidden xl:table-cell px-1.5 sm:px-2 py-1 align-middle text-xs';
export const PHYSICAL_DISK_CELL_PARENT_CLASS = PHYSICAL_DISK_CELL_ROLE_CLASS;
export const PHYSICAL_DISK_CELL_HEALTH_CLASS = 'px-1.5 sm:px-2 py-1 align-middle text-xs md:min-w-[160px]';
export const PHYSICAL_DISK_CELL_HEALTH_CLASS =
'px-1.5 sm:px-2 py-1 align-middle text-xs md:min-w-[160px]';
export const PHYSICAL_DISK_CELL_TEMP_CLASS =
'hidden md:table-cell px-1.5 sm:px-2 py-1 align-middle text-xs whitespace-nowrap w-[72px]';
export const PHYSICAL_DISK_CELL_SIZE_CLASS =
'px-1.5 sm:px-2 py-1 align-middle text-xs whitespace-nowrap w-[96px]';
export const PHYSICAL_DISK_NAME_WRAP_CLASS = 'flex min-w-0 items-center gap-1.5 whitespace-nowrap';
export const PHYSICAL_DISK_NAME_TEXT_CLASS = 'block min-w-0 truncate text-[12px] font-semibold text-base-content';
export const PHYSICAL_DISK_NAME_TEXT_CLASS =
'block min-w-0 truncate text-[12px] font-semibold text-base-content';
export const PHYSICAL_DISK_SOURCE_BADGE_CLASS =
'inline-flex min-w-[3.25rem] justify-center px-1.5 py-px text-[9px] font-medium';
export const PHYSICAL_DISK_VALUE_TEXT_CLASS = 'block truncate text-[11px] text-base-content';
export const PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS = 'text-[11px] text-muted';
export const PHYSICAL_DISK_HEALTH_WRAP_CLASS = 'flex min-w-0 items-center gap-1.5 whitespace-nowrap';
export const PHYSICAL_DISK_HEALTH_WRAP_CLASS =
'flex min-w-0 items-center gap-1.5 whitespace-nowrap';
export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'shrink-0 text-[11px] font-semibold';
export const PHYSICAL_DISK_HEALTH_SUMMARY_CLASS = 'hidden xl:block truncate text-[11px] text-muted';
export const PHYSICAL_DISK_TEMPERATURE_CLASS = 'text-[11px] font-medium';
@ -124,14 +138,43 @@ export function getPhysicalDiskPlatformLabel(_resource: Resource, fallbackLabel:
return fallbackLabel || 'Unknown';
}
const readStringArray = (value: unknown): string[] =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: [];
const readPhysicalDiskSourceCandidates = (resource: Resource): string[] => {
const directSources = readStringArray((resource as { sources?: unknown }).sources);
const platformSources = readStringArray(
(resource.platformData as { sources?: unknown } | undefined)?.sources,
);
const sourceStatus = (resource.platformData as { sourceStatus?: unknown } | undefined)
?.sourceStatus;
const sourceStatusSources =
sourceStatus && typeof sourceStatus === 'object' ? Object.keys(sourceStatus) : [];
return [...platformSources, ...directSources, ...sourceStatusSources];
};
export function getPhysicalDiskSourceKey(resource: Resource): string {
const resolvedFromSources = resolvePlatformTypeFromSources(
readPhysicalDiskSourceCandidates(resource),
);
return normalizeStorageSourceKey(resolvedFromSources || resource.platformType);
}
export function getPhysicalDiskSourceBadgePresentation(resource: Resource): {
label: string;
className: string;
} {
const presentation = getSourcePlatformPresentation(resource.platformType);
const sourceKey = getPhysicalDiskSourceKey(resource);
const presentation = getSourcePlatformPresentation(sourceKey);
return {
label: presentation?.label || getPhysicalDiskPlatformLabel(resource, getSourcePlatformLabel(resource.platformType)),
className: `${presentation?.tone || 'text-base-content'} ${PHYSICAL_DISK_SOURCE_BADGE_CLASS}`.trim(),
label:
presentation?.label ||
getPhysicalDiskPlatformLabel(resource, getSourcePlatformLabel(sourceKey)),
className:
`${presentation?.tone || 'text-base-content'} ${PHYSICAL_DISK_SOURCE_BADGE_CLASS}`.trim(),
};
}
@ -142,7 +185,9 @@ export function getPhysicalDiskHostLabel(
return (disk.node || resource.parentName || '').trim();
}
export function extractPhysicalDiskPresentationData(resource: Resource): PhysicalDiskPresentationData {
export function extractPhysicalDiskPresentationData(
resource: Resource,
): PhysicalDiskPresentationData {
const pd = resource.physicalDisk || ((resource.platformData as any)?.physicalDisk ?? {});
const diskNode = getPhysicalDiskNodeIdentity(resource);
const riskReasons = Array.isArray(pd.risk?.reasons)
@ -208,19 +253,32 @@ export function matchesPhysicalDiskSearch(
disk: PhysicalDiskPresentationData,
searchTerm: string,
): boolean {
const term = searchTerm.toLowerCase();
return [
const parsed = parseStorageSearchQuery(searchTerm);
const nodeHints = [
disk.node,
resource.parentName,
resource.identity?.hostname,
resource.canonicalIdentity?.hostname,
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
if (!matchesStorageNodeTerms(nodeHints, parsed.nodeTerms)) {
return false;
}
if (parsed.freeTerms.length === 0) return true;
const haystack = [
disk.model,
disk.devPath,
disk.serial,
disk.node,
getPhysicalDiskRoleLabel(disk),
getPhysicalDiskParentLabel(disk),
getPhysicalDiskPlatformLabel(resource, getSourcePlatformLabel(resource.platformType) || ''),
getPhysicalDiskPlatformLabel(
resource,
getSourcePlatformLabel(getPhysicalDiskSourceKey(resource)),
),
]
.join(' ')
.toLowerCase()
.includes(term);
.toLowerCase();
return parsed.freeTerms.every((term) => haystack.includes(term));
}
export function comparePhysicalDiskPresentation(
@ -236,10 +294,37 @@ export function comparePhysicalDiskPresentation(
return (aDisk.devPath || aResource.name).localeCompare(bDisk.devPath || bResource.name);
}
export function matchesPhysicalDiskFilterState(
resource: Resource,
disk: PhysicalDiskPresentationData,
options: {
sourceFilter?: string;
healthFilter?: StorageHealthFilter;
searchTerm?: string;
},
): boolean {
const selectedSource = normalizeStorageSourceKey(options.sourceFilter || 'all');
if (selectedSource !== 'all' && getPhysicalDiskSourceKey(resource) !== selectedSource) {
return false;
}
const healthFilter = options.healthFilter || 'all';
if (
healthFilter !== 'all' &&
!matchesPhysicalDiskHealthFilter(getPhysicalDiskNormalizedHealth(resource, disk), healthFilter)
) {
return false;
}
return matchesPhysicalDiskSearch(resource, disk, options.searchTerm || '');
}
export function filterAndSortPhysicalDisks(
disks: Resource[],
options: {
selectedNode: Resource | null;
sourceFilter?: string;
healthFilter?: StorageHealthFilter;
searchTerm: string;
getDiskData: (disk: Resource) => PhysicalDiskPresentationData;
matchesNode: (disk: Resource, node: { id: string; name: string; instance?: string }) => boolean;
@ -257,11 +342,13 @@ export function filterAndSortPhysicalDisks(
);
}
if (options.searchTerm) {
visibleDisks = visibleDisks.filter((disk) =>
matchesPhysicalDiskSearch(disk, options.getDiskData(disk), options.searchTerm),
);
}
visibleDisks = visibleDisks.filter((disk) =>
matchesPhysicalDiskFilterState(disk, options.getDiskData(disk), {
sourceFilter: options.sourceFilter,
healthFilter: options.healthFilter,
searchTerm: options.searchTerm,
}),
);
return [...visibleDisks].sort((a, b) => {
const aData = options.getDiskData(a);
@ -275,8 +362,8 @@ export function hasPhysicalDiskSmartWarning(disk: PhysicalDiskPresentationData):
if (!attrs) return false;
return Boolean(
(attrs.reallocatedSectors && attrs.reallocatedSectors > 0) ||
(attrs.pendingSectors && attrs.pendingSectors > 0) ||
(attrs.mediaErrors && attrs.mediaErrors > 0),
(attrs.pendingSectors && attrs.pendingSectors > 0) ||
(attrs.mediaErrors && attrs.mediaErrors > 0),
);
}
@ -314,6 +401,29 @@ export function getPhysicalDiskHealthStatus(
};
}
export function getPhysicalDiskNormalizedHealth(
resource: Resource,
disk: PhysicalDiskPresentationData,
): NormalizedHealth {
if (resource.status === 'offline') return 'offline';
const status = getPhysicalDiskHealthStatus(disk).label;
if (status === 'Replace Now') return 'critical';
if (status === 'Needs Attention') return 'warning';
if (status === 'Healthy') return 'healthy';
return 'unknown';
}
export function matchesPhysicalDiskHealthFilter(
health: NormalizedHealth,
filter: StorageHealthFilter,
): boolean {
if (filter === 'all') return true;
if (filter === 'attention') {
return health === 'warning' || health === 'critical' || health === 'offline';
}
return health === filter;
}
export function getPhysicalDiskHealthSummary(status: DiskHealthStatusPresentation): string {
const summary = status.summary?.trim() || '';
if (!summary || summary === 'No active disk-health issues.') {

View file

@ -10,6 +10,7 @@ export type StorageBackupPlatform = KnownStorageBackupPlatform | (string & {});
export type PlatformFamily = 'onprem' | 'container' | 'virtualization' | 'cloud' | 'generic';
export type NormalizedHealth = 'healthy' | 'warning' | 'critical' | 'offline' | 'unknown';
export type StorageHealthFilter = 'all' | 'attention' | NormalizedHealth;
export type StorageCategory =
| 'pool'

View file

@ -1,5 +1,6 @@
import type { NormalizedHealth, StorageRecord } from './models';
import type { NormalizedHealth, StorageHealthFilter, StorageRecord } from './models';
import { normalizeStorageSourceKey, orderStorageSourceKeys } from '@/utils/storageSources';
import { matchesStorageNodeTerms, parseStorageSearchQuery } from './storageSearchQuery';
import {
getStorageRecordActionSummary,
getStorageRecordContent,
@ -82,6 +83,11 @@ export const buildStorageSourceOptions = (records: StorageRecord[]): string[] =>
export const matchesStorageRecordSearch = (record: StorageRecord, query: string): boolean => {
if (!query) return true;
const parsed = parseStorageSearchQuery(query);
if (!matchesStorageNodeTerms(getStorageRecordNodeHints(record), parsed.nodeTerms)) {
return false;
}
if (parsed.freeTerms.length === 0) return true;
const haystack = [
record.name,
record.category,
@ -103,16 +109,27 @@ export const matchesStorageRecordSearch = (record: StorageRecord, query: string)
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
return parsed.freeTerms.every((term) => haystack.includes(term));
};
export interface FilterStorageRecordsOptions {
sourceFilter: string;
healthFilter: 'all' | NormalizedHealth;
healthFilter: StorageHealthFilter;
selectedNode: StorageNodeOption | null;
search: string;
}
export const matchesStorageHealthFilter = (
health: NormalizedHealth,
filter: StorageHealthFilter,
): boolean => {
if (filter === 'all') return true;
if (filter === 'attention') {
return health === 'warning' || health === 'critical' || health === 'offline';
}
return health === filter;
};
export const filterStorageRecords = (
records: StorageRecord[],
options: FilterStorageRecordsOptions,
@ -125,9 +142,7 @@ export const filterStorageRecords = (
? true
: normalizeStorageSourceKey(record.source.platform) === selectedSource,
)
.filter((record) =>
options.healthFilter === 'all' ? true : record.health === options.healthFilter,
)
.filter((record) => matchesStorageHealthFilter(record.health, options.healthFilter))
.filter((record) => matchesStorageRecordNode(record, options.selectedNode))
.filter((record) => matchesStorageRecordSearch(record, query));
};

View file

@ -0,0 +1,27 @@
export interface ParsedStorageSearchQuery {
freeTerms: string[];
nodeTerms: string[];
}
export const parseStorageSearchQuery = (query: string): ParsedStorageSearchQuery => {
const freeTerms: string[] = [];
const nodeTerms: string[] = [];
for (const token of query.trim().toLowerCase().split(/\s+/)) {
if (!token) continue;
if (token.startsWith('node:')) {
const nodeTerm = token.slice('node:'.length).trim();
if (nodeTerm) nodeTerms.push(nodeTerm);
continue;
}
freeTerms.push(token);
}
return { freeTerms, nodeTerms };
};
export const matchesStorageNodeTerms = (hints: string[], nodeTerms: string[]): boolean => {
if (nodeTerms.length === 0) return true;
const normalizedHints = hints.map((hint) => hint.trim().toLowerCase()).filter(Boolean);
return nodeTerms.every((term) => normalizedHints.some((hint) => hint.includes(term)));
};