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:
parent
c3a5aacd21
commit
ae3a349124
14 changed files with 715 additions and 149 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
217
frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx
Normal file
217
frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
115
frontend-modern/src/components/Docker/StackedContainerBar.tsx
Normal file
115
frontend-modern/src/components/Docker/StackedContainerBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
97
frontend-modern/src/components/Storage/ZFSHealthMap.tsx
Normal file
97
frontend-modern/src/components/Storage/ZFSHealthMap.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 >
|
||||
);
|
||||
};
|
||||
|
|
|
|||
67
frontend-modern/src/components/shared/TemperatureGauge.tsx
Normal file
67
frontend-modern/src/components/shared/TemperatureGauge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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') };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue