diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index 69cbb8012b..d8fe0164b3 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -4,12 +4,16 @@ import { createMemo, createResource, createSignal, + type Accessor, type Component, type JSX, } from 'solid-js'; import ArchiveIcon from 'lucide-solid/icons/archive'; import CameraIcon from 'lucide-solid/icons/camera'; import ActivityIcon from 'lucide-solid/icons/activity'; +import ArrowDownIcon from 'lucide-solid/icons/arrow-down'; +import ArrowUpIcon from 'lucide-solid/icons/arrow-up'; +import ArrowUpDownIcon from 'lucide-solid/icons/arrow-up-down'; import ChevronRightIcon from 'lucide-solid/icons/chevron-right'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; @@ -259,6 +263,151 @@ function RowMetricBar(props: { // the guest row's leftmost dot and the per-snapshot inner row dot use the // same `classifySnapshotRowAge` so colours match the coverage strip. +// Sortable column header — matches Storage's pattern (StoragePoolsTable.tsx). +// Clicking an inactive column sorts it with the supplied default direction; +// clicking the active column flips direction. Renders the idle / asc / desc +// arrow trio so the affordance reads identically across the app. +const SORT_BUTTON_CLASS = + 'inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm outline-none transition-colors hover:text-base-content focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface'; +const SORT_ICON_CLASS = 'h-3 w-3 shrink-0'; + +function SortableHead(props: { + label: string; + sortKey: K; + currentSort: Accessor; + direction: Accessor<'asc' | 'desc'>; + onSort: (key: K) => void; + align?: 'left' | 'right' | 'center'; + headClass: string; +}) { + const isActive = () => props.currentSort() === props.sortKey; + const buttonAlignClass = () => { + if (props.align === 'right') return 'justify-end'; + if (props.align === 'center') return 'justify-center'; + return 'justify-start'; + }; + const ariaLabel = () => { + if (!isActive()) return `Sort by ${props.label}`; + return `Sort by ${props.label} ${props.direction() === 'asc' ? 'descending' : 'ascending'}`; + }; + return ( + + + + ); +} + +// Case-insensitive string comparator. Undefined / empty values ALWAYS sort +// to the end regardless of direction — flipping a "—" row to the top of a +// desc sort is never what the user wants (e.g. a running task with no +// finished duration should not headline a "slowest tasks" view). +function cmpString( + a: string | undefined, + b: string | undefined, + direction: 'asc' | 'desc', +): number { + const av = (a ?? '').toLowerCase(); + const bv = (b ?? '').toLowerCase(); + if (!av && !bv) return 0; + if (!av) return 1; + if (!bv) return -1; + const cmp = av === bv ? 0 : av < bv ? -1 : 1; + return direction === 'asc' ? cmp : -cmp; +} + +function cmpNumber( + a: number | undefined, + b: number | undefined, + direction: 'asc' | 'desc', +): number { + const av = typeof a === 'number' && Number.isFinite(a) ? a : undefined; + const bv = typeof b === 'number' && Number.isFinite(b) ? b : undefined; + if (av === undefined && bv === undefined) return 0; + if (av === undefined) return 1; + if (bv === undefined) return -1; + const cmp = av - bv; + return direction === 'asc' ? cmp : -cmp; +} + +function cmpBool(a: boolean, b: boolean, direction: 'asc' | 'desc'): number { + const cmp = (a ? 1 : 0) - (b ? 1 : 0); + return direction === 'asc' ? cmp : -cmp; +} + +// Per-tab sort key types and their default directions. Numeric / timestamp +// columns default to `desc` (biggest / newest first) since that's the +// sysadmin-default question on a backup page. String columns default to +// `asc` (A→Z). Boolean columns default to `desc` so "true" sorts first. + +type SnapshotSortKey = 'guest' | 'node' | 'latest' | 'count' | 'size'; +const SNAPSHOT_SORT_DEFAULT_DIRECTION: Record = { + guest: 'asc', + node: 'asc', + latest: 'desc', + count: 'desc', + size: 'desc', +}; + +type ArchiveSortKey = + | 'volume' + | 'guest' + | 'storage' + | 'node' + | 'format' + | 'created' + | 'size' + | 'protected' + | 'verified'; +const ARCHIVE_SORT_DEFAULT_DIRECTION: Record = { + volume: 'asc', + guest: 'asc', + storage: 'asc', + node: 'asc', + format: 'asc', + created: 'desc', + size: 'desc', + protected: 'desc', + verified: 'desc', +}; + +type TaskSortKey = 'status' | 'guest' | 'node' | 'started' | 'duration' | 'size'; +const TASK_SORT_DEFAULT_DIRECTION: Record = { + status: 'asc', + guest: 'asc', + node: 'asc', + started: 'desc', + duration: 'desc', + size: 'desc', +}; + export const ProxmoxBackupsTable: Component<{ emptyIcon: JSX.Element; }> = (props) => { @@ -274,6 +423,38 @@ export const ProxmoxBackupsTable: Component<{ const [selectedDateKey, setSelectedDateKey] = createSignal(null); const [archiveMetricMode, setArchiveMetricMode] = createSignal('count'); const [expandedGuests, setExpandedGuests] = createSignal>(new Set()); + + const [snapshotSortKey, setSnapshotSortKey] = createSignal('latest'); + const [snapshotSortDirection, setSnapshotSortDirection] = createSignal<'asc' | 'desc'>('desc'); + const [archiveSortKey, setArchiveSortKey] = createSignal('created'); + const [archiveSortDirection, setArchiveSortDirection] = createSignal<'asc' | 'desc'>('desc'); + const [taskSortKey, setTaskSortKey] = createSignal('started'); + const [taskSortDirection, setTaskSortDirection] = createSignal<'asc' | 'desc'>('desc'); + + const handleSnapshotSort = (key: SnapshotSortKey) => { + if (snapshotSortKey() === key) { + setSnapshotSortDirection(snapshotSortDirection() === 'asc' ? 'desc' : 'asc'); + } else { + setSnapshotSortKey(key); + setSnapshotSortDirection(SNAPSHOT_SORT_DEFAULT_DIRECTION[key]); + } + }; + const handleArchiveSort = (key: ArchiveSortKey) => { + if (archiveSortKey() === key) { + setArchiveSortDirection(archiveSortDirection() === 'asc' ? 'desc' : 'asc'); + } else { + setArchiveSortKey(key); + setArchiveSortDirection(ARCHIVE_SORT_DEFAULT_DIRECTION[key]); + } + }; + const handleTaskSort = (key: TaskSortKey) => { + if (taskSortKey() === key) { + setTaskSortDirection(taskSortDirection() === 'asc' ? 'desc' : 'asc'); + } else { + setTaskSortKey(key); + setTaskSortDirection(TASK_SORT_DEFAULT_DIRECTION[key]); + } + }; const toggleDay = (key: string) => setSelectedDateKey((current) => (current === key ? null : key)); const toggleGuestExpansion = (key: string) => @@ -487,7 +668,22 @@ export const ProxmoxBackupsTable: Component<{ if (filter === 'with-ram' && enriched.withRamCount === 0) continue; rows.push(enriched); } - rows.sort((a, b) => (b.newestMs ?? 0) - (a.newestMs ?? 0)); + const sortKey = snapshotSortKey(); + const direction = snapshotSortDirection(); + rows.sort((a, b) => { + switch (sortKey) { + case 'guest': + return cmpString(guestLabel(a.type, a.vmid), guestLabel(b.type, b.vmid), direction); + case 'node': + return cmpString(a.node, b.node, direction); + case 'latest': + return cmpNumber(a.newestMs, b.newestMs, direction); + case 'count': + return cmpNumber(a.count, b.count, direction); + case 'size': + return cmpNumber(a.totalBytes, b.totalBytes, direction); + } + }); return rows; }); @@ -495,7 +691,7 @@ export const ProxmoxBackupsTable: Component<{ const term = search().trim().toLowerCase(); const filter = archiveFilter(); const dateKey = selectedDateKey(); - return archives().filter((arc) => { + const list = archives().filter((arc) => { if (filter === 'protected' && !arc.protected) return false; if (filter === 'verified' && !arc.verified) return false; if (filter === 'unverified' && arc.verified) return false; @@ -510,13 +706,38 @@ export const ProxmoxBackupsTable: Component<{ .toLowerCase() .includes(term); }); + const sortKey = archiveSortKey(); + const direction = archiveSortDirection(); + list.sort((a, b) => { + switch (sortKey) { + case 'volume': + return cmpString(a.volid, b.volid, direction); + case 'guest': + return cmpString(guestLabel(a.type, a.vmid), guestLabel(b.type, b.vmid), direction); + case 'storage': + return cmpString(a.storage, b.storage, direction); + case 'node': + return cmpString(a.node, b.node, direction); + case 'format': + return cmpString(a.format, b.format, direction); + case 'created': + return cmpNumber(archiveTimestampMs(a), archiveTimestampMs(b), direction); + case 'size': + return cmpNumber(a.size, b.size, direction); + case 'protected': + return cmpBool(a.protected, b.protected, direction); + case 'verified': + return cmpBool(a.verified, b.verified, direction); + } + }); + return list; }); const filteredTasks = createMemo(() => { const term = search().trim().toLowerCase(); const filter = taskFilter(); const dateKey = selectedDateKey(); - return tasks().filter((task) => { + const list = tasks().filter((task) => { const classify = classifyTaskStatus(task.status); if (filter === 'ok' && classify.variant !== 'success') return false; if (filter === 'failed' && classify.variant !== 'danger') return false; @@ -532,6 +753,29 @@ export const ProxmoxBackupsTable: Component<{ .toLowerCase() .includes(term); }); + const sortKey = taskSortKey(); + const direction = taskSortDirection(); + list.sort((a, b) => { + switch (sortKey) { + case 'status': + return cmpString( + classifyTaskStatus(a.status).label, + classifyTaskStatus(b.status).label, + direction, + ); + case 'guest': + return cmpString(guestLabel(a.type, a.vmid), guestLabel(b.type, b.vmid), direction); + case 'node': + return cmpString(a.node, b.node, direction); + case 'started': + return cmpNumber(taskTimestampMs(a), taskTimestampMs(b), direction); + case 'duration': + return cmpNumber(taskDurationSeconds(a), taskDurationSeconds(b), direction); + case 'size': + return cmpNumber(a.size, b.size, direction); + } + }); + return list; }); const totalForTab = createMemo(() => { @@ -895,17 +1139,51 @@ export const ProxmoxBackupsTable: Component<{ - Guest - Node - - Latest - - - Snapshots - - - Total size - + + + + + RAM @@ -1103,25 +1381,87 @@ export const ProxmoxBackupsTable: Component<{
- Volume - Guest - - Storage - - Node - Format - - Created - - - Size - - - Protection - - - Verified - + + + + + + + + + @@ -1243,18 +1583,60 @@ export const ProxmoxBackupsTable: Component<{
- Status - Guest - Node - - Started - - - Duration - - - Size - + + + + + + Error