Refine memory visualization with StackedMemoryBar component

- Add StackedMemoryBar component to visualize Active/Balloon/Swap memory
- Integrate StackedMemoryBar into NodeSummaryTable, GuestRow, HostsOverview, and DockerUnifiedTable
- Add TemperatureGauge component for temperature visualization
- Standardize replication job status indicators with StatusDot
- Fix mock data generator to use realistic balloon memory values (0 by default, small % when active)
- Add StackedContainerBar for Docker container status visualization
- Add ZFSHealthMap component for storage pool health visualization
This commit is contained in:
courtmanr@gmail.com 2025-11-29 13:48:30 +00:00
parent c3a5aacd21
commit ae3a349124
14 changed files with 715 additions and 149 deletions

View file

@ -4,6 +4,7 @@ import type { VM, Container } from '@/types/api';
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
import { TagBadges } from './TagBadges';
import { StackedDiskBar } from './StackedDiskBar';
import { StackedMemoryBar } from './StackedMemoryBar';
import { StatusDot } from '@/components/shared/StatusDot';
import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status';
@ -673,15 +674,28 @@ export function GuestRow(props: GuestRowProps) {
return (
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
<div title={memoryTooltip() ?? undefined} class="w-full text-center xl:text-left">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={isMobile()}
class="w-full"
/>
<Show when={isMobile()}>
<div class="md:hidden">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={true}
class="w-full"
/>
</div>
</Show>
<div class="hidden md:block w-full">
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
balloon={props.guest.memory?.balloon || 0}
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}
/>
</div>
</div>
</div>
);

View file

@ -0,0 +1,217 @@
import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { formatBytes, formatPercent } from '@/utils/format';
interface StackedMemoryBarProps {
used: number;
total: number;
swapUsed?: number;
swapTotal?: number;
balloon?: number;
}
// Colors for memory segments
const MEMORY_COLORS = {
active: 'rgba(34, 197, 94, 0.6)', // green
balloon: 'rgba(234, 179, 8, 0.6)', // yellow
swap: 'rgba(168, 85, 247, 0.6)', // purple
};
export function StackedMemoryBar(props: StackedMemoryBarProps) {
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());
});
// Calculate segments
const segments = createMemo(() => {
if (props.total <= 0) return [];
const balloon = props.balloon || 0;
// Active memory is used minus balloon (since balloon is "used" but reclaimed)
// Note: This is a simplification. Proxmox reports 'used' including balloon.
const active = Math.max(0, props.used - balloon);
// Calculate percentages relative to RAM total (swap is extra)
// We visualize swap as an overlay or separate segment if we want to show it relative to RAM
// But typically swap is separate.
// Let's stack RAM components (Active + Balloon) within the RAM bar.
// And maybe show Swap as a separate indicator or just in tooltip?
// The user asked for "Detailed Memory Composition".
// Let's stack Active and Balloon within the main bar.
// If Swap is used, we can perhaps show it as a segment that "overflows" or just a separate color if we treat total as RAM+Swap?
// Standard approach: Bar represents RAM. Segments are Active, Balloon.
// Swap is usually separate. But we can include it if we want to show "Memory Pressure".
// Let's stick to RAM composition for the bar: Active (Green), Balloon (Yellow).
// Swap usage will be shown in tooltip and maybe change the bar color if critical?
// Actually, let's try to include Swap if it's significant, but it might be confusing if it exceeds 100% of RAM.
// Alternative: The bar is RAM.
// Green: Active
// Yellow: Balloon
const activePercent = (active / props.total) * 100;
const balloonPercent = (balloon / props.total) * 100;
const segs = [
{ type: 'Active', bytes: active, percent: activePercent, color: MEMORY_COLORS.active },
{ type: 'Balloon', bytes: balloon, percent: balloonPercent, color: MEMORY_COLORS.balloon },
].filter(s => s.bytes > 0);
return segs;
});
const swapPercent = createMemo(() => {
if (!props.swapTotal || props.swapTotal <= 0) return 0;
return (props.swapUsed || 0) / props.swapTotal * 100;
});
const hasSwap = createMemo(() => (props.swapTotal || 0) > 0);
const displayLabel = createMemo(() => {
const percent = (props.used / props.total) * 100;
return formatPercent(percent);
});
const displaySublabel = createMemo(() => {
return `${formatBytes(props.used, 0)}/${formatBytes(props.total, 0)}`;
});
// Estimate text width for label fitting (approx 6px per char + padding)
const estimateTextWidth = (text: string): number => {
return text.length * 6 + 10;
};
const showSublabel = createMemo(() => {
const fullText = `${displayLabel()} (${displaySublabel()})`;
return containerWidth() >= estimateTextWidth(fullText);
});
const handleMouseEnter = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
setShowTooltip(true);
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<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 cursor-help"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Stacked segments */}
<div class="absolute top-0 left-0 h-full w-full flex">
<For each={segments()}>
{(segment, idx) => (
<div
class="h-full transition-all duration-300"
style={{
width: `${segment.percent}%`,
'background-color': segment.color,
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
)}
</For>
</div>
{/* Swap Indicator (Thin line at bottom if swap is used) */}
{/* Swap Indicator (Thin line at bottom if swap is used) */}
<Show when={hasSwap() && (props.swapUsed || 0) > 0}>
<div
class="absolute bottom-0 left-0 h-[3px] w-full bg-purple-500"
style={{ width: `${Math.min(swapPercent(), 100)}%` }}
/>
</Show>
{/* Label overlay */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{displayLabel()}</span>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({displaySublabel()})
</span>
</Show>
</span>
</span>
</div>
{/* Tooltip */}
<Show when={showTooltip()}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
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] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Memory Composition
</div>
{/* RAM Breakdown */}
<div class="flex justify-between gap-3 py-0.5">
<span class="text-green-400">Active</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(Math.max(0, props.used - (props.balloon || 0)), 0)}
</span>
</div>
<Show when={(props.balloon || 0) > 0}>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-yellow-400">Balloon</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.balloon || 0, 0)}
</span>
</div>
</Show>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-gray-400">Free</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.total - props.used, 0)}
</span>
</div>
{/* Swap Section */}
<Show when={hasSwap()}>
<div class="mt-1 pt-1 border-t border-gray-600">
<div class="flex justify-between gap-3 py-0.5">
<span class="text-purple-400">Swap</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.swapUsed || 0, 0)} / {formatBytes(props.swapTotal || 0, 0)}
</span>
</div>
</div>
</Show>
</div>
</div>
</Portal>
</Show>
</div>
);
}

View file

@ -1,9 +1,10 @@
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import { StackedContainerBar } from './StackedContainerBar';
import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { renderDockerStatusBadge } from './DockerStatusBadge';
import { resolveHostRuntime } from './runtimeDisplay';
import { formatPercent, formatUptime } from '@/utils/format';
import { formatUptime } from '@/utils/format';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot';
@ -20,6 +21,8 @@ export interface DockerHostSummary {
diskLabel?: string;
runningPercent: number;
runningCount: number;
stoppedCount: number;
errorCount: number;
totalCount: number;
uptimeSeconds: number;
lastSeenRelative: string;
@ -64,18 +67,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
return lastSeen;
};
const runningStatusClass = (summary: DockerHostSummary) => {
if (!summary.totalCount || summary.totalCount <= 0) {
return 'text-gray-400 dark:text-gray-500';
}
if (summary.runningPercent >= 99) {
return 'text-green-600 dark:text-green-400';
}
if (summary.runningPercent >= 70) {
return 'text-yellow-600 dark:text-yellow-400';
}
return 'text-red-600 dark:text-red-400';
};
const sortedSummaries = createMemo(() => {
const list = [...props.summaries()];
@ -320,17 +312,17 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
/>
</td>
<td class="px-0.5 md:px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full whitespace-nowrap">
<div class="flex justify-center items-center h-full whitespace-nowrap w-full px-2">
<Show
when={summary.totalCount > 0}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}
>
<span
class={`text-xs font-medium whitespace-nowrap ${runningStatusClass(summary)}`}
title={`${formatPercent(summary.runningPercent)} running`}
>
{summary.runningCount}/{summary.totalCount}
</span>
<StackedContainerBar
running={summary.runningCount}
stopped={summary.stoppedCount}
error={summary.errorCount}
total={summary.totalCount}
/>
</Show>
</div>
</td>

View file

@ -84,6 +84,13 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
const totalContainers = host.containers?.length ?? 0;
const runningContainers =
host.containers?.filter((container) => container.state?.toLowerCase() === 'running').length ?? 0;
const stoppedContainers =
host.containers?.filter((container) =>
['exited', 'stopped', 'created'].includes(container.state?.toLowerCase() || '')
).length ?? 0;
// Count anything that isn't running or stopped/created as an error/warning state (restarting, dead, paused, etc)
const errorContainers = totalContainers - runningContainers - stoppedContainers;
const runningPercent = totalContainers > 0 ? clampPercent((runningContainers / totalContainers) * 100) : 0;
const cpuPercent = clampPercent(host.cpuUsagePercent ?? 0);
@ -128,6 +135,8 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
diskLabel,
runningPercent,
runningCount: runningContainers,
stoppedCount: stoppedContainers,
errorCount: errorContainers,
totalCount: totalContainers,
uptimeSeconds,
lastSeenRelative,

View file

@ -22,6 +22,7 @@ import {
} from '@/utils/status';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import type { ColumnConfig } from '@/types/responsive';
const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => {
@ -1195,17 +1196,34 @@ const DockerContainerRow: Component<{
</div>
);
case 'memory':
const memoryTotal = () => container.memoryLimitBytes && container.memoryLimitBytes > 0
? container.memoryLimitBytes
: host.totalMemoryBytes;
return (
<div class="px-2 py-0.5 flex items-center overflow-hidden">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey}
sublabel={memUsageLabel()}
isRunning={isRunning() && (container.memoryUsageBytes ?? 0) > 0}
showMobile={props.isMobile()}
class="w-full"
/>
<Show when={props.isMobile()}>
<div class="md:hidden w-full">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
sublabel={memUsageLabel()}
resourceId={metricsKey}
isRunning={isRunning() && (container.memoryUsageBytes ?? 0) > 0}
showMobile={true}
class="w-full"
/>
</div>
</Show>
<div class="hidden md:block w-full">
<StackedMemoryBar
used={container.memoryUsageBytes || 0}
total={memoryTotal()}
balloon={0}
swapUsed={0}
swapTotal={0}
/>
</div>
</div>
);
case 'disk':

View file

@ -0,0 +1,115 @@
import { Show, For, createMemo, createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
interface StackedContainerBarProps {
running: number;
stopped: number;
error: number;
total: number;
}
// Colors for container states
const STATE_COLORS = {
running: 'rgba(34, 197, 94, 0.6)', // green
stopped: 'rgba(156, 163, 175, 0.6)', // gray
error: 'rgba(239, 68, 68, 0.6)', // red
};
export function StackedContainerBar(props: StackedContainerBarProps) {
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
let containerRef: HTMLDivElement | undefined;
// Calculate segments
const segments = createMemo(() => {
if (props.total <= 0) return [];
const runningPercent = (props.running / props.total) * 100;
const stoppedPercent = (props.stopped / props.total) * 100;
const errorPercent = (props.error / props.total) * 100;
return [
{ type: 'Running', count: props.running, percent: runningPercent, color: STATE_COLORS.running },
{ type: 'Stopped', count: props.stopped, percent: stoppedPercent, color: STATE_COLORS.stopped },
{ type: 'Error', count: props.error, percent: errorPercent, color: STATE_COLORS.error },
].filter(s => s.count > 0);
});
const handleMouseEnter = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
setShowTooltip(true);
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<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 cursor-help"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Stacked segments */}
<div class="absolute top-0 left-0 h-full w-full flex">
<For each={segments()}>
{(segment, idx) => (
<div
class="h-full transition-all duration-300"
style={{
width: `${segment.percent}%`,
'background-color': segment.color,
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
)}
</For>
</div>
{/* Label overlay */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{props.running}/{props.total}</span>
</span>
</span>
</div>
{/* Tooltip */}
<Show when={showTooltip()}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
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-[120px] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Container Status
</div>
<For each={segments()}>
{(item, idx) => (
<div class="flex justify-between gap-3 py-0.5" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
<span
class="truncate"
style={{ color: item.color.replace('0.6)', '1)') }}
>
{item.type}
</span>
<span class="whitespace-nowrap text-gray-300">
{item.count}
</span>
</div>
)}
</For>
</div>
</div>
</Portal>
</Show>
</div>
);
}

View file

@ -12,6 +12,8 @@ import { useWebSocket } from '@/App';
import { StatusDot } from '@/components/shared/StatusDot';
import { getHostStatusIndicator } from '@/utils/status';
import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import type { ColumnConfig } from '@/types/responsive';
import { STANDARD_COLUMNS } from '@/types/responsive';
@ -25,6 +27,7 @@ const HOST_COLUMNS: ColumnConfig[] = [
{ 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: 'temperature', label: 'Temp', priority: 'secondary', minWidth: '80px', maxWidth: '100px' },
{ id: 'disk', label: 'Disk', minWidth: '140px', maxWidth: '156px', priority: 'secondary' },
{ ...STANDARD_COLUMNS.uptime, maxWidth: '100px', align: 'right', sortKey: 'uptime' },
];
@ -153,8 +156,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
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) {
@ -215,15 +217,55 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
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"
/>
<Show when={isMobile()}>
<div class="md:hidden">
<MetricText value={memPercent()} type="memory" />
</div>
</Show>
<div class="hidden md:block w-full">
<StackedMemoryBar
used={host.memory?.used || 0}
total={host.memory?.total || 0}
balloon={host.memory?.balloon || 0}
swapUsed={host.memory?.swapUsed || 0}
swapTotal={host.memory?.swapTotal || 0}
/>
</div>
</div>
);
case 'temperature':
const tempValue = (() => {
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;
})();
return (
<div class="px-2 py-1 overflow-hidden">
<Show when={tempValue !== null} fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}>
<TemperatureGauge value={tempValue!} />
</Show>
</div>
);
case 'disk':

View file

@ -5,7 +5,8 @@ import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import type { ReplicationJob } from '@/types/api';
import { StatusDot } from '@/components/shared/StatusDot';
import { getReplicationJobStatusIndicator } from '@/utils/status';
function formatDuration(durationSeconds?: number, durationHuman?: string): string {
if (durationHuman && durationHuman.trim()) return durationHuman;
@ -24,29 +25,7 @@ function formatDuration(durationSeconds?: number, durationHuman?: string): strin
return `${hours}:${minutes}:${seconds}`;
}
function getStatusBadge(job: ReplicationJob) {
const status = (job.status || job.state || '').toLowerCase();
const lastStatus = (job.lastSyncStatus || '').toLowerCase();
if (status.includes('error') || lastStatus.includes('error')) {
return {
tone: 'danger' as const,
label: status || lastStatus || 'Error',
};
}
if (status.includes('sync')) {
return {
tone: 'warning' as const,
label: job.status || job.state || 'Syncing',
};
}
return {
tone: 'success' as const,
label: job.status || job.state || 'Idle',
};
}
function formatRate(limit?: number): string {
if (!limit || limit <= 0) return '—';
@ -119,7 +98,7 @@ const Replication: Component = () => {
<tbody class="divide-y divide-gray-200 dark:divide-gray-800 text-sm text-gray-700 dark:text-gray-200">
<For each={replicationJobs()}>
{(job) => {
const badge = getStatusBadge(job);
const indicator = getReplicationJobStatusIndicator(job);
return (
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors">
<td class="px-4 py-3">
@ -173,16 +152,17 @@ const Replication: Component = () => {
</Show>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold capitalize"
classList={{
'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-300': badge.tone === 'success',
'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200': badge.tone === 'warning',
'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300': badge.tone === 'danger',
}}
>
{badge.label}
</span>
<div class="flex items-center gap-2">
<StatusDot
variant={indicator.variant}
title={indicator.label}
ariaLabel={indicator.label}
size="sm"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{indicator.label}
</span>
</div>
<Show when={job.error}>
<div class="mt-1 text-xs text-red-500 dark:text-red-400 line-clamp-2">
{job.error}

View file

@ -8,6 +8,7 @@ import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
import { StorageFilter } from './StorageFilter';
import { DiskList } from './DiskList';
import { ZFSHealthMap } from './ZFSHealthMap';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
@ -1057,6 +1058,12 @@ const Storage: Component = () => {
>
{storage.name}
</span>
{/* ZFS Health Map */}
<Show when={zfsPool && zfsPool.devices && zfsPool.devices.length > 0}>
<div class="mx-1.5">
<ZFSHealthMap pool={zfsPool!} />
</div>
</Show>
{/* ZFS Health Badge */}
<Show when={zfsPool && zfsPool.state !== 'ONLINE'}>
<span

View file

@ -0,0 +1,97 @@
import { Component, For, Show, createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { ZFSPool, ZFSDevice } from '@/types/api';
interface ZFSHealthMapProps {
pool: ZFSPool;
}
export const ZFSHealthMap: Component<ZFSHealthMapProps> = (props) => {
const [hoveredDevice, setHoveredDevice] = createSignal<ZFSDevice | null>(null);
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
// Filter out non-disk devices if needed, or just show all top-level vdevs/disks
// Usually 'disk', 'mirror', 'raidz*' are top level.
// We want to visualize the health of the underlying storage units.
// If the API returns a flat list of all devices including children, we might want to filter.
// Assuming 'devices' contains the relevant units to display.
const devices = () => props.pool.devices || [];
const getDeviceColor = (device: ZFSDevice) => {
const state = device.state?.toUpperCase();
if (state === 'ONLINE') return 'bg-green-500/80 dark:bg-green-500/70 hover:bg-green-400';
if (state === 'DEGRADED') return 'bg-yellow-500/80 dark:bg-yellow-500/70 hover:bg-yellow-400';
if (state === 'FAULTED' || state === 'UNAVAIL' || state === 'OFFLINE') return 'bg-red-500/80 dark:bg-red-500/70 hover:bg-red-400';
return 'bg-gray-400/80 dark:bg-gray-500/70 hover:bg-gray-300';
};
const isResilvering = (device: ZFSDevice) => {
// Check pool scan status or device message/state for resilvering
const scan = props.pool.scan?.toLowerCase() || '';
return scan.includes('resilver') || (device.message || '').toLowerCase().includes('resilver');
};
const handleMouseEnter = (e: MouseEvent, device: ZFSDevice) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
setHoveredDevice(device);
};
const handleMouseLeave = () => {
setHoveredDevice(null);
};
return (
<div class="flex items-center gap-0.5">
<For each={devices()}>
{(device) => (
<div
class={`w-2.5 h-3 rounded-sm cursor-help transition-colors duration-200 ${getDeviceColor(device)} ${isResilvering(device) ? 'animate-pulse' : ''}`}
onMouseEnter={(e) => handleMouseEnter(e, device)}
onMouseLeave={handleMouseLeave}
/>
)}
</For>
<Show when={hoveredDevice()}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
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-[120px] border border-gray-700">
<div class="font-medium mb-0.5 text-gray-200">
{hoveredDevice()?.name}
</div>
<div class="text-gray-400 mb-1">
{hoveredDevice()?.type}
</div>
<div class="flex items-center gap-2 border-t border-gray-700/50 pt-1">
<span class={`font-semibold ${hoveredDevice()?.state === 'ONLINE' ? 'text-green-400' :
hoveredDevice()?.state === 'DEGRADED' ? 'text-yellow-400' :
'text-red-400'
}`}>
{hoveredDevice()?.state}
</span>
<Show when={hoveredDevice()?.readErrors || hoveredDevice()?.writeErrors || hoveredDevice()?.checksumErrors}>
<span class="text-red-400">
(E: {hoveredDevice()?.readErrors}/{hoveredDevice()?.writeErrors}/{hoveredDevice()?.checksumErrors})
</span>
</Show>
</div>
<Show when={hoveredDevice()?.message}>
<div class="text-gray-400 mt-1 italic max-w-[200px] break-words">
{hoveredDevice()?.message}
</div>
</Show>
</div>
</div>
</Portal>
</Show>
</div>
);
};

View file

@ -11,7 +11,9 @@ import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot';
import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
import { type ColumnPriority } from '@/hooks/useBreakpoint';
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive';
import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
// Icons for mobile headers
const ClockIcon = (props: { class?: string }) => (
@ -712,21 +714,32 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
case 'memory':
return (
<div class={`${baseCellClass} ${alignClass}`}>
<ResponsiveMetricCell
value={memoryPercentValue}
type="memory"
resourceId={metricsKey}
sublabel={
isPVEItem && node!.memory
? `${formatBytes(node!.memory.used, 0)}/${formatBytes(node!.memory.total, 0)}`
: isPBSItem && pbs!.memoryTotal
? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}`
: undefined
}
isRunning={online}
showMobile={isMobile()}
class="w-full"
/>
<Show when={isMobile()}>
<div class="md:hidden w-full">
<MetricText value={memoryPercentValue} type="memory" />
</div>
</Show>
<div class="hidden md:block w-full">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={memoryPercentValue}
type="memory"
resourceId={metricsKey}
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined}
isRunning={online}
showMobile={false}
class="w-full"
/>
}>
<StackedMemoryBar
used={node!.memory?.used || 0}
total={node!.memory?.total || 0}
balloon={node!.memory?.balloon || 0}
swapUsed={node!.memory?.swapUsed || 0}
swapTotal={node!.memory?.swapTotal || 0}
/>
</Show>
</div>
</div>
);
@ -760,13 +773,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
>
{(() => {
const value = cpuTemperatureValue as number;
const severityClass =
value >= 80
? 'text-red-600 dark:text-red-400'
: value >= 70
? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
const temp = node!.temperature;
const cpuMinValue =
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
@ -780,43 +786,23 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const hasGPU = gpus.length > 0;
if (hasMinMax || hasGPU) {
const min = Math.round(cpuMinValue!);
const max = Math.round(cpuMaxValue!);
const getTooltipColor = (temp: number) => {
if (temp >= 80) return 'text-red-400';
if (temp >= 70) return 'text-yellow-400';
return 'text-green-400';
};
const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined;
const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined;
return (
<span class="relative inline-block group/temp">
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
{value}°C
</span>
<span class="invisible group-hover/temp:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
{hasMinMax && (
<div>
<span class="text-gray-300">CPU:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
</div>
)}
{hasGPU && gpus.map((gpu) => {
const gpuTemp = gpu.edge ?? gpu.junction ?? gpu.mem ?? 0;
return (
<div>
<span class="text-gray-300">GPU:</span> <span class={getTooltipColor(gpuTemp)}>{Math.round(gpuTemp)}</span>°C
{gpu.edge && ` E:${Math.round(gpu.edge)}`}
{gpu.junction && ` J:${Math.round(gpu.junction)}`}
{gpu.mem && ` M:${Math.round(gpu.mem)}`}
</div>
);
})}
</span>
</span>
<div title={`Min: ${min ?? '-'}°C, Max: ${max ?? '-'}°C${hasGPU ? `\nGPU: ${gpus.map(g => `${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}>
<TemperatureGauge
value={value}
min={min}
max={max}
/>
</div>
);
}
return <span class={`text-xs font-medium ${severityClass}`}>{value}°C</span>;
return (
<TemperatureGauge value={value} />
);
})()}
</Show>
</div>
@ -852,9 +838,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</div>
);
}}
</For>
</div>
</div>
</Card>
</For >
</div >
</div >
</Card >
);
};

View file

@ -0,0 +1,67 @@
import { Component, createMemo, Show } from 'solid-js';
interface TemperatureGaugeProps {
value: number;
min?: number | null;
max?: number | null;
critical?: number;
warning?: number;
label?: string;
class?: string;
}
export const TemperatureGauge: Component<TemperatureGaugeProps> = (props) => {
const critical = props.critical ?? 80;
const warning = props.warning ?? 70;
// Calculate percentage (assuming 0-100°C range for simplicity, or slightly dynamic)
// Most CPUs idle around 30-40, max out at 100.
const percent = createMemo(() => Math.min(100, Math.max(0, props.value)));
const colorClass = createMemo(() => {
if (props.value >= critical) return 'bg-red-500 dark:bg-red-500';
if (props.value >= warning) return 'bg-yellow-500 dark:bg-yellow-500';
return 'bg-green-500 dark:bg-green-500';
});
const textColorClass = createMemo(() => {
if (props.value >= critical) return 'text-red-700 dark:text-red-400';
if (props.value >= warning) return 'text-yellow-700 dark:text-yellow-400';
return 'text-green-700 dark:text-green-400';
});
return (
<div class={`flex items-center gap-2 ${props.class || ''}`}>
{/* Text Value */}
<span class={`text-xs font-medium w-[36px] text-right ${textColorClass()}`}>
{Math.round(props.value)}°C
</span>
{/* Bar */}
<div class="relative flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden min-w-[40px] max-w-[80px]">
<div
class={`h-full rounded-full transition-all duration-500 ${colorClass()}`}
style={{ width: `${percent()}%` }}
/>
{/* Min Marker */}
<Show when={props.min !== null && props.min !== undefined}>
<div
class="absolute top-0 bottom-0 w-0.5 bg-blue-400 opacity-70"
style={{ left: `${Math.min(100, Math.max(0, props.min!))}%` }}
title={`Min: ${Math.round(props.min!)}°C`}
/>
</Show>
{/* Max Marker */}
<Show when={props.max !== null && props.max !== undefined}>
<div
class="absolute top-0 bottom-0 w-0.5 bg-red-400 opacity-70"
style={{ left: `${Math.min(100, Math.max(0, props.max!))}%` }}
title={`Max: ${Math.round(props.max!)}°C`}
/>
</Show>
</div>
</div>
);
};

View file

@ -7,6 +7,7 @@ import type {
DockerHost,
DockerContainer,
DockerService,
ReplicationJob,
} from '@/types/api';
const ONLINE_STATUS = 'online';
@ -244,3 +245,21 @@ export function getDockerServiceStatusIndicator(
return { variant: 'warning', label: `Degraded (${running}/${desired})` };
}
export function getReplicationJobStatusIndicator(
job: Partial<ReplicationJob> | undefined | null,
): StatusIndicator {
if (!job) return defaultIndicator;
const status = normalize(job.status || job.state);
const lastStatus = normalize(job.lastSyncStatus);
if (status.includes('error') || lastStatus.includes('error')) {
return { variant: 'danger', label: formatStatusLabel(status || lastStatus, 'Error') };
}
if (status.includes('sync')) {
return { variant: 'warning', label: formatStatusLabel(status, 'Syncing') };
}
return { variant: 'success', label: formatStatusLabel(status, 'Idle') };
}

View file

@ -874,9 +874,14 @@ func generateVM(nodeName string, instance string, vmid int, config MockConfig) m
memUsage = 0.80 + rand.Float64()*0.1 // 80-90%
}
usedMem := int64(float64(totalMem) * memUsage)
balloon := totalMem
if rand.Float64() < 0.7 {
balloon = int64(float64(totalMem) * (0.65 + rand.Float64()*0.25))
balloon := int64(0)
if rand.Float64() < 0.2 {
// Simulate ballooning active (10-30% of total memory)
balloon = int64(float64(totalMem) * (0.1 + rand.Float64()*0.2))
// Ensure balloon doesn't exceed used memory (which would result in 0 active)
if balloon > usedMem {
balloon = int64(float64(usedMem) * 0.8)
}
}
swapTotal := int64(0)
@ -1877,10 +1882,8 @@ func generateContainer(nodeName string, instance string, vmid int, config MockCo
memUsage = 0.82 + rand.Float64()*0.08 // 82-90%
}
usedMem := int64(float64(totalMem) * memUsage)
balloon := totalMem
if rand.Float64() < 0.5 {
balloon = int64(float64(totalMem) * (0.7 + rand.Float64()*0.2))
}
balloon := int64(0)
// LXC containers typically don't use ballooning in the same way, keep it 0 for clear "Active" usage
swapTotal := int64(0)
swapUsed := int64(0)