mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 20:22:53 +00:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user