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
This commit is contained in:
rcourtman 2025-11-30 09:55:01 +00:00
parent 73e5ff0914
commit 6ae7ed4ae0
2 changed files with 346 additions and 3 deletions

View file

@ -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])
}
}
})
}
}

View file

@ -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<string, UnifiedBackup[]>();
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 */}
<div class="block lg:hidden space-y-3">
<For each={groupedData()}>
<For each={paginatedData()}>
{(group) => (
<div class="space-y-1">
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 px-2 py-1 sticky top-0 bg-gray-50 dark:bg-gray-900 z-10">
@ -2117,7 +2188,7 @@ const UnifiedBackups: Component = () => {
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={groupedData()}>
<For each={paginatedData()}>
{(group) => (
<>
<tr class="bg-gray-50 dark:bg-gray-900/40">
@ -2367,6 +2438,48 @@ const UnifiedBackups: Component = () => {
</For>
</tbody>
</table>
{/* Pagination Controls */}
<Show when={totalPages() > 1}>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div class="text-sm text-gray-600 dark:text-gray-400">
Showing {((currentPage() - 1) * ITEMS_PER_PAGE) + 1} - {Math.min(currentPage() * ITEMS_PER_PAGE, totalItems())} of {totalItems()} backups
</div>
<div class="flex items-center gap-2">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage() === 1}
class="px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700"
>
First
</button>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage() === 1}
class="px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700"
>
Previous
</button>
<span class="text-sm text-gray-600 dark:text-gray-400 px-2">
Page {currentPage()} of {totalPages()}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages(), p + 1))}
disabled={currentPage() === totalPages()}
class="px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700"
>
Next
</button>
<button
onClick={() => setCurrentPage(totalPages())}
disabled={currentPage() === totalPages()}
class="px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700"
>
Last
</button>
</div>
</div>
</Show>
</Show>
</Show>
</div>