mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Improve storage and backups sorting UX
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user