Pulse/frontend-modern/src/components/Dashboard/GuestDrawer.tsx
rcourtman a15dc6b899 feat: Toggleable table columns with rich tooltips for Proxmox dashboard
Replace drawer-based info display with inline columns that can be toggled:
- Add IP, Uptime, Node, Backup, OS, Tags columns (user-toggleable)
- Add ColumnPicker dropdown to show/hide columns with localStorage persistence
- Columns auto-show based on screen width using priority system
- Remove GuestDrawer - all info now visible inline or via tooltips

Rich hover tooltips:
- Disk bar: Shows all mount points with usage %, color-coded by severity
- Memory bar: Shows used/free/balloon/swap breakdown
- IP column: Shows network icon + count, hover for interfaces, MACs, IPs, traffic

Also:
- Create useColumnVisibility hook for responsive column management
- Create ColumnPicker component for column toggle UI
- Update drawer layouts in Hosts/Docker tabs for consistency
2025-12-05 12:12:56 +00:00

501 lines
26 KiB
TypeScript

import { Component, Show, For, createSignal, createEffect } from 'solid-js';
import { VM, Container } from '@/types/api';
import { formatBytes, formatPercent, formatSpeed, formatUptime } from '@/utils/format';
import { DiskList } from './DiskList';
import { aiChatStore } from '@/stores/aiChat';
import { AIAPI } from '@/api/ai';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import { logger } from '@/utils/logger';
type Guest = VM | Container;
interface GuestDrawerProps {
guest: Guest;
metricsKey: string;
onClose: () => void;
}
export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
const [aiEnabled, setAiEnabled] = createSignal(false);
const [annotations, setAnnotations] = createSignal<string[]>([]);
const [newAnnotation, setNewAnnotation] = createSignal('');
const [saving, setSaving] = createSignal(false);
// Build guest ID for metadata
const guestId = () => props.guest.id || `${props.guest.instance}-${props.guest.vmid}`;
// Check if AI is enabled and load annotations on mount
createEffect(() => {
AIAPI.getSettings()
.then((settings) => setAiEnabled(settings.enabled && settings.configured))
.catch((err) => logger.debug('[GuestDrawer] AI settings check failed:', err));
// Load existing annotations
GuestMetadataAPI.getMetadata(guestId())
.then((meta) => {
if (meta.notes && Array.isArray(meta.notes)) setAnnotations(meta.notes);
})
.catch((err) => logger.debug('[GuestDrawer] Failed to load annotations:', err));
});
// Update AI context whenever guest changes or annotations are loaded
createEffect(() => {
const guestType = props.guest.type === 'qemu' ? 'vm' : 'container';
// Track annotations to re-run when they change
void annotations();
aiChatStore.setTargetContext(guestType, guestId(), {
guestName: props.guest.name,
...buildGuestContext(),
});
});
// Note: We no longer clear context on unmount - context accumulates across navigation
// Users can clear individual items or all context from the AI panel
const saveAnnotations = async (updated: string[]) => {
setSaving(true);
try {
await GuestMetadataAPI.updateMetadata(guestId(), { notes: updated });
logger.debug('[GuestDrawer] Annotations saved');
} catch (err) {
logger.error('[GuestDrawer] Failed to save annotations:', err);
} finally {
setSaving(false);
}
};
const addAnnotation = () => {
const text = newAnnotation().trim();
if (!text) return;
const updated = [...annotations(), text];
setAnnotations(updated);
setNewAnnotation('');
saveAnnotations(updated);
};
const removeAnnotation = (index: number) => {
const updated = annotations().filter((_, i) => i !== index);
setAnnotations(updated);
saveAnnotations(updated);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addAnnotation();
}
};
const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
};
// Build comprehensive context for AI
const buildGuestContext = () => {
const guest = props.guest;
const context: Record<string, unknown> = {
name: guest.name,
type: guest.type === 'qemu' ? 'Virtual Machine' : 'LXC Container',
vmid: guest.vmid,
node: guest.node, // PVE node this guest lives on
guest_node: guest.node, // For backend agent routing
status: guest.status,
uptime: guest.uptime ? formatUptime(guest.uptime) : 'Not running',
};
// CPU info
if (guest.cpu !== undefined) {
context.cpu_usage = formatPercent(guest.cpu * 100);
}
if (guest.cpus) {
context.cpu_cores = guest.cpus;
}
// Memory info
if (guest.memory) {
context.memory_used = formatBytes(guest.memory.used || 0);
context.memory_total = formatBytes(guest.memory.total || 0);
context.memory_usage = formatPercent(guest.memory.usage || 0);
if (guest.memory.balloon && guest.memory.balloon !== guest.memory.total) {
context.memory_balloon = formatBytes(guest.memory.balloon);
}
if (guest.memory.swapTotal && guest.memory.swapTotal > 0) {
context.swap_used = formatBytes(guest.memory.swapUsed || 0);
context.swap_total = formatBytes(guest.memory.swapTotal);
}
}
// Disk info
if (guest.disk && guest.disk.total > 0) {
context.disk_used = formatBytes(guest.disk.used || 0);
context.disk_total = formatBytes(guest.disk.total || 0);
context.disk_usage = formatPercent((guest.disk.used / guest.disk.total) * 100);
}
// I/O rates
if (guest.diskRead !== undefined) {
context.disk_read_rate = formatSpeed(guest.diskRead);
}
if (guest.diskWrite !== undefined) {
context.disk_write_rate = formatSpeed(guest.diskWrite);
}
if (guest.networkIn !== undefined) {
context.network_in_rate = formatSpeed(guest.networkIn);
}
if (guest.networkOut !== undefined) {
context.network_out_rate = formatSpeed(guest.networkOut);
}
// OS info (both VMs and containers can have this)
if (guest.osName) context.os_name = guest.osName;
if (guest.osVersion) context.os_version = guest.osVersion;
if (guest.agentVersion) context.guest_agent = guest.agentVersion;
if (guest.ipAddresses?.length) context.ip_addresses = guest.ipAddresses;
// Tags
if (guest.tags?.length) {
context.tags = guest.tags;
}
// Backup info - Pulse already has this from PBS
if (guest.lastBackup) {
const backupDate = new Date(guest.lastBackup);
const now = new Date();
const daysSinceBackup = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
context.last_backup = backupDate.toISOString();
context.days_since_backup = daysSinceBackup;
if (daysSinceBackup > 30) {
context.backup_status = 'CRITICAL - No backup in over 30 days';
} else if (daysSinceBackup > 7) {
context.backup_status = 'Warning - Backup is over a week old';
} else {
context.backup_status = 'OK';
}
} else {
context.backup_status = 'NEVER - No backup recorded';
}
// User annotations (for AI context)
if (annotations().length > 0) {
context.user_annotations = annotations();
}
return context;
};
const handleAskAI = () => {
const guestType = props.guest.type === 'qemu' ? 'vm' : 'container';
const guestId = props.guest.id || `${props.guest.instance}-${props.guest.vmid}`;
aiChatStore.openForTarget(guestType, guestId, {
guestName: props.guest.name,
...buildGuestContext(),
});
};
const hasOsInfo = () => {
// Both VMs and containers can have OS info
return (props.guest.osName?.length ?? 0) > 0 || (props.guest.osVersion?.length ?? 0) > 0;
};
const osName = () => {
return props.guest.osName || '';
};
const osVersion = () => {
return props.guest.osVersion || '';
};
const hasAgentInfo = () => {
return !!props.guest.agentVersion;
};
const agentVersion = () => {
return props.guest.agentVersion || '';
};
const ipAddresses = () => {
return props.guest.ipAddresses || [];
};
const memoryExtraLines = () => {
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 hasFilesystemDetails = () => {
if (!props.guest.disks) return false;
return props.guest.disks.length > 0;
};
const networkInterfaces = () => {
// Both VMs and containers can have network interfaces
return props.guest.networkInterfaces || [];
};
const hasNetworkInterfaces = () => {
return networkInterfaces().length > 0;
};
return (
<div class="space-y-3">
{/* Flex layout - items grow to fill space, max ~4 per row */}
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
{/* System Info - always show */}
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">System</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.cpus}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.cpus}</span>
</div>
</Show>
<Show when={props.guest.uptime > 0}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.guest.uptime)}</span>
</div>
</Show>
<Show when={props.guest.node}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Node</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.node}</span>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Agent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2" title={isVM(props.guest) ? `QEMU guest agent ${agentVersion()}` : agentVersion()}>
{isVM(props.guest) ? `QEMU ${agentVersion()}` : agentVersion()}
</span>
</div>
</Show>
</div>
</div>
{/* Guest Info - OS and IPs */}
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Guest Info</div>
<div class="space-y-2">
<Show when={hasOsInfo()}>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<Show when={osName().length > 0}>
<span class="font-medium">{osName()}</span>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500 mx-1"></span>
</Show>
<Show when={osVersion().length > 0}>
<span>{osVersion()}</span>
</Show>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
{/* Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Memory</div>
<div class="space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</div>
</Show>
{/* Backup Info */}
<Show when={props.guest.lastBackup}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Backup</div>
<div class="space-y-1 text-[11px]">
{(() => {
const backupDate = new Date(props.guest.lastBackup);
const now = new Date();
const daysSince = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
const isOld = daysSince > 7;
const isCritical = daysSince > 30;
return (
<>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Last Backup</span>
<span class={`font-medium ${isCritical ? 'text-red-600 dark:text-red-400' : isOld ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'}`}>
{daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`}
</span>
</div>
<div class="text-[10px] text-gray-400 dark:text-gray-500">
{backupDate.toLocaleDateString()}
</div>
</>
);
})()}
</div>
</div>
</Show>
{/* Tags */}
<Show when={props.guest.tags && (Array.isArray(props.guest.tags) ? props.guest.tags.length > 0 : props.guest.tags.length > 0)}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Tags</div>
<div class="flex flex-wrap gap-1">
<For each={Array.isArray(props.guest.tags) ? props.guest.tags : (props.guest.tags?.split(',') || [])}>
{(tag) => (
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{tag.trim()}
</span>
)}
</For>
</div>
</div>
</Show>
{/* Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Filesystems</div>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
/>
</div>
</div>
</Show>
{/* Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Network</div>
<div class="space-y-2">
<For each={networkInterfaces().slice(0, 4)}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
<div class="flex items-center gap-2 text-[11px] font-medium text-gray-700 dark:text-gray-200">
<span class="truncate">{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[9px] text-gray-400 dark:text-gray-500 font-normal">{iface.mac}</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1">
<For each={addresses}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex gap-3 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<span>RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
</div>
{/* Bottom row: AI Context & Ask AI - only show when AI is enabled */}
<Show when={aiEnabled()}>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2">
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">AI Context</span>
<Show when={saving()}>
<span class="text-[9px] text-gray-400">saving...</span>
</Show>
</div>
{/* Existing annotations */}
<Show when={annotations().length > 0}>
<div class="flex flex-wrap gap-1.5">
<For each={annotations()}>
{(annotation, index) => (
<span class="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded-md bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
<span class="max-w-[300px] truncate">{annotation}</span>
<button
type="button"
onClick={() => removeAnnotation(index())}
class="ml-0.5 p-0.5 rounded hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors"
title="Remove"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
)}
</For>
</div>
</Show>
{/* Add new annotation + Ask AI */}
<div class="flex items-center gap-2">
<input
type="text"
value={newAnnotation()}
onInput={(e) => setNewAnnotation(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder="Add context for AI (press Enter)..."
class="flex-1 px-2 py-1.5 text-[11px] rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
/>
<button
type="button"
onClick={addAnnotation}
disabled={!newAnnotation().trim()}
class="px-2 py-1.5 text-[11px] rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
<button
type="button"
onClick={handleAskAI}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white text-[11px] font-medium shadow-sm hover:from-purple-600 hover:to-pink-600 transition-all"
title={`Ask AI about ${props.guest.name}`}
>
<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="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-2.576.43a18.003 18.003 0 01-5.118 0l-2.576-.43c-1.717-.293-2.299-2.379-1.067-3.611L5 14.5" />
</svg>
Ask AI
</button>
</div>
</div>
</Show>
</div>
);
};