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
This commit is contained in:
Pulse Monitor
2025-08-26 16:57:36 +00:00
parent 9bbf6757e3
commit 74ef032eaf
3 changed files with 208 additions and 42 deletions
@@ -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()}
/>
@@ -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<string, number>;
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<PBSNodeTableProps> = (props) => {
// Track which PBS instances are expanded to show datastores/namespaces
const [expandedInstances, setExpandedInstances] = createSignal<Set<string>>(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<PBSNodeTableProps> = (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 (
<>
<tr
class={`
border-b border-gray-100 dark:border-gray-700/50
@@ -124,10 +145,42 @@ export const PBSNodeTable: Component<PBSNodeTableProps> = (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);
}}
>
<td class="px-2 py-0.5">
<div class="flex items-center gap-2">
<Show when={hasDatastoresWithNamespaces()}>
<button
class="expand-button p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
toggleExpanded(pbs.name);
}}
title={isExpanded(pbs.name) ? "Collapse" : "Expand"}
>
<svg
class={`w-3 h-3 text-gray-500 dark:text-gray-400 transition-transform ${
isExpanded(pbs.name) ? 'rotate-90' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</Show>
<span class={`h-2 w-2 rounded-full ${isOnline() ? 'bg-green-500' : 'bg-red-500'}`}></span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{pbs.name}
@@ -186,6 +239,84 @@ export const PBSNodeTable: Component<PBSNodeTableProps> = (props) => {
</span>
</td>
</tr>
{/* Expandable rows for datastores and namespaces */}
<Show when={isExpanded(pbs.name) && pbs.datastores}>
<For each={pbs.datastores}>
{(datastore) => (
<>
{/* Datastore row */}
<tr class="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-700/30">
<td colspan="8" class="px-8 py-1">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{datastore.name}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
({datastore.namespaces?.length || 0} namespaces)
</span>
</div>
<div class="flex items-center gap-4 text-xs">
<div class="flex items-center gap-1">
<span class="text-gray-500">Used:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{formatBytes(datastore.used || 0)}
</span>
</div>
<div class="flex items-center gap-1">
<span class="text-gray-500">Total:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{formatBytes(datastore.total || 0)}
</span>
</div>
<Show when={datastore.deduplicationFactor && datastore.deduplicationFactor > 0}>
<div class="flex items-center gap-1">
<span class="text-gray-500">Dedup:</span>
<span class="font-medium text-green-600 dark:text-green-400">
{datastore.deduplicationFactor!.toFixed(1)}:1
</span>
</div>
</Show>
</div>
</div>
</td>
</tr>
{/* 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>
)}
</For>
</Show>
</>
)}
</For>
</Show>
</>
);
}}
</For>
@@ -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<UnifiedNodeSelectorProps> = (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}
/>