From 74ef032eaf1486f235e060dc8f09aa9b3be25c9a Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Tue, 26 Aug 2025 16:57:36 +0000 Subject: [PATCH] feat: add expandable namespace rows to PBS instances table - PBS instances with datastores/namespaces now have expand/collapse buttons - expanded view shows hierarchical structure: instance > datastore > namespace - clicking a namespace filters the backup list to that specific namespace - displays datastore storage usage and deduplication factor when available - namespace filter format: pbs:instanceName:datastoreName:namespace --- .../src/components/Backups/UnifiedBackups.tsx | 107 ++++++++------ .../src/components/shared/PBSNodeTable.tsx | 135 +++++++++++++++++- .../components/shared/UnifiedNodeSelector.tsx | 8 ++ 3 files changed, 208 insertions(+), 42 deletions(-) diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index 217d9a48d..7652f4fbc 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -343,49 +343,72 @@ const UnifiedBackups: Component = () => { // Search filter - with advanced filtering support like Dashboard if (search) { - // Split by commas first - const searchParts = search.split(',').map(t => t.trim()).filter(t => t); - - // Separate filters from text searches - const filters: string[] = []; - const textSearches: string[] = []; - - searchParts.forEach(part => { - if (part.includes('>') || part.includes('<') || part.includes(':')) { - filters.push(part); - } else { - textSearches.push(part.toLowerCase()); + // Check for special PBS namespace filter format first + if (search.startsWith('pbs:')) { + const parts = search.split(':'); + if (parts.length >= 4) { + // Format: pbs:instanceName:datastoreName:namespace + const [, instanceName, datastoreName, ...namespaceParts] = parts; + const namespace = namespaceParts.join(':'); // Handle namespaces with colons + + data = data.filter(item => { + // Only PBS backups + if (item.backupType !== 'remote') return false; + // Match instance + if (item.node !== instanceName) return false; + // Match datastore + if (item.datastore !== datastoreName) return false; + // Match namespace (root namespace is represented as '/' or empty) + const itemNamespace = item.namespace || '/'; + const searchNamespace = namespace || '/'; + return itemNamespace === searchNamespace; + }); } - }); - - // Apply filters if any - if (filters.length > 0) { - // Join filters with AND operator - const filterString = filters.join(' AND '); - const stack = parseFilterStack(filterString); - if (stack.filters.length > 0) { - data = data.filter(item => evaluateFilterStack(item, stack)); + } else { + // Split by commas first + const searchParts = search.split(',').map(t => t.trim()).filter(t => t); + + // Separate filters from text searches + const filters: string[] = []; + const textSearches: string[] = []; + + searchParts.forEach(part => { + if (part.includes('>') || part.includes('<') || part.includes(':')) { + filters.push(part); + } else { + textSearches.push(part.toLowerCase()); + } + }); + + // Apply filters if any + if (filters.length > 0) { + // Join filters with AND operator + const filterString = filters.join(' AND '); + const stack = parseFilterStack(filterString); + if (stack.filters.length > 0) { + data = data.filter(item => evaluateFilterStack(item, stack)); + } } - } - // Apply text search if any - if (textSearches.length > 0) { - data = data.filter(item => - textSearches.some(term => { - const searchFields = [ - item.vmid?.toString(), - item.name, - item.node, - item.backupName, - item.description, - item.storage, - item.datastore, - item.namespace - ].filter(Boolean).map(field => field!.toString().toLowerCase()); - - return searchFields.some(field => field.includes(term)); - }) - ); + // Apply text search if any + if (textSearches.length > 0) { + data = data.filter(item => + textSearches.some(term => { + const searchFields = [ + item.vmid?.toString(), + item.name, + item.node, + item.backupName, + item.description, + item.storage, + item.datastore, + item.namespace + ].filter(Boolean).map(field => field!.toString().toLowerCase()); + + return searchFields.some(field => field.includes(term)); + }) + ); + } } } @@ -828,6 +851,10 @@ const UnifiedBackups: Component = () => { setIsSearchLocked(false); } }} + onNamespaceSelect={(namespaceFilter) => { + setSearchTerm(namespaceFilter); + setIsSearchLocked(true); + }} filteredBackups={(searchTerm() || backupTypeFilter() !== 'all') ? filteredData() : undefined} searchTerm={searchTerm()} /> diff --git a/frontend-modern/src/components/shared/PBSNodeTable.tsx b/frontend-modern/src/components/shared/PBSNodeTable.tsx index ef6ce0dd8..533a9c79c 100644 --- a/frontend-modern/src/components/shared/PBSNodeTable.tsx +++ b/frontend-modern/src/components/shared/PBSNodeTable.tsx @@ -1,4 +1,4 @@ -import { Component, For, Show, createMemo } from 'solid-js'; +import { Component, For, Show, createMemo, createSignal } from 'solid-js'; import type { PBSInstance } from '@/types/api'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { formatBytes, formatUptime } from '@/utils/format'; @@ -8,11 +8,27 @@ interface PBSNodeTableProps { backupCounts?: Record; selectedNode: string | null; onNodeClick: (nodeId: string) => void; + onNamespaceClick?: (instanceName: string, datastoreName: string, namespace: string) => void; currentTab?: 'dashboard' | 'storage' | 'backups'; filteredBackups?: any[]; } export const PBSNodeTable: Component = (props) => { + // Track which PBS instances are expanded to show datastores/namespaces + const [expandedInstances, setExpandedInstances] = createSignal>(new Set()); + + const toggleExpanded = (instanceName: string) => { + const expanded = new Set(expandedInstances()); + if (expanded.has(instanceName)) { + expanded.delete(instanceName); + } else { + expanded.add(instanceName); + } + setExpandedInstances(expanded); + }; + + const isExpanded = (instanceName: string) => expandedInstances().has(instanceName); + // Filter and sort PBS instances const sortedInstances = createMemo(() => { if (!props.pbsInstances) return []; @@ -115,7 +131,12 @@ export const PBSNodeTable: Component = (props) => { const isSelected = () => props.selectedNode === pbs.name; const isClickable = props.currentTab === 'backups'; + const hasDatastoresWithNamespaces = () => { + return pbs.datastores?.some(ds => ds.namespaces && ds.namespaces.length > 0); + }; + return ( + <> = (props) => { ${!isOnline() ? 'opacity-60' : ''} ${isSelected() && isClickable ? 'bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 scale-[1.005] shadow-sm border-l-4 border-l-blue-600 dark:border-l-blue-500' : ''} `} - onClick={() => isClickable && props.onNodeClick(pbs.name)} + onClick={(e) => { + // If clicking the expand button, don't trigger node click + if ((e.target as HTMLElement).closest('.expand-button')) { + return; + } + isClickable && props.onNodeClick(pbs.name); + }} >
+ + + {pbs.name} @@ -186,6 +239,84 @@ export const PBSNodeTable: Component = (props) => { + + {/* Expandable rows for datastores and namespaces */} + + + {(datastore) => ( + <> + {/* Datastore row */} + + +
+
+ + + + + {datastore.name} + + + ({datastore.namespaces?.length || 0} namespaces) + +
+
+
+ Used: + + {formatBytes(datastore.used || 0)} + +
+
+ Total: + + {formatBytes(datastore.total || 0)} + +
+ 0}> +
+ Dedup: + + {datastore.deduplicationFactor!.toFixed(1)}:1 + +
+
+
+
+ + + + {/* Namespace rows */} + 0}> + + {(namespace) => ( + { + if (props.onNamespaceClick) { + props.onNamespaceClick(pbs.name, datastore.name, namespace.path || '/'); + } + }} + > + +
+ + + + + {namespace.path || '/ (root)'} + +
+ + + )} +
+
+ + )} +
+
+ ); }} diff --git a/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx b/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx index 2a011ebb1..5efdec95c 100644 --- a/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx +++ b/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx @@ -6,6 +6,7 @@ import { PBSNodeTable } from './PBSNodeTable'; interface UnifiedNodeSelectorProps { currentTab: 'dashboard' | 'storage' | 'backups'; onNodeSelect?: (nodeId: string | null, nodeType: 'pve' | 'pbs' | null) => void; + onNamespaceSelect?: (namespace: string) => void; nodes?: any[]; filteredVms?: any[]; filteredContainers?: any[]; @@ -104,6 +105,13 @@ export const UnifiedNodeSelector: Component = (props) backupCounts={backupCounts()} selectedNode={selectedNode()} onNodeClick={handlePBSNodeClick} + onNamespaceClick={(instanceName, datastoreName, namespace) => { + // Build a search string that filters for this specific namespace + const searchStr = `pbs:${instanceName}:${datastoreName}:${namespace}`; + if (props.onNamespaceSelect) { + props.onNamespaceSelect(searchStr); + } + }} currentTab={props.currentTab} filteredBackups={props.filteredBackups} />