Pulse/frontend-modern/src/components/Dashboard/Dashboard.tsx

887 lines
34 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 } from './GuestRow';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
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 } from '@/utils/status';
import { getNodeDisplayName } from '@/utils/nodes';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
interface DashboardProps {
vms: VM[];
containers: Container[];
nodes: Node[];
}
type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'stopped';
type GroupingMode = 'grouped' | 'flat';
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<Record<string, GuestMetadata>>({});
// 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 === 'stopped' ? raw : ('all' as StatusMode),
});
// Grouping mode - grouped by node or flat list
const [groupingMode, setGroupingMode] = usePersistentSignal<GroupingMode>(
'dashboardGroupingMode',
'grouped',
{
deserialize: (raw) => (raw === 'grouped' || raw === 'flat' ? raw : 'grouped'),
},
);
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');
// Load all guest metadata on mount (single API call for all guests)
onMount(async () => {
try {
const metadata = await GuestMetadataAPI.getAllMetadata();
setGuestMetadata(metadata || {});
} catch (err) {
// Silently fail - metadata is optional for display
console.debug('Failed to load guest metadata:', err);
}
});
// Callback to update a guest's custom URL in metadata
const handleCustomUrlUpdate = (guestId: string, url: string) => {
setGuestMetadata((prev) => ({
...prev,
[guestId]: {
...(prev[guestId] || { id: guestId }),
customUrl: url || undefined,
},
}));
};
// 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';
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');
// 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
// 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);
});
// Combine VMs and containers into a single list
const allGuests = createMemo(() => {
const vms = props.vms || [];
const containers = props.containers || [];
return [...vms, ...containers];
});
// 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') {
guests = guests.filter((g) => g.type === 'lxc');
}
// Filter by status
if (statusMode() === 'running') {
guests = guests.filter((g) => g.status === 'running');
} else if (statusMode() === 'stopped') {
guests = guests.filter((g) => g.status !== 'running');
}
// 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 vms = guests.filter((g) => g.type === 'qemu').length;
const containers = guests.filter((g) => g.type === 'lxc').length;
return {
total: guests.length,
running,
stopped: guests.length - running,
vms,
containers,
};
});
const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => {
console.log('handleNodeSelect called:', nodeId, nodeType);
// Track selected node for filtering (independent of search)
if (nodeType === 'pve' || nodeType === null) {
setSelectedNode(nodeId);
console.log('Set selected node to:', 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);
}
}
};
return (
<div class="space-y-3">
<ProxmoxSectionNav current="overview" />
{/* Unified Node Selector */}
<UnifiedNodeSelector
currentTab="dashboard"
onNodeSelect={handleNodeSelect}
nodes={props.nodes}
filteredVms={filteredGuests().filter((g) => g.type === 'qemu')}
filteredContainers={filteredGuests().filter((g) => g.type === 'lxc')}
searchTerm={search()}
/>
{/* Dashboard Filter */}
<DashboardFilter
search={search}
setSearch={setSearch}
isSearchLocked={isSearchLocked}
viewMode={viewMode}
setViewMode={setViewMode}
statusMode={statusMode}
setStatusMode={setStatusMode}
groupingMode={groupingMode}
setGroupingMode={setGroupingMode}
setSortKey={setSortKey}
setSortDirection={setSortDirection}
searchInputRef={(el) => (searchInputRef = el)}
/>
{/* 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" class="mb-4 overflow-hidden">
<ScrollableTable minWidth="760px">
<table class="w-full min-w-[760px] md:min-w-[900px] table-fixed border-collapse">
<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-600">
<th
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[130px] sm:w-[150px] lg:w-[180px] xl:w-[200px] 2xl:w-[240px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabindex="0"
role="button"
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[48px] sm:w-[56px] lg:w-[60px] xl:w-[64px] 2xl:w-[72px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[64px] lg:w-[72px] xl:w-[80px] 2xl:w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('vmid')}
>
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[76px] sm:w-[86px] lg:w-[96px] xl:w-[108px] 2xl:w-[128px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[180px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[180px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[180px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[96px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskRead')}
>
Disk Read{' '}
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[96px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskWrite')}
>
Disk Write{' '}
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[96px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[62px] lg:w-[70px] xl:w-[78px] 2xl:w-[96px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkOut')}
>
Net Out{' '}
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
// Sort by friendly node name first, falling back to instance ID for stability
const nodeA = nodeByInstance()[instanceIdA];
const nodeB = nodeByInstance()[instanceIdB];
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
const nameCompare = labelA.localeCompare(labelB);
if (nameCompare !== 0) return nameCompare;
// If labels match (unlikely), fall back to the instance IDs
return instanceIdA.localeCompare(instanceIdB);
})}
fallback={<></>}
>
{([instanceId, guests]) => {
const node = nodeByInstance()[instanceId];
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} colspan={11} />
</Show>
<For each={guests} fallback={<></>}>
{(guest) => {
// Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid"
const guestId =
guest.id ||
(guest.instance === guest.node
? `${guest.node}-${guest.vmid}`
: `${guest.instance}-${guest.node}-${guest.vmid}`);
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
return (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate}
/>
</ComponentErrorBoundary>
);
}}
</For>
</>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</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>
<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>
);
}