feat: display temperature min/max range in tooltip and remove unused NodeCard
- Add instant-display tooltip on temperature column showing min-max range - Color-code min/max temperatures individually (green/yellow/red) - Remove unused NodeCard.tsx component from codebase - Keep table row height consistent by using tooltip instead of inline display 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
97b9c6739c
commit
d79b8e8883
2 changed files with 26 additions and 276 deletions
|
|
@ -1,276 +0,0 @@
|
||||||
import { Component, Show, createMemo, createEffect } from 'solid-js';
|
|
||||||
import type { Node } from '@/types/api';
|
|
||||||
import { formatUptime } from '@/utils/format';
|
|
||||||
import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
|
|
||||||
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
|
|
||||||
import { useWebSocket } from '@/App';
|
|
||||||
import { Card } from '@/components/shared/Card';
|
|
||||||
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
|
|
||||||
import { getCpuTemperature } from '@/utils/temperature';
|
|
||||||
|
|
||||||
interface NodeCardProps {
|
|
||||||
node: Node;
|
|
||||||
isSelected?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NodeCard: Component<NodeCardProps> = (props) => {
|
|
||||||
const { activeAlerts } = useWebSocket();
|
|
||||||
// Early return if node data is incomplete
|
|
||||||
if (!props.node || !props.node.memory || !props.node.disk) {
|
|
||||||
return (
|
|
||||||
<Card padding="sm" class="flex w-[180px] flex-col gap-1">
|
|
||||||
<div class="text-sm text-gray-500">Loading node data...</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOnline = () =>
|
|
||||||
props.node.status === 'online' &&
|
|
||||||
props.node.uptime > 0 &&
|
|
||||||
props.node.connectionHealth !== 'error';
|
|
||||||
|
|
||||||
// Memoize CPU percent to avoid multiple calculations
|
|
||||||
const cpuPercent = createMemo(() => {
|
|
||||||
const percent = Math.round(props.node.cpu * 100);
|
|
||||||
return percent;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track CPU updates (logging removed for cleaner output)
|
|
||||||
createEffect(() => {
|
|
||||||
cpuPercent(); // Just track the value changes
|
|
||||||
});
|
|
||||||
|
|
||||||
const memPercent = createMemo(() => {
|
|
||||||
if (!props.node.memory) return 0;
|
|
||||||
// Use the pre-calculated usage percentage from the backend
|
|
||||||
return Math.round(props.node.memory.usage || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const diskPercent = createMemo(() => {
|
|
||||||
if (!props.node.disk || props.node.disk.total === 0) return 0;
|
|
||||||
return Math.round((props.node.disk.used / props.node.disk.total) * 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayName = () => getNodeDisplayName(props.node);
|
|
||||||
const showActualName = () => hasAlternateDisplayName(props.node);
|
|
||||||
|
|
||||||
// Calculate normalized load (load average / cpu count)
|
|
||||||
const normalizedLoad = () => {
|
|
||||||
if (props.node.loadAverage && props.node.loadAverage.length > 0) {
|
|
||||||
const load1m = props.node.loadAverage[0];
|
|
||||||
if (typeof load1m === 'number' && !isNaN(load1m)) {
|
|
||||||
// Use CPU cores from cpuInfo if available, otherwise assume 4
|
|
||||||
const cpuCount = props.node.cpuInfo?.cores || 4;
|
|
||||||
return (load1m / cpuCount).toFixed(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'N/A';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to create compact progress bar
|
|
||||||
const createProgressBar = (percentage: number, label: string, colorClass: string) => {
|
|
||||||
const bgColorClass = 'bg-gray-200 dark:bg-gray-600';
|
|
||||||
const progressColorClass =
|
|
||||||
{
|
|
||||||
red: 'bg-red-500/70 dark:bg-red-500/60',
|
|
||||||
yellow: 'bg-yellow-500/70 dark:bg-yellow-500/60',
|
|
||||||
green: 'bg-green-500/70 dark:bg-green-500/60',
|
|
||||||
}[colorClass] || 'bg-gray-500/70 dark:bg-gray-500/60';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="w-[140px]">
|
|
||||||
<div class="flex justify-between items-center mb-0.5">
|
|
||||||
<span class="text-[10px] font-medium text-gray-600 dark:text-gray-400">{label}</span>
|
|
||||||
<span class="text-[10px] font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{percentage}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class={`relative w-full h-2 rounded-full overflow-hidden ${bgColorClass}`}>
|
|
||||||
<div
|
|
||||||
class={`absolute top-0 left-0 h-full transition-all duration-300 ${progressColorClass}`}
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get color based on percentage and metric type
|
|
||||||
const getColor = (percentage: number, metric: 'cpu' | 'memory' | 'disk') => {
|
|
||||||
if (metric === 'cpu') {
|
|
||||||
if (percentage >= 90) return 'red';
|
|
||||||
if (percentage >= 80) return 'yellow';
|
|
||||||
return 'green';
|
|
||||||
} else if (metric === 'memory') {
|
|
||||||
if (percentage >= 85) return 'red';
|
|
||||||
if (percentage >= 75) return 'yellow';
|
|
||||||
return 'green';
|
|
||||||
} else if (metric === 'disk') {
|
|
||||||
if (percentage >= 90) return 'red';
|
|
||||||
if (percentage >= 80) return 'yellow';
|
|
||||||
return 'green';
|
|
||||||
}
|
|
||||||
return 'green';
|
|
||||||
};
|
|
||||||
|
|
||||||
const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts);
|
|
||||||
const nodeAlerts = createMemo(() =>
|
|
||||||
getResourceAlerts(props.node.id || props.node.name, activeAlerts),
|
|
||||||
);
|
|
||||||
const unacknowledgedNodeAlerts = createMemo(() => nodeAlerts().filter((alert) => !alert.acknowledged));
|
|
||||||
|
|
||||||
const cpuTemperature = createMemo(() => getCpuTemperature(props.node.temperature));
|
|
||||||
const cpuTemperatureValue = createMemo(() => {
|
|
||||||
const value = cpuTemperature();
|
|
||||||
return value !== null ? Math.round(value) : null;
|
|
||||||
});
|
|
||||||
const temperatureTooltip = createMemo(() => {
|
|
||||||
const hasCPU = props.node.temperature?.hasCPU ?? props.node.temperature?.available;
|
|
||||||
if (!hasCPU) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const value = cpuTemperatureValue();
|
|
||||||
if (value === null) {
|
|
||||||
return 'CPU sensor unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
const temp = props.node.temperature;
|
|
||||||
const hasMinMax = temp && temp.cpuMin > 0 && temp.cpuMaxRecord > 0;
|
|
||||||
|
|
||||||
if (hasMinMax) {
|
|
||||||
const min = Math.round(temp.cpuMin);
|
|
||||||
const max = Math.round(temp.cpuMaxRecord);
|
|
||||||
return `CPU: ${value}°C (${min}-${max}°C since monitoring started)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `CPU: ${value}°C`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine border/ring style based on status and alerts
|
|
||||||
const getBorderClass = () => {
|
|
||||||
// Selected nodes get blue ring
|
|
||||||
if (props.isSelected) {
|
|
||||||
return 'ring-2 ring-blue-500 border-blue-200 dark:border-blue-500';
|
|
||||||
}
|
|
||||||
// Offline nodes get red ring
|
|
||||||
if (!isOnline()) {
|
|
||||||
return 'ring-2 ring-red-500 border-red-200 dark:border-red-600';
|
|
||||||
}
|
|
||||||
// Alert nodes get colored ring based on severity
|
|
||||||
if (alertStyles.hasUnacknowledgedAlert) {
|
|
||||||
return alertStyles.severity === 'critical'
|
|
||||||
? 'ring-2 ring-red-500 border-red-200 dark:border-red-600'
|
|
||||||
: 'ring-2 ring-orange-500 border-orange-200 dark:border-orange-500';
|
|
||||||
}
|
|
||||||
if (alertStyles.hasAcknowledgedOnlyAlert) {
|
|
||||||
return 'ring-2 ring-gray-400 border-gray-200 dark:border-gray-600 dark:ring-gray-500';
|
|
||||||
}
|
|
||||||
// Normal nodes get standard border
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get background class from alert styles but remove the border-l-4 part
|
|
||||||
const getBackgroundClass = () => {
|
|
||||||
if (!alertStyles.rowClass) return '';
|
|
||||||
// Remove border classes from rowClass to avoid conflicts
|
|
||||||
return alertStyles.rowClass.replace(/border-[^\s]+/g, '').trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
padding="sm"
|
|
||||||
class={`flex w-[180px] flex-col gap-2 ${getBorderClass()} ${getBackgroundClass()}`.trim()}
|
|
||||||
hoverable
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<h3 class="text-xs font-semibold truncate text-gray-800 dark:text-gray-200 flex items-center gap-1">
|
|
||||||
<a
|
|
||||||
href={
|
|
||||||
props.node.host ||
|
|
||||||
(props.node.name.includes(':')
|
|
||||||
? `https://${props.node.name}`
|
|
||||||
: `https://${props.node.name}:8006`)
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
|
|
||||||
title={`Open ${props.node.name} web interface`}
|
|
||||||
>
|
|
||||||
{displayName()}
|
|
||||||
</a>
|
|
||||||
<Show when={showActualName()}>
|
|
||||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">({props.node.name})</span>
|
|
||||||
</Show>
|
|
||||||
{/* Cluster/Standalone indicator - more compact */}
|
|
||||||
<Show when={props.node.isClusterMember !== undefined}>
|
|
||||||
<span
|
|
||||||
class={`text-[9px] px-1 py-0.5 rounded-full font-medium ${
|
|
||||||
props.node.isClusterMember
|
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
|
||||||
}`}
|
|
||||||
title={props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
|
|
||||||
>
|
|
||||||
{props.node.isClusterMember ? 'C' : 'S'}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={alertStyles.hasUnacknowledgedAlert}>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<AlertIndicator severity={alertStyles.severity} alerts={unacknowledgedNodeAlerts()} />
|
|
||||||
<Show when={(alertStyles.unacknowledgedCount || 0) > 1}>
|
|
||||||
<AlertCountBadge
|
|
||||||
count={alertStyles.unacknowledgedCount || 0}
|
|
||||||
severity={alertStyles.severity!}
|
|
||||||
alerts={unacknowledgedNodeAlerts()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
class={`h-2 w-2 rounded-full flex-shrink-0 ${isOnline() ? 'bg-green-500' : 'bg-red-500'}`}
|
|
||||||
title={isOnline() ? 'Online' : 'Offline'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metrics - Compact */}
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
{createProgressBar(cpuPercent(), 'CPU', getColor(cpuPercent(), 'cpu'))}
|
|
||||||
{createProgressBar(memPercent(), 'Memory', getColor(memPercent(), 'memory'))}
|
|
||||||
{createProgressBar(diskPercent(), 'Disk', getColor(diskPercent(), 'disk'))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Info - More compact */}
|
|
||||||
<div class="flex justify-between text-[9px] text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
<span title={`Uptime: ${formatUptime(props.node.uptime)}`}>
|
|
||||||
↑{formatUptime(props.node.uptime)}
|
|
||||||
</span>
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
cpuTemperatureValue() !== null &&
|
|
||||||
(props.node.temperature?.hasCPU ?? props.node.temperature?.available)
|
|
||||||
}
|
|
||||||
fallback={
|
|
||||||
<span title={`Load: ${normalizedLoad()}`}>⚡{normalizedLoad()}</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class={`font-medium ${
|
|
||||||
(cpuTemperatureValue() ?? 0) > 80
|
|
||||||
? 'text-red-500'
|
|
||||||
: (cpuTemperatureValue() ?? 0) > 60
|
|
||||||
? 'text-yellow-500'
|
|
||||||
: 'text-green-500'
|
|
||||||
}`}
|
|
||||||
title={temperatureTooltip() || undefined}
|
|
||||||
>
|
|
||||||
🌡{cpuTemperatureValue()}°C
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NodeCard;
|
|
||||||
|
|
@ -619,6 +619,32 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
: value >= 70
|
: value >= 70
|
||||||
? 'text-yellow-600 dark:text-yellow-400'
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
: 'text-green-600 dark:text-green-400';
|
: 'text-green-600 dark:text-green-400';
|
||||||
|
|
||||||
|
const temp = node!.temperature;
|
||||||
|
const hasMinMax = temp && temp.cpuMin && temp.cpuMin > 0 && temp.cpuMaxRecord && temp.cpuMaxRecord > 0;
|
||||||
|
|
||||||
|
if (hasMinMax) {
|
||||||
|
const min = Math.round(temp.cpuMin);
|
||||||
|
const max = Math.round(temp.cpuMaxRecord);
|
||||||
|
|
||||||
|
const getTooltipColor = (temp: number) => {
|
||||||
|
if (temp >= 80) return 'text-red-400';
|
||||||
|
if (temp >= 70) return 'text-yellow-400';
|
||||||
|
return 'text-green-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class="relative inline-block group">
|
||||||
|
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
|
||||||
|
{value}°C
|
||||||
|
</span>
|
||||||
|
<span class="invisible group-hover: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">
|
||||||
|
<span class="text-gray-300">Range:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <span class={`text-xs font-medium ${severityClass}`}>{value}°C</span>;
|
return <span class={`text-xs font-medium ${severityClass}`}>{value}°C</span>;
|
||||||
})()}
|
})()}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue