- Add exponential backoff retry for Docker agent startup (main.go) - Fix Docker resource/image column widths with proper truncation - Unify IP tooltip styling across hosts and guests with detailed network info - Improve column visibility defaults and sticky column handling - Various component refinements for Dashboard, Storage, and Backups views
264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
|
|
import { Portal } from 'solid-js/web';
|
|
import { formatBytes, formatPercent } from '@/utils/format';
|
|
import { Sparkline } from '@/components/shared/Sparkline';
|
|
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
|
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
|
|
|
|
interface StackedMemoryBarProps {
|
|
used: number;
|
|
total: number;
|
|
swapUsed?: number;
|
|
swapTotal?: number;
|
|
balloon?: number;
|
|
resourceId?: string; // Required for sparkline mode to fetch history
|
|
}
|
|
|
|
// 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 { viewMode, timeRange } = useMetricsViewMode();
|
|
|
|
// Get metric history for sparkline
|
|
// Depends on metricsVersion to re-fetch when data is seeded (e.g., on time range change)
|
|
const metricHistory = createMemo(() => {
|
|
// Subscribe to version changes so we re-read when new data is seeded
|
|
getMetricsVersion();
|
|
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
|
|
return getMetricHistoryForRange(props.resourceId, timeRange());
|
|
});
|
|
|
|
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;
|
|
|
|
// Proxmox balloon semantics:
|
|
// - balloon = 0: ballooning not enabled/configured
|
|
// - balloon = total (maxmem): ballooning configured but at max (no actual ballooning)
|
|
// - balloon < total: active ballooning, guest limited to 'balloon' bytes
|
|
//
|
|
// Only show balloon segment when actual ballooning is in effect
|
|
const hasActiveBallooning = balloon > 0 && balloon < props.total;
|
|
|
|
// Used memory is what the guest is actually consuming
|
|
const usedPercent = (props.used / props.total) * 100;
|
|
|
|
if (hasActiveBallooning) {
|
|
// With active ballooning:
|
|
// - Green: actual used memory
|
|
// - Yellow: balloon limit marker (shows where the guest is capped)
|
|
// The balloon limit shows as a segment from used to balloon
|
|
const balloonLimitPercent = Math.max(0, (balloon / props.total) * 100 - usedPercent);
|
|
|
|
const segs = [
|
|
{ type: 'Active', bytes: props.used, percent: usedPercent, color: MEMORY_COLORS.active },
|
|
];
|
|
|
|
// Only show balloon segment if there's room between used and balloon limit
|
|
if (balloonLimitPercent > 0 && balloon > props.used) {
|
|
segs.push({
|
|
type: 'Balloon',
|
|
bytes: balloon - props.used,
|
|
percent: balloonLimitPercent,
|
|
color: MEMORY_COLORS.balloon,
|
|
});
|
|
}
|
|
|
|
return segs.filter(s => s.bytes > 0);
|
|
}
|
|
|
|
// No active ballooning - just show used memory as green
|
|
return [
|
|
{ type: 'Active', bytes: props.used, percent: usedPercent, color: MEMORY_COLORS.active },
|
|
].filter(s => s.bytes > 0);
|
|
});
|
|
|
|
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 (
|
|
<Show
|
|
when={viewMode() === 'sparklines' && props.resourceId}
|
|
fallback={
|
|
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
|
|
<div
|
|
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
{/* Stacked segments - use absolute positioning like MetricBar for correct width scaling */}
|
|
<For each={segments()}>
|
|
{(segment, idx) => {
|
|
// Calculate left offset as sum of previous segments
|
|
const leftOffset = () => {
|
|
let offset = 0;
|
|
const segs = segments();
|
|
for (let i = 0; i < idx(); i++) {
|
|
offset += segs[i].percent;
|
|
}
|
|
return offset;
|
|
};
|
|
return (
|
|
<div
|
|
class="absolute top-0 h-full transition-all duration-300"
|
|
style={{
|
|
left: `${leftOffset()}%`,
|
|
width: `${segment.percent}%`,
|
|
'background-color': segment.color,
|
|
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
|
|
}}
|
|
/>
|
|
);
|
|
}}
|
|
</For>
|
|
|
|
{/* 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">Used</span>
|
|
<span class="whitespace-nowrap text-gray-300">
|
|
{formatBytes(props.used, 0)}
|
|
</span>
|
|
</div>
|
|
|
|
<Show when={(props.balloon || 0) > 0 && (props.balloon || 0) < props.total}>
|
|
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
|
|
<span class="text-yellow-400">Balloon Limit</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>
|
|
}
|
|
>
|
|
{/* Sparkline mode - full width, flex centered like stacked bars */}
|
|
<div class="metric-text w-full h-4 flex items-center justify-center min-w-0 overflow-hidden">
|
|
<Sparkline
|
|
data={metricHistory()}
|
|
metric="memory"
|
|
width={0}
|
|
height={16}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
);
|
|
}
|