diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index 1f67d10c0..e0ecad38f 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -645,6 +645,32 @@ const UnifiedBackups: Component = () => { }; + // Calculate deduplication factor for PBS backups + const dedupFactor = createMemo(() => { + // Get all PBS instances with datastores + if (!state.pbs || state.pbs.length === 0) return null; + + // Collect all deduplication factors from all datastores + const dedupFactors: number[] = []; + state.pbs.forEach(instance => { + if (instance.datastores) { + instance.datastores.forEach(ds => { + if (ds.deduplicationFactor && ds.deduplicationFactor > 0) { + dedupFactors.push(ds.deduplicationFactor); + } + }); + } + }); + + if (dedupFactors.length === 0) return null; + + // Calculate average deduplication factor across all datastores + const avgFactor = dedupFactors.reduce((sum, f) => sum + f, 0) / dedupFactors.length; + + // Format as ratio + return avgFactor.toFixed(1) + ':1'; + }); + // Calculate backup frequency data for chart const chartData = createMemo(() => { const days = chartTimeRange(); @@ -684,49 +710,72 @@ const UnifiedBackups: Component = () => { // Apply search filter - with advanced filtering support like the table 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 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 + + dataForChart = dataForChart.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) { - dataForChart = dataForChart.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) { + dataForChart = dataForChart.filter(item => evaluateFilterStack(item, stack)); + } + } + + // Apply text search if any + if (textSearches.length > 0) { + dataForChart = dataForChart.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) { - dataForChart = dataForChart.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)); - }) - ); } } @@ -827,7 +876,8 @@ const UnifiedBackups: Component = () => { }} onNamespaceSelect={(namespaceFilter) => { setSearchTerm(namespaceFilter); - setIsSearchLocked(true); + // Only lock if we're setting a filter, unlock if clearing + setIsSearchLocked(namespaceFilter !== ''); }} filteredBackups={(searchTerm() || backupTypeFilter() !== 'all') ? filteredData() : undefined} searchTerm={searchTerm()} @@ -980,7 +1030,9 @@ const UnifiedBackups: Component = () => { 0}>
-

Backup Frequency

+
+

Backup Frequency

+
-
- - - Snapshots - - - - PVE - - - - PBS - +
+ +
+ Deduplication: + {dedupFactor()} +
+
+ +
+
+
+ + + Snapshots + + + + PVE + + + + PBS + +
diff --git a/frontend-modern/src/components/shared/PBSNodeTable.tsx b/frontend-modern/src/components/shared/PBSNodeTable.tsx index d0594eff9..ef1fafca8 100644 --- a/frontend-modern/src/components/shared/PBSNodeTable.tsx +++ b/frontend-modern/src/components/shared/PBSNodeTable.tsx @@ -11,6 +11,7 @@ interface PBSNodeTableProps { onNamespaceClick?: (instanceName: string, datastoreName: string, namespace: string) => void; currentTab?: 'dashboard' | 'storage' | 'backups'; filteredBackups?: any[]; + searchTerm?: string; } export const PBSNodeTable: Component = (props) => { @@ -29,6 +30,13 @@ export const PBSNodeTable: Component = (props) => { const isExpanded = (instanceName: string) => expandedInstances().has(instanceName); + // Check if a namespace is currently selected/filtered + const isNamespaceSelected = (instanceName: string, datastoreName: string, namespace: string) => { + if (!props.searchTerm) return false; + const expectedFilter = `pbs:${instanceName}:${datastoreName}:${namespace}`; + return props.searchTerm === expectedFilter; + }; + // Filter and sort PBS instances const sortedInstances = createMemo(() => { if (!props.pbsInstances) return []; @@ -281,27 +289,50 @@ export const PBSNodeTable: Component = (props) => { {/* Namespace rows */} 0}> - {(namespace) => ( - { - if (props.onNamespaceClick) { - props.onNamespaceClick(pbs.name, datastore.name, namespace.path || '/'); - } - }} - > - -
- - - - - {namespace.path || '/ (root)'} - -
- - - )} + {(namespace) => { + const isSelected = () => isNamespaceSelected(pbs.name, datastore.name, namespace.path || '/'); + + return ( + { + if (props.onNamespaceClick) { + props.onNamespaceClick(pbs.name, datastore.name, namespace.path || '/'); + } + }} + > + +
+ + + + + {namespace.path || '/ (root)'} + + + + (filtering) + + +
+ + + ); + }}
diff --git a/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx b/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx index 5efdec95c..d0c0c078b 100644 --- a/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx +++ b/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx @@ -108,12 +108,22 @@ export const UnifiedNodeSelector: Component = (props) 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); + // If already selected, clear the filter, otherwise set it + if (props.searchTerm === searchStr) { + // Clear the filter + if (props.onNamespaceSelect) { + props.onNamespaceSelect(''); + } + } else { + // Set the filter + if (props.onNamespaceSelect) { + props.onNamespaceSelect(searchStr); + } } }} currentTab={props.currentTab} filteredBackups={props.filteredBackups} + searchTerm={props.searchTerm} />
diff --git a/pkg/pbs/client.go b/pkg/pbs/client.go index ac342e520..9f7d679c5 100644 --- a/pkg/pbs/client.go +++ b/pkg/pbs/client.go @@ -403,6 +403,27 @@ func (c *Client) GetDatastores(ctx context.Context) ([]Datastore, error) { // Now get status for each datastore var datastores []Datastore for _, ds := range datastoreList.Data { + // Try to get RRD data first which has more statistics + rrdResp, err := c.get(ctx, fmt.Sprintf("/admin/datastore/%s/rrd", ds.Store)) + var dedupFactor float64 + if err == nil { + defer rrdResp.Body.Close() + rrdBody, _ := io.ReadAll(rrdResp.Body) + + var rrdResult struct { + Data []struct { + Time float64 `json:"time"` + DedupFactor float64 `json:"dedup_factor"` + } `json:"data"` + } + + if json.Unmarshal(rrdBody, &rrdResult) == nil && len(rrdResult.Data) > 0 { + // Get the most recent deduplication factor + dedupFactor = rrdResult.Data[len(rrdResult.Data)-1].DedupFactor + log.Info().Float64("dedup_from_rrd", dedupFactor).Str("store", ds.Store).Msg("Got dedup factor from RRD") + } + } + // Get individual datastore status statusResp, err := c.get(ctx, fmt.Sprintf("/admin/datastore/%s/status", ds.Store)) if err != nil { @@ -427,11 +448,7 @@ func (c *Client) GetDatastores(ctx context.Context) ([]Datastore, error) { } var statusResult struct { - Data struct { - Total int64 `json:"total"` - Used int64 `json:"used"` - Avail int64 `json:"avail"` - } `json:"data"` + Data map[string]interface{} `json:"data"` } if err := json.Unmarshal(statusBody, &statusResult); err != nil { @@ -447,23 +464,63 @@ func (c *Client) GetDatastores(ctx context.Context) ([]Datastore, error) { continue } + // Extract fields from the map + total, _ := statusResult.Data["total"].(float64) + used, _ := statusResult.Data["used"].(float64) + avail, _ := statusResult.Data["avail"].(float64) + + // Check for deduplication_factor in status response + if df, ok := statusResult.Data["deduplication-factor"].(float64); ok { + dedupFactor = df + } else if df, ok := statusResult.Data["deduplication_factor"].(float64); ok { + dedupFactor = df + } + + // If still no dedup factor, try gc-status endpoint + if dedupFactor == 0 { + gcResp, err := c.get(ctx, fmt.Sprintf("/admin/datastore/%s/gc", ds.Store)) + if err == nil { + defer gcResp.Body.Close() + gcBody, _ := io.ReadAll(gcResp.Body) + var gcResult struct { + Data struct { + IndexDataBytes float64 `json:"index-data-bytes"` + DiskBytes float64 `json:"disk-bytes"` + } `json:"data"` + } + if json.Unmarshal(gcBody, &gcResult) == nil { + // Calculate deduplication factor from index-data-bytes / disk-bytes + if gcResult.Data.DiskBytes > 0 && gcResult.Data.IndexDataBytes > 0 { + dedupFactor = gcResult.Data.IndexDataBytes / gcResult.Data.DiskBytes + log.Info(). + Float64("index_bytes", gcResult.Data.IndexDataBytes). + Float64("disk_bytes", gcResult.Data.DiskBytes). + Float64("dedup_factor", dedupFactor). + Str("store", ds.Store). + Msg("Calculated dedup factor from gc endpoint") + } + } + } + } + // Create datastore with status info datastore := Datastore{ Store: ds.Store, - Total: statusResult.Data.Total, - Used: statusResult.Data.Used, - Avail: statusResult.Data.Avail, - // Note: PBS doesn't provide deduplication factor in the status API - // This would need to be calculated from chunk store statistics - DeduplicationFactor: 0, + Total: int64(total), + Used: int64(used), + Avail: int64(avail), + DeduplicationFactor: dedupFactor, } - log.Debug(). + // Log all fields to see what's available + log.Info(). Str("store", datastore.Store). Int64("total", datastore.Total). Int64("used", datastore.Used). Int64("avail", datastore.Avail). - Msg("PBS datastore status retrieved") + Float64("dedup_factor", datastore.DeduplicationFactor). + Interface("all_fields", statusResult.Data). + Msg("PBS datastore status - ALL FIELDS") datastores = append(datastores, datastore) }