Add EnhancedCPUBar component with Load Average visualization
- Create EnhancedCPUBar component to show CPU usage with Load Average overlay - Add vertical marker indicating 1-minute Load Average relative to core count - Purple glow indicator when Load > Cores (system overload) - Detailed tooltip showing Usage, Load, and Capacity metrics - Full sparkline mode support for historical view - Integrate into NodeSummaryTable for Proxmox nodes - Integrate into DockerHostSummaryTable for Docker hosts - Integrate into HostsOverview for standalone hosts
This commit is contained in:
parent
ea18aad96c
commit
3ed190e8bc
4 changed files with 209 additions and 26 deletions
154
frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx
Normal file
154
frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { Show, createMemo, createSignal } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { formatPercent } from '@/utils/format';
|
||||
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
||||
import { getMetricHistory } from '@/stores/metricsHistory';
|
||||
import { Sparkline } from '@/components/shared/Sparkline';
|
||||
|
||||
interface EnhancedCPUBarProps {
|
||||
usage: number; // CPU Usage % (0-100)
|
||||
loadAverage?: number; // 1-minute load average
|
||||
cores?: number; // Number of cores
|
||||
model?: string; // CPU Model name (for tooltip)
|
||||
resourceId?: string; // For sparkline history
|
||||
}
|
||||
|
||||
export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
|
||||
const [showTooltip, setShowTooltip] = createSignal(false);
|
||||
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
// Calculate Load % relative to core count
|
||||
const loadPercent = createMemo(() => {
|
||||
if (props.loadAverage === undefined || !props.cores || props.cores === 0) return 0;
|
||||
return (props.loadAverage / props.cores) * 100;
|
||||
});
|
||||
|
||||
const isOverloaded = createMemo(() => loadPercent() > 100);
|
||||
|
||||
// Bar color based on usage
|
||||
const barColor = createMemo(() => {
|
||||
if (props.usage >= 90) return 'bg-red-500/60 dark:bg-red-500/50';
|
||||
if (props.usage >= 80) return 'bg-yellow-500/60 dark:bg-yellow-500/50';
|
||||
return 'bg-green-500/60 dark:bg-green-500/50';
|
||||
});
|
||||
|
||||
// Load marker position (capped at 100%)
|
||||
const markerPosition = createMemo(() => Math.min(loadPercent(), 100));
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const { viewMode } = useMetricsViewMode();
|
||||
|
||||
// Get metric history for sparkline
|
||||
const metricHistory = createMemo(() => {
|
||||
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
|
||||
return getMetricHistory(props.resourceId);
|
||||
});
|
||||
|
||||
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 max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Usage Bar */}
|
||||
<div
|
||||
class={`absolute top-0 left-0 h-full transition-all duration-300 ${barColor()}`}
|
||||
style={{ width: `${Math.min(props.usage, 100)}%` }}
|
||||
/>
|
||||
|
||||
{/* Load Average Marker */}
|
||||
<Show when={props.loadAverage !== undefined && props.cores}>
|
||||
<div
|
||||
class={`absolute top-0 bottom-0 w-[2px] z-10 transition-all duration-300 ${isOverloaded() ? 'bg-purple-600 dark:bg-purple-400 shadow-[0_0_4px_rgba(147,51,234,0.8)]' : 'bg-gray-800 dark:bg-gray-200 opacity-60'
|
||||
}`}
|
||||
style={{ left: `${markerPosition()}%` }}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Label */}
|
||||
<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">
|
||||
{formatPercent(props.usage)}
|
||||
</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-[160px] border border-gray-700">
|
||||
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
|
||||
CPU Details
|
||||
</div>
|
||||
|
||||
<Show when={props.model}>
|
||||
<div class="text-[9px] text-gray-400 mb-1.5 truncate max-w-[200px]">
|
||||
{props.model}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-between gap-3 py-0.5">
|
||||
<span class="text-gray-400">Usage</span>
|
||||
<span class={`font-medium ${props.usage > 90 ? 'text-red-400' : 'text-gray-200'}`}>
|
||||
{formatPercent(props.usage)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={props.loadAverage !== undefined && props.cores}>
|
||||
<div class="flex justify-between gap-3 py-0.5">
|
||||
<span class="text-gray-400">Load (1m)</span>
|
||||
<span class={`font-medium ${isOverloaded() ? 'text-purple-400' : 'text-gray-200'}`}>
|
||||
{props.loadAverage?.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3 py-0.5">
|
||||
<span class="text-gray-400">Capacity</span>
|
||||
<span class={`font-medium ${isOverloaded() ? 'text-purple-400' : 'text-gray-200'}`}>
|
||||
{loadPercent().toFixed(0)}% of {props.cores} cores
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Sparkline mode */}
|
||||
<div class="metric-text w-full h-6 flex items-center gap-1.5">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Sparkline
|
||||
data={metricHistory()}
|
||||
metric="cpu"
|
||||
width={0}
|
||||
height={24}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] font-medium text-gray-800 dark:text-gray-100 whitespace-nowrap flex-shrink-0 min-w-[35px]">
|
||||
{formatPercent(props.usage)}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@ import { buildMetricKey } from '@/utils/metricsKeys';
|
|||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { getDockerHostStatusIndicator } from '@/utils/status';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||
import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive';
|
||||
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
|
||||
|
||||
export interface DockerHostSummary {
|
||||
host: DockerHost;
|
||||
|
|
@ -283,13 +284,19 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
|||
</div>
|
||||
</td>
|
||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||
<ResponsiveMetricCell
|
||||
value={summary.cpuPercent}
|
||||
type="cpu"
|
||||
resourceId={metricsKey}
|
||||
isRunning={online}
|
||||
showMobile={isMobile()}
|
||||
/>
|
||||
<Show when={isMobile()}>
|
||||
<div class="md:hidden">
|
||||
<MetricText value={summary.cpuPercent} type="cpu" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="hidden md:block w-full">
|
||||
<EnhancedCPUBar
|
||||
usage={summary.cpuPercent}
|
||||
loadAverage={summary.host.loadAverage?.[0]}
|
||||
cores={summary.host.cpus}
|
||||
resourceId={metricsKey}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||
<ResponsiveMetricCell
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import { HostsFilter } from './HostsFilter';
|
|||
import { useWebSocket } from '@/App';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { getHostStatusIndicator } from '@/utils/status';
|
||||
import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive';
|
||||
import { MetricText, useGridTemplate } from '@/components/shared/responsive';
|
||||
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
|
||||
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
|
||||
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
||||
import type { ColumnConfig } from '@/types/responsive';
|
||||
import { STANDARD_COLUMNS } from '@/types/responsive';
|
||||
|
|
@ -204,14 +205,18 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
|||
case 'cpu':
|
||||
return (
|
||||
<div class="px-2 py-1 overflow-hidden">
|
||||
<ResponsiveMetricCell
|
||||
value={cpuPercent()}
|
||||
type="cpu"
|
||||
label={formatPercent(cpuPercent())}
|
||||
isRunning={true}
|
||||
showMobile={isMobile()}
|
||||
class="w-full"
|
||||
/>
|
||||
<Show when={isMobile()}>
|
||||
<div class="md:hidden">
|
||||
<MetricText value={cpuPercent()} type="cpu" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="hidden md:block w-full">
|
||||
<EnhancedCPUBar
|
||||
usage={cpuPercent()}
|
||||
loadAverage={host.loadAverage?.[0]}
|
||||
cores={host.cpuCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'memory':
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
|
|||
import { type ColumnPriority } from '@/hooks/useBreakpoint';
|
||||
import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive';
|
||||
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
|
||||
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
|
||||
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
||||
|
||||
// Icons for mobile headers
|
||||
|
|
@ -699,15 +700,31 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
|||
case 'cpu':
|
||||
return (
|
||||
<div class={`${baseCellClass} ${alignClass}`}>
|
||||
<ResponsiveMetricCell
|
||||
value={cpuPercentValue}
|
||||
type="cpu"
|
||||
resourceId={metricsKey}
|
||||
sublabel={isPVEItem && node!.cpuInfo?.cores ? `${node!.cpuInfo.cores} cores` : undefined}
|
||||
isRunning={online}
|
||||
showMobile={isMobile()}
|
||||
class="w-full"
|
||||
/>
|
||||
<Show when={isMobile()}>
|
||||
<div class="md:hidden w-full">
|
||||
<MetricText value={cpuPercentValue} type="cpu" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="hidden md:block w-full">
|
||||
<Show when={isPVEItem} fallback={
|
||||
<ResponsiveMetricCell
|
||||
value={cpuPercentValue}
|
||||
type="cpu"
|
||||
resourceId={metricsKey}
|
||||
isRunning={online}
|
||||
showMobile={false}
|
||||
class="w-full"
|
||||
/>
|
||||
}>
|
||||
<EnhancedCPUBar
|
||||
usage={cpuPercentValue}
|
||||
loadAverage={node!.loadAverage?.[0]}
|
||||
cores={node!.cpuInfo?.cores}
|
||||
model={node!.cpuInfo?.model}
|
||||
resourceId={metricsKey}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue