diff --git a/frontend-modern/src/components/Dashboard/MetricBar.tsx b/frontend-modern/src/components/Dashboard/MetricBar.tsx index eaf54cd..455cb6e 100644 --- a/frontend-modern/src/components/Dashboard/MetricBar.tsx +++ b/frontend-modern/src/components/Dashboard/MetricBar.tsx @@ -9,6 +9,7 @@ interface MetricBarProps { sublabel?: string; type?: 'cpu' | 'memory' | 'disk' | 'generic'; resourceId?: string; // Required for sparkline mode to fetch history + class?: string; } // Estimate text width based on character count (rough approximation for 10px font) @@ -103,7 +104,7 @@ export function MetricBar(props: MetricBarProps) { fallback={ // Original progress bar mode - capped width for better appearance on wide screens
-
+
diff --git a/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx b/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx index 87d5fcd..98aa642 100644 --- a/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx +++ b/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx @@ -1,4 +1,5 @@ import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js'; +import { Portal } from 'solid-js/web'; import type { Disk } from '@/types/api'; import { formatBytes, formatPercent } from '@/utils/format'; @@ -109,8 +110,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) { const diskPercent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0; // Use warning/critical colors for high usage, otherwise use the color palette const color = diskPercent >= 90 ? getUsageColor(90) : - diskPercent >= 80 ? getUsageColor(80) : - SEGMENT_COLORS[idx % SEGMENT_COLORS.length]; + diskPercent >= 80 ? getUsageColor(80) : + SEGMENT_COLORS[idx % SEGMENT_COLORS.length]; return { disk, widthPercent: Math.min(usedPercent, 100), @@ -133,8 +134,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) { total: formatBytes(disk.total, 0), percent: formatPercent(percent), color: percent >= 90 ? getUsageColor(90) : - percent >= 80 ? getUsageColor(80) : - SEGMENT_COLORS[idx % SEGMENT_COLORS.length], + percent >= 80 ? getUsageColor(80) : + SEGMENT_COLORS[idx % SEGMENT_COLORS.length], }; }); } @@ -228,35 +229,37 @@ export function StackedDiskBar(props: StackedDiskBarProps) { {/* Tooltip for multi-disk breakdown */} -
-
-
- Disk Breakdown + +
+
+
+ Disk Breakdown +
+ + {(item, idx) => ( +
0 }}> + + {item.label} + + + {item.percent} ({item.used}/{item.total}) + +
+ )} +
- - {(item, idx) => ( -
0 }}> - - {item.label} - - - {item.percent} ({item.used}/{item.total}) - -
- )} -
-
+
); diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 5d895d5..df807d4 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -4,17 +4,31 @@ import { useNavigate } from '@solidjs/router'; import type { Host } from '@/types/api'; import { formatBytes, formatPercent, formatRelativeTime, formatUptime } from '@/utils/format'; import { Card } from '@/components/shared/Card'; -import { ScrollableTable } from '@/components/shared/ScrollableTable'; 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 { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive'; +import type { ColumnConfig } from '@/types/responsive'; +import { STANDARD_COLUMNS } from '@/types/responsive'; // Global drawer state to persist across re-renders const drawerState = new Map(); +type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'uptime'; + +const HOST_COLUMNS: ColumnConfig[] = [ + { ...STANDARD_COLUMNS.name, label: 'Host', minWidth: '200px', sortKey: 'name' }, + { id: 'platform', label: 'Platform', priority: 'primary', minWidth: '120px', flex: 1, sortable: true, sortKey: 'platform' }, + { ...STANDARD_COLUMNS.cpu, maxWidth: '156px', sortKey: 'cpu' }, + { ...STANDARD_COLUMNS.memory, maxWidth: '156px', sortKey: 'memory' }, + { id: 'disk', label: 'Disk', minWidth: '140px', maxWidth: '156px', priority: 'secondary' }, + { ...STANDARD_COLUMNS.uptime, maxWidth: '100px', align: 'right', sortKey: 'uptime' }, +]; + interface HostsOverviewProps { hosts: Host[]; connectionHealth: Record; @@ -25,6 +39,20 @@ export const HostsOverview: Component = (props) => { 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 handleSort = (key: string) => { + if (sortKey() === key) { + setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key as SortKey); + setSortDirection('asc'); + } + }; + + // Use responsive grid template + const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: HOST_COLUMNS }); // Keyboard listener to auto-focus search let searchInputRef: HTMLInputElement | undefined; @@ -64,13 +92,35 @@ export const HostsOverview: Component = (props) => { return !connected() && !reconnecting(); }); - const sortedHosts = createMemo(() => - [...props.hosts].sort((a, b) => { - const aName = a.displayName || a.hostname || a.id; - const bName = b.displayName || b.hostname || b.id; - return aName.localeCompare(bName); - }), - ); + 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 '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(); @@ -100,8 +150,136 @@ export const HostsOverview: Component = (props) => { return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host)); }); + const renderCell = (column: ColumnConfig, host: Host) => { + const cpuPercent = () => host.cpuUsage ?? 0; + const memPercent = () => host.memory?.usage ?? 0; + const memUsed = () => formatBytes(host.memory?.used ?? 0, 0); + const memTotal = () => formatBytes(host.memory?.total ?? 0, 0); + const hostStatus = createMemo(() => getHostStatusIndicator(host)); + + switch (column.id) { + case 'name': + return ( +
+
+ +
+

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

+ +

+ {host.hostname} +

+
+ +

+ Updated {formatRelativeTime(host.lastSeen!)} +

+
+
+
+
+ ); + case 'platform': + return ( +
+
+

{host.platform || '—'}

+ +

+ {host.osName} {host.osVersion} +

+
+
+
+ ); + case 'cpu': + return ( +
+ +
+ ); + case 'memory': + return ( +
+ +
+ ); + case 'disk': + const diskStats = (() => { + 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 diskPercent = diskStats.percent; + + + return ( +
+ +
+ +
+
+ +
+ ); + case 'uptime': + return ( +
+ —} + > + + {formatUptime(host.uptimeSeconds!)} + + +
+ ); + default: + return null; + } + }; + return ( -
+
= (props) => { } > - - - - - - - - - - - - - - - {(host) => { - const cpuPercent = () => host.cpuUsage ?? 0; - const memPercent = () => host.memory?.usage ?? 0; - const memUsed = () => formatBytes(host.memory?.used ?? 0, 0); - const memTotal = () => formatBytes(host.memory?.total ?? 0, 0); - // Calculate aggregate disk usage from all disks - const diskStats = createMemo(() => { - 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 diskPercent = () => diskStats().percent; - const diskUsed = () => formatBytes(diskStats().used, 0); - const diskTotal = () => formatBytes(diskStats().total, 0); - const hostStatus = createMemo(() => getHostStatusIndicator(host)); +
+ {/* Header */} +
+ + {(column) => ( +
column.sortable && column.sortKey && handleSort(column.sortKey)} + > +
+ {column.label} + {column.sortKey === sortKey() && ( + + {sortDirection() === 'asc' ? '▲' : '▼'} + + )} +
+
+ )} +
+
- // Drawer state - const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); - - // Check if we have additional info to show in drawer - const hasDrawerContent = createMemo(() => { - return ( - (host.disks && host.disks.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 = 'border-b border-gray-200 dark:border-gray-700 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}`; - }; + {/* Rows */} +
+ + {(host) => { + // Drawer state + const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); + // Check if we have additional info to show in drawer + const hasDrawerContent = createMemo(() => { return ( - <> -
- - - - - - - - {/* Drawer - Additional Info */} - - - - - - - ); - }} - - -
- Host - - Platform - - CPU - - Memory - - Disk - - Uptime -
-
-
- -

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

+ (host.disks && host.disks.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 = 'grid items-center 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}`; + }; + + return ( + <> +
+ + {(column) => renderCell(column, host)} + +
+ + {/* Drawer - Additional Info */} + +
+
+ {/* 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} +
+
- -

- {host.hostname} -

-
- -

- Updated {formatRelativeTime(host.lastSeen!)} -

-
-
-
-

{host.platform || '—'}

- -

- {host.osName} {host.osVersion} -

-
-
-
- 0} - fallback={} - > - - - - 0} - fallback={} - > - - - - 0} - fallback={} - > - - - - —} - > - - {formatUptime(host.uptimeSeconds!)} - - -
-
- {/* 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)} + {/* Network Interfaces */} + 0}> +
+
Network
+
+ + {(iface) => ( +
+
{iface.name}
+ 0}> +
+ + {(addr) => ( + + {addr} -
- 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 + {/* 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)}
- )} - -
-
- - - {/* 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} + 0}> +
+
-
- Devices - - {array.activeDevices}/{array.totalDevices} - {array.failedDevices > 0 && ({array.failedDevices} failed)} - -
- 0}> -
- Rebuild - - {array.rebuildPercent.toFixed(1)}% - -
- -
- Speed - {array.rebuildSpeed} -
-
-
-
+
); }} - +
-
-
+
+ + {/* 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} +
+
+
+
+
+ ); + }} +
+
+
+
+ + + ); + }} + +
+