From 6ae7ed4ae048b6f90203147d5a6046d08c3be644 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 09:55:01 +0000 Subject: [PATCH] Add pagination to backup list for large backup counts - Add pagination (100 items per page) to prevent UI lockup with 2500+ backups - Show year in date labels for non-current year backups - Reset to page 1 when filters change - Add First/Previous/Next/Last navigation controls Fixes #541 --- cmd/pulse-sensor-proxy/config_test.go | 230 ++++++++++++++++++ .../src/components/Backups/UnifiedBackups.tsx | 119 ++++++++- 2 files changed, 346 insertions(+), 3 deletions(-) diff --git a/cmd/pulse-sensor-proxy/config_test.go b/cmd/pulse-sensor-proxy/config_test.go index c5e4740..62a953a 100644 --- a/cmd/pulse-sensor-proxy/config_test.go +++ b/cmd/pulse-sensor-proxy/config_test.go @@ -82,3 +82,233 @@ allowed_nodes: t.Fatalf("expected duplicate comments to collapse, got %q", result) } } + +func TestParseBool(t *testing.T) { + tests := []struct { + name string + input string + expected bool + wantErr bool + }{ + // Truthy values + {"true lowercase", "true", true, false}, + {"TRUE uppercase", "TRUE", true, false}, + {"True mixed", "True", true, false}, + {"1", "1", true, false}, + {"yes", "yes", true, false}, + {"YES", "YES", true, false}, + {"on", "on", true, false}, + {"ON", "ON", true, false}, + + // Falsy values + {"false lowercase", "false", false, false}, + {"FALSE uppercase", "FALSE", false, false}, + {"False mixed", "False", false, false}, + {"0", "0", false, false}, + {"no", "no", false, false}, + {"NO", "NO", false, false}, + {"off", "off", false, false}, + {"OFF", "OFF", false, false}, + + // Whitespace handling + {"true with leading space", " true", true, false}, + {"false with trailing space", "false ", false, false}, + {"yes with surrounding spaces", " yes ", true, false}, + + // Invalid values + {"invalid string", "maybe", false, true}, + {"empty string", "", false, true}, + {"numeric 2", "2", false, true}, + {"random word", "enabled", false, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := parseBool(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("parseBool(%q) expected error, got nil", tc.input) + } + return + } + if err != nil { + t.Errorf("parseBool(%q) unexpected error: %v", tc.input, err) + return + } + if result != tc.expected { + t.Errorf("parseBool(%q) = %v, want %v", tc.input, result, tc.expected) + } + }) + } +} + +func TestParseUint32List(t *testing.T) { + tests := []struct { + name string + input string + expected []uint32 + wantErr bool + }{ + {"single value", "1000", []uint32{1000}, false}, + {"multiple values", "1000,2000,3000", []uint32{1000, 2000, 3000}, false}, + {"with spaces", "1000, 2000, 3000", []uint32{1000, 2000, 3000}, false}, + {"empty string", "", nil, false}, + {"only spaces", " ", nil, false}, + {"zero value", "0", []uint32{0}, false}, + {"max uint32", "4294967295", []uint32{4294967295}, false}, + {"with empty parts", "1000,,2000", []uint32{1000, 2000}, false}, + {"negative value", "-1", nil, true}, + {"overflow uint32", "4294967296", nil, true}, + {"non-numeric", "abc", nil, true}, + {"mixed valid invalid", "1000,abc", nil, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := parseUint32List(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("parseUint32List(%q) expected error, got nil", tc.input) + } + return + } + if err != nil { + t.Errorf("parseUint32List(%q) unexpected error: %v", tc.input, err) + return + } + if len(result) != len(tc.expected) { + t.Errorf("parseUint32List(%q) length = %d, want %d", tc.input, len(result), len(tc.expected)) + return + } + for i, v := range result { + if v != tc.expected[i] { + t.Errorf("parseUint32List(%q)[%d] = %d, want %d", tc.input, i, v, tc.expected[i]) + } + } + }) + } +} + +func TestSplitAndTrim(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + {"simple comma separated", "a,b,c", []string{"a", "b", "c"}}, + {"with spaces", "a, b, c", []string{"a", "b", "c"}}, + {"with leading/trailing spaces", " a , b , c ", []string{"a", "b", "c"}}, + {"empty string", "", nil}, + {"single value", "single", []string{"single"}}, + {"empty parts filtered", "a,,b", []string{"a", "b"}}, + {"only spaces between commas", "a, ,b", []string{"a", "b"}}, + {"all empty parts", ",,", nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := splitAndTrim(tc.input) + if len(result) != len(tc.expected) { + t.Errorf("splitAndTrim(%q) length = %d, want %d", tc.input, len(result), len(tc.expected)) + return + } + for i, v := range result { + if v != tc.expected[i] { + t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tc.input, i, v, tc.expected[i]) + } + } + }) + } +} + +func TestNormalizeNodes(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + {"simple list", []string{"node1", "node2"}, []string{"node1", "node2"}}, + {"with whitespace", []string{" node1 ", "node2"}, []string{"node1", "node2"}}, + {"deduplicates case-insensitive", []string{"Node1", "NODE1", "node1"}, []string{"Node1"}}, + {"preserves original case", []string{"MyNode", "mynode"}, []string{"MyNode"}}, + {"filters empty strings", []string{"node1", "", "node2"}, []string{"node1", "node2"}}, + {"filters whitespace-only", []string{"node1", " ", "node2"}, []string{"node1", "node2"}}, + {"empty input", []string{}, nil}, + {"nil input", nil, nil}, + {"mixed duplicates", []string{"a", "B", "a", "b", "C"}, []string{"a", "B", "C"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := normalizeNodes(tc.input) + if len(result) != len(tc.expected) { + t.Errorf("normalizeNodes(%v) length = %d, want %d", tc.input, len(result), len(tc.expected)) + return + } + for i, v := range result { + if v != tc.expected[i] { + t.Errorf("normalizeNodes(%v)[%d] = %q, want %q", tc.input, i, v, tc.expected[i]) + } + } + }) + } +} + +func TestParseAllowedSubnets(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + wantErr bool + }{ + // Valid CIDRs + {"single IPv4 CIDR", []string{"192.168.1.0/24"}, []string{"192.168.1.0/24"}, false}, + {"multiple IPv4 CIDRs", []string{"192.168.1.0/24", "10.0.0.0/8"}, []string{"192.168.1.0/24", "10.0.0.0/8"}, false}, + {"IPv6 CIDR", []string{"2001:db8::/32"}, []string{"2001:db8::/32"}, false}, + + // Single IPs converted to CIDRs + {"single IPv4 converted", []string{"192.168.1.100"}, []string{"192.168.1.100/32"}, false}, + {"single IPv6 converted", []string{"2001:db8::1"}, []string{"2001:db8::1/128"}, false}, + + // Whitespace handling + {"with whitespace", []string{" 192.168.1.0/24 "}, []string{"192.168.1.0/24"}, false}, + {"empty entries filtered", []string{"192.168.1.0/24", "", "10.0.0.0/8"}, []string{"192.168.1.0/24", "10.0.0.0/8"}, false}, + + // Deduplication + {"deduplicates exact", []string{"192.168.1.0/24", "192.168.1.0/24"}, []string{"192.168.1.0/24"}, false}, + {"deduplicates converted", []string{"192.168.1.100", "192.168.1.100"}, []string{"192.168.1.100/32"}, false}, + + // Invalid inputs + {"invalid format", []string{"not-a-subnet"}, nil, true}, + {"invalid CIDR", []string{"192.168.1.0/33"}, nil, true}, + {"partial valid", []string{"192.168.1.0/24", "invalid"}, nil, true}, + + // Empty input + {"empty input", []string{}, nil, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := parseAllowedSubnets(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("parseAllowedSubnets(%v) expected error, got nil", tc.input) + } + return + } + if err != nil { + t.Errorf("parseAllowedSubnets(%v) unexpected error: %v", tc.input, err) + return + } + if len(result) != len(tc.expected) { + t.Errorf("parseAllowedSubnets(%v) length = %d, want %d; result = %v", tc.input, len(result), len(tc.expected), result) + return + } + for i, v := range result { + if v != tc.expected[i] { + t.Errorf("parseAllowedSubnets(%v)[%d] = %q, want %q", tc.input, i, v, tc.expected[i]) + } + } + }) + } +} diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index d6848ef..e8f4bf2 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -94,6 +94,10 @@ const UnifiedBackups: Component = () => { } | null>(null); const [chartTimeRange, setChartTimeRange] = createSignal(30); const [isSearchLocked, setIsSearchLocked] = createSignal(false); + + // Pagination state - limit rows to prevent UI lockup with large backup counts + const ITEMS_PER_PAGE = 100; + const [currentPage, setCurrentPage] = createSignal(1); const availableBackupsTooltipText = 'Daily counts of backups still available for restore across snapshots, PVE storage, and PBS.'; @@ -654,15 +658,21 @@ const UnifiedBackups: Component = () => { 'December', ]; + const currentYear = now.getFullYear(); + data.forEach((item) => { const date = new Date(item.backupTime * 1000); const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const backupYear = date.getFullYear(); let label: string; const month = months[date.getMonth()]; const day = date.getDate(); const suffix = getDaySuffix(day); - const absoluteDate = `${month} ${day}${suffix}`; + // Show year for non-current year backups + const absoluteDate = backupYear === currentYear + ? `${month} ${day}${suffix}` + : `${month} ${day}${suffix}, ${backupYear}`; if (dateOnly.getTime() === today.getTime()) { label = `Today (${absoluteDate})`; @@ -718,6 +728,67 @@ const UnifiedBackups: Component = () => { return groups; }); + // Total item count across all groups (for pagination) + const totalItems = createMemo(() => { + return groupedData().reduce((sum, group) => sum + group.items.length, 0); + }); + + // Total pages + const totalPages = createMemo(() => { + return Math.max(1, Math.ceil(totalItems() / ITEMS_PER_PAGE)); + }); + + // Reset to page 1 when filters change + createEffect(() => { + // Track filter dependencies + searchTerm(); + selectedNode(); + typeFilter(); + backupTypeFilter(); + statusFilter(); + selectedDateRange(); + // Reset page + setCurrentPage(1); + }); + + // Paginated data - slice across groups to get ITEMS_PER_PAGE items + const paginatedData = createMemo(() => { + const groups = groupedData(); + const start = (currentPage() - 1) * ITEMS_PER_PAGE; + const end = start + ITEMS_PER_PAGE; + + // Flatten all items with their group info, slice, then regroup + const allItems: { group: DateGroup; item: UnifiedBackup }[] = []; + for (const group of groups) { + for (const item of group.items) { + allItems.push({ group, item }); + } + } + + const sliced = allItems.slice(start, end); + + // Regroup the sliced items + const resultGroups: DateGroup[] = []; + const groupMap = new Map(); + + for (const { group, item } of sliced) { + if (!groupMap.has(group.label)) { + groupMap.set(group.label, []); + } + groupMap.get(group.label)!.push(item); + } + + // Preserve original group order + for (const group of groups) { + const items = groupMap.get(group.label); + if (items && items.length > 0) { + resultGroups.push({ label: group.label, items }); + } + } + + return resultGroups; + }); + // Sort handler const handleSort = (key: BackupSortKey) => { if (sortKey() === key) { @@ -1938,7 +2009,7 @@ const UnifiedBackups: Component = () => { > {/* Mobile Card View - Compact */}
- + {(group) => (
@@ -2117,7 +2188,7 @@ const UnifiedBackups: Component = () => { - + {(group) => ( <> @@ -2367,6 +2438,48 @@ const UnifiedBackups: Component = () => { + + {/* Pagination Controls */} + 1}> +
+
+ Showing {((currentPage() - 1) * ITEMS_PER_PAGE) + 1} - {Math.min(currentPage() * ITEMS_PER_PAGE, totalItems())} of {totalItems()} backups +
+
+ + + + Page {currentPage()} of {totalPages()} + + + +
+
+