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:
Richard Courtman 2025-10-18 08:57:10 +00:00
parent 97b9c6739c
commit d79b8e8883
2 changed files with 26 additions and 276 deletions

View file

@ -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;

View file

@ -619,6 +619,32 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
: value >= 70
? 'text-yellow-600 dark:text-yellow-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>;
})()}
</Show>