Pulse/frontend-modern/src/components/Dashboard/Dashboard.tsx
rcourtman 0ddc30c1e1 Redesign Ceph, Replication, and Mail Gateway pages for UI consistency
- Ceph: Complete rework with summary cards (storage, clusters, services, pools),
  search with type-to-search, proper loading/disconnected/empty states, cluster
  overview table, pools table with filtering, health badges with tooltips

- Replication: Add summary cards (total/healthy/issues/next sync), status filter
  buttons (All/Healthy/Warning/Error), search with type-to-search, countdown timers
  for next sync with overdue/imminent highlighting, row highlighting for problem jobs,
  error tooltips, loading state

- Mail Gateway: Add aggregate summary cards across all instances (mail/spam/virus/
  quarantine/queue), visual threat rate progress bars, status badges with health
  indicators, queue depth tooltips with breakdown, streamlined stats grid with
  color-coded threat categories, improved cluster nodes table

All three pages now follow the same design patterns as Dashboard, Hosts, Docker,
and Storage using Card, EmptyState, and consistent styling.
2025-12-13 00:08:29 +00:00

1264 lines
45 KiB
TypeScript

import { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import type { VM, Container, Node } from '@/types/api';
import { GuestRow, GUEST_COLUMNS, type GuestColumnDef } from './GuestRow';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
import { DashboardFilter } from './DashboardFilter';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import type { GuestMetadata } from '@/api/guestMetadata';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { isNodeOnline, OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from '@/utils/status';
import { getNodeDisplayName } from '@/utils/nodes';
import { logger } from '@/utils/logger';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { getBackupInfo } from '@/utils/format';
import { aiChatStore } from '@/stores/aiChat';
type GuestMetadataRecord = Record<string, GuestMetadata>;
type IdleCallbackHandle = number;
type IdleCallback = (deadline?: { didTimeout: boolean; timeRemaining: () => number }) => void;
type IdleCapableWindow = Window & {
requestIdleCallback?: (callback: IdleCallback, options?: { timeout?: number }) => IdleCallbackHandle;
cancelIdleCallback?: (handle: IdleCallbackHandle) => void;
};
let cachedGuestMetadata: GuestMetadataRecord | null = null;
let lastPersistedGuestMetadataJSON: string | null = null;
let pendingPersistMetadata: GuestMetadataRecord | null = null;
let persistHandle: number | null = null;
let persistHandleType: 'idle' | 'timeout' | null = null;
const instrumentationEnabled = import.meta.env.DEV && typeof performance !== 'undefined';
const readGuestMetadataCache = (): GuestMetadataRecord => {
if (cachedGuestMetadata) {
return cachedGuestMetadata;
}
if (typeof window === 'undefined') {
cachedGuestMetadata = {};
return cachedGuestMetadata;
}
try {
const raw = window.localStorage.getItem(STORAGE_KEYS.GUEST_METADATA);
if (!raw) {
cachedGuestMetadata = {};
lastPersistedGuestMetadataJSON = null;
return cachedGuestMetadata;
}
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
cachedGuestMetadata = parsed as GuestMetadataRecord;
lastPersistedGuestMetadataJSON = raw;
return cachedGuestMetadata;
}
} catch (err) {
logger.warn('Failed to parse cached guest metadata', err);
}
cachedGuestMetadata = {};
lastPersistedGuestMetadataJSON = null;
return cachedGuestMetadata;
};
const clearPendingPersistHandle = (idleWindow: IdleCapableWindow) => {
if (persistHandle === null || persistHandleType === null) {
return;
}
if (persistHandleType === 'idle' && idleWindow.cancelIdleCallback) {
idleWindow.cancelIdleCallback(persistHandle);
} else if (persistHandleType === 'timeout') {
window.clearTimeout(persistHandle);
}
persistHandle = null;
persistHandleType = null;
};
const runGuestMetadataPersist = () => {
if (typeof window === 'undefined' || !pendingPersistMetadata) {
pendingPersistMetadata = null;
return;
}
const metadata = pendingPersistMetadata;
pendingPersistMetadata = null;
const markBase = instrumentationEnabled ? `guest-metadata:persist:${Date.now()}` : null;
if (markBase) {
performance.mark(`${markBase}:start`);
}
let serialized: string;
try {
serialized = JSON.stringify(metadata);
} catch (err) {
if (markBase) {
performance.mark(`${markBase}:end`);
performance.measure(markBase, `${markBase}:start`, `${markBase}:end`);
performance.clearMarks(`${markBase}:start`);
performance.clearMarks(`${markBase}:end`);
performance.clearMeasures(markBase);
}
logger.warn('Failed to serialize guest metadata cache', err);
return;
}
if (serialized === lastPersistedGuestMetadataJSON) {
if (markBase) {
performance.mark(`${markBase}:end`);
performance.measure(markBase, `${markBase}:start`, `${markBase}:end`);
const entries = performance.getEntriesByName(markBase);
const entry = entries[entries.length - 1];
if (entry) {
logger.debug('[guestMetadataCache] skipped persist (unchanged)', {
durationMs: entry.duration,
});
}
performance.clearMarks(`${markBase}:start`);
performance.clearMarks(`${markBase}:end`);
performance.clearMeasures(markBase);
}
return;
}
try {
window.localStorage.setItem(STORAGE_KEYS.GUEST_METADATA, serialized);
lastPersistedGuestMetadataJSON = serialized;
if (markBase) {
performance.mark(`${markBase}:end`);
performance.measure(markBase, `${markBase}:start`, `${markBase}:end`);
const entries = performance.getEntriesByName(markBase);
const entry = entries[entries.length - 1];
if (entry) {
logger.debug('[guestMetadataCache] persisted entries', {
count: Object.keys(metadata).length,
durationMs: entry.duration,
});
}
performance.clearMarks(`${markBase}:start`);
performance.clearMarks(`${markBase}:end`);
performance.clearMeasures(markBase);
}
} catch (err) {
if (markBase) {
performance.mark(`${markBase}:end`);
performance.measure(markBase, `${markBase}:start`, `${markBase}:end`);
performance.clearMarks(`${markBase}:start`);
performance.clearMarks(`${markBase}:end`);
performance.clearMeasures(markBase);
}
logger.warn('Failed to persist guest metadata cache', err);
}
};
const queueGuestMetadataPersist = (metadata: GuestMetadataRecord) => {
cachedGuestMetadata = metadata;
if (typeof window === 'undefined') {
return;
}
pendingPersistMetadata = metadata;
const idleWindow = window as IdleCapableWindow;
clearPendingPersistHandle(idleWindow);
const schedule: IdleCallback = () => {
persistHandle = null;
persistHandleType = null;
runGuestMetadataPersist();
};
if (idleWindow.requestIdleCallback) {
persistHandleType = 'idle';
persistHandle = idleWindow.requestIdleCallback(schedule, { timeout: 750 });
} else {
persistHandleType = 'timeout';
persistHandle = window.setTimeout(schedule, 0);
}
};
interface DashboardProps {
vms: VM[];
containers: Container[];
nodes: Node[];
}
type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'degraded' | 'stopped';
type BackupMode = 'all' | 'needs-backup';
type GroupingMode = 'grouped' | 'flat';
type ProblemsMode = 'all' | 'problems';
export function Dashboard(props: DashboardProps) {
const navigate = useNavigate();
const ws = useWebSocket();
const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = ws;
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const [search, setSearch] = createSignal('');
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
const [guestMetadata, setGuestMetadata] = createSignal<GuestMetadataRecord>(
readGuestMetadataCache(),
);
const updateGuestMetadataState = (updater: (prev: GuestMetadataRecord) => GuestMetadataRecord) =>
setGuestMetadata((prev) => {
const next = updater(prev);
if (next === prev) {
return prev;
}
queueGuestMetadataPersist(next);
return next;
});
// Combine VMs and containers into a single list for filtering
const allGuests = createMemo<(VM | Container)[]>(() => [...props.vms, ...props.containers]);
// Initialize from localStorage with proper type checking
const [viewMode, setViewMode] = usePersistentSignal<ViewMode>('dashboardViewMode', 'all', {
deserialize: (raw) => (raw === 'all' || raw === 'vm' || raw === 'lxc' ? raw : 'all'),
});
const [statusMode, setStatusMode] = usePersistentSignal<StatusMode>('dashboardStatusMode', 'all', {
deserialize: (raw) =>
raw === 'all' || raw === 'running' || raw === 'degraded' || raw === 'stopped'
? (raw as StatusMode)
: 'all',
});
// Backup filter mode - filter by backup status
const [backupMode, setBackupMode] = usePersistentSignal<BackupMode>('dashboardBackupMode', 'all', {
deserialize: (raw) => (raw === 'all' || raw === 'needs-backup' ? raw : 'all'),
});
// Grouping mode - grouped by node or flat list
const [groupingMode, setGroupingMode] = usePersistentSignal<GroupingMode>(
'dashboardGroupingMode',
'grouped',
{
deserialize: (raw) => (raw === 'grouped' || raw === 'flat' ? raw : 'grouped'),
},
);
// Problems mode - show only guests with issues
const [problemsMode, setProblemsMode] = usePersistentSignal<ProblemsMode>(
'dashboardProblemsMode',
'all',
{
deserialize: (raw) => (raw === 'all' || raw === 'problems' ? raw : 'all'),
},
);
const [showFilters, setShowFilters] = usePersistentSignal<boolean>(
'dashboardShowFilters',
false,
{
deserialize: (raw) => raw === 'true',
serialize: (value) => String(value),
},
);
// Sorting state - default to VMID ascending (matches Proxmox order)
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Column visibility management
// OS and IP columns are hidden by default since they require guest agent and may show dashes
const columnVisibility = useColumnVisibility(
STORAGE_KEYS.DASHBOARD_HIDDEN_COLUMNS,
GUEST_COLUMNS as GuestColumnDef[],
['os', 'ip'] // Default hidden columns for cleaner first-run experience
);
const visibleColumns = columnVisibility.visibleColumns;
const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id));
// Total columns for colspan calculations
const totalColumns = createMemo(() => visibleColumns().length);
// Load all guest metadata on mount (single API call for all guests)
onMount(async () => {
try {
const metadata = await GuestMetadataAPI.getAllMetadata();
updateGuestMetadataState(() => metadata || {});
} catch (err) {
// Silently fail - metadata is optional for display
logger.debug('Failed to load guest metadata', err);
}
});
// Callback to update a guest's custom URL in metadata
const handleCustomUrlUpdate = (guestId: string, url: string) => {
const trimmedUrl = url.trim();
const nextUrl = trimmedUrl === '' ? undefined : trimmedUrl;
const currentUrl = guestMetadata()[guestId]?.customUrl;
if (currentUrl === nextUrl) {
return;
}
updateGuestMetadataState((prev) => {
const previousEntry = prev[guestId];
if (nextUrl === undefined) {
if (!previousEntry || typeof previousEntry.customUrl === 'undefined') {
return prev;
}
const { customUrl: _removed, ...restEntry } = previousEntry;
const hasAdditionalMetadata = Object.entries(restEntry).some(
([key, value]) => key !== 'id' && value !== undefined,
);
if (!hasAdditionalMetadata) {
const { [guestId]: _omit, ...rest } = prev;
return rest;
}
return {
...prev,
[guestId]: {
...restEntry,
id: restEntry.id ?? guestId,
},
};
}
if (previousEntry && previousEntry.customUrl === nextUrl) {
return prev;
}
const nextEntry: GuestMetadata = {
...(previousEntry || { id: guestId }),
customUrl: nextUrl,
};
return {
...prev,
[guestId]: nextEntry,
};
});
};
// Create a mapping from node ID to node object
const nodeByInstance = createMemo(() => {
const map: Record<string, Node> = {};
props.nodes.forEach((node) => {
map[node.id] = node;
});
return map;
});
const resolveParentNode = (guest: VM | Container): Node | undefined => {
if (!guest) return undefined;
const nodes = nodeByInstance();
if (guest.id) {
const lastDash = guest.id.lastIndexOf('-');
if (lastDash > 0) {
const nodeId = guest.id.slice(0, lastDash);
if (nodes[nodeId]) {
return nodes[nodeId];
}
}
}
const compositeKey = `${guest.instance}-${guest.node}`;
if (nodes[compositeKey]) {
return nodes[compositeKey];
}
return undefined;
};
// Sort handler
const handleSort = (key: keyof (VM | Container)) => {
if (sortKey() === key) {
// Toggle direction for the same column
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
// New column - set key and default direction
setSortKey(key);
// Set default sort direction based on column type
if (
key === 'cpu' ||
key === 'memory' ||
key === 'disk' ||
key === 'diskRead' ||
key === 'diskWrite' ||
key === 'networkIn' ||
key === 'networkOut' ||
key === 'uptime'
) {
setSortDirection('desc');
} else {
setSortDirection('asc');
}
}
};
const getDiskUsagePercent = (guest: VM | Container): number | null => {
const disk = guest?.disk;
if (!disk) return null;
const clamp = (value: number) => Math.min(100, Math.max(0, value));
if (typeof disk.usage === 'number' && Number.isFinite(disk.usage)) {
// Some sources report usage as a ratio (0-1), others as a percentage (0-100)
const usageValue = disk.usage > 1 ? disk.usage : disk.usage * 100;
return clamp(usageValue);
}
if (
typeof disk.used === 'number' &&
Number.isFinite(disk.used) &&
typeof disk.total === 'number' &&
Number.isFinite(disk.total) &&
disk.total > 0
) {
return clamp((disk.used / disk.total) * 100);
}
return null;
};
// Handle keyboard shortcuts
let searchInputRef: HTMLInputElement | undefined;
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input, textarea, or contenteditable
const target = e.target as HTMLElement;
const isInputField =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
// Escape key behavior
if (e.key === 'Escape') {
// First check if we have search/filters to clear (including tag filters and node selection)
const hasActiveFilters =
search().trim() ||
sortKey() !== 'vmid' ||
sortDirection() !== 'asc' ||
selectedNode() !== null ||
viewMode() !== 'all' ||
statusMode() !== 'all' ||
backupMode() !== 'all' ||
problemsMode() !== 'all';
if (hasActiveFilters) {
// Clear ALL filters including search text, tag filters, node selection, and view modes
setSearch('');
setIsSearchLocked(false);
setSortKey('vmid');
setSortDirection('asc');
setSelectedNode(null);
setViewMode('all');
setStatusMode('all');
setBackupMode('all');
setProblemsMode('all');
// Blur the search input if it's focused
if (searchInputRef && document.activeElement === searchInputRef) {
searchInputRef.blur();
}
} else {
// No active filters, toggle the filters section visibility
setShowFilters(!showFilters());
}
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// If it's a printable character and user is not in an input field
// Check if AI chat is open - if so, focus that instead
if (aiChatStore.focusInput()) {
// AI chat input was focused, let the character be typed there
return;
}
// Otherwise, focus the search input
// Expand filters section if collapsed
if (!showFilters()) {
setShowFilters(true);
}
// Focus the search input and let the character be typed
if (searchInputRef) {
searchInputRef.focus();
// Don't prevent default - let the character be typed
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Compute guests with problems - used for AI investigation regardless of filter state
const problemGuests = createMemo(() => {
return allGuests().filter((g) => {
// Skip templates
if (g.template) return false;
// Check for degraded status
const status = (g.status || '').toLowerCase();
const isDegraded = DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status) && status !== 'stopped');
if (isDegraded) return true;
// Check for backup issues
const backupInfo = getBackupInfo(g.lastBackup);
if (backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never') {
return true;
}
// Check for high resource usage (>90%)
if (g.cpu > 0.9) return true;
if (g.memory && g.memory.usage && g.memory.usage > 90) return true;
return false;
});
});
// Filter guests based on current settings
const filteredGuests = createMemo(() => {
let guests = allGuests();
// Filter by selected node using both instance and node name for uniqueness
const selectedNodeId = selectedNode();
if (selectedNodeId) {
// Find the node to get both instance and name for precise matching
const node = props.nodes.find((n) => n.id === selectedNodeId);
if (node) {
guests = guests.filter(
(g) => g.instance === node.instance && g.node === node.name,
);
}
}
// Filter by type
if (viewMode() === 'vm') {
guests = guests.filter((g) => g.type === 'qemu');
} else if (viewMode() === 'lxc') {
// Include both traditional LXC and OCI containers (Proxmox 9.1+)
guests = guests.filter((g) => g.type === 'lxc' || g.type === 'oci');
}
// Filter by status
if (statusMode() === 'running') {
guests = guests.filter((g) => g.status === 'running');
} else if (statusMode() === 'degraded') {
guests = guests.filter((g) => {
const status = (g.status || '').toLowerCase();
return (
DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status))
);
});
} else if (statusMode() === 'stopped') {
guests = guests.filter((g) => g.status !== 'running');
}
// Filter by backup status
if (backupMode() === 'needs-backup') {
guests = guests.filter((g) => {
// Skip templates - they don't need backups
if (g.template) return false;
const backupInfo = getBackupInfo(g.lastBackup);
// Show guests that need backup: stale, critical, or never backed up
return backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never';
});
}
// Filter by problems mode - show guests that need attention
if (problemsMode() === 'problems') {
guests = guests.filter((g) => {
// Skip templates
if (g.template) return false;
// Check for degraded status
const status = (g.status || '').toLowerCase();
const isDegraded = DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status) && status !== 'stopped');
if (isDegraded) return true;
// Check for backup issues
const backupInfo = getBackupInfo(g.lastBackup);
if (backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never') {
return true;
}
// Check for high resource usage (>90%)
if (g.cpu > 0.9) return true;
if (g.memory && g.memory.usage && g.memory.usage > 90) return true;
return false;
});
}
// Apply search/filter
const searchTerm = search().trim();
if (searchTerm) {
// Split by commas first
const searchParts = searchTerm
.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) {
guests = guests.filter((g) => evaluateFilterStack(g, stack));
}
}
// Apply text search if any
if (textSearches.length > 0) {
guests = guests.filter((g) =>
textSearches.some(
(term) =>
g.name.toLowerCase().includes(term) ||
g.vmid.toString().includes(term) ||
g.node.toLowerCase().includes(term) ||
g.status.toLowerCase().includes(term),
),
);
}
}
// Don't filter by thresholds anymore - dimming is handled in GuestRow component
return guests;
});
// Group by node or return flat list based on grouping mode
const groupedGuests = createMemo(() => {
const guests = filteredGuests();
// If flat mode, return all guests in a single group
if (groupingMode() === 'flat') {
const groups: Record<string, (VM | Container)[]> = { '': guests };
// Sort the flat list
const key = sortKey();
const dir = sortDirection();
if (key) {
groups[''] = groups[''].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[key] as
| string
| number
| boolean
| null
| undefined;
let bVal: string | number | boolean | null | undefined = b[key] as
| string
| number
| boolean
| null
| undefined;
// Special handling for percentage-based columns
if (key === 'cpu') {
// CPU is displayed as percentage
aVal = a.cpu * 100;
bVal = b.cpu * 100;
} else if (key === 'memory') {
// Memory is displayed as percentage (use pre-calculated usage)
aVal = a.memory ? a.memory.usage || 0 : 0;
bVal = b.memory ? b.memory.usage || 0 : 0;
} else if (key === 'disk') {
aVal = getDiskUsagePercent(a);
bVal = getDiskUsagePercent(b);
}
// Handle null/undefined/empty values - put at end for both asc and desc
const aIsEmpty = aVal === null || aVal === undefined || aVal === '';
const bIsEmpty = bVal === null || bVal === undefined || bVal === '';
if (aIsEmpty && bIsEmpty) return 0;
if (aIsEmpty) return 1;
if (bIsEmpty) return -1;
// Type-specific value preparation
if (typeof aVal === 'number' && typeof bVal === 'number') {
// Numeric comparison
const comparison = aVal < bVal ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
} else {
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr === bStr) return 0;
const comparison = aStr < bStr ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
}
});
}
return groups;
}
// Group by node ID (instance + node name) to match Node.ID format
const groups: Record<string, (VM | Container)[]> = {};
guests.forEach((guest) => {
// Node.ID is formatted as "instance-nodename", so we need to match that
const nodeId = `${guest.instance}-${guest.node}`;
if (!groups[nodeId]) {
groups[nodeId] = [];
}
groups[nodeId].push(guest);
});
// Sort within each node group
const key = sortKey();
const dir = sortDirection();
if (key) {
Object.keys(groups).forEach((node) => {
groups[node] = groups[node].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[key] as
| string
| number
| boolean
| null
| undefined;
let bVal: string | number | boolean | null | undefined = b[key] as
| string
| number
| boolean
| null
| undefined;
// Special handling for percentage-based columns
if (key === 'cpu') {
// CPU is displayed as percentage
aVal = a.cpu * 100;
bVal = b.cpu * 100;
} else if (key === 'memory') {
// Memory is displayed as percentage (use pre-calculated usage)
aVal = a.memory ? a.memory.usage || 0 : 0;
bVal = b.memory ? b.memory.usage || 0 : 0;
} else if (key === 'disk') {
aVal = getDiskUsagePercent(a);
bVal = getDiskUsagePercent(b);
}
// Handle null/undefined/empty values - put at end for both asc and desc
const aIsEmpty = aVal === null || aVal === undefined || aVal === '';
const bIsEmpty = bVal === null || bVal === undefined || bVal === '';
if (aIsEmpty && bIsEmpty) return 0;
if (aIsEmpty) return 1;
if (bIsEmpty) return -1;
// Type-specific value preparation
if (typeof aVal === 'number' && typeof bVal === 'number') {
// Numeric comparison
const comparison = aVal < bVal ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
} else {
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr === bStr) return 0;
const comparison = aStr < bStr ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
}
});
});
}
return groups;
});
const totalStats = createMemo(() => {
const guests = filteredGuests();
const running = guests.filter((g) => g.status === 'running').length;
const degraded = guests.filter((g) => {
const status = (g.status || '').toLowerCase();
// Count as degraded if explicitly in degraded list, or if not running and not offline/stopped
return (
DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status))
);
}).length;
const stopped = guests.length - running - degraded;
const vms = guests.filter((g) => g.type === 'qemu').length;
const containers = guests.filter((g) => g.type === 'lxc' || g.type === 'oci').length;
return {
total: guests.length,
running,
degraded,
stopped,
vms,
containers,
};
});
const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => {
logger.debug('handleNodeSelect called', { nodeId, nodeType });
// If AI sidebar is open, add node to AI context instead of filtering
if (aiChatStore.isOpen && nodeId && nodeType === 'pve') {
const node = props.nodes.find((n) => n.id === nodeId);
if (node) {
// Toggle: remove if already in context, add if not
if (aiChatStore.hasContextItem(nodeId)) {
aiChatStore.removeContextItem(nodeId);
} else {
aiChatStore.addContextItem('node', nodeId, node.name, {
nodeName: node.name,
name: node.name,
type: 'Proxmox Node',
status: node.status,
instance: node.instance,
});
}
}
return;
}
// Track selected node for filtering (independent of search)
if (nodeType === 'pve' || nodeType === null) {
setSelectedNode(nodeId);
logger.debug('Set selected node', { nodeId });
// Show filters if a node is selected
if (nodeId && !showFilters()) {
setShowFilters(true);
}
}
};
const handleTagClick = (tag: string) => {
const currentSearch = search().trim();
const tagFilter = `tags:${tag}`;
// Check if this tag filter already exists
if (currentSearch.includes(tagFilter)) {
// Remove the tag filter
let newSearch = currentSearch;
// Handle different cases of where the tag filter might be
if (currentSearch === tagFilter) {
// It's the only filter
newSearch = '';
} else if (currentSearch.startsWith(tagFilter + ',')) {
// It's at the beginning
newSearch = currentSearch.replace(tagFilter + ',', '').trim();
} else if (currentSearch.endsWith(', ' + tagFilter)) {
// It's at the end
newSearch = currentSearch.replace(', ' + tagFilter, '').trim();
} else if (currentSearch.includes(', ' + tagFilter + ',')) {
// It's in the middle
newSearch = currentSearch.replace(', ' + tagFilter + ',', ',').trim();
} else if (currentSearch.includes(tagFilter + ', ')) {
// It's at the beginning with space after comma
newSearch = currentSearch.replace(tagFilter + ', ', '').trim();
}
setSearch(newSearch);
if (!newSearch) {
setIsSearchLocked(false);
}
} else {
// Add the tag filter
if (!currentSearch || isSearchLocked()) {
setSearch(tagFilter);
setIsSearchLocked(false);
} else {
// Add tag filter to existing search with comma separator
setSearch(`${currentSearch}, ${tagFilter}`);
}
// Make sure filters are visible
if (!showFilters()) {
setShowFilters(true);
}
}
};
// Handle row click - add guest to AI context (works even when sidebar is closed)
const handleGuestRowClick = (guest: VM | Container) => {
// Only enable if AI is configured
if (!aiChatStore.enabled) return;
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const guestType = guest.type === 'qemu' ? 'vm' : 'container';
const isOCI = guest.type === 'oci' || ('isOci' in guest && guest.isOci === true);
// Toggle: remove if already in context, add if not
if (aiChatStore.hasContextItem(guestId)) {
aiChatStore.removeContextItem(guestId);
// If no items left in context and sidebar is open, keep it open for now
} else {
// Build context with OCI-specific info when applicable
const contextData: Record<string, unknown> = {
guestName: guest.name,
name: guest.name,
type: guest.type === 'qemu' ? 'Virtual Machine' : (isOCI ? 'OCI Container' : 'LXC Container'),
vmid: guest.vmid,
node: guest.node,
status: guest.status,
};
// Add OCI image info if available
if (isOCI && 'osTemplate' in guest && guest.osTemplate) {
contextData.ociImage = guest.osTemplate;
}
aiChatStore.addContextItem(guestType, guestId, guest.name, contextData);
// Auto-open the sidebar when first item is selected
if (!aiChatStore.isOpen) {
aiChatStore.open();
}
}
};
return (
<div class="space-y-3">
<ProxmoxSectionNav current="overview" />
{/* Unified Node Selector */}
<UnifiedNodeSelector
currentTab="dashboard"
globalTemperatureMonitoringEnabled={ws.state.temperatureMonitoringEnabled}
onNodeSelect={handleNodeSelect}
nodes={props.nodes}
filteredVms={filteredGuests().filter((g) => g.type === 'qemu')}
filteredContainers={filteredGuests().filter((g) => g.type === 'lxc' || g.type === 'oci')}
searchTerm={search()}
/>
{/* Dashboard Filter */}
<DashboardFilter
search={search}
setSearch={setSearch}
isSearchLocked={isSearchLocked}
viewMode={viewMode}
setViewMode={setViewMode}
statusMode={statusMode}
setStatusMode={setStatusMode}
backupMode={backupMode}
setBackupMode={setBackupMode}
problemsMode={problemsMode}
setProblemsMode={setProblemsMode}
filteredProblemGuests={problemGuests}
groupingMode={groupingMode}
setGroupingMode={setGroupingMode}
setSortKey={setSortKey}
setSortDirection={setSortDirection}
searchInputRef={(el) => (searchInputRef = el)}
availableColumns={columnVisibility.availableToggles()}
isColumnHidden={columnVisibility.isHiddenByUser}
onColumnToggle={columnVisibility.toggle}
onColumnReset={columnVisibility.resetToDefaults}
/>
{/* Loading State */}
<Show when={connected() && !initialDataReceived()}>
<Card padding="lg">
<EmptyState
icon={
<svg
class="mx-auto h-12 w-12 animate-spin text-gray-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
title="Loading dashboard data..."
description={
reconnecting()
? 'Reconnecting to monitoring service…'
: 'Connecting to monitoring service'
}
/>
</Card>
</Show>
{/* Empty State - No PVE Nodes Configured */}
<Show
when={
connected() &&
initialDataReceived() &&
props.nodes.filter((n) => n.type === 'pve').length === 0 &&
props.vms.length === 0 &&
props.containers.length === 0
}
>
<Card padding="lg">
<EmptyState
icon={
<svg
class="h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
title="No Proxmox VE nodes configured"
description="Add a Proxmox VE node in the Settings tab to start monitoring your infrastructure."
actions={
<button
type="button"
onClick={() => navigate('/settings')}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
}
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<Card padding="lg" tone="danger">
<EmptyState
icon={
<svg
class="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
title="Connection lost"
description={
reconnecting()
? 'Attempting to reconnect…'
: 'Unable to connect to the backend server'
}
tone="danger"
actions={
!reconnecting() ? (
<button
onClick={() => reconnect()}
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Reconnect now
</button>
) : undefined
}
/>
</Card>
</Show>
{/* Table View */}
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<ComponentErrorBoundary name="Guest Table">
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": "900px" }}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
<For each={visibleColumns()}>
{(col) => {
const isFirst = () => col.id === visibleColumns()[0]?.id;
const sortKeyForCol = col.sortKey as keyof (VM | Container) | undefined;
const isSortable = !!sortKeyForCol;
const isSorted = () => sortKeyForCol && sortKey() === sortKeyForCol;
return (
<th
class={`py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
style={{
...(col.width ? { "min-width": col.width } : {}),
"vertical-align": 'middle'
}}
onClick={() => isSortable && handleSort(sortKeyForCol!)}
title={col.icon ? col.label : undefined}
>
<div class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`} style={{ "min-height": "14px" }}>
{col.icon ? (
<span class="flex items-center">{col.icon}</span>
) : (
col.label
)}
{isSorted() && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
</th>
);
}}
</For>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
const nodeA = nodeByInstance()[instanceIdA];
const nodeB = nodeByInstance()[instanceIdB];
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB);
})}
fallback={<></>}
>
{([instanceId, guests]) => {
const node = nodeByInstance()[instanceId];
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns()} />
</Show>
<For each={guests} fallback={<></>}>
{(guest, index) => {
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
// Get adjacent guest IDs for merged AI context borders
const prevGuest = guests[index() - 1];
const nextGuest = guests[index() + 1];
const prevGuestId = prevGuest ? (prevGuest.id || `${prevGuest.instance}-${prevGuest.vmid}`) : null;
const nextGuestId = nextGuest ? (nextGuest.id || `${nextGuest.instance}-${nextGuest.vmid}`) : null;
return (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate}
isGroupedView={groupingMode() === 'grouped'}
visibleColumnIds={visibleColumnIds()}
aboveGuestId={prevGuestId}
belowGuestId={nextGuestId}
onRowClick={aiChatStore.enabled ? handleGuestRowClick : undefined}
/>
</ComponentErrorBoundary>
);
}}
</For>
</>
);
}}
</For>
</tbody>
</table>
</div>
</Card>
</ComponentErrorBoundary>
</Show>
<Show
when={
connected() &&
initialDataReceived() &&
filteredGuests().length === 0 &&
(props.vms.length > 0 || props.containers.length > 0)
}
>
<Card padding="lg" class="mb-4">
<EmptyState
icon={
<svg
class="h-12 w-12 text-gray-400"
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>
}
title="No guests found"
description={
search() && search().trim() !== ''
? `No guests match your search "${search()}"`
: 'No guests match your current filters'
}
/>
</Card>
</Show>
{/* Stats */}
<Show when={connected() && initialDataReceived()}>
<div class="mb-4">
<div class="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700 rounded">
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-green-500 rounded-full"></span>
{totalStats().running} running
</span>
<Show when={totalStats().degraded > 0}>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-orange-500 rounded-full"></span>
{totalStats().degraded} degraded
</span>
</Show>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-red-500 rounded-full"></span>
{totalStats().stopped} stopped
</span>
</div>
</div>
</Show>
</div>
);
}