Improve storage and backups sorting UX

This commit is contained in:
rcourtman
2025-09-29 18:09:41 +00:00
parent 6433493232
commit 4872d58227
4 changed files with 276 additions and 32 deletions
@@ -13,9 +13,27 @@ interface BackupsFilterProps {
typeFilter?: () => 'all' | 'VM' | 'LXC' | 'Host';
setTypeFilter?: (value: 'all' | 'VM' | 'LXC' | 'Host') => void;
hasHostBackups?: () => boolean;
sortKey: () => string;
setSortKey: (value: string) => void;
sortDirection: () => 'asc' | 'desc';
setSortDirection: (value: 'asc' | 'desc') => void;
sortOptions?: { value: string; label: string }[];
onReset?: () => void;
}
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
const sortOptions = props.sortOptions ?? [
{ value: 'backupTime', label: 'Time' },
{ value: 'name', label: 'Guest Name' },
{ value: 'node', label: 'Node' },
{ value: 'vmid', label: 'VMID' },
{ value: 'backupType', label: 'Backup Type' },
{ value: 'size', label: 'Size' },
{ value: 'storage', label: 'Storage' },
{ value: 'verified', label: 'Verified' },
{ value: 'type', label: 'Guest Type' },
{ value: 'owner', label: 'Owner' }
];
return (
<Card class="backups-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
@@ -184,12 +202,48 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Sort controls */}
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Sort</span>
<select
value={props.sortKey()}
onChange={(e) => props.setSortKey(e.currentTarget.value)}
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400"
>
{sortOptions.map(option => (
<option value={option.value}>{option.label}</option>
))}
</select>
<button
type="button"
title={`Sort ${props.sortDirection() === 'asc' ? 'descending' : 'ascending'}`}
onClick={() => props.setSortDirection(props.sortDirection() === 'asc' ? 'desc' : 'asc')}
class="inline-flex items-center justify-center h-7 w-7 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<svg
class={`h-4 w-4 transition-transform ${props.sortDirection() === 'asc' ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
onClick={() => {
props.setSearch('');
props.setViewMode('all');
props.setGroupBy('date');
if (props.onReset) {
props.onReset();
} else {
props.setSearch('');
props.setViewMode('all');
props.setGroupBy('date');
}
}}
title="Reset all filters"
class="flex items-center justify-center px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400
@@ -206,7 +260,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
</button>
{/* Active Indicator */}
<Show when={props.search() || props.viewMode() !== 'all' || props.groupBy() !== 'date'}>
<Show when={props.search().trim() !== '' || props.viewMode() !== 'all' || props.groupBy() !== 'date' || props.sortKey() !== 'backupTime' || props.sortDirection() !== 'desc'}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
@@ -215,4 +269,4 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
</div>
</Card>
);
};
};
@@ -1,4 +1,4 @@
import { Component, createSignal, Show, For, createMemo, createEffect } from 'solid-js';
import { Component, createSignal, Show, For, createMemo, createEffect, onMount } from 'solid-js';
import { useWebSocket } from '@/App';
import { formatBytes, formatAbsoluteTime, formatRelativeTime, formatUptime } from '@/utils/format';
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
@@ -46,12 +46,47 @@ const UnifiedBackups: Component = () => {
else if (value === 'pve') setBackupTypeFilter('local');
else if (value === 'pbs') setBackupTypeFilter('remote');
};
const [sortKey, setSortKey] = createSignal<keyof UnifiedBackup>('backupTime');
type BackupSortKey = keyof Pick<UnifiedBackup,
'backupTime' | 'name' | 'node' | 'vmid' | 'backupType' | 'size' | 'storage' | 'verified' | 'type' | 'owner'>;
const [sortKey, setSortKey] = createSignal<BackupSortKey>('backupTime');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('desc');
const [selectedDateRange, setSelectedDateRange] = createSignal<{ start: number; end: number } | null>(null);
const [chartTimeRange, setChartTimeRange] = createSignal(30);
const [tooltip, setTooltip] = createSignal<{ text: string; x: number; y: number } | null>(null);
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
const sortKeyOptions: { value: BackupSortKey; label: string }[] = [
{ value: 'backupTime', label: 'Time' },
{ value: 'name', label: 'Guest Name' },
{ value: 'node', label: 'Node' },
{ value: 'vmid', label: 'VMID' },
{ value: 'backupType', label: 'Backup Type' },
{ value: 'size', label: 'Size' },
{ value: 'storage', label: 'Storage' },
{ value: 'verified', label: 'Verified' },
{ value: 'type', label: 'Guest Type' },
{ value: 'owner', label: 'Owner' }
];
onMount(() => {
const savedSortKey = localStorage.getItem('backupsSortKey') as BackupSortKey | null;
if (savedSortKey && sortKeyOptions.some(option => option.value === savedSortKey)) {
setSortKey(savedSortKey);
}
const savedSortDirection = localStorage.getItem('backupsSortDirection');
if (savedSortDirection === 'asc' || savedSortDirection === 'desc') {
setSortDirection(savedSortDirection);
}
});
createEffect(() => {
localStorage.setItem('backupsSortKey', sortKey());
});
createEffect(() => {
localStorage.setItem('backupsSortDirection', sortDirection());
});
// Extract PBS instance from search term
const selectedPBSInstance = createMemo(() => {
@@ -655,7 +690,7 @@ const UnifiedBackups: Component = () => {
});
// Sort handler
const handleSort = (key: keyof UnifiedBackup) => {
const handleSort = (key: BackupSortKey) => {
if (sortKey() === key) {
// Toggle direction for the same column
const newDir = sortDirection() === 'asc' ? 'desc' : 'asc';
@@ -1554,6 +1589,12 @@ const UnifiedBackups: Component = () => {
typeFilter={typeFilter}
setTypeFilter={setTypeFilter}
hasHostBackups={hasHostBackups}
sortOptions={sortKeyOptions}
sortKey={sortKey}
setSortKey={(value) => setSortKey(value as BackupSortKey)}
sortDirection={sortDirection}
setSortDirection={setSortDirection}
onReset={resetFilters}
/>
{/* Table */}
@@ -1,4 +1,4 @@
import { Component, For, Show, createSignal, createMemo, createEffect } from 'solid-js';
import { Component, For, Show, createSignal, createMemo, createEffect, onMount } from 'solid-js';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { formatBytes } from '@/utils/format';
@@ -17,9 +17,9 @@ const Storage: Component = () => {
const [tabView, setTabView] = createSignal<'pools' | 'disks'>('pools');
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
// TODO: Implement sorting in sortedStorage function
// const [sortKey, setSortKey] = createSignal('name');
// const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
type StorageSortKey = 'name' | 'node' | 'type' | 'status' | 'usage' | 'free' | 'total';
const [sortKey, setSortKey] = createSignal<StorageSortKey>('name');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Create a mapping from node name to host URL
const nodeHostMap = createMemo(() => {
@@ -32,16 +32,44 @@ const Storage: Component = () => {
return map;
});
const sortKeyOptions: { value: StorageSortKey; label: string }[] = [
{ value: 'name', label: 'Name' },
{ value: 'node', label: 'Node' },
{ value: 'type', label: 'Type' },
{ value: 'status', label: 'Status' },
{ value: 'usage', label: 'Usage %' },
{ value: 'free', label: 'Free Capacity' },
{ value: 'total', label: 'Total Capacity' }
];
// Load preferences from localStorage
createEffect(() => {
onMount(() => {
const savedViewMode = localStorage.getItem('storageViewMode');
if (savedViewMode === 'storage') setViewMode('storage');
const savedSortKey = localStorage.getItem('storageSortKey') as StorageSortKey | null;
if (savedSortKey && sortKeyOptions.some(option => option.value === savedSortKey)) {
setSortKey(savedSortKey);
}
const savedSortDirection = localStorage.getItem('storageSortDirection');
if (savedSortDirection === 'desc' || savedSortDirection === 'asc') {
setSortDirection(savedSortDirection);
}
});
// Save preferences to localStorage
// Persist preferences
createEffect(() => {
localStorage.setItem('storageViewMode', viewMode());
});
createEffect(() => {
localStorage.setItem('storageSortKey', sortKey());
});
createEffect(() => {
localStorage.setItem('storageSortDirection', sortDirection());
});
// Filter storage - in storage view, filter out 0 capacity and deduplicate
@@ -109,24 +137,98 @@ const Storage: Component = () => {
// Apply node selection filter
const nodeFilter = selectedNode();
if (nodeFilter) {
storage = storage.filter(s => s.node.toLowerCase() === nodeFilter.toLowerCase());
const normalizedNode = nodeFilter.toLowerCase();
storage = storage.filter(s => {
const primary = s.node?.toLowerCase();
const extraNodes = s.nodes?.map(node => node.toLowerCase()) || [];
return primary === normalizedNode || extraNodes.includes(normalizedNode);
});
}
// Apply search filter
const search = searchTerm().toLowerCase();
let search = searchTerm().toLowerCase().trim();
if (search) {
// Regular search
storage = storage.filter(s =>
s.name.toLowerCase().includes(search) ||
s.node.toLowerCase().includes(search) ||
s.type.toLowerCase().includes(search) ||
s.content?.toLowerCase().includes(search) ||
(s.status && s.status.toLowerCase().includes(search))
);
const nodePattern = /node:([a-z0-9_.:-]+)/i;
const nodeMatch = search.match(nodePattern);
let nodeQuery: string | null = null;
if (nodeMatch) {
nodeQuery = nodeMatch[1].toLowerCase();
search = search.replace(nodeMatch[0], '').trim();
}
if (nodeQuery) {
storage = storage.filter(s => {
const primary = s.node?.toLowerCase();
const extraNodes = s.nodes?.map(node => node.toLowerCase()) || [];
return primary === nodeQuery || extraNodes.includes(nodeQuery);
});
}
if (search) {
const terms = search.split(/\s+/).filter(Boolean);
storage = storage.filter(s => {
const haystack = [
s.name,
s.node,
s.type,
s.content,
s.status,
...(s.nodes ?? []),
...(s.pbsNames ?? [])
].filter(Boolean).map(value => value!.toLowerCase());
return terms.every(term => haystack.some(entry => entry.includes(term)));
});
}
}
// Always sort by name alphabetically for consistent order
return storage.sort((a, b) => a.name.localeCompare(b.name));
const numericCompare = (a: number, b: number) => {
const normalizedA = Number.isFinite(a) ? a : -Infinity;
const normalizedB = Number.isFinite(b) ? b : -Infinity;
if (normalizedA === normalizedB) return 0;
return normalizedA < normalizedB ? -1 : 1;
};
const result = storage.sort((a, b) => {
let comparison = 0;
switch (sortKey()) {
case 'node': {
const nodeA = (a.nodes && a.nodes.length > 0 ? a.nodes[0] : a.node) ?? '';
const nodeB = (b.nodes && b.nodes.length > 0 ? b.nodes[0] : b.node) ?? '';
comparison = nodeA.localeCompare(nodeB, undefined, { sensitivity: 'base' });
break;
}
case 'type':
comparison = (a.type ?? '').localeCompare(b.type ?? '', undefined, { sensitivity: 'base' });
break;
case 'status':
comparison = (a.status ?? '').localeCompare(b.status ?? '', undefined, { sensitivity: 'base' });
break;
case 'usage':
comparison = numericCompare(a.usage ?? 0, b.usage ?? 0);
break;
case 'free':
comparison = numericCompare(a.free ?? 0, b.free ?? 0);
break;
case 'total':
comparison = numericCompare(a.total ?? 0, b.total ?? 0);
break;
case 'name':
default:
comparison = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
break;
}
if (comparison === 0) {
comparison = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
}
return sortDirection() === 'asc' ? comparison : -comparison;
});
return result;
});
// Group storage by node or storage
@@ -165,8 +267,8 @@ const Storage: Component = () => {
setSearchTerm('');
setSelectedNode(null);
setViewMode('node');
// setSortKey('name');
// setSortDirection('asc');
setSortKey('name');
setSortDirection('asc');
};
@@ -253,8 +355,11 @@ const Storage: Component = () => {
setSearch={setSearchTerm}
groupBy={viewMode}
setGroupBy={setViewMode}
setSortKey={() => {}}
setSortDirection={() => {}}
sortOptions={sortKeyOptions}
sortKey={sortKey}
setSortKey={setSortKey}
sortDirection={sortDirection}
setSortDirection={setSortDirection}
searchInputRef={(el) => searchInputRef = el}
/>
</Show>
@@ -7,12 +7,25 @@ interface StorageFilterProps {
setSearch: (value: string) => void;
groupBy?: () => 'node' | 'storage';
setGroupBy?: (value: 'node' | 'storage') => void;
sortKey: () => string;
setSortKey: (value: string) => void;
sortDirection: () => 'asc' | 'desc';
setSortDirection: (value: 'asc' | 'desc') => void;
sortOptions?: { value: string; label: string }[];
searchInputRef?: (el: HTMLInputElement) => void;
}
export const StorageFilter: Component<StorageFilterProps> = (props) => {
const sortOptions = props.sortOptions ?? [
{ value: 'name', label: 'Name' },
{ value: 'node', label: 'Node' },
{ value: 'type', label: 'Type' },
{ value: 'status', label: 'Status' },
{ value: 'usage', label: 'Usage %' },
{ value: 'free', label: 'Free Capacity' },
{ value: 'total', label: 'Total Capacity' }
];
return (
<Card class="storage-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
@@ -89,6 +102,37 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Sort controls */}
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Sort</span>
<select
value={props.sortKey()}
onChange={(e) => props.setSortKey(e.currentTarget.value)}
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400"
>
{sortOptions.map(option => (
<option value={option.value}>{option.label}</option>
))}
</select>
<button
type="button"
title={`Sort ${props.sortDirection() === 'asc' ? 'descending' : 'ascending'}`}
onClick={() => props.setSortDirection(props.sortDirection() === 'asc' ? 'desc' : 'asc')}
class="inline-flex items-center justify-center h-7 w-7 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<svg
class={`h-4 w-4 transition-transform ${props.sortDirection() === 'asc' ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
onClick={() => {
@@ -112,7 +156,7 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
</button>
{/* Active Indicator */}
<Show when={props.search()}>
<Show when={props.search().trim() !== '' || props.sortKey() !== 'name' || props.sortDirection() !== 'asc' || (props.groupBy && props.groupBy!() !== 'node')}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
@@ -121,4 +165,4 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
</div>
</Card>
);
};
};