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