mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 11:46:28 +00:00
feat: enhance backup search with advanced filtering like Dashboard
- Add parseFilterStack support to Backups tab for advanced filtering - Support field-specific filters: vmid:, node:, type:, storage:, datastore:, namespace: - Support comparison operators: size>1GB for backup size filtering - Support boolean fields: verified:true, protected:true for PBS backups - Add search help tooltip with backup-specific filter examples - Update placeholder text to show relevant backup search examples - Make searchQuery utils generic to work with any data type, not just VM/Container - Backups tab now has same powerful search capabilities as Dashboard
This commit is contained in:
@@ -3,6 +3,7 @@ import { useWebSocket } from '@/App';
|
||||
import { formatBytes, formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
|
||||
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import PBSCard from '@/components/Dashboard/PBSCard';
|
||||
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
|
||||
|
||||
type BackupType = 'snapshot' | 'local' | 'remote';
|
||||
type GuestType = 'VM' | 'LXC' | 'Template' | 'ISO';
|
||||
@@ -312,25 +313,52 @@ const UnifiedBackups: Component = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
// Search filter - with advanced filtering support like Dashboard
|
||||
if (search) {
|
||||
const searchTerms = search.split(',').map(term => term.trim()).filter(term => term.length > 0);
|
||||
data = data.filter(item =>
|
||||
searchTerms.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));
|
||||
})
|
||||
);
|
||||
// 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));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Type filter
|
||||
@@ -1163,7 +1191,7 @@ const UnifiedBackups: Component = () => {
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search VMID, Name, Node, Storage (use ',' for OR)"
|
||||
placeholder="Search: name, vmid:104, node:pbs1, size>1GB, type:VM"
|
||||
value={searchTerm()}
|
||||
onInput={(e) => {
|
||||
if (!isSearchLocked()) {
|
||||
@@ -1179,6 +1207,36 @@ const UnifiedBackups: Component = () => {
|
||||
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
|
||||
{/* Help tooltip button */}
|
||||
<button
|
||||
class="absolute right-3 top-2.5 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
type="button"
|
||||
aria-label="Search help"
|
||||
title="Search Examples:
|
||||
• jellyfin - Find backups with 'jellyfin' in name
|
||||
• plex,media - Find backups with 'plex' OR 'media'
|
||||
• vmid:104 - Find backups for specific VM/container
|
||||
• node:pve1 - Backups from specific node
|
||||
• node:pbs-docker - Backups from specific PBS
|
||||
• size>1000000000 - Backups larger than 1GB
|
||||
• type:VM - Only VM backups
|
||||
• type:LXC - Only LXC backups
|
||||
• storage:local - Backups on specific storage
|
||||
• datastore:default - PBS backups in specific datastore
|
||||
• namespace:production - PBS backups in namespace
|
||||
• verified:true - Only verified PBS backups
|
||||
• protected:true - Only protected backups
|
||||
|
||||
Combine searches:
|
||||
• media,size>5000000000 - 'media' AND >5GB
|
||||
• node:pbs1,type:VM - PBS1 backups AND VMs only"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -42,8 +42,8 @@ export interface FilterStack {
|
||||
export function parseFilter(term: string): ParsedFilter {
|
||||
term = term.trim();
|
||||
|
||||
// Try to parse metric condition (e.g., "cpu>80")
|
||||
const metricMatch = term.match(/^(cpu|memory|disk|diskRead|diskWrite|networkIn|networkOut)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i);
|
||||
// Try to parse metric condition (e.g., "cpu>80", "size>1000000000")
|
||||
const metricMatch = term.match(/^(\w+)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i);
|
||||
if (metricMatch) {
|
||||
const [, field, operator, value] = metricMatch;
|
||||
const parsedValue = parseFloat(value);
|
||||
@@ -62,8 +62,8 @@ export function parseFilter(term: string): ParsedFilter {
|
||||
};
|
||||
}
|
||||
|
||||
// Try to parse text condition (e.g., "name:prod")
|
||||
const textMatch = term.match(/^(name|node|vmid)\s*:\s*(.+)$/i);
|
||||
// Try to parse text condition (e.g., "name:prod", "storage:local", "type:VM")
|
||||
const textMatch = term.match(/^(\w+)\s*:\s*(.+)$/i);
|
||||
if (textMatch) {
|
||||
const [, field, value] = textMatch;
|
||||
return {
|
||||
@@ -129,8 +129,8 @@ function parseCondition(conditionStr: string): Condition | null {
|
||||
} as MetricCondition;
|
||||
}
|
||||
|
||||
// Try to parse text condition (e.g., "name:prod")
|
||||
const textMatch = conditionStr.match(/^(name|node|vmid)\s*:\s*(.+)$/i);
|
||||
// Try to parse text condition (e.g., "name:prod", "storage:local", etc.)
|
||||
const textMatch = conditionStr.match(/^(\w+)\s*:\s*(.+)$/i);
|
||||
if (textMatch) {
|
||||
const [, field, value] = textMatch;
|
||||
return {
|
||||
@@ -186,7 +186,7 @@ export function parseSearchQuery(query: string): ParsedQuery {
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateMetricCondition(guest: VM | Container, condition: MetricCondition): boolean {
|
||||
function evaluateMetricCondition(guest: VM | Container | any, condition: MetricCondition): boolean {
|
||||
let value: number;
|
||||
|
||||
switch (condition.field) {
|
||||
@@ -201,7 +201,12 @@ function evaluateMetricCondition(guest: VM | Container, condition: MetricConditi
|
||||
value = guest.disk ? guest.disk.usage : 0;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
// For backup-specific numeric fields like 'size'
|
||||
if (guest[condition.field] !== undefined) {
|
||||
value = Number(guest[condition.field]) || 0;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
switch (condition.operator) {
|
||||
@@ -221,17 +226,28 @@ function evaluateMetricCondition(guest: VM | Container, condition: MetricConditi
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateTextCondition(guest: VM | Container, condition: TextCondition): boolean {
|
||||
function evaluateTextCondition(guest: VM | Container | any, condition: TextCondition): boolean {
|
||||
const searchValue = condition.value.toLowerCase();
|
||||
|
||||
switch (condition.field) {
|
||||
case 'name':
|
||||
return guest.name.toLowerCase().includes(searchValue);
|
||||
return guest.name?.toLowerCase().includes(searchValue) || false;
|
||||
case 'node':
|
||||
return guest.node.toLowerCase().includes(searchValue);
|
||||
return guest.node?.toLowerCase().includes(searchValue) || false;
|
||||
case 'vmid':
|
||||
return guest.vmid.toString().includes(searchValue);
|
||||
return guest.vmid?.toString().includes(searchValue) || false;
|
||||
default:
|
||||
// For backup-specific fields
|
||||
if (guest[condition.field]) {
|
||||
const fieldValue = guest[condition.field];
|
||||
if (typeof fieldValue === 'string') {
|
||||
return fieldValue.toLowerCase().includes(searchValue);
|
||||
} else if (typeof fieldValue === 'number') {
|
||||
return fieldValue.toString().includes(searchValue);
|
||||
} else if (typeof fieldValue === 'boolean') {
|
||||
return fieldValue.toString() === searchValue;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -270,8 +286,8 @@ export function evaluateSearchQuery(guest: VM | Container, query: ParsedQuery):
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate a filter stack against a guest
|
||||
export function evaluateFilterStack(guest: VM | Container, stack: FilterStack): boolean {
|
||||
// Evaluate a filter stack against a guest or backup item
|
||||
export function evaluateFilterStack(guest: VM | Container | any, stack: FilterStack): boolean {
|
||||
if (stack.filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user