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:
parent
73e5ff0914
commit
6ae7ed4ae0
2 changed files with 346 additions and 3 deletions
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue