Pulse/frontend-modern/src/utils/searchQuery.ts
2025-10-11 23:29:47 +00:00

417 lines
13 KiB
TypeScript

import type { VM, Container, PBSBackup, StorageBackup, BackupTask } from '@/types/api';
import type { UnifiedBackup } from '@/types/backups';
export type ComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '==';
export type LogicalOperator = 'AND' | 'OR';
export interface MetricCondition {
field:
| 'cpu'
| 'memory'
| 'disk'
| 'diskRead'
| 'diskWrite'
| 'networkIn'
| 'networkOut'
| 'uptime';
operator: ComparisonOperator;
value: number;
}
export interface TextCondition {
field: 'name' | 'node' | 'vmid' | 'tags';
value: string;
}
export type Condition = MetricCondition | TextCondition;
export interface ParsedQuery {
conditions: Condition[];
logicalOperator: LogicalOperator;
rawText?: string; // Fallback for simple text search
}
// New interfaces for stackable filters
export interface ParsedFilter {
type: 'metric' | 'text' | 'raw';
rawText?: string;
field?: string;
operator?: ComparisonOperator;
value?: string | number;
}
export interface FilterStack {
filters: ParsedFilter[];
operators: LogicalOperator[]; // Operators between filters (length = filters.length - 1)
logicalOperator?: LogicalOperator; // Deprecated, kept for compatibility
}
// Parse a single filter from a search term
export function parseFilter(term: string): ParsedFilter {
term = term.trim();
// 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);
if (isNaN(parsedValue)) {
return {
type: 'raw',
rawText: term,
};
}
return {
type: 'metric',
field: field.toLowerCase(),
operator: operator as ComparisonOperator,
value: parsedValue,
};
}
// Try to parse text condition (e.g., "name:prod", "tags:production", "storage:local", "type:VM")
const textMatch = term.match(/^(\w+)\s*:\s*(.+)$/i);
if (textMatch) {
const [, field, value] = textMatch;
return {
type: 'text',
field: field.toLowerCase(),
value: value.trim(),
};
}
// Otherwise treat as raw text search
return {
type: 'raw',
rawText: term,
};
}
// Parse multiple filters from a search string
export function parseFilterStack(searchString: string): FilterStack {
const trimmed = searchString.trim();
if (!trimmed) {
return { filters: [], operators: [] };
}
// Split by AND/OR operators while preserving them
const regex = /\s+(AND|OR)\s+/gi;
const parts = trimmed.split(regex);
const filters: ParsedFilter[] = [];
const operators: LogicalOperator[] = [];
// Process the parts
for (let i = 0; i < parts.length; i++) {
const part = parts[i].trim();
if (!part) continue;
if (i % 2 === 0) {
// Even indices are filter expressions
const filter = parseFilter(part);
filters.push(filter);
} else {
// Odd indices are operators
operators.push(part.toUpperCase() as LogicalOperator);
}
}
// For backward compatibility, include logicalOperator as the first operator
const logicalOperator = operators.length > 0 ? operators[0] : 'AND';
return { filters, operators, logicalOperator };
}
function parseCondition(conditionStr: string): Condition | null {
// Try to parse metric condition (e.g., "cpu>80")
const metricMatch = conditionStr.match(
/^(cpu|memory|disk|diskRead|diskWrite|networkIn|networkOut)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i,
);
if (metricMatch) {
const [, field, operator, value] = metricMatch;
return {
field: field.toLowerCase() as MetricCondition['field'],
operator: operator as ComparisonOperator,
value: (() => {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
})(),
} as MetricCondition;
}
// Try to parse text condition (e.g., "name:prod", "tags:production", "storage:local", etc.)
const textMatch = conditionStr.match(/^(\w+)\s*:\s*(.+)$/i);
if (textMatch) {
const [, field, value] = textMatch;
return {
field: field.toLowerCase() as 'name' | 'node' | 'vmid' | 'tags',
value: value.trim(),
} as TextCondition;
}
return null;
}
export function parseSearchQuery(query: string): ParsedQuery {
query = query.trim();
// Check for logical operators
const hasAnd = /\bAND\b/i.test(query);
const hasOr = /\bOR\b/i.test(query);
// If no operators or invalid query, treat as simple text search
if (!hasAnd && !hasOr && !query.match(/[><=:]/)) {
return {
conditions: [],
logicalOperator: 'AND',
rawText: query,
};
}
// Split by logical operator
const logicalOperator: LogicalOperator = hasAnd ? 'AND' : 'OR';
const parts = query.split(hasAnd ? /\bAND\b/i : /\bOR\b/i);
const conditions: Condition[] = [];
for (const part of parts) {
const condition = parseCondition(part.trim());
if (condition) {
conditions.push(condition);
}
}
// If no valid conditions parsed, fall back to text search
if (conditions.length === 0) {
return {
conditions: [],
logicalOperator: 'AND',
rawText: query,
};
}
return {
conditions,
logicalOperator,
};
}
type FilterableItem = VM | Container | PBSBackup | StorageBackup | BackupTask | UnifiedBackup;
function evaluateMetricCondition(guest: FilterableItem, condition: MetricCondition): boolean {
let value: number;
switch (condition.field) {
case 'cpu':
// CPU is stored as decimal (0-1), convert to percentage
value = ('cpu' in guest ? guest.cpu || 0 : 0) * 100;
break;
case 'memory':
value = 'memory' in guest && guest.memory ? guest.memory.usage : 0;
break;
case 'disk':
value = 'disk' in guest && guest.disk ? guest.disk.usage : 0;
break;
case 'uptime':
// Uptime in seconds (only for running VMs/containers)
value =
'status' in guest && guest.status === 'running' && 'uptime' in guest
? guest.uptime || 0
: 0;
break;
default:
// For backup-specific numeric fields like 'size'
if (typeof guest === 'object' && guest !== null && condition.field in guest) {
const fieldValue = (guest as unknown as Record<string, unknown>)[condition.field];
if (fieldValue !== undefined) {
value = Number(fieldValue) || 0;
} else {
return false;
}
} else {
return false;
}
}
switch (condition.operator) {
case '>':
return value > condition.value;
case '<':
return value < condition.value;
case '>=':
return value >= condition.value;
case '<=':
return value <= condition.value;
case '=':
case '==':
return Math.abs(value - condition.value) < 0.01;
default:
return false;
}
}
function evaluateTextCondition(guest: FilterableItem, condition: TextCondition): boolean {
const searchValue = condition.value.toLowerCase();
switch (condition.field) {
case 'name':
return 'name' in guest && guest.name ? guest.name.toLowerCase().includes(searchValue) : false;
case 'node':
return 'node' in guest && guest.node ? guest.node.toLowerCase().includes(searchValue) : false;
case 'vmid':
return 'vmid' in guest && guest.vmid ? guest.vmid.toString().includes(searchValue) : false;
case 'tags':
// Check if guest has any tags that match the search value
if (!('tags' in guest) || !guest.tags) return false;
const tagsArray = Array.isArray(guest.tags)
? guest.tags.filter((tag): tag is string => typeof tag === 'string')
: typeof guest.tags === 'string'
? guest.tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [];
if (tagsArray.length === 0) return false;
// Support comma-separated tag searches (OR logic)
const searchTags = searchValue
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
return searchTags.some((searchTag) =>
tagsArray.some((tag) => tag.toLowerCase().includes(searchTag.toLowerCase())),
);
default:
// For backup-specific fields
if (typeof guest === 'object' && guest !== null && condition.field in guest) {
const fieldValue = (guest as unknown as Record<string, unknown>)[condition.field];
if (fieldValue) {
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;
}
}
export function evaluateSearchQuery(guest: FilterableItem, query: ParsedQuery): boolean {
// If it's a simple text search
if (query.rawText) {
const searchTerms = query.rawText
.toLowerCase()
.split(',')
.map((term) => term.trim())
.filter((term) => term.length > 0);
return searchTerms.some(
(term) =>
('name' in guest && guest.name && guest.name.toLowerCase().includes(term)) ||
('vmid' in guest && guest.vmid && guest.vmid.toString().includes(term)) ||
('node' in guest && guest.node && guest.node.toLowerCase().includes(term)) ||
('status' in guest && guest.status && guest.status.toLowerCase().includes(term)),
);
}
// If no conditions, match all
if (query.conditions.length === 0) {
return true;
}
// Evaluate conditions
const results = query.conditions.map((condition) => {
if ('operator' in condition) {
return evaluateMetricCondition(guest, condition);
} else {
return evaluateTextCondition(guest, condition);
}
});
// Apply logical operator
if (query.logicalOperator === 'AND') {
return results.every((result) => result);
} else {
return results.some((result) => result);
}
}
// Evaluate a filter stack against a guest or backup item
export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack): boolean {
if (stack.filters.length === 0) {
return true;
}
const results = stack.filters.map((filter) => {
if (filter.type === 'metric' && filter.field && filter.operator && filter.value !== undefined) {
const condition: MetricCondition = {
field: filter.field as MetricCondition['field'],
operator: filter.operator,
value: filter.value as number,
};
return evaluateMetricCondition(guest, condition);
} else if (filter.type === 'text' && filter.field && filter.value) {
// Handle tags field specifically since it might not be in the type union
if (filter.field === 'tags') {
const condition: TextCondition = {
field: 'tags',
value: filter.value as string,
};
return evaluateTextCondition(guest, condition);
}
const condition: TextCondition = {
field: filter.field as TextCondition['field'],
value: filter.value as string,
};
return evaluateTextCondition(guest, condition);
} else if (filter.type === 'raw' && filter.rawText) {
const term = filter.rawText.toLowerCase();
// Check name, vmid, node, status, and tags for raw text matches
const nameMatch =
'name' in guest &&
typeof guest.name === 'string' &&
guest.name.toLowerCase().includes(term);
const vmidMatch = 'vmid' in guest && !!guest.vmid && guest.vmid.toString().includes(term);
const nodeMatch =
'node' in guest &&
typeof guest.node === 'string' &&
guest.node.toLowerCase().includes(term);
const statusMatch =
'status' in guest &&
typeof guest.status === 'string' &&
guest.status.toLowerCase().includes(term);
// Also check if any tags contain the search term
const tagMatch =
'tags' in guest && Array.isArray(guest.tags)
? guest.tags
.filter((tag): tag is string => typeof tag === 'string')
.some((tag) => tag.toLowerCase().includes(term))
: false;
return nameMatch || vmidMatch || nodeMatch || statusMatch || tagMatch;
}
return true;
});
// If only one filter, return its result
if (results.length === 1) {
return results[0];
}
// Apply operators between filters
let result = results[0];
for (let i = 0; i < stack.operators.length && i < results.length - 1; i++) {
const operator = stack.operators[i];
const nextResult = results[i + 1];
if (operator === 'AND') {
result = result && nextResult;
} else {
result = result || nextResult;
}
}
return result;
}