Fix frontend typings and update notifications layout

This commit is contained in:
rcourtman
2025-09-29 12:48:04 +00:00
parent 2173da1b92
commit b12b4ba4ef
21 changed files with 394 additions and 382 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ function App() {
? savedDarkMode === 'true'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
const [darkMode, setDarkMode] = createSignal(initialDarkMode);
const [hasLoadedServerTheme, setHasLoadedServerTheme] = createSignal(false);
const [, setHasLoadedServerTheme] = createSignal(false);
// Apply dark mode immediately on initialization
if (initialDarkMode) {
+2 -2
View File
@@ -1,6 +1,6 @@
export default function Test() {
return <div style={{ padding: '20px', fontSize: '24px' }}>
return <div style={{ padding: '20px', 'font-size': '24px' }}>
<h1>TEST - APP IS WORKING!</h1>
<p>If you see this, the basic app infrastructure works.</p>
</div>;
}
}
@@ -3,15 +3,15 @@ import type { Alert } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface Resource {
export interface Resource {
id: string;
name: string;
node?: string;
instance?: string;
type?: string;
resourceType?: string;
thresholds?: Record<string, number>;
defaults?: Record<string, number>;
thresholds?: Record<string, number | undefined>;
defaults?: Record<string, number | undefined>;
disabled?: boolean;
disableConnectivity?: boolean;
hasOverride?: boolean;
@@ -28,15 +28,15 @@ interface ResourceTableProps {
groupedResources?: Record<string, Resource[]>;
columns: string[];
activeAlerts?: Record<string, Alert>;
onEdit: (resourceId: string, thresholds: Record<string, number>, defaults: Record<string, number>) => void;
onEdit: (resourceId: string, thresholds: Record<string, number | undefined>, defaults: Record<string, number | undefined>) => void;
onSaveEdit: (resourceId: string) => void;
onCancelEdit: () => void;
onRemoveOverride: (resourceId: string) => void;
onToggleDisabled?: (resourceId: string) => void;
onToggleNodeConnectivity?: (nodeId: string) => void;
editingId: () => string | null;
editingThresholds: () => Record<string, number>;
setEditingThresholds: (value: Record<string, number>) => void;
editingThresholds: () => Record<string, number | undefined>;
setEditingThresholds: (value: Record<string, number | undefined>) => void;
formatMetricValue: (metric: string, value: number | undefined) => string;
hasActiveAlert: (resourceId: string, metric: string) => boolean;
}
@@ -117,15 +117,24 @@ export function ResourceTable(props: ResourceTableProps) {
<For each={resources}>
{(resource) => {
const isEditing = () => props.editingId() === resource.id;
const thresholds = () => isEditing() ? props.editingThresholds() : resource.thresholds;
const thresholds = (): Record<string, number | undefined> => {
if (isEditing()) {
return props.editingThresholds();
}
return resource.thresholds ?? {};
};
const displayValue = (metric: string): number => {
const thresh = thresholds();
const defaults = resource.defaults || {};
if (isEditing()) {
const val = thresh?.[metric] || defaults[metric];
return typeof val === 'string' ? (parseFloat(val) || 0) : (val || 0);
const val = thresh[metric] ?? defaults[metric];
return typeof val === 'number' ? val : Number(val) || 0;
}
return resource.thresholds?.[metric] || defaults[metric] || 0;
const liveValue = resource.thresholds?.[metric];
if (typeof liveValue === 'number') {
return liveValue;
}
return typeof defaults[metric] === 'number' ? (defaults[metric] as number) : 0;
};
const isOverridden = (metric: string) => {
return resource.thresholds?.[metric] !== undefined && resource.thresholds?.[metric] !== null;
@@ -308,7 +317,11 @@ export function ResourceTable(props: ResourceTableProps) {
</>
}>
<button type="button"
onClick={() => props.onEdit(resource.id, resource.thresholds || {}, resource.defaults || {})}
onClick={() => props.onEdit(
resource.id,
resource.thresholds ? { ...resource.thresholds } : {},
resource.defaults ? { ...resource.defaults } : {}
)}
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
title="Edit thresholds"
>
@@ -343,7 +356,12 @@ export function ResourceTable(props: ResourceTableProps) {
<For each={props.resources}>
{(resource) => {
const isEditing = () => props.editingId() === resource.id;
const thresholds = () => isEditing() ? props.editingThresholds() : resource.thresholds;
const thresholds = (): Record<string, number | undefined> => {
if (isEditing()) {
return props.editingThresholds();
}
return resource.thresholds ?? {};
};
const displayValue = (metric: string): number => {
const thresh = thresholds();
const defaults = resource.defaults || {};
@@ -440,11 +458,23 @@ export function ResourceTable(props: ResourceTableProps) {
type="number"
min="-1"
max={metric.includes('disk') || metric.includes('memory') || metric.includes('cpu') || metric === 'usage' ? 100 : 10000}
value={thresholds()[metric] || ''}
onInput={(e) => props.setEditingThresholds({
...props.editingThresholds(),
[metric]: parseInt(e.currentTarget.value) || undefined
})}
value={(() => {
const currentThresholds = thresholds();
const rawValue = currentThresholds[metric];
return rawValue ?? '';
})()}
onInput={(e) => {
const parsed = e.currentTarget.value.trim();
let nextValue: number | undefined;
if (parsed !== '') {
const numeric = Number(parsed);
nextValue = Number.isFinite(numeric) ? numeric : undefined;
}
props.setEditingThresholds({
...props.editingThresholds(),
[metric]: nextValue
});
}}
class="w-14 px-1 py-0.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
@@ -535,7 +565,11 @@ export function ResourceTable(props: ResourceTableProps) {
</>
}>
<button type="button"
onClick={() => props.onEdit(resource.id, resource.thresholds || {}, resource.defaults || {})}
onClick={() => props.onEdit(
resource.id,
resource.thresholds ? { ...resource.thresholds } : {},
resource.defaults ? { ...resource.defaults } : {}
)}
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
title="Edit thresholds"
>
@@ -1,6 +1,7 @@
import { createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
import type { VM, Container, Node, Alert, Storage, PBSInstance } from '@/types/api';
import { ResourceTable } from './ResourceTable';
import type { RawOverrideConfig } from '@/types/alerts';
import { ResourceTable, Resource } from './ResourceTable';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
@@ -41,8 +42,8 @@ interface SimpleThresholds {
interface ThresholdsTableProps {
overrides: () => Override[];
setOverrides: (overrides: Override[]) => void;
rawOverridesConfig: () => Record<string, unknown>;
setRawOverridesConfig: (config: Record<string, unknown>) => void;
rawOverridesConfig: () => Record<string, RawOverrideConfig>;
setRawOverridesConfig: (config: Record<string, RawOverrideConfig>) => void;
allGuests: () => (VM | Container)[];
nodes: Node[];
storage: Storage[];
@@ -64,7 +65,7 @@ interface ThresholdsTableProps {
export function ThresholdsTable(props: ThresholdsTableProps) {
const [searchTerm, setSearchTerm] = createSignal('');
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editingThresholds, setEditingThresholds] = createSignal<Record<string, number>>({});
const [editingThresholds, setEditingThresholds] = createSignal<Record<string, number | undefined>>({});
let searchInputRef: HTMLInputElement | undefined;
@@ -135,10 +136,10 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
};
// Process nodes with their overrides
const nodesWithOverrides = createMemo((prev) => {
const nodesWithOverrides = createMemo<Resource[]>((prev = []) => {
// If we're currently editing, return the previous value to avoid re-renders
if (editingId()) {
return prev || [];
return prev;
}
const search = searchTerm().toLowerCase();
@@ -173,13 +174,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
return nodes.filter(n => n.name.toLowerCase().includes(search));
}
return nodes;
});
}, []);
// Process guests with their overrides and group by node
const guestsGroupedByNode = createMemo((prev) => {
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
// If we're currently editing, return the previous value to avoid re-renders
if (editingId()) {
return prev || {};
return prev;
}
const search = searchTerm().toLowerCase();
@@ -225,7 +226,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
: guests;
// Group by node
const grouped: Record<string, typeof filteredGuests> = {};
const grouped: Record<string, Resource[]> = {};
filteredGuests.forEach(guest => {
const node = guest.node || 'Unknown';
if (!grouped[node]) {
@@ -243,13 +244,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
});
return grouped;
});
}, {});
// Process PBS servers with their overrides
const pbsServersWithOverrides = createMemo((prev) => {
const pbsServersWithOverrides = createMemo<Resource[]>((prev = []) => {
// If we're currently editing, return the previous value to avoid re-renders
if (editingId()) {
return prev || [];
return prev;
}
const search = searchTerm().toLowerCase();
@@ -258,7 +259,9 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
// Get PBS instances from props
const pbsInstances = props.pbsInstances || [];
const pbsServers = pbsInstances.filter((pbs) => (pbs.cpu || 0) > 0 || (pbs.memory?.usage || 0) > 0).map((pbs) => {
const pbsServers = pbsInstances
.filter((pbs) => (pbs.cpu || 0) > 0 || (pbs.memory || 0) > 0)
.map((pbs) => {
// PBS IDs already have "pbs-" prefix from backend, don't double it
const pbsId = pbs.id;
const override = overridesMap.get(pbsId);
@@ -302,13 +305,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
);
}
return pbsServers;
});
}, []);
// Process storage with their overrides
const storageWithOverrides = createMemo((prev) => {
const storageWithOverrides = createMemo<Resource[]>((prev = []) => {
// If we're currently editing, return the previous value to avoid re-renders
if (editingId()) {
return prev || [];
return prev;
}
const search = searchTerm().toLowerCase();
@@ -346,10 +349,14 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
);
}
return storageDevices;
});
}, []);
const startEditing = (resourceId: string, currentThresholds: Record<string, number>, defaults: Record<string, number>) => {
const startEditing = (
resourceId: string,
currentThresholds: Record<string, number | undefined>,
defaults: Record<string, number | undefined>
) => {
setEditingId(resourceId);
// Merge defaults with overrides for editing
const mergedThresholds = { ...defaults, ...currentThresholds };
@@ -364,14 +371,14 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
if (!resource) return;
const editedThresholds = editingThresholds();
const defaultThresholds = resource.defaults;
const defaultThresholds = (resource.defaults ?? {}) as Record<string, number | undefined>;
// Only include values that differ from defaults
const overrideThresholds: Record<string, number> = {};
Object.keys(editedThresholds).forEach(key => {
const editedValue = editedThresholds[key];
const defaultValue = defaultThresholds[key as keyof typeof defaultThresholds];
if (editedValue !== defaultValue && editedValue !== undefined && editedValue !== '') {
if (editedValue !== undefined && editedValue !== defaultValue) {
overrideThresholds[key] = editedValue;
}
});
@@ -417,13 +424,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
}
// Update raw config
const newRawConfig = { ...props.rawOverridesConfig() };
const hysteresisThresholds: Record<string, any> = {};
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
const hysteresisThresholds: RawOverrideConfig = {};
Object.entries(overrideThresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
hysteresisThresholds[metric] = {
trigger: value,
clear: Math.max(0, (value as number) - 5)
clear: Math.max(0, value - 5)
};
}
});
@@ -505,15 +512,15 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
}
// Update raw config
const newRawConfig = { ...props.rawOverridesConfig() };
const hysteresisThresholds: Record<string, any> = {};
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
const hysteresisThresholds: RawOverrideConfig = {};
// Only add threshold overrides that differ from defaults
Object.entries(override.thresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
if (typeof value === 'number') {
hysteresisThresholds[metric] = {
trigger: value,
clear: Math.max(0, (value as number) - 5)
clear: Math.max(0, value - 5)
};
}
});
@@ -9,31 +9,10 @@ import { BackupsFilter } from './BackupsFilter';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { SectionHeader } from '@/components/shared/SectionHeader';
import type { BackupType, GuestType, UnifiedBackup } from '@/types/backups';
type BackupType = 'snapshot' | 'local' | 'remote';
type GuestType = 'VM' | 'LXC' | 'Host' | 'Template' | 'ISO';
type FilterableGuestType = 'VM' | 'LXC' | 'Host';
interface UnifiedBackup {
backupType: BackupType;
vmid: number;
name: string;
type: GuestType;
node: string;
backupTime: number;
backupName: string;
description: string;
status: string;
size: number | null;
storage: string | null;
datastore: string | null;
namespace: string | null;
verified: boolean | null;
protected: boolean;
encrypted?: boolean;
owner?: string;
}
// Types for PBS backups - temporarily disabled to avoid unused warnings
// type PBSBackup = any;
// type PBSSnapshot = any;
@@ -1008,7 +987,6 @@ const UnifiedBackups: Component = () => {
// Only lock if we're setting a filter, unlock if clearing
setIsSearchLocked(namespaceFilter !== '');
}}
filteredBackups={(searchTerm() || backupTypeFilter() !== 'all') ? filteredData() : undefined}
searchTerm={searchTerm()}
/>
@@ -15,7 +15,6 @@ import { GuestMetadataAPI } from '@/api/guestMetadata';
import type { GuestMetadata } from '@/api/guestMetadata';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface DashboardProps {
vms: VM[];
@@ -31,11 +31,6 @@ interface GuestRowProps {
export function GuestRow(props: GuestRowProps) {
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
// Create guest ID for metadata
const guestId = createMemo(() => {
return props.guest.id || `${props.guest.node}-${props.guest.vmid}`;
});
// Update custom URL when prop changes
createEffect(() => {
setCustomUrl(props.customUrl);
@@ -146,7 +141,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Tag badges */}
<TagBadges
tags={props.guest.tags}
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
maxVisible={3}
onTagClick={props.onTagClick}
activeSearch={props.activeSearch}
@@ -240,4 +235,4 @@ export function GuestRow(props: GuestRowProps) {
</tr>
);
}
}
@@ -2,7 +2,6 @@ import { Component, Show, createMemo } from 'solid-js';
import type { PBSInstance } from '@/types/api';
import { formatUptime, formatBytes } from '@/utils/format';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface PBSCardProps {
instance: PBSInstance;
@@ -4,7 +4,7 @@ import { getTagColorWithSpecial } from '@/utils/tagColors';
import { useDarkMode } from '@/App';
interface TagBadgesProps {
tags: string[];
tags?: string[];
maxVisible?: number;
isDarkMode?: boolean;
onTagClick?: (tag: string) => void;
@@ -133,4 +133,4 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
</Portal>
</Show>
);
};
};
@@ -29,6 +29,16 @@ interface DiscoveredServer {
release?: string;
}
type RawDiscoveredServer = {
ip?: string;
port?: number;
type?: string;
version?: string;
hostname?: string;
name?: string;
release?: string;
};
interface ClusterEndpoint {
Host?: string;
IP?: string;
@@ -227,54 +237,99 @@ const Settings: Component = () => {
}
};
const updateDiscoveredNodesFromServers = (servers: RawDiscoveredServer[] | undefined | null, options: { merge?: boolean } = {}) => {
const { merge = false } = options;
if (!servers || servers.length === 0) {
if (!merge) {
setDiscoveredNodes([]);
}
return;
}
// Prepare sets of configured hosts and cluster member IPs to filter duplicates
const configuredHosts = new Set<string>();
const clusterMemberIPs = new Set<string>();
nodes().forEach((n) => {
const cleanedHost = n.host.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
configuredHosts.add(cleanedHost.toLowerCase());
if (n.type === 'pve' && 'isCluster' in n && n.isCluster && 'clusterEndpoints' in n && n.clusterEndpoints) {
n.clusterEndpoints.forEach((endpoint: ClusterEndpoint) => {
if (endpoint.IP) {
clusterMemberIPs.add(endpoint.IP.toLowerCase());
}
if (endpoint.Host) {
clusterMemberIPs.add(endpoint.Host.toLowerCase());
}
});
}
});
const normalized = servers
.map((server): DiscoveredServer | null => {
const ip = (server.ip || '').trim();
const type = (server.type || '').toLowerCase();
const port = typeof server.port === 'number' ? server.port : type === 'pbs' ? 8007 : 8006;
if (!ip || (type !== 'pve' && type !== 'pbs')) {
return null;
}
const hostname = (server.hostname || server.name || '').trim();
const version = (server.version || '').trim();
const release = (server.release || '').trim();
return {
ip,
port,
type: type as 'pve' | 'pbs',
version: version || 'Unknown',
hostname: hostname || undefined,
release: release || undefined,
};
})
.filter((server): server is DiscoveredServer => server !== null);
const filtered = normalized.filter((server) => {
const serverIP = server.ip.toLowerCase();
const serverHostname = server.hostname?.toLowerCase();
if (configuredHosts.has(serverIP) || (serverHostname && configuredHosts.has(serverHostname))) {
return false;
}
if (clusterMemberIPs.has(serverIP) || (serverHostname && clusterMemberIPs.has(serverHostname))) {
return false;
}
return true;
});
if (merge) {
setDiscoveredNodes((prev) => {
const existingMap = new Map(prev.map((item) => [`${item.ip}:${item.port}`, item]));
filtered.forEach((server) => {
existingMap.set(`${server.ip}:${server.port}`, server);
});
return Array.from(existingMap.values());
});
} else {
setDiscoveredNodes(filtered);
}
};
const loadDiscoveredNodes = async () => {
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/discover');
if (response.ok) {
const data = await response.json();
if (data.servers && Array.isArray(data.servers)) {
// Get all configured hosts and cluster member IPs
const configuredHosts = new Set<string>();
const clusterMemberIPs = new Set<string>();
nodes().forEach(n => {
// Add the main host
const host = n.host.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
configuredHosts.add(host.toLowerCase());
// If it's a cluster, add all member IPs
if (n.type === 'pve' && 'isCluster' in n && n.isCluster && 'clusterEndpoints' in n && n.clusterEndpoints) {
n.clusterEndpoints.forEach((endpoint: ClusterEndpoint) => {
if (endpoint.IP) {
clusterMemberIPs.add(endpoint.IP.toLowerCase());
}
if (endpoint.Host) {
clusterMemberIPs.add(endpoint.Host.toLowerCase());
}
});
}
});
// Filter out nodes that are already configured or part of a cluster
const filtered = data.servers.filter((server: DiscoveredServer) => {
const serverIP = server.ip?.toLowerCase();
const serverHostname = server.hostname?.toLowerCase();
// Check if this server is already configured directly
if ((serverIP && configuredHosts.has(serverIP)) || (serverHostname && configuredHosts.has(serverHostname))) {
return false;
}
// Check if this server is part of a configured cluster
if ((serverIP && clusterMemberIPs.has(serverIP)) || (serverHostname && clusterMemberIPs.has(serverHostname))) {
return false;
}
return true;
});
setDiscoveredNodes(filtered);
if (Array.isArray(data.servers)) {
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]);
} else {
updateDiscoveredNodesFromServers([]);
}
}
} catch (error) {
@@ -299,27 +354,16 @@ const Settings: Component = () => {
});
const unsubscribeDiscovery = eventBus.on('discovery_updated', (data) => {
// If this is an immediate update (from node deletion), merge with existing
if (data && data.immediate && data.servers) {
setDiscoveredNodes(prev => {
// Create a map of existing servers by IP:port
const existingMap = new Map(prev.map(s => [`${s.ip}:${s.port}`, s]));
// Add/update the new servers
data.servers.forEach((server) => {
const discoveredServer: DiscoveredServer = {
...server,
type: server.type as 'pbs' | 'pve'
};
existingMap.set(`${server.ip}:${server.port}`, discoveredServer);
});
// Convert back to array
return Array.from(existingMap.values());
});
} else {
// Full discovery update - reload from API
loadDiscoveredNodes();
if (!data) {
updateDiscoveredNodesFromServers([]);
return;
}
if (Array.isArray(data.servers)) {
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[], { merge: !!data.immediate });
} else if (!data.immediate) {
// Ensure we clear stale results when the update explicitly reports no servers
updateDiscoveredNodesFromServers([]);
}
});
@@ -2088,7 +2132,7 @@ const Settings: Component = () => {
{(() => {
const sanitizeForGitHub = (data: Record<string, unknown>) => {
// Deep clone the data
const sanitized = JSON.parse(JSON.stringify(data));
const sanitized = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
// Sanitize IP addresses (keep first octet for network type identification)
const sanitizeIP = (ip: string) => {
@@ -2111,78 +2155,112 @@ const Settings: Component = () => {
// Sanitize nodes
if (sanitized.nodes) {
sanitized.nodes = (sanitized.nodes as Array<Record<string, unknown>>).map((node, index: number) => ({
...node,
id: `${node.type}-${index}`,
name: sanitizeHostname(node.name),
host: node.host ? node.host.replace(/https?:\/\/[^:\/]+/, 'https://REDACTED') : node.host,
tokenName: node.tokenName ? 'token-REDACTED' : node.tokenName,
clusterName: node.clusterName ? 'cluster-REDACTED' : node.clusterName,
clusterEndpoints: node.clusterEndpoints ? (node.clusterEndpoints as Array<Record<string, unknown>>).map((ep, epIndex: number) => ({
...ep,
NodeName: `node-${epIndex + 1}`,
Host: `node-${epIndex + 1}`,
IP: sanitizeIP(ep.IP)
})) : node.clusterEndpoints
}));
sanitized.nodes = (sanitized.nodes as Array<Record<string, unknown>>).map((node, index: number) => {
const nodeType = typeof node.type === 'string' ? (node.type as string) : 'node';
const nodeName = typeof node.name === 'string' ? (node.name as string) : '';
const nodeHost = typeof node.host === 'string' ? (node.host as string) : '';
const tokenName = typeof node.tokenName === 'string' ? (node.tokenName as string) : undefined;
const clusterName = typeof node.clusterName === 'string' ? (node.clusterName as string) : undefined;
const clusterEndpoints = Array.isArray(node.clusterEndpoints)
? (node.clusterEndpoints as Array<Record<string, unknown>>).map((ep, epIndex: number) => ({
...ep,
NodeName: `node-${epIndex + 1}`,
Host: `node-${epIndex + 1}`,
IP: sanitizeIP(typeof ep.IP === 'string' ? ep.IP : '')
}))
: node.clusterEndpoints;
return {
...node,
id: `${nodeType}-${index}`,
name: sanitizeHostname(nodeName),
host: nodeHost ? nodeHost.replace(/https?:\/\/[^:\/]+/, 'https://REDACTED') : nodeHost,
tokenName: tokenName ? 'token-REDACTED' : tokenName,
clusterName: clusterName ? 'cluster-REDACTED' : clusterName,
clusterEndpoints
};
});
}
// Sanitize storage
if (sanitized.storage) {
sanitized.storage = (sanitized.storage as Array<Record<string, unknown>>).map((s, index: number) => ({
...s,
id: `storage-${index}`,
node: sanitizeHostname(s.node),
name: `storage-${index}`
}));
sanitized.storage = (sanitized.storage as Array<Record<string, unknown>>).map((s, index: number) => {
const storageNode = typeof s.node === 'string' ? s.node : '';
return {
...s,
id: `storage-${index}`,
node: sanitizeHostname(storageNode),
name: `storage-${index}`
};
});
}
// Sanitize backups
if (sanitized.backups) {
const backups = sanitized.backups as Record<string, unknown> | undefined;
if (backups) {
// Sanitize PVE backup tasks
if (sanitized.backups.pveBackupTasks) {
sanitized.backups.pveBackupTasks = (sanitized.backups.pveBackupTasks as Array<Record<string, unknown>>).map((b, index: number) => ({
...b,
node: sanitizeHostname(b.node),
storage: `storage-${index}`,
vmid: b.vmid ? `vm-${b.vmid}` : b.vmid
}));
if (Array.isArray(backups.pveBackupTasks)) {
backups.pveBackupTasks = (backups.pveBackupTasks as Array<Record<string, unknown>>).map((b, index: number) => {
const backupNode = typeof b.node === 'string' ? b.node : '';
const backupVmid = typeof b.vmid === 'number' ? b.vmid : undefined;
return {
...b,
node: sanitizeHostname(backupNode),
storage: `storage-${index}`,
vmid: backupVmid !== undefined ? `vm-${backupVmid}` : backupVmid
};
});
}
// Sanitize PVE storage backups
if (sanitized.backups.pveStorageBackups) {
sanitized.backups.pveStorageBackups = (sanitized.backups.pveStorageBackups as Array<Record<string, unknown>>).map((b, index: number) => ({
...b,
node: sanitizeHostname(b.node),
storage: `storage-${index}`,
vmid: b.vmid ? `vm-${b.vmid}` : b.vmid,
volid: b.volid ? `vol-REDACTED` : b.volid
}));
if (Array.isArray(backups.pveStorageBackups)) {
backups.pveStorageBackups = (backups.pveStorageBackups as Array<Record<string, unknown>>).map((b, index: number) => {
const backupNode = typeof b.node === 'string' ? b.node : '';
const backupVmid = typeof b.vmid === 'number' ? b.vmid : undefined;
const volid = typeof b.volid === 'string' ? b.volid : undefined;
return {
...b,
node: sanitizeHostname(backupNode),
storage: `storage-${index}`,
vmid: backupVmid !== undefined ? `vm-${backupVmid}` : backupVmid,
volid: volid ? 'vol-REDACTED' : volid
};
});
}
// Sanitize PBS backups
if (sanitized.backups.pbsBackups) {
sanitized.backups.pbsBackups = (sanitized.backups.pbsBackups as Array<Record<string, unknown>>).map((b, index: number) => ({
...b,
datastore: `datastore-${index}`,
backupId: b.backupId ? `backup-${index}` : b.backupId,
vmName: b.vmName ? `vm-REDACTED` : b.vmName
}));
if (Array.isArray(backups.pbsBackups)) {
backups.pbsBackups = (backups.pbsBackups as Array<Record<string, unknown>>).map((b, index: number) => {
const backupId = typeof b.backupId === 'string' ? b.backupId : undefined;
const vmName = typeof b.vmName === 'string' ? b.vmName : undefined;
return {
...b,
datastore: `datastore-${index}`,
backupId: backupId ? `backup-${index}` : backupId,
vmName: vmName ? 'vm-REDACTED' : vmName
};
});
}
}
// Sanitize active alerts
if (sanitized.activeAlerts) {
sanitized.activeAlerts = (sanitized.activeAlerts as Array<Record<string, unknown>>).map((alert) => ({
...alert,
node: sanitizeHostname(alert.node),
details: alert.details ? alert.details.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, 'xxx.xxx.xxx.xxx') : alert.details
}));
const activeAlerts = sanitized.activeAlerts as Array<Record<string, unknown>> | undefined;
if (activeAlerts) {
sanitized.activeAlerts = activeAlerts.map((alert) => {
const alertNode = typeof alert.node === 'string' ? alert.node : '';
const details = typeof alert.details === 'string' ? alert.details : undefined;
return {
...alert,
node: sanitizeHostname(alertNode),
details: details ? details.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, 'xxx.xxx.xxx.xxx') : details
};
});
}
// Sanitize websocket URL
if (sanitized.websocket?.url) {
sanitized.websocket.url = sanitized.websocket.url.replace(/\/\/[^\/]+/, '//REDACTED');
const websocketInfo = sanitized.websocket as Record<string, unknown> | undefined;
if (websocketInfo && typeof websocketInfo.url === 'string') {
websocketInfo.url = websocketInfo.url.replace(/\/\/[^\/]+/, '//REDACTED');
}
// Add sanitization notice
@@ -2195,7 +2273,7 @@ const Settings: Component = () => {
let diagnostics: Record<string, unknown> = {
timestamp: new Date().toISOString(),
version: '2.1.0',
pulseVersion: state.version || 'unknown',
pulseVersion: state.stats?.version || 'unknown',
environment: {
userAgent: navigator.userAgent,
platform: navigator.platform,
@@ -2240,7 +2318,7 @@ const Settings: Component = () => {
cpu: n.cpu,
memory: n.memory,
uptime: n.uptime,
version: n.version
version: n.pveVersion ?? n.kernelVersion
})) || [],
storage: state.storage?.map(s => ({
id: s.id,
@@ -2265,14 +2343,14 @@ const Settings: Component = () => {
// Physical disks - critical for troubleshooting
physicalDisks: state.physicalDisks?.map(d => ({
node: d.node,
device: d.device,
device: d.device || d.devPath,
model: d.model,
size: d.size,
type: d.type,
health: d.health,
wearout: d.wearout,
rpm: d.rpm,
smart: d.smart
smart: d.smart ?? null
})) || [],
backups: {
pveBackupTasks: state.pveBackups?.backupTasks?.slice(0, 10) || [],
@@ -2287,10 +2365,6 @@ const Settings: Component = () => {
apiCallDuration: state.performance?.apiCallDuration || {}
},
activeAlerts: state.activeAlerts?.slice(0, 20) || [],
// Alert configuration - helps debug threshold save issues
alertConfig: state.alertConfig || null,
// Recent errors if any
recentErrors: state.errors?.slice(0, 20) || [],
settings: {
}
};
@@ -1,7 +1,6 @@
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import { Component, For, Show, createMemo } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { formatBytes } from '@/utils/format';
import { useWebSocket } from '@/App';
import type { PhysicalDisk } from '@/types/api';
interface DiskListProps {
@@ -11,8 +10,6 @@ interface DiskListProps {
}
export const DiskList: Component<DiskListProps> = (props) => {
const { state } = useWebSocket();
// Filter disks based on selected node and search term
const filteredDisks = createMemo(() => {
let disks = props.disks || [];
@@ -195,4 +192,4 @@ export const DiskList: Component<DiskListProps> = (props) => {
</Show>
</div>
);
};
};
@@ -2,7 +2,6 @@ import { Component, For, Show, createSignal, createMemo, createEffect } from 'so
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { formatBytes } from '@/utils/format';
import { createTooltipSystem } from '@/components/shared/Tooltip';
import type { Storage as StorageType } from '@/types/api';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
@@ -10,7 +9,6 @@ import { StorageFilter } from './StorageFilter';
import { DiskList } from './DiskList';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { SectionHeader } from '@/components/shared/SectionHeader';
const Storage: Component = () => {
@@ -23,9 +21,6 @@ const Storage: Component = () => {
// const [sortKey, setSortKey] = createSignal('name');
// const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Create tooltip system
const TooltipComponent = createTooltipSystem();
// Create a mapping from node name to host URL
const nodeHostMap = createMemo(() => {
const map: Record<string, string> = {};
@@ -407,14 +402,16 @@ const Storage: Component = () => {
const rowClass = `${isDisabled ? 'opacity-60' : ''} ${alertBg} hover:shadow-sm transition-all duration-200`;
// Create row style with inset box-shadow for alert border
const rowStyle = createMemo(() => {
const styles: Record<string, string> = {};
if (alertStyles.hasAlert) {
const color = alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
styles['box-shadow'] = `inset 4px 0 0 0 ${color}`;
}
return styles;
});
const rowStyle = createMemo(() => {
const styles: Record<string, string> = {};
if (alertStyles.hasAlert) {
const color = alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
styles['box-shadow'] = `inset 4px 0 0 0 ${color}`;
}
return styles;
});
const zfsPool = storage.zfsPool;
return (
<>
@@ -428,17 +425,17 @@ const Storage: Component = () => {
{storage.name}
</span>
{/* ZFS Health Badge */}
<Show when={storage.zfsPool && storage.zfsPool.state !== 'ONLINE'}>
<Show when={zfsPool && zfsPool.state !== 'ONLINE'}>
<span class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
storage.zfsPool.state === 'DEGRADED'
zfsPool?.state === 'DEGRADED'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}>
{storage.zfsPool.state}
{zfsPool?.state}
</span>
</Show>
{/* ZFS Error Badge */}
<Show when={storage.zfsPool && (storage.zfsPool.readErrors > 0 || storage.zfsPool.writeErrors > 0 || storage.zfsPool.checksumErrors > 0)}>
<Show when={zfsPool && (zfsPool.readErrors > 0 || zfsPool.writeErrors > 0 || zfsPool.checksumErrors > 0)}>
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">
ERRORS
</span>
@@ -8,7 +8,7 @@ type SectionHeaderProps = {
size?: 'sm' | 'md' | 'lg';
titleClass?: string;
descriptionClass?: string;
} & JSX.HTMLAttributes<HTMLDivElement>;
} & Omit<JSX.HTMLAttributes<HTMLDivElement>, 'title'>;
export function SectionHeader(props: SectionHeaderProps) {
const merged = mergeProps({ align: 'left' as const, size: 'md' as const, titleClass: '', descriptionClass: '' }, props);
@@ -10,7 +10,7 @@ export function Toggle(props: ToggleProps) {
const merged = mergeProps({ containerClass: '' }, props);
const [local, rest] = splitProps(merged, ['label', 'description', 'containerClass', 'class', 'disabled']);
const isDisabled = () => Boolean(local.disabled ?? rest.disabled);
const isDisabled = () => Boolean(local.disabled);
const isChecked = () => {
const value = rest.checked as unknown;
if (typeof value === 'function') {
@@ -26,7 +26,7 @@ export function Toggle(props: ToggleProps) {
return (
<label class={`flex items-center gap-3 ${local.containerClass ?? ''} ${local.class ?? ''}`.trim()}>
<span class={`relative inline-flex h-6 w-11 flex-shrink-0 items-center ${isDisabled() ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}>
<input type="checkbox" class="sr-only" {...rest} />
<input type="checkbox" class="sr-only" disabled={local.disabled} {...rest} />
<span
class={`absolute inset-0 rounded-full transition ${
isChecked()
@@ -1,7 +1,7 @@
import { Component, createSignal, createEffect, createMemo, onMount, onCleanup } from 'solid-js';
import { useWebSocket } from '@/App';
import { NodeSummaryTable } from './NodeSummaryTable';
import type { Node, VM, Container, Storage, PBSBackup } from '@/types/api';
import type { Node, VM, Container, Storage } from '@/types/api';
interface UnifiedNodeSelectorProps {
currentTab: 'dashboard' | 'storage' | 'backups';
@@ -11,7 +11,6 @@ interface UnifiedNodeSelectorProps {
filteredVms?: VM[];
filteredContainers?: Container[];
filteredStorage?: Storage[];
filteredBackups?: PBSBackup[];
searchTerm?: string;
}
@@ -109,4 +108,4 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
/>
</div>
);
};
};
+8 -32
View File
@@ -3,6 +3,7 @@ import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
import { CustomRulesTab } from '@/components/Alerts/CustomRulesTab';
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
import type { RawOverrideConfig } from '@/types/alerts';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
@@ -13,7 +14,7 @@ import { showSuccess, showError } from '@/utils/toast';
import { AlertsAPI } from '@/api/alerts';
import { NotificationsAPI, Webhook } from '@/api/notifications';
import type { EmailConfig } from '@/api/notifications';
import type { HysteresisThreshold, AlertThresholds } from '@/types/alerts';
import type { HysteresisThreshold } from '@/types/alerts';
import type { Alert, State, VM, Container } from '@/types/api';
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history' | 'custom-rules';
@@ -23,31 +24,6 @@ interface DestinationsRef {
emailConfig?: () => EmailConfig;
}
// ScheduleConfig interface - used for loading schedule configuration
interface ScheduleConfig {
quietHours?: {
enabled: boolean;
start: string;
end: string;
days: Record<string, boolean>;
timezone?: string;
};
cooldown?: number;
groupingWindow?: number;
grouping?: {
enabled: boolean;
window: number;
byNode?: boolean;
byGuest?: boolean;
};
maxAlertsHour?: number;
escalation?: {
enabled: boolean;
levels?: Array<{ after: number; notify: string }>;
};
}
// Override interface for both guests and nodes
interface Override {
id: string; // Full ID (e.g. "Main-node1-105" for guest, "node-node1" for node, "pbs-name" for PBS)
@@ -110,7 +86,7 @@ export function Alerts() {
let destinationsRef: DestinationsRef = {};
const [overrides, setOverrides] = createSignal<Override[]>([]);
const [rawOverridesConfig, setRawOverridesConfig] = createSignal<Record<string, unknown>>({}); // Store raw config
const [rawOverridesConfig, setRawOverridesConfig] = createSignal<Record<string, RawOverrideConfig>>({}); // Store raw config
// Email configuration state moved to parent to persist across tab changes
const [emailConfig, setEmailConfig] = createSignal<UIEmailConfig>({
@@ -476,7 +452,7 @@ export function Alerts() {
};
// Helper to extract trigger values for all thresholds
const extractTriggerValues = (thresholds: AlertThresholds): Record<string, number> => {
const extractTriggerValues = (thresholds: RawOverrideConfig): Record<string, number> => {
const result: Record<string, number> = {};
Object.entries(thresholds).forEach(([key, value]) => {
// Skip non-threshold fields
@@ -1042,14 +1018,14 @@ interface ThresholdsTabProps {
timeThreshold: () => number;
timeThresholds: () => { guest: number; node: number; storage: number; pbs: number };
overrides: () => Override[];
rawOverridesConfig: () => Record<string, unknown>;
rawOverridesConfig: () => Record<string, RawOverrideConfig>;
setGuestDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
setNodeDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
setStorageDefault: (value: number) => void;
setTimeThreshold: (value: number) => void;
setTimeThresholds: (value: { guest: number; node: number; storage: number; pbs: number }) => void;
setOverrides: (value: Override[]) => void;
setRawOverridesConfig: (value: Record<string, unknown>) => void;
setRawOverridesConfig: (value: Record<string, RawOverrideConfig>) => void;
activeAlerts: Record<string, Alert>;
setHasUnsavedChanges: (value: boolean) => void;
}
@@ -1149,7 +1125,7 @@ function DestinationsTab(props: DestinationsTabProps) {
};
return (
<div class="grid w-full max-w-full gap-6 md:gap-8 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)]">
<div class="flex w-full max-w-full flex-col gap-6 md:gap-8">
<SettingsPanel
title="Email notifications"
description="Configure SMTP delivery for alert emails."
@@ -1689,7 +1665,7 @@ function ScheduleTab(props: ScheduleTabProps) {
type="button"
onClick={() => {
const lastLevel = escalation().levels[escalation().levels.length - 1];
const newAfter = lastLevel ? lastLevel.after + 30 : 15;
const newAfter = typeof lastLevel?.after === 'number' ? lastLevel.after + 30 : 15;
setEscalation({
...escalation(),
levels: [...escalation().levels, { after: newAfter, notify: 'all' }]
+3 -3
View File
@@ -169,7 +169,7 @@ export function createWebSocketStore(url: string) {
// Transform tags from comma-separated strings to arrays
const transformedVMs = message.data.vms.map((vm: VM) => {
const originalTags = vm.tags;
let transformedTags;
let transformedTags: string[];
if (originalTags && typeof originalTags === 'string' && originalTags.trim()) {
// String with content - split into array
@@ -195,7 +195,7 @@ export function createWebSocketStore(url: string) {
// Transform tags from comma-separated strings to arrays
const transformedContainers = message.data.containers.map((container: Container) => {
const originalTags = container.tags;
let transformedTags;
let transformedTags: string[];
if (originalTags && typeof originalTags === 'string' && originalTags.trim()) {
// String with content - split into array
@@ -454,4 +454,4 @@ export function createWebSocketStore(url: string) {
}
}
};
}
}
+7 -2
View File
@@ -25,6 +25,11 @@ export interface AlertThresholds {
[key: string]: HysteresisThreshold | number | undefined;
}
export type RawOverrideConfig = AlertThresholds & {
disabled?: boolean;
disableConnectivity?: boolean;
};
export interface CustomAlertRule {
id: string;
name: string;
@@ -53,7 +58,7 @@ export interface AlertConfig {
nodeDefaults: AlertThresholds;
storageDefault: HysteresisThreshold;
customRules?: CustomAlertRule[];
overrides: Record<string, AlertThresholds>; // key: resource ID
overrides: Record<string, RawOverrideConfig>; // key: resource ID
minimumDelta?: number;
suppressionWindow?: number;
hysteresisMargin?: number;
@@ -127,4 +132,4 @@ export interface AlertConfig {
// 0: Global defaults
// 1-99: Reserved for system rules
// 100+: Custom user rules
// 1000+: Guest-specific overrides
// 1000+: Guest-specific overrides
+17 -3
View File
@@ -59,7 +59,7 @@ export interface VM {
uptime: number;
template: boolean;
lastBackup: string;
tags: string[];
tags: string[] | string | null;
lock: string;
lastSeen: string;
}
@@ -83,7 +83,7 @@ export interface Container {
uptime: number;
template: boolean;
lastBackup: string;
tags: string[];
tags: string[] | string | null;
lock: string;
lastSeen: string;
}
@@ -254,6 +254,7 @@ export interface PhysicalDisk {
node: string;
instance: string;
devPath: string;
device?: string;
model: string;
serial: string;
type: 'nvme' | 'sata' | 'sas' | string;
@@ -264,6 +265,7 @@ export interface PhysicalDisk {
rpm: number;
used: string;
lastChecked: string;
smart?: unknown;
}
export interface CPUInfo {
@@ -410,8 +412,20 @@ export type WSMessage =
}>;
errors?: string[];
timestamp?: number;
immediate?: boolean;
scanning?: boolean;
cached?: boolean;
}}
| { type: 'discovery_started'; data?: {
subnet?: string;
timestamp?: number;
scanning?: boolean;
}}
| { type: 'discovery_complete'; data?: {
timestamp?: number;
scanning?: boolean;
}};
// Utility types
export type Status = 'running' | 'stopped' | 'paused' | 'unknown';
export type GuestType = 'qemu' | 'lxc';
export type GuestType = 'qemu' | 'lxc';
+5 -78
View File
@@ -1,95 +1,22 @@
// Unified backup types for the backup view
export type BackupType = 'snapshot' | 'local' | 'remote';
export type GuestType = 'VM' | 'LXC' | 'Host' | 'Template' | 'ISO';
export interface UnifiedBackup {
// Common fields
backupType: 'backup' | 'snapshot' | 'pbs';
backupType: BackupType;
vmid: number;
name: string;
type: 'VM' | 'LXC' | 'CT';
type: GuestType;
node: string;
backupTime: number; // Unix timestamp in seconds
backupTime: number;
backupName: string;
description: string;
status: string;
size: number | null;
storage: string | null;
// PBS specific
datastore: string | null;
namespace: string | null;
verified: boolean | null;
// Common flags
protected: boolean;
encrypted?: boolean;
// UI specific
instance?: string;
isPBS?: boolean;
}
// PBS-specific backup file info
export interface PBSBackupFile {
filename: string;
size: number;
crypt?: string;
}
// Extended PBS backup with file details
export interface PBSBackupWithFiles {
id: string;
instance: string;
datastore: string;
namespace?: string;
backupType: string;
vmid: number;
backupTime: string;
size: number;
protected: boolean;
verified: boolean;
comment?: string;
files: PBSBackupFile[];
}
// PBS datastore with snapshots
export interface PBSDatastoreSnapshot {
id: string;
backupTime: string;
size: number;
owner?: string;
verified?: boolean;
protected?: boolean;
files?: PBSBackupFile[];
}
export interface PBSDatastore {
name: string;
total: number;
used: number;
free: number;
snapshots: PBSDatastoreSnapshot[];
}
export interface PBSInstanceData {
id: string;
name: string;
host: string;
backups: PBSBackupWithFiles[];
datastores: PBSDatastore[];
}
// Filter options for the backup view
export interface BackupFilters {
instance: string;
type: 'all' | 'VM' | 'LXC';
node: string;
storage: string;
protected: 'all' | 'protected' | 'unprotected';
verified: 'all' | 'verified' | 'unverified';
}
// Sorting options
export interface BackupSort {
key: keyof UnifiedBackup;
order: 'asc' | 'desc';
}
+39 -28
View File
@@ -1,4 +1,5 @@
import type { VM, Container, PBSBackup, StorageBackup, BackupTask } from '@/types/api';
import type { UnifiedBackup } from '@/types/backups';
export type ComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '==';
export type LogicalOperator = 'AND' | 'OR';
@@ -186,7 +187,7 @@ export function parseSearchQuery(query: string): ParsedQuery {
};
}
type FilterableItem = VM | Container | PBSBackup | StorageBackup | BackupTask;
type FilterableItem = VM | Container | PBSBackup | StorageBackup | BackupTask | UnifiedBackup;
function evaluateMetricCondition(guest: FilterableItem, condition: MetricCondition): boolean {
let value: number;
@@ -208,9 +209,13 @@ function evaluateMetricCondition(guest: FilterableItem, condition: MetricConditi
break;
default:
// For backup-specific numeric fields like 'size'
const fieldValue = (guest as Record<string, unknown>)[condition.field];
if (fieldValue !== undefined) {
value = Number(fieldValue) || 0;
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;
}
@@ -245,25 +250,30 @@ function evaluateTextCondition(guest: FilterableItem, condition: TextCondition):
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 || !Array.isArray(guest.tags) || guest.tags.length === 0) return false;
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 =>
guest.tags!.some((tag: string) => {
if (typeof tag !== 'string') return false;
return tag.toLowerCase().includes(searchTag.toLowerCase());
})
return searchTags.some(searchTag =>
tagsArray.some(tag => tag.toLowerCase().includes(searchTag.toLowerCase()))
);
default:
// For backup-specific fields
const fieldValue = (guest 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;
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;
@@ -335,16 +345,17 @@ export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack):
} else if (filter.type === 'raw' && filter.rawText) {
const term = filter.rawText.toLowerCase();
// Check name, vmid, node, status, and tags for raw text matches
const basicMatch = ('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));
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 && guest.tags && Array.isArray(guest.tags) &&
guest.tags.some((tag: string) => tag.toLowerCase().includes(term));
return basicMatch || tagMatch;
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;
});
@@ -368,4 +379,4 @@ export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack):
}
return result;
}
}