417 lines
13 KiB
TypeScript
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;
|
|
}
|