feat: add stacked disk bar to hosts overview and fix tooltip positioning

- Replace standard disk metric bar with StackedDiskBar in HostsOverview
    - Show detailed per-disk usage breakdown on hover
    - Use Portal for tooltips to ensure correct positioning and z-index
    - Add class prop support to MetricBar
This commit is contained in:
courtmanr@gmail.com 2025-11-28 22:10:15 +00:00
parent 1d0c8b3ce1
commit 6852004ffb
3 changed files with 480 additions and 391 deletions

View file

@ -9,6 +9,7 @@ interface MetricBarProps {
sublabel?: string; sublabel?: string;
type?: 'cpu' | 'memory' | 'disk' | 'generic'; type?: 'cpu' | 'memory' | 'disk' | 'generic';
resourceId?: string; // Required for sparkline mode to fetch history resourceId?: string; // Required for sparkline mode to fetch history
class?: string;
} }
// Estimate text width based on character count (rough approximation for 10px font) // Estimate text width based on character count (rough approximation for 10px font)
@ -103,7 +104,7 @@ export function MetricBar(props: MetricBarProps) {
fallback={ fallback={
// Original progress bar mode - capped width for better appearance on wide screens // Original progress bar mode - capped width for better appearance on wide screens
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center"> <div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div class="relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"> <div class={`relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded ${props.class || ''}`}>
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} /> <div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none"> <span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5"> <span class="flex items-center gap-1 whitespace-nowrap px-0.5">

View file

@ -1,4 +1,5 @@
import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js'; import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { Disk } from '@/types/api'; import type { Disk } from '@/types/api';
import { formatBytes, formatPercent } from '@/utils/format'; import { formatBytes, formatPercent } from '@/utils/format';
@ -228,15 +229,16 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
{/* Tooltip for multi-disk breakdown */} {/* Tooltip for multi-disk breakdown */}
<Show when={showTooltip() && hasMultipleDisks()}> <Show when={showTooltip() && hasMultipleDisks()}>
<Portal mount={document.body}>
<div <div
class="fixed z-50 pointer-events-none" class="fixed z-[9999] pointer-events-none"
style={{ style={{
left: `${tooltipPos().x}px`, left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`, top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)', transform: 'translate(-50%, -100%)',
}} }}
> >
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px]"> <div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1"> <div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Disk Breakdown Disk Breakdown
</div> </div>
@ -257,6 +259,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
</For> </For>
</div> </div>
</div> </div>
</Portal>
</Show> </Show>
</div> </div>
); );

View file

@ -4,17 +4,31 @@ import { useNavigate } from '@solidjs/router';
import type { Host } from '@/types/api'; import type { Host } from '@/types/api';
import { formatBytes, formatPercent, formatRelativeTime, formatUptime } from '@/utils/format'; import { formatBytes, formatPercent, formatRelativeTime, formatUptime } from '@/utils/format';
import { Card } from '@/components/shared/Card'; import { Card } from '@/components/shared/Card';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { MetricBar } from '@/components/Dashboard/MetricBar'; import { MetricBar } from '@/components/Dashboard/MetricBar';
import { StackedDiskBar } from '@/components/Dashboard/StackedDiskBar';
import { HostsFilter } from './HostsFilter'; import { HostsFilter } from './HostsFilter';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
import { StatusDot } from '@/components/shared/StatusDot'; import { StatusDot } from '@/components/shared/StatusDot';
import { getHostStatusIndicator } from '@/utils/status'; 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 // Global drawer state to persist across re-renders
const drawerState = new Map<string, boolean>(); const drawerState = new Map<string, boolean>();
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 { interface HostsOverviewProps {
hosts: Host[]; hosts: Host[];
connectionHealth: Record<string, boolean>; connectionHealth: Record<string, boolean>;
@ -25,6 +39,20 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
const wsContext = useWebSocket(); const wsContext = useWebSocket();
const [search, setSearch] = createSignal(''); const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all'); const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
const [sortKey, setSortKey] = createSignal<SortKey>('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 // Keyboard listener to auto-focus search
let searchInputRef: HTMLInputElement | undefined; let searchInputRef: HTMLInputElement | undefined;
@ -64,13 +92,35 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
return !connected() && !reconnecting(); return !connected() && !reconnecting();
}); });
const sortedHosts = createMemo(() => const sortedHosts = createMemo(() => {
[...props.hosts].sort((a, b) => { const hosts = [...props.hosts];
const aName = a.displayName || a.hostname || a.id; const key = sortKey();
const bName = b.displayName || b.hostname || b.id; const direction = sortDirection();
return aName.localeCompare(bName);
}), 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 matchesSearch = (host: Host) => {
const term = search().toLowerCase(); const term = search().toLowerCase();
@ -100,8 +150,136 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host)); 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 ( return (
<div class="space-y-0"> <div class="pl-4 pr-2 py-1 overflow-hidden">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{host.displayName || host.hostname || host.id}
</p>
<Show when={host.displayName && host.displayName !== host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{host.hostname}
</p>
</Show>
<Show when={host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
Updated {formatRelativeTime(host.lastSeen!)}
</p>
</Show>
</div>
</div>
</div>
);
case 'platform':
return (
<div class="px-2 py-1 overflow-hidden">
<div class="text-xs text-gray-700 dark:text-gray-300">
<p class="font-medium capitalize">{host.platform || '—'}</p>
<Show when={host.osName}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
{host.osName} {host.osVersion}
</p>
</Show>
</div>
</div>
);
case 'cpu':
return (
<div class="px-2 py-1 overflow-hidden">
<ResponsiveMetricCell
value={cpuPercent()}
type="cpu"
label={formatPercent(cpuPercent())}
isRunning={true}
showMobile={isMobile()}
class="w-full"
/>
</div>
);
case 'memory':
return (
<div class="px-2 py-1 overflow-hidden">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
label={`${memUsed()} / ${memTotal()}`}
sublabel={formatPercent(memPercent())}
isRunning={true}
showMobile={isMobile()}
class="w-full"
/>
</div>
);
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 (
<div class="px-2 py-1 overflow-hidden">
<Show when={isMobile()}>
<div class="md:hidden">
<MetricText value={diskPercent} type="disk" />
</div>
</Show>
<div class="hidden md:block w-full">
<StackedDiskBar
disks={host.disks}
aggregateDisk={{
total: diskStats.total,
used: diskStats.used,
free: diskStats.total - diskStats.used,
usage: diskStats.percent / 100
}}
/>
</div>
</div>
);
case 'uptime':
return (
<div class="px-2 py-1 text-right overflow-hidden">
<Show
when={host.uptimeSeconds}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<span class="text-xs text-gray-700 dark:text-gray-300">
{formatUptime(host.uptimeSeconds!)}
</span>
</Show>
</div>
);
default:
return null;
}
};
return (
<div class="space-y-4">
<Show when={isLoading()}> <Show when={isLoading()}>
<Card padding="lg"> <Card padding="lg">
<EmptyState <EmptyState
@ -171,53 +349,35 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
} }
> >
<Card padding="none" tone="glass" class="overflow-hidden"> <Card padding="none" tone="glass" class="overflow-hidden">
<ScrollableTable> <div class="overflow-x-auto">
<table class="w-full border-collapse"> {/* Header */}
<thead> <div
<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"> class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0"
<th class="pl-4 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]"> style={{ 'grid-template-columns': gridTemplate() }}
Host >
</th> <For each={visibleColumns()}>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%]"> {(column) => (
Platform <div
</th> class={`${column.id === 'name' ? 'pl-4 pr-2' : 'px-2'} py-1 flex items-center ${column.align === 'right' ? 'justify-end' : 'justify-start'} ${column.sortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]"> onClick={() => column.sortable && column.sortKey && handleSort(column.sortKey)}
CPU >
</th> <div class="flex items-center gap-1">
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]"> {column.label}
Memory {column.sortKey === sortKey() && (
</th> <span class="text-gray-400">
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]"> {sortDirection() === 'asc' ? '▲' : '▼'}
Disk </span>
</th> )}
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]"> </div>
Uptime </div>
</th> )}
</tr> </For>
</thead> </div>
<tbody>
{/* Rows */}
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
<For each={filteredHosts()}> <For each={filteredHosts()}>
{(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);
// 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));
// Drawer state // Drawer state
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
@ -260,7 +420,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
}); });
const rowClass = () => { const rowClass = () => {
const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200'; const base = 'grid items-center transition-all duration-200';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : ''; const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
@ -269,94 +429,20 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
return ( return (
<> <>
<tr class={rowClass()} onClick={toggleDrawer} aria-expanded={drawerOpen()}> <div
<td class="pl-4 pr-2 py-2"> class={rowClass()}
<div> style={{ 'grid-template-columns': gridTemplate() }}
<div class="flex items-center gap-2 min-w-0"> onClick={toggleDrawer}
<StatusDot aria-expanded={drawerOpen()}
variant={hostStatus().variant} >
title={hostStatus().label} <For each={visibleColumns()}>
ariaLabel={hostStatus().label} {(column) => renderCell(column, host)}
size="xs" </For>
/>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{host.displayName || host.hostname || host.id}
</p>
</div> </div>
<Show when={host.displayName && host.displayName !== host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{host.hostname}
</p>
</Show>
<Show when={host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
Updated {formatRelativeTime(host.lastSeen!)}
</p>
</Show>
</div>
</td>
<td class="px-2 py-2">
<div class="text-xs text-gray-700 dark:text-gray-300">
<p class="font-medium capitalize">{host.platform || '—'}</p>
<Show when={host.osName}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
{host.osName} {host.osVersion}
</p>
</Show>
</div>
</td>
<td class="px-2 py-2">
<Show
when={cpuPercent() > 0}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<MetricBar
label={formatPercent(cpuPercent())}
value={cpuPercent()}
type="cpu"
/>
</Show>
</td>
<td class="px-2 py-2">
<Show
when={memPercent() > 0}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<MetricBar
label={`${memUsed()} / ${memTotal()}`}
value={memPercent()}
type="memory"
/>
</Show>
</td>
<td class="px-2 py-2">
<Show
when={diskPercent() > 0}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<MetricBar
label={`${diskUsed()} / ${diskTotal()}`}
value={diskPercent()}
type="disk"
/>
</Show>
</td>
<td class="px-2 py-2">
<Show
when={host.uptimeSeconds}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<span class="text-xs text-gray-700 dark:text-gray-300">
{formatUptime(host.uptimeSeconds!)}
</span>
</Show>
</td>
</tr>
{/* Drawer - Additional Info */} {/* Drawer - Additional Info */}
<Show when={drawerOpen() && hasDrawerContent()}> <Show when={drawerOpen() && hasDrawerContent()}>
<tr class="bg-gray-50 dark:bg-gray-900/50"> <div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 border-t border-gray-100 dark:border-gray-800/50">
<td class="px-4 py-3" colSpan={6}>
<div class="flex flex-wrap justify-start gap-3"> <div class="flex flex-wrap justify-start gap-3">
{/* System Info */} {/* System Info */}
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30"> <div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
@ -444,6 +530,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
value={diskPercent()} value={diskPercent()}
label={formatPercent(diskPercent())} label={formatPercent(diskPercent())}
type="disk" type="disk"
class="max-w-none"
/> />
</div> </div>
</Show> </Show>
@ -527,16 +614,14 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</For> </For>
</Show> </Show>
</div> </div>
</td> </div>
</tr>
</Show> </Show>
</> </>
); );
}} }}
</For> </For>
</tbody> </div>
</table> </div>
</ScrollableTable>
</Card> </Card>
</Show> </Show>
</> </>