Pulse/frontend-modern/src/utils/searchQuery.ts
rcourtman 6eb1a10d9b Refactor: Code cleanup and localStorage consolidation
This commit includes comprehensive codebase cleanup and refactoring:

## Code Cleanup
- Remove dead TypeScript code (types/monitoring.ts - 194 lines duplicate)
- Remove unused Go functions (GetClusterNodes, MigratePassword, GetClusterHealthInfo)
- Clean up commented-out code blocks across multiple files
- Remove unused TypeScript exports (helpTextClass, private tag color helpers)
- Delete obsolete test files and components

## localStorage Consolidation
- Centralize all storage keys into STORAGE_KEYS constant
- Update 5 files to use centralized keys:
  * utils/apiClient.ts (AUTH, LEGACY_TOKEN)
  * components/Dashboard/Dashboard.tsx (GUEST_METADATA)
  * components/Docker/DockerHosts.tsx (DOCKER_METADATA)
  * App.tsx (PLATFORMS_SEEN)
  * stores/updates.ts (UPDATES)
- Benefits: Single source of truth, prevents typos, better maintainability

## Previous Work Committed
- Docker monitoring improvements and disk metrics
- Security enhancements and setup fixes
- API refactoring and cleanup
- Documentation updates
- Build system improvements

## Testing
- All frontend tests pass (29 tests)
- All Go tests pass (15 packages)
- Production build successful
- Zero breaking changes

Total: 186 files changed, 5825 insertions(+), 11602 deletions(-)
2025-11-04 21:50:46 +00:00

296 lines
9.3 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;
}
// 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 };
}
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;
}
}
// 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;
}