Pulse/frontend-modern/src/components/Dashboard/GuestRow.tsx
rcourtman 5dce402d62 fix: make I/O metrics reactive to WebSocket updates
The I/O columns were not updating in real-time because they accessed
props.guest directly instead of through reactive memos. This wraps
diskRead, diskWrite, networkIn, networkOut in createMemo() to properly
track changes from WebSocket data updates.
2025-11-25 21:36:27 +00:00

730 lines
28 KiB
TypeScript

import { GuestDrawer } from './GuestDrawer';
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
import type { VM, Container } from '@/types/api';
import { formatBytes, formatUptime, formatSpeed } from '@/utils/format';
import { TagBadges } from './TagBadges';
import { StatusDot } from '@/components/shared/StatusDot';
import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger';
import { buildMetricKey } from '@/utils/metricsKeys';
import { type ColumnPriority } from '@/hooks/useBreakpoint';
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive';
type Guest = VM | Container;
/**
* Get color class for I/O values based on throughput (bytes/sec)
* Higher throughput = more prominent color to draw attention
*/
function getIOColorClass(bytesPerSec: number): string {
const mbps = bytesPerSec / (1024 * 1024);
if (mbps < 1) return 'text-gray-400 dark:text-gray-500';
if (mbps < 10) return 'text-gray-600 dark:text-gray-300';
if (mbps < 50) return 'text-gray-700 dark:text-gray-200 font-medium';
return 'text-gray-900 dark:text-white font-semibold';
}
// Global state for currently expanded drawer (only one drawer open at a time)
const [currentlyExpandedGuestId, setCurrentlyExpandedGuestId] = createSignal<string | null>(null);
// Global editing state - use a signal so all components react
const [currentlyEditingGuestId, setCurrentlyEditingGuestId] = createSignal<string | null>(null);
// Store the editing value globally so it survives re-renders
const editingValues = new Map<string, string>();
// Signal to trigger reactivity when editing values change
const [editingValuesVersion, setEditingValuesVersion] = createSignal(0);
const GROUPED_FIRST_CELL_INDENT = 'pl-5 sm:pl-6 lg:pl-8';
const DEFAULT_FIRST_CELL_INDENT = 'pl-4';
const buildGuestId = (guest: Guest) => {
if (guest.id) return guest.id;
return `${guest.instance}-${guest.vmid}`;
};
// Type guard for VM vs Container
const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
};
// Column configuration using the priority system
interface ColumnDef {
id: string;
label: string;
priority: ColumnPriority;
minWidth?: string;
maxWidth?: string;
flex?: number;
}
const GUEST_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', flex: 1.5 },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '32px' },
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '36px' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '50px' },
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', flex: 1 },
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', flex: 1 },
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', flex: 1 },
// I/O columns - fixed width, no flex
{ id: 'diskRead', label: 'D Read', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
{ id: 'diskWrite', label: 'D Write', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
];
interface GuestRowProps {
guest: Guest;
alertStyles?: {
rowClass: string;
indicatorClass: string;
badgeClass: string;
hasAlert: boolean;
alertCount: number;
severity: 'critical' | 'warning' | null;
hasPoweredOffAlert?: boolean;
hasNonPoweredOffAlert?: boolean;
hasUnacknowledgedAlert?: boolean;
unacknowledgedCount?: number;
acknowledgedCount?: number;
hasAcknowledgedOnlyAlert?: boolean;
};
customUrl?: string;
onTagClick?: (tag: string) => void;
activeSearch?: string;
parentNodeOnline?: boolean;
onCustomUrlUpdate?: (guestId: string, url: string) => void;
isGroupedView?: boolean;
}
export function GuestRow(props: GuestRowProps) {
const guestId = createMemo(() => buildGuestId(props.guest));
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
// Use the responsive grid template hook for dynamic column visibility
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: GUEST_COLUMNS });
// Create namespaced metrics key
const metricsKey = createMemo(() => {
const kind = props.guest.type === 'qemu' ? 'vm' : 'container';
return buildMetricKey(kind, guestId());
});
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
const drawerOpen = createMemo(() => currentlyExpandedGuestId() === guestId());
const editingUrlValue = createMemo(() => {
editingValuesVersion(); // Subscribe to changes
return editingValues.get(guestId()) || '';
});
let urlInputRef: HTMLInputElement | undefined;
const hasFilesystemDetails = createMemo(() => (props.guest.disks?.length ?? 0) > 0);
const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []);
const networkInterfaces = createMemo(() => props.guest.networkInterfaces ?? []);
const hasNetworkInterfaces = createMemo(() => networkInterfaces().length > 0);
const osName = createMemo(() => props.guest.osName?.trim() ?? '');
const osVersion = createMemo(() => props.guest.osVersion?.trim() ?? '');
const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? '');
const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0);
const hasAgentInfo = createMemo(() => agentVersion().length > 0);
// Update custom URL when prop changes, but only if we're not currently editing
createEffect(() => {
// Don't update customUrl from props if this guest is currently being edited
if (currentlyEditingGuestId() !== guestId()) {
const prevUrl = customUrl();
const newUrl = props.customUrl;
// Only animate when URL transitions from empty to having a value
if (!prevUrl && newUrl) {
setShouldAnimateIcon(true);
// Remove animation class after it completes
setTimeout(() => setShouldAnimateIcon(false), 200);
}
setCustomUrl(newUrl);
}
});
const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
// I/O metrics - must use memos for reactivity with WebSocket updates
const diskRead = createMemo(() => props.guest.diskRead || 0);
const diskWrite = createMemo(() => props.guest.diskWrite || 0);
const networkIn = createMemo(() => props.guest.networkIn || 0);
const networkOut = createMemo(() => props.guest.networkOut || 0);
const memPercent = createMemo(() => {
if (!props.guest.memory) return 0;
return props.guest.memory.usage || 0;
});
const memoryUsageLabel = createMemo(() => {
if (!props.guest.memory) return undefined;
const used = props.guest.memory.used ?? 0;
const total = props.guest.memory.total ?? 0;
return `${formatBytes(used, 0)}/${formatBytes(total, 0)}`;
});
const memoryExtraLines = createMemo(() => {
if (!props.guest.memory) return undefined;
const lines: string[] = [];
const total = props.guest.memory.total ?? 0;
if (
props.guest.memory.balloon &&
props.guest.memory.balloon > 0 &&
props.guest.memory.balloon !== total
) {
lines.push(`Balloon: ${formatBytes(props.guest.memory.balloon, 0)}`);
}
if (props.guest.memory.swapTotal && props.guest.memory.swapTotal > 0) {
const swapUsed = props.guest.memory.swapUsed ?? 0;
lines.push(`Swap: ${formatBytes(swapUsed, 0)} / ${formatBytes(props.guest.memory.swapTotal, 0)}`);
}
return lines.length > 0 ? lines : undefined;
});
const memoryTooltip = createMemo(() => memoryExtraLines()?.join('\n') ?? undefined);
const hasDrawerContent = createMemo(
() =>
hasOsInfo() ||
hasAgentInfo() ||
ipAddresses().length > 0 ||
(memoryExtraLines()?.length ?? 0) > 0 ||
hasFilesystemDetails() ||
hasNetworkInterfaces(),
);
const hasFallbackContent = createMemo(
() => !hasDrawerContent() && (props.guest.type === 'qemu' || props.guest.type === 'lxc'),
);
const canShowDrawer = createMemo(() => hasDrawerContent() || hasFallbackContent());
createEffect(() => {
if (!canShowDrawer() && drawerOpen()) {
setCurrentlyExpandedGuestId(null);
}
});
const toggleDrawer = (event: MouseEvent) => {
if (!canShowDrawer()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, input, [data-prevent-toggle]')) {
return;
}
setCurrentlyExpandedGuestId(prev => prev === guestId() ? null : guestId());
};
const startEditingUrl = (event: MouseEvent) => {
event.stopPropagation();
const currentEditing = currentlyEditingGuestId();
if (currentEditing !== null && currentEditing !== guestId()) {
const currentInput = document.querySelector(`input[data-guest-id="${currentEditing}"]`) as HTMLInputElement;
if (currentInput) {
currentInput.blur();
}
}
editingValues.set(guestId(), customUrl() || '');
setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(guestId());
};
createEffect(() => {
if (isEditingUrl() && urlInputRef) {
urlInputRef.focus();
urlInputRef.select();
}
});
let isCurrentlyMounted = true;
createEffect(() => {
if (isEditingUrl() && isCurrentlyMounted) {
const handleGlobalClick = (e: MouseEvent) => {
if (currentlyEditingGuestId() !== guestId()) return;
const target = e.target as HTMLElement;
const isClickingGuestName = target.closest('[data-guest-name-editable]');
if (!target.closest('[data-url-editor]') && !isClickingGuestName) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
cancelEditingUrl();
}
};
const handleGlobalMouseDown = (e: MouseEvent) => {
if (currentlyEditingGuestId() !== guestId()) return;
const target = e.target as HTMLElement;
const isClickingGuestName = target.closest('[data-guest-name-editable]');
if (!target.closest('[data-url-editor]') && !isClickingGuestName) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
};
document.addEventListener('mousedown', handleGlobalMouseDown, true);
document.addEventListener('click', handleGlobalClick, true);
return () => {
document.removeEventListener('mousedown', handleGlobalMouseDown, true);
document.removeEventListener('click', handleGlobalClick, true);
};
}
});
const saveUrl = async () => {
if (currentlyEditingGuestId() !== guestId()) return;
const newUrl = (editingValues.get(guestId()) || '').trim();
editingValues.delete(guestId());
setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(null);
if (newUrl === (customUrl() || '')) return;
try {
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl });
const hadUrl = !!customUrl();
if (!hadUrl && newUrl) {
setShouldAnimateIcon(true);
setTimeout(() => setShouldAnimateIcon(false), 200);
}
setCustomUrl(newUrl || undefined);
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(guestId(), newUrl);
}
if (newUrl) {
showSuccess('Guest URL saved');
} else {
showSuccess('Guest URL cleared');
}
} catch (err: any) {
logger.error('Failed to save guest URL:', err);
showError(err.message || 'Failed to save guest URL');
}
};
const deleteUrl = async () => {
if (currentlyEditingGuestId() !== guestId()) return;
editingValues.delete(guestId());
setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(null);
if (customUrl()) {
try {
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: '' });
setCustomUrl(undefined);
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(guestId(), '');
}
showSuccess('Guest URL removed');
} catch (err: any) {
logger.error('Failed to remove guest URL:', err);
showError(err.message || 'Failed to remove guest URL');
}
}
};
const cancelEditingUrl = () => {
if (currentlyEditingGuestId() !== guestId()) return;
editingValues.delete(guestId());
setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(null);
};
const diskPercent = createMemo(() => {
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
if (props.guest.disk.usage === -1) return -1;
return (props.guest.disk.used / props.guest.disk.total) * 100;
});
const hasDiskUsage = createMemo(() => {
if (!props.guest.disk) return false;
if (props.guest.disk.total <= 0) return false;
return diskPercent() !== -1;
});
const parentOnline = createMemo(() => props.parentNodeOnline !== false);
const isRunning = createMemo(() => isGuestRunning(props.guest, parentOnline()));
const guestStatus = createMemo(() => getGuestPowerIndicator(props.guest, parentOnline()));
const lockLabel = createMemo(() => (props.guest.lock || '').trim());
const getDiskStatusTooltip = () => {
if (!isVM(props.guest)) return 'Disk stats unavailable';
const vm = props.guest as VM;
const reason = vm.diskStatusReason;
switch (reason) {
case 'agent-not-running':
return 'Guest agent not running. Install and start qemu-guest-agent in the VM.';
case 'agent-timeout':
return 'Guest agent timeout. Agent may need to be restarted.';
case 'permission-denied':
return 'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).';
case 'agent-disabled':
return 'Guest agent is disabled in VM configuration. Enable it in VM Options.';
case 'no-filesystems':
return 'No filesystems found. VM may be booting or using a Live ISO.';
case 'special-filesystems-only':
return 'Only special filesystems detected (ISO/squashfs). This is normal for Live systems.';
case 'agent-error':
return 'Error communicating with guest agent.';
case 'no-data':
return 'No disk data available from Proxmox API.';
default:
return 'Disk stats unavailable. Guest agent may not be installed.';
}
};
const hasUnacknowledgedAlert = createMemo(() => !!props.alertStyles?.hasUnacknowledgedAlert);
const hasAcknowledgedOnlyAlert = createMemo(() => !!props.alertStyles?.hasAcknowledgedOnlyAlert);
const showAlertHighlight = createMemo(
() => hasUnacknowledgedAlert() || hasAcknowledgedOnlyAlert(),
);
const alertAccentColor = createMemo(() => {
if (!showAlertHighlight()) return undefined;
if (hasUnacknowledgedAlert()) {
return props.alertStyles?.severity === 'critical' ? '#ef4444' : '#eab308';
}
return '#9ca3af';
});
const rowClass = createMemo(() => {
const base = 'transition-all duration-200 relative';
const hover = 'hover:shadow-sm';
const alertBg = hasUnacknowledgedAlert()
? props.alertStyles?.severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30'
: 'bg-yellow-50 dark:bg-yellow-950/20'
: 'bg-white dark:bg-gray-800';
const defaultHover = hasUnacknowledgedAlert()
? ''
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
const clickable = canShowDrawer() ? 'cursor-pointer' : '';
const expanded = drawerOpen() && !hasUnacknowledgedAlert()
? 'bg-gray-50 dark:bg-gray-800/40'
: '';
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`;
});
const rowStyle = createMemo(() => {
if (!showAlertHighlight()) return {};
const color = alertAccentColor();
if (!color) return {};
return {
'box-shadow': `inset 4px 0 0 0 ${color}`,
};
});
// Render cell content based on column type
const renderCell = (column: ColumnDef) => {
switch (column.id) {
case 'name':
return (
<div class={`px-1 py-1 flex items-center min-w-0 ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
<div class="flex items-center gap-2 min-w-0 w-full">
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<StatusDot
variant={guestStatus().variant}
title={guestStatus().label}
ariaLabel={guestStatus().label}
size="xs"
/>
<div class="flex-1 min-w-0">
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 min-w-0">
<span
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis whitespace-nowrap"
style="cursor: text;"
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
data-guest-name-editable
>
{props.guest.name}
</span>
<Show when={customUrl()}>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
</div>
}
>
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
data-guest-id={guestId()}
onInput={(e) => {
editingValues.set(guestId(), e.currentTarget.value);
setEditingValuesVersion(v => v + 1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://192.168.1.100:8006"
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div>
</Show>
</div>
</div>
<Show when={!isEditingUrl()}>
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
<TagBadges
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
maxVisible={3}
onTagClick={props.onTagClick}
activeSearch={props.activeSearch}
/>
</div>
</Show>
<Show when={lockLabel()}>
<span
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"
title={`Guest is locked (${lockLabel()})`}
>
Lock: {lockLabel()}
</span>
</Show>
</div>
</div>
);
case 'type':
return (
<div class="px-0.5 py-1 flex justify-center items-center">
<span
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded ${props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}
title={isVM(props.guest) ? 'Virtual Machine' : 'LXC Container'}
>
{isVM(props.guest) ? 'VM' : 'LXC'}
</span>
</div>
);
case 'vmid':
return (
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[30px] md:w-full flex justify-center md:justify-start items-center text-xs text-gray-600 dark:text-gray-400">
{props.guest.vmid}
</div>
);
case 'uptime':
return (
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[40px] md:w-full flex justify-center md:justify-start items-center">
<div class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
<Show when={isRunning()} fallback="-">
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
{formatUptime(props.guest.uptime, true)}
</Show>
</Show>
</div>
</div>
);
case 'cpu':
return (
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
<ResponsiveMetricCell
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
sublabel={
props.guest.cpus
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
: undefined
}
isRunning={isRunning()}
showMobile={isMobile()}
class="w-full"
/>
</div>
);
case 'memory':
return (
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
<div title={memoryTooltip() ?? undefined} class="w-full text-center xl:text-left">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={isMobile()}
class="w-full"
/>
</div>
</div>
);
case 'disk':
return (
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
<Show
when={hasDiskUsage()}
fallback={
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
-
</span>
}
>
<ResponsiveMetricCell
value={diskPercent()}
type="disk"
resourceId={metricsKey()}
sublabel={
props.guest.disk
? `${formatBytes(props.guest.disk.used, 0)}/${formatBytes(props.guest.disk.total, 0)}`
: undefined
}
isRunning={true}
showMobile={isMobile()}
class="w-full"
/>
</Show>
</div>
);
case 'diskRead':
return (
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
<span class={getIOColorClass(diskRead())}>{formatSpeed(diskRead())}</span>
</Show>
</div>
);
case 'diskWrite':
return (
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
<span class={getIOColorClass(diskWrite())}>{formatSpeed(diskWrite())}</span>
</Show>
</div>
);
case 'netIn':
return (
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
<span class={getIOColorClass(networkIn())}>{formatSpeed(networkIn())}</span>
</Show>
</div>
);
case 'netOut':
return (
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
<span class={getIOColorClass(networkOut())}>{formatSpeed(networkOut())}</span>
</Show>
</div>
);
default:
return null;
}
};
return (
<>
<div
class={`${rowClass()} grid items-center`}
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
onClick={toggleDrawer}
aria-expanded={drawerOpen()}
>
<For each={visibleColumns()}>
{(column) => renderCell(column)}
</For>
</div>
<Show when={drawerOpen() && canShowDrawer()}>
<div class="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<div class="p-4">
<GuestDrawer
guest={props.guest}
metricsKey={metricsKey()}
onClose={() => setCurrentlyExpandedGuestId(null)}
/>
</div>
</div>
</Show>
</>
);
}