From 71580181d0ba0fc194f45982c4b61e295579b4fd Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 28 Nov 2025 10:36:23 +0000 Subject: [PATCH] Add stacked bar visualization for multiple disks Show individual disk usage segments as a stacked bar in the dashboard table. Each segment represents one disk's used space proportional to total capacity. Multi-disk systems display a count badge [N] and hovering shows a tooltip with per-disk breakdown (name, used/total, %). Single-disk and aggregate fallback still work as before. Related to #489 --- .../src/components/Dashboard/GuestRow.tsx | 29 +- .../components/Dashboard/StackedDiskBar.tsx | 263 ++++++++++++++++++ 2 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 frontend-modern/src/components/Dashboard/StackedDiskBar.tsx diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 697d971..0b50c1c 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -1,8 +1,9 @@ import { GuestDrawer } from './GuestDrawer'; import { createMemo, createSignal, createEffect, Show, For } from 'solid-js'; import type { VM, Container } from '@/types/api'; -import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus } from '@/utils/format'; +import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format'; import { TagBadges } from './TagBadges'; +import { StackedDiskBar } from './StackedDiskBar'; import { StatusDot } from '@/components/shared/StatusDot'; import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status'; @@ -696,19 +697,19 @@ export function GuestRow(props: GuestRowProps) { } > - + {/* Mobile: simple percentage text */} + +
+ {formatPercent(diskPercent())} +
+
+ {/* Desktop: stacked disk bar for multiple disks */} +
+ +
); diff --git a/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx b/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx new file mode 100644 index 0000000..87d5fcd --- /dev/null +++ b/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx @@ -0,0 +1,263 @@ +import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js'; +import type { Disk } from '@/types/api'; +import { formatBytes, formatPercent } from '@/utils/format'; + +interface StackedDiskBarProps { + /** Array of disk objects - if empty/undefined, falls back to aggregate */ + disks?: Disk[]; + /** Aggregate disk data (fallback when disks array unavailable) */ + aggregateDisk?: Disk; +} + +// Color palette for disk segments - distinct colors for visual differentiation +const SEGMENT_COLORS = [ + 'rgba(34, 197, 94, 0.6)', // green + 'rgba(59, 130, 246, 0.6)', // blue + 'rgba(168, 85, 247, 0.6)', // purple + 'rgba(249, 115, 22, 0.6)', // orange + 'rgba(236, 72, 153, 0.6)', // pink + 'rgba(20, 184, 166, 0.6)', // teal +]; + +// Get color based on usage percentage +function getUsageColor(percentage: number): string { + if (percentage >= 90) return 'rgba(239, 68, 68, 0.6)'; // red + if (percentage >= 80) return 'rgba(234, 179, 8, 0.6)'; // yellow + return 'rgba(34, 197, 94, 0.6)'; // green +} + +// Estimate text width for label fitting +const estimateTextWidth = (text: string): number => { + return text.length * 5.5 + 8; +}; + +export function StackedDiskBar(props: StackedDiskBarProps) { + const [containerWidth, setContainerWidth] = createSignal(100); + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); + let containerRef: HTMLDivElement | undefined; + + onMount(() => { + if (!containerRef) return; + setContainerWidth(containerRef.offsetWidth); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + observer.observe(containerRef); + onCleanup(() => observer.disconnect()); + }); + + // Determine if we have multiple disks or should use aggregate + const hasMultipleDisks = createMemo(() => { + const disks = props.disks; + return disks && disks.length > 1; + }); + + const hasSingleDisk = createMemo(() => { + const disks = props.disks; + return disks && disks.length === 1; + }); + + // Calculate total capacity across all disks + const totalCapacity = createMemo(() => { + if (!props.disks || props.disks.length === 0) { + return props.aggregateDisk?.total ?? 0; + } + return props.disks.reduce((sum, d) => sum + (d.total || 0), 0); + }); + + const totalUsed = createMemo(() => { + if (!props.disks || props.disks.length === 0) { + return props.aggregateDisk?.used ?? 0; + } + return props.disks.reduce((sum, d) => sum + (d.used || 0), 0); + }); + + const overallPercent = createMemo(() => { + const total = totalCapacity(); + if (total <= 0) return 0; + return (totalUsed() / total) * 100; + }); + + // For single disk or aggregate, get the percentage + const singleDiskPercent = createMemo(() => { + if (hasSingleDisk() && props.disks?.[0]) { + const disk = props.disks[0]; + if (!disk.total || disk.total <= 0) return 0; + return (disk.used / disk.total) * 100; + } + if (props.aggregateDisk) { + if (!props.aggregateDisk.total || props.aggregateDisk.total <= 0) return 0; + return (props.aggregateDisk.used / props.aggregateDisk.total) * 100; + } + return 0; + }); + + // Calculate segment widths for stacked bar (each disk's used space as % of total capacity) + const segments = createMemo(() => { + if (!hasMultipleDisks() || !props.disks) return []; + + const total = totalCapacity(); + if (total <= 0) return []; + + return props.disks.map((disk, idx) => { + const usedPercent = (disk.used / total) * 100; + 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]; + return { + disk, + widthPercent: Math.min(usedPercent, 100), + diskUsagePercent: diskPercent, + color, + index: idx, + }; + }); + }); + + // Generate tooltip content + const tooltipContent = createMemo(() => { + if (hasMultipleDisks() && props.disks) { + return props.disks.map((disk, idx) => { + const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0; + const label = disk.mountpoint || disk.device || `Disk ${idx + 1}`; + return { + label, + used: formatBytes(disk.used, 0), + total: formatBytes(disk.total, 0), + percent: formatPercent(percent), + color: percent >= 90 ? getUsageColor(90) : + percent >= 80 ? getUsageColor(80) : + SEGMENT_COLORS[idx % SEGMENT_COLORS.length], + }; + }); + } + return []; + }); + + // Label for the bar + const displayLabel = createMemo(() => { + if (hasMultipleDisks()) { + return formatPercent(overallPercent()); + } + return formatPercent(singleDiskPercent()); + }); + + // Sublabel showing used/total + const displaySublabel = createMemo(() => { + return `${formatBytes(totalUsed(), 0)}/${formatBytes(totalCapacity(), 0)}`; + }); + + // Check if sublabel fits + const showSublabel = createMemo(() => { + const fullText = `${displayLabel()} (${displaySublabel()})`; + return containerWidth() >= estimateTextWidth(fullText); + }); + + const handleMouseEnter = (e: MouseEvent) => { + if (hasMultipleDisks()) { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + } + }; + + const handleMouseLeave = () => { + setShowTooltip(false); + }; + + return ( +
+
+ {/* Stacked segments for multiple disks */} + +
+ + {(segment, idx) => ( +
+ )} + +
+ + + {/* Single bar for single disk or aggregate */} + +
+ + + {/* Label overlay */} + + + {displayLabel()} + + + ({displaySublabel()}) + + + + + [{props.disks?.length}] + + + + +
+ + {/* Tooltip for multi-disk breakdown */} + +
+
+
+ Disk Breakdown +
+ + {(item, idx) => ( +
0 }}> + + {item.label} + + + {item.percent} ({item.used}/{item.total}) + +
+ )} +
+
+
+
+
+ ); +}