From 2a85408a7f495cecd3e95ea84b5bb8c37f1628dc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 23 Apr 2026 23:06:04 +0100 Subject: [PATCH] Fix storage surface filters --- .../src/components/Storage/DiskList.tsx | 6 +- .../src/components/Storage/Storage.tsx | 15 +- .../components/Storage/StorageContentCard.tsx | 9 +- .../components/Storage/StorageControls.tsx | 2 + .../src/components/Storage/StorageFilter.tsx | 5 + .../components/Storage/StoragePageSummary.tsx | 15 +- .../__tests__/storagePageState.test.ts | 52 +++--- .../__tests__/useDiskListModel.test.ts | 4 + .../__tests__/useStorageFilterState.test.ts | 25 +-- .../useStorageFilterToolbarModel.test.ts | 4 + .../__tests__/useStoragePageSummary.test.ts | 13 +- .../components/Storage/storagePageState.ts | 47 +++--- .../components/Storage/useDiskListModel.ts | 5 + .../Storage/useStorageFilterState.ts | 35 +++- .../Storage/useStorageFilterToolbarModel.ts | 50 +++--- .../src/components/Storage/useStorageModel.ts | 4 +- .../components/Storage/useStoragePageData.ts | 19 ++- .../Storage/useStoragePageFilters.ts | 10 +- .../components/Storage/useStoragePageModel.ts | 38 +++-- .../Storage/useStoragePageSummary.ts | 76 ++++++--- .../__tests__/diskPresentation.test.ts | 34 +++- .../__tests__/storageModelCore.test.ts | 17 +- .../storageBackups/diskPresentation.ts | 158 +++++++++++++++--- .../src/features/storageBackups/models.ts | 1 + .../storageBackups/storageModelCore.ts | 27 ++- .../storageBackups/storageSearchQuery.ts | 27 +++ 26 files changed, 510 insertions(+), 188 deletions(-) create mode 100644 frontend-modern/src/features/storageBackups/storageSearchQuery.ts diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index aef6920af..7588d970f 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -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 = (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 = (props) => { - diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index b58b64dec..4454c1c83 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -28,6 +28,7 @@ const Storage: Component = () => { setSearch, sourceFilter, setSourceFilter, + healthFilter, sortKey, setSortKey, sortDirection, @@ -76,11 +77,7 @@ const Storage: Component = () => { } = useStoragePageModel(); return ( -
+
{ { onJumpToActiveRow={jumpToActiveStorageRow} onScopeToDegradedPools={() => { setView('pools'); - setStorageFilterStatus('warning'); + setStorageFilterStatus('attention'); }} onScopeToFailingDisks={() => { setView('disks'); + setStorageFilterStatus('attention'); }} /> @@ -154,6 +155,8 @@ const Storage: Component = () => { view={view} physicalDisks={physicalDisks} nodes={nodes} + sourceFilter={sourceFilter} + healthFilter={healthFilter} selectedNodeId={selectedNodeId} search={search} groupedRecords={groupedRecords} diff --git a/frontend-modern/src/components/Storage/StorageContentCard.tsx b/frontend-modern/src/components/Storage/StorageContentCard.tsx index 867b2ac4b..487ad9bb6 100644 --- a/frontend-modern/src/components/Storage/StorageContentCard.tsx +++ b/frontend-modern/src/components/Storage/StorageContentCard.tsx @@ -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 = (props) => = (props) => { sourceFilter={props.sourceFilter} setSourceFilter={props.setSourceFilter} sourceOptions={props.sourceOptions} + selectedNodeId={props.selectedNodeId} + setSelectedNodeId={props.setSelectedNodeId} leadingFilters={leadingFilters()} mobileTrailing={props.mobileTrailing} utilityActions={props.utilityActions} diff --git a/frontend-modern/src/components/Storage/StorageFilter.tsx b/frontend-modern/src/components/Storage/StorageFilter.tsx index d4c2cd062..78ace24cd 100644 --- a/frontend-modern/src/components/Storage/StorageFilter.tsx +++ b/frontend-modern/src/components/Storage/StorageFilter.tsx @@ -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 = (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, }); diff --git a/frontend-modern/src/components/Storage/StoragePageSummary.tsx b/frontend-modern/src/components/Storage/StoragePageSummary.tsx index edce5b71d..7aad50a55 100644 --- a/frontend-modern/src/components/Storage/StoragePageSummary.tsx +++ b/frontend-modern/src/components/Storage/StoragePageSummary.tsx @@ -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 = (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, diff --git a/frontend-modern/src/components/Storage/__tests__/storagePageState.test.ts b/frontend-modern/src/components/Storage/__tests__/storagePageState.test.ts index 5382fdc25..3a3ea250a 100644 --- a/frontend-modern/src/components/Storage/__tests__/storagePageState.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/storagePageState.test.ts @@ -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 => ...overrides, }) as Resource; -const makeStorageRecord = (overrides: Partial = {}): 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 => ({ + 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('all'); const [selectedNodeId, setSelectedNodeId] = createSignal('all'); const [groupBy, setGroupBy] = createSignal('none'); const [sortKey, setSortKey] = createSignal('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'); diff --git a/frontend-modern/src/components/Storage/__tests__/useDiskListModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useDiskListModel.test.ts index 75e61b914..c2fe8ccf6 100644 --- a/frontend-modern/src/components/Storage/__tests__/useDiskListModel.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useDiskListModel.test.ts @@ -53,6 +53,8 @@ describe('useDiskListModel', () => { const [nodes] = createSignal([buildNode('node-tower', 'tower')]); const [selectedNode] = createSignal('node-tower'); const [searchTerm] = createSignal('cache'); + const [sourceFilter] = createSignal('proxmox-pve'); + const [healthFilter] = createSignal('healthy' as const); const [selectedDiskId, setSelectedDiskId] = createSignal(null); const { result } = renderHook(() => @@ -60,6 +62,8 @@ describe('useDiskListModel', () => { disks, nodes, selectedNode, + sourceFilter, + healthFilter, searchTerm, selectedDiskId, setSelectedDiskId, diff --git a/frontend-modern/src/components/Storage/__tests__/useStorageFilterState.test.ts b/frontend-modern/src/components/Storage/__tests__/useStorageFilterState.test.ts index 23f08f6a0..5988189f3 100644 --- a/frontend-modern/src/components/Storage/__tests__/useStorageFilterState.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useStorageFilterState.test.ts @@ -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([ - { id: 'node-1', label: 'pve1' }, - ]); - const [diskNodeOptions] = createSignal([ - { id: 'node-2', label: 'tower' }, - ]); + const [nodeOptions] = createSignal([{ id: 'node-1', label: 'pve1' }]); + const [diskNodeOptions] = createSignal([{ 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('all'); const [groupBy] = createSignal('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([ - { id: 'all', label: 'All Nodes' }, - ]); + const [nodeOptions] = createSignal([{ id: 'all', label: 'All Nodes' }]); const [diskNodeOptions] = createSignal([ { 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('all'); const [groupBy] = createSignal('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'); }); diff --git a/frontend-modern/src/components/Storage/__tests__/useStorageFilterToolbarModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useStorageFilterToolbarModel.test.ts index ee1b5ca78..1f1539c04 100644 --- a/frontend-modern/src/components/Storage/__tests__/useStorageFilterToolbarModel.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useStorageFilterToolbarModel.test.ts @@ -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', () => { diff --git a/frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts b/frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts index e243800f4..4b8031642 100644 --- a/frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts @@ -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, diff --git a/frontend-modern/src/components/Storage/storagePageState.ts b/frontend-modern/src/components/Storage/storagePageState.ts index 857a26ef6..9f329c2a4 100644 --- a/frontend-modern/src/components/Storage/storagePageState.ts +++ b/frontend-modern/src/components/Storage/storagePageState.ts @@ -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; setView: (value: StorageView) => void; sourceFilter: Accessor; setSourceFilter: (value: string) => void; - healthFilter: Accessor<'all' | NormalizedHealth>; - setHealthFilter: (value: 'all' | NormalizedHealth) => void; + healthFilter: Accessor; + setHealthFilter: (value: StorageHealthFilter) => void; selectedNodeId: Accessor; setSelectedNodeId: (value: string) => void; groupBy: Accessor; @@ -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, - key: string, -): Set => { +export const toggleExpandedStorageGroup = (previous: Set, key: string): Set => { const next = new Set(previous); if (next.has(key)) next.delete(key); else next.add(key); diff --git a/frontend-modern/src/components/Storage/useDiskListModel.ts b/frontend-modern/src/components/Storage/useDiskListModel.ts index a90631eb1..ecd4b89b5 100644 --- a/frontend-modern/src/components/Storage/useDiskListModel.ts +++ b/frontend-modern/src/components/Storage/useDiskListModel.ts @@ -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, diff --git a/frontend-modern/src/components/Storage/useStorageFilterState.ts b/frontend-modern/src/components/Storage/useStorageFilterState.ts index 94c1f4604..a826c136f 100644 --- a/frontend-modern/src/components/Storage/useStorageFilterState.ts +++ b/frontend-modern/src/components/Storage/useStorageFilterState.ts @@ -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; setSelectedNodeId: (value: string) => void; sourceOptions: Accessor; - healthFilter: Accessor<'all' | NormalizedHealth>; - setHealthFilter: (value: 'all' | NormalizedHealth) => void; + diskSourceOptions?: Accessor; + sourceFilter: Accessor; + setSourceFilter: (value: string) => void; + healthFilter: Accessor; + setHealthFilter: (value: StorageHealthFilter) => void; groupBy: Accessor; }; @@ -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, diff --git a/frontend-modern/src/components/Storage/useStorageFilterToolbarModel.ts b/frontend-modern/src/components/Storage/useStorageFilterToolbarModel.ts index b9af03269..345079afe 100644 --- a/frontend-modern/src/components/Storage/useStorageFilterToolbarModel.ts +++ b/frontend-modern/src/components/Storage/useStorageFilterToolbarModel.ts @@ -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; setSourceFilter?: (value: string) => void; + selectedNodeId?: Accessor; + setSelectedNodeId?: (value: string) => void; sortOptions?: StorageOption[]; sourceOptions?: Accessor; }; @@ -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 { diff --git a/frontend-modern/src/components/Storage/useStorageModel.ts b/frontend-modern/src/components/Storage/useStorageModel.ts index 7ece879e2..dde10fc6e 100644 --- a/frontend-modern/src/components/Storage/useStorageModel.ts +++ b/frontend-modern/src/components/Storage/useStorageModel.ts @@ -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; search: Accessor; sourceFilter: Accessor; - healthFilter: Accessor<'all' | NormalizedHealth>; + healthFilter: Accessor; selectedNodeId: Accessor; nodeOptions: Accessor; sortKey: Accessor; diff --git a/frontend-modern/src/components/Storage/useStoragePageData.ts b/frontend-modern/src/components/Storage/useStoragePageData.ts index ad4f96f28..fb69f86ac 100644 --- a/frontend-modern/src/components/Storage/useStoragePageData.ts +++ b/frontend-modern/src/components/Storage/useStoragePageData.ts @@ -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; @@ -26,7 +23,7 @@ type UseStoragePageDataOptions = { cephResources: Accessor; search: Accessor; sourceFilter: Accessor; - healthFilter: Accessor<'all' | NormalizedHealth>; + healthFilter: Accessor; selectedNodeId: Accessor; sortKey: Accessor; 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, diff --git a/frontend-modern/src/components/Storage/useStoragePageFilters.ts b/frontend-modern/src/components/Storage/useStoragePageFilters.ts index db48b92f5..0c1e45d82 100644 --- a/frontend-modern/src/components/Storage/useStoragePageFilters.ts +++ b/frontend-modern/src/components/Storage/useStoragePageFilters.ts @@ -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('all'); const [view, setView] = createSignal(DEFAULT_STORAGE_VIEW); const [selectedNodeId, setSelectedNodeId] = createSignal(DEFAULT_STORAGE_SELECTED_NODE_ID); const [sortKey, setSortKey] = createSignal(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(DEFAULT_STORAGE_GROUP_KEY); const isActiveStorageRoute = () => options.location.pathname === '/storage'; @@ -81,4 +82,3 @@ export const useStoragePageFilters = (options: UseStoragePageFiltersOptions) => setGroupBy, }; }; - diff --git a/frontend-modern/src/components/Storage/useStoragePageModel.ts b/frontend-modern/src/components/Storage/useStoragePageModel.ts index e0a76a3dc..5edce9630 100644 --- a/frontend-modern/src/components/Storage/useStoragePageModel.ts +++ b/frontend-modern/src/components/Storage/useStoragePageModel.ts @@ -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(); 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>(); 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, diff --git a/frontend-modern/src/components/Storage/useStoragePageSummary.ts b/frontend-modern/src/components/Storage/useStoragePageSummary.ts index 3eac2d8a6..ebb3411b5 100644 --- a/frontend-modern/src/components/Storage/useStoragePageSummary.ts +++ b/frontend-modern/src/components/Storage/useStoragePageSummary.ts @@ -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; + search: Accessor; + sourceFilter: Accessor; + healthFilter: Accessor; selectedNodeId: Accessor; nodeOptions: Accessor; physicalDisks: Accessor; @@ -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 { diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts index 98a78b4a6..7c650a5b4 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts @@ -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([]); }); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts index 56bb89bc3..5c121dc54 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts @@ -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, }); }); diff --git a/frontend-modern/src/features/storageBackups/diskPresentation.ts b/frontend-modern/src/features/storageBackups/diskPresentation.ts index f5d8127d5..28e7ca412 100644 --- a/frontend-modern/src/features/storageBackups/diskPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskPresentation.ts @@ -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.') { diff --git a/frontend-modern/src/features/storageBackups/models.ts b/frontend-modern/src/features/storageBackups/models.ts index a87dc0626..671b22fc9 100644 --- a/frontend-modern/src/features/storageBackups/models.ts +++ b/frontend-modern/src/features/storageBackups/models.ts @@ -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' diff --git a/frontend-modern/src/features/storageBackups/storageModelCore.ts b/frontend-modern/src/features/storageBackups/storageModelCore.ts index 4c82347c6..752800898 100644 --- a/frontend-modern/src/features/storageBackups/storageModelCore.ts +++ b/frontend-modern/src/features/storageBackups/storageModelCore.ts @@ -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)); }; diff --git a/frontend-modern/src/features/storageBackups/storageSearchQuery.ts b/frontend-modern/src/features/storageBackups/storageSearchQuery.ts new file mode 100644 index 000000000..3400016a2 --- /dev/null +++ b/frontend-modern/src/features/storageBackups/storageSearchQuery.ts @@ -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))); +};