feat: enhance PBS namespace filtering and display

- Add expandable namespace rows to PBS instances table
- Show deduplication factor from PBS GC status (calculated from index-data-bytes/disk-bytes)
- Move deduplication display to bottom left of backup frequency chart
- Add namespace highlighting when filtered (blue background, filtering indicator)
- Fix backup frequency chart to properly handle PBS namespace filters
- Allow clicking namespace again to clear filter (toggle behavior)
- Improve visual feedback for selected namespaces with color changes
This commit is contained in:
Pulse Monitor
2025-08-26 18:03:07 +00:00
parent f385ee94b3
commit a3e3139ca7
4 changed files with 253 additions and 92 deletions
@@ -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 = () => {
<Show when={filteredData().length > 0}>
<div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Backup Frequency</h3>
<div class="flex items-center gap-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Backup Frequency</h3>
</div>
<div class="flex items-center gap-2 text-xs">
<div class="flex items-center gap-1">
<button type="button"
@@ -1344,19 +1396,30 @@ const UnifiedBackups: Component = () => {
/>
</Show>
</div>
<div class="flex justify-end items-center gap-3 text-xs mt-2">
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded bg-yellow-500"></span>
<span class="text-gray-600 dark:text-gray-400">Snapshots</span>
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded bg-orange-500"></span>
<span class="text-gray-600 dark:text-gray-400">PVE</span>
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded bg-violet-500"></span>
<span class="text-gray-600 dark:text-gray-400">PBS</span>
</span>
<div class="flex justify-between items-center text-xs mt-2">
<Show when={dedupFactor()}>
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Deduplication:</span>
<span class="font-medium text-green-600 dark:text-green-400">{dedupFactor()}</span>
</div>
</Show>
<Show when={!dedupFactor()}>
<div></div>
</Show>
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded bg-yellow-500"></span>
<span class="text-gray-600 dark:text-gray-400">Snapshots</span>
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded bg-orange-500"></span>
<span class="text-gray-600 dark:text-gray-400">PVE</span>
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded bg-violet-500"></span>
<span class="text-gray-600 dark:text-gray-400">PBS</span>
</span>
</div>
</div>
</div>
</Show>
@@ -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<PBSNodeTableProps> = (props) => {
@@ -29,6 +30,13 @@ export const PBSNodeTable: Component<PBSNodeTableProps> = (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<PBSNodeTableProps> = (props) => {
{/* Namespace rows */}
<Show when={datastore.namespaces && datastore.namespaces.length > 0}>
<For each={datastore.namespaces}>
{(namespace) => (
<tr
class="bg-gray-25 dark:bg-gray-850 hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer transition-colors border-b border-gray-50 dark:border-gray-800"
onClick={() => {
if (props.onNamespaceClick) {
props.onNamespaceClick(pbs.name, datastore.name, namespace.path || '/');
}
}}
>
<td colspan="8" class="px-12 py-0.5">
<div class="flex items-center gap-2">
<svg class="w-3 h-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h10M7 12h10m-7 5h4" />
</svg>
<span class="text-xs text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">
{namespace.path || '/ (root)'}
</span>
</div>
</td>
</tr>
)}
{(namespace) => {
const isSelected = () => isNamespaceSelected(pbs.name, datastore.name, namespace.path || '/');
return (
<tr
class={`
cursor-pointer transition-all duration-150 border-b border-gray-50 dark:border-gray-800
${isSelected()
? 'bg-blue-100 dark:bg-blue-900/40 hover:bg-blue-150 dark:hover:bg-blue-900/50 font-medium'
: 'bg-gray-25 dark:bg-gray-850 hover:bg-blue-50 dark:hover:bg-blue-900/20'}
`}
onClick={() => {
if (props.onNamespaceClick) {
props.onNamespaceClick(pbs.name, datastore.name, namespace.path || '/');
}
}}
>
<td colspan="8" class="px-12 py-0.5">
<div class="flex items-center gap-2">
<svg
class={`w-3 h-3 ${isSelected() ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h10M7 12h10m-7 5h4" />
</svg>
<span class={`text-xs ${
isSelected()
? 'text-blue-700 dark:text-blue-300'
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400'
}`}>
{namespace.path || '/ (root)'}
</span>
<Show when={isSelected()}>
<span class="text-xs text-blue-600 dark:text-blue-400 ml-auto mr-2">
(filtering)
</span>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</Show>
</>
@@ -108,12 +108,22 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (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}
/>
</Show>
</div>
+70 -13
View File
@@ -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)
}