import type { Component } from 'solid-js'; import { For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import type { Host } from '@/types/api'; import { formatBytes, formatNumber, formatPercent, formatRelativeTime, formatUptime } from '@/utils/format'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { StackedDiskBar } from '@/components/Dashboard/StackedDiskBar'; import { HostsFilter } from './HostsFilter'; import { useWebSocket } from '@/App'; import { StatusDot } from '@/components/shared/StatusDot'; import { getHostStatusIndicator } from '@/utils/status'; import { MetricText } from '@/components/shared/responsive'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import { GuestMetadataAPI } from '@/api/guestMetadata'; import { AIAPI } from '@/api/ai'; import { aiChatStore } from '@/stores/aiChat'; import { logger } from '@/utils/logger'; // Global drawer state to persist across re-renders const drawerState = new Map(); type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime'; interface HostsOverviewProps { hosts: Host[]; connectionHealth: Record; } export const HostsOverview: Component = (props) => { const navigate = useNavigate(); const wsContext = useWebSocket(); const [search, setSearch] = createSignal(''); const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all'); const [sortKey, setSortKey] = createSignal('name'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const { isMobile } = useBreakpoint(); const handleSort = (key: SortKey) => { if (sortKey() === key) { setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); } else { setSortKey(key); setSortDirection('asc'); } }; const renderSortIndicator = (key: SortKey) => { if (sortKey() !== key) return null; return sortDirection() === 'asc' ? '▲' : '▼'; }; // Keyboard listener to auto-focus search let searchInputRef: HTMLInputElement | undefined; const handleKeyDown = (e: KeyboardEvent) => { // Don't interfere if user is already typing in an input const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } // Don't interfere with modifier key shortcuts (except Shift for capitals) if (e.ctrlKey || e.metaKey || e.altKey) { return; } // Focus search on printable characters and start typing if (e.key.length === 1 && searchInputRef) { e.preventDefault(); searchInputRef.focus(); setSearch(search() + e.key); } }; onMount(() => { document.addEventListener('keydown', handleKeyDown); }); onCleanup(() => { document.removeEventListener('keydown', handleKeyDown); }); const connected = () => wsContext.connected(); const reconnecting = () => wsContext.reconnecting(); const reconnect = () => wsContext.reconnect(); const isInitialLoading = createMemo(() => { // Only show loading spinner when we've never been connected and have no hosts return !connected() && !reconnecting() && props.hosts.length === 0; }); const sortedHosts = createMemo(() => { const hosts = [...props.hosts]; const key = sortKey(); const direction = sortDirection(); return hosts.sort((a, b) => { let comparison = 0; switch (key) { case 'name': comparison = (a.displayName || a.hostname || a.id).localeCompare(b.displayName || b.hostname || b.id); break; case 'platform': comparison = (a.platform || '').localeCompare(b.platform || ''); break; case 'cpu': comparison = (a.cpuUsage ?? 0) - (b.cpuUsage ?? 0); break; case 'memory': comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0); break; case 'disk': { const aDisk = a.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0; const bDisk = b.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0; comparison = aDisk - bDisk; break; } case 'uptime': comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0); break; default: comparison = 0; } return direction === 'asc' ? comparison : -comparison; }); }); const matchesSearch = (host: Host) => { const term = search().toLowerCase(); if (!term) return true; const hostname = (host.hostname || '').toLowerCase(); const displayName = (host.displayName || '').toLowerCase(); const platform = (host.platform || '').toLowerCase(); const osName = (host.osName || '').toLowerCase(); return ( hostname.includes(term) || displayName.includes(term) || platform.includes(term) || osName.includes(term) ); }; const matchesStatus = (host: Host) => { const filter = statusFilter(); if (filter === 'all') return true; const normalized = (host.status || '').toLowerCase(); return normalized === filter; }; const filteredHosts = createMemo(() => { return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host)); }); const getTemperatureValue = (host: Host) => { if (!host.sensors?.temperatureCelsius) return null; const temps = host.sensors.temperatureCelsius; // Try to find a "package" or "composite" temperature first const packageKey = Object.keys(temps).find(k => k.toLowerCase().includes('package') || k.toLowerCase().includes('composite') || k.toLowerCase().includes('tctl') ); if (packageKey) return temps[packageKey]; // Fallback: average of all core temps const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core')); if (coreKeys.length > 0) { const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0); return sum / coreKeys.length; } // Fallback: just take the first available value if any const values = Object.values(temps); if (values.length > 0) return values[0]; return null; }; const getDiskStats = (host: Host) => { if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 }; const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0); const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0); return { percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0, used: totalUsed, total: totalSize }; }; const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"; return (
} title="Loading host data..." description="Connecting to the monitoring service." /> {/* Disconnected State */} } title="Connection lost" description={ reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server' } tone="danger" actions={ !reconnecting() ? ( ) : undefined } /> {/* Filters */} setSearch('')} searchInputRef={(el) => (searchInputRef = el)} /> {/* Host Table */} 0} fallback={ } >
{(host) => { // Drawer state const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); // AI and annotations state const [aiEnabled, setAiEnabled] = createSignal(false); const [annotations, setAnnotations] = createSignal([]); const [newAnnotation, setNewAnnotation] = createSignal(''); const [saving, setSaving] = createSignal(false); // Load AI settings and annotations when drawer opens createEffect(() => { if (drawerOpen()) { AIAPI.getSettings() .then((settings) => setAiEnabled(settings.enabled && settings.configured)) .catch((err) => logger.debug('[HostsOverview] AI settings check failed:', err)); GuestMetadataAPI.getMetadata(`host-${host.id}`) .then((meta) => { if (meta.notes && Array.isArray(meta.notes)) setAnnotations(meta.notes); }) .catch((err) => logger.debug('[HostsOverview] Failed to load annotations:', err)); } }); const saveAnnotations = async (updated: string[]) => { setSaving(true); try { await GuestMetadataAPI.updateMetadata(`host-${host.id}`, { notes: updated }); } catch (err) { logger.error('[HostsOverview] 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 handleAskAI = () => { const context: Record = { hostName: host.displayName || host.hostname, hostname: host.hostname, platform: host.platform, osName: host.osName, osVersion: host.osVersion, cpuUsage: host.cpuUsage ? `${host.cpuUsage.toFixed(1)}%` : undefined, memoryUsage: host.memory?.usage ? `${host.memory.usage.toFixed(1)}%` : undefined, uptime: host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : undefined, }; if (annotations().length > 0) context.user_annotations = annotations(); aiChatStore.openForTarget('host', host.id, context); }; // Check if we have additional info to show in drawer const hasDrawerContent = createMemo(() => { return ( (host.disks && host.disks.length > 0) || (host.diskIO && host.diskIO.length > 0) || (host.networkInterfaces && host.networkInterfaces.length > 0) || (host.raid && host.raid.length > 0) || host.loadAverage || host.cpuCount || host.kernelVersion || host.architecture || host.agentVersion || (host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0) ); }); const toggleDrawer = (event: MouseEvent) => { if (!hasDrawerContent()) return; const target = event.target as HTMLElement; if (target.closest('a, button, [data-prevent-toggle]')) { return; } setDrawerOpen((prev) => !prev); }; // Sync drawer state createEffect(on(() => host.id, (id) => { const stored = drawerState.get(id); if (stored !== undefined) { setDrawerOpen(stored); } else { setDrawerOpen(false); } })); createEffect(() => { drawerState.set(host.id, drawerOpen()); }); const rowClass = () => { const base = 'transition-all duration-200'; const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : ''; return `${base} ${hover} ${clickable} ${expanded}`; }; const hostStatus = createMemo(() => getHostStatusIndicator(host)); const cpuPercent = host.cpuUsage ?? 0; const memPercent = host.memory?.usage ?? 0; const tempValue = getTemperatureValue(host); const diskStats = getDiskStats(host); return ( <> {/* Host Name */} {/* Platform */} {/* CPU */} {/* Memory */} {/* Temperature */} {/* Disk */} {/* Uptime */} {/* Agent Version */} {/* Drawer - Additional Info */} ); }}
handleSort('name')} > Host {renderSortIndicator('name')} handleSort('platform')}> Platform {renderSortIndicator('platform')} handleSort('cpu')}> CPU {renderSortIndicator('cpu')} handleSort('memory')}> Memory {renderSortIndicator('memory')} Temp handleSort('disk')}> Disk {renderSortIndicator('disk')} handleSort('uptime')}> Uptime {renderSortIndicator('uptime')} Agent

{host.displayName || host.hostname || host.id}

{host.hostname}

Updated {formatRelativeTime(host.lastSeen!)}

{host.platform || '—'}

{host.osName} {host.osVersion}

—}>
—} > {formatUptime(host.uptimeSeconds!)}
—} > {host.agentVersion}
{/* System Info */}
System
CPUs {host.cpuCount}
0}>
Load Avg {host.loadAverage!.map(l => l.toFixed(2)).join(', ')}
Arch {host.architecture}
Kernel {host.kernelVersion}
Agent {host.agentVersion}
{/* Network Interfaces */} 0}>
Network
{(iface) => (
{iface.name}
0}>
{(addr) => ( {addr} )}
)}
{/* Disk Info */} 0}>
Disks
{(disk) => { const diskPercent = () => disk.usage ?? 0; return (
{disk.mountpoint || disk.device} {formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
0}>
); }}
{/* Disk I/O */} 0}>
Disk I/O
{(io) => (
{io.device}
Read: {formatBytes(io.readBytes ?? 0, 1)}
Write: {formatBytes(io.writeBytes ?? 0, 1)}
Read Ops: {formatNumber(io.readOps ?? 0)}
Write Ops: {formatNumber(io.writeOps ?? 0)}
)}
{/* Temperature Sensors */} 0}>
Temperatures
{([name, temp]) => (
{name} 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}> {temp.toFixed(1)}°C
)}
{/* RAID Arrays */} 0}> {(array) => { const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0; const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0; const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean'); const stateColor = () => { if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold'; if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold'; if (isHealthy()) return 'text-green-600 dark:text-green-400'; return 'text-gray-600 dark:text-gray-300'; }; return (
RAID {array.level.replace('raid', '')} - {array.device}
State {array.state}
Devices {array.activeDevices}/{array.totalDevices} {array.failedDevices > 0 && ({array.failedDevices} failed)}
0}>
Rebuild {array.rebuildPercent.toFixed(1)}%
Speed {array.rebuildSpeed}
); }}
{/* AI Context & Ask AI row */}
AI Context saving...
{/* Existing annotations */} 0}>
{(annotation, index) => ( {annotation} )}
{/* Add new annotation + Ask AI */}
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" />
} > } title="No hosts reporting" description="Install the Pulse host agent on Linux, macOS, or Windows machines to begin monitoring." actions={ } />
); };