mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Fix storage surface filters
This commit is contained in:
parent
16efcba31f
commit
2a85408a7f
26 changed files with 510 additions and 188 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.') {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue