diff --git a/frontend-modern/src/components/Backups/BackupsFilter.tsx b/frontend-modern/src/components/Backups/BackupsFilter.tsx index 153f2c72a..97170a0cd 100644 --- a/frontend-modern/src/components/Backups/BackupsFilter.tsx +++ b/frontend-modern/src/components/Backups/BackupsFilter.tsx @@ -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 = (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 (
@@ -184,12 +202,48 @@ export const BackupsFilter: Component = (props) => { + {/* Sort controls */} +
+ Sort + + +
+ + + {/* Reset Button */} {/* Active Indicator */} - + Active @@ -215,4 +269,4 @@ export const BackupsFilter: Component = (props) => {
); -}; \ No newline at end of file +}; diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index 2454d2032..bfef2b635 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -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('backupTime'); + type BackupSortKey = keyof Pick; + const [sortKey, setSortKey] = createSignal('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 */} diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 8a21fa6a0..cfd848ae1 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -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(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('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} /> diff --git a/frontend-modern/src/components/Storage/StorageFilter.tsx b/frontend-modern/src/components/Storage/StorageFilter.tsx index d3e7c75d9..9e0f18db8 100644 --- a/frontend-modern/src/components/Storage/StorageFilter.tsx +++ b/frontend-modern/src/components/Storage/StorageFilter.tsx @@ -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 = (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 (
@@ -89,6 +102,37 @@ export const StorageFilter: Component = (props) => { + {/* Sort controls */} +
+ Sort + + +
+ + {/* Reset Button */} {/* Active Indicator */} - + Active @@ -121,4 +165,4 @@ export const StorageFilter: Component = (props) => {
); -}; \ No newline at end of file +};