feat(webui): thermal status gauge with CPU/GPU temperature display (Issue #308) #312
@ -65,6 +65,9 @@ import { WaypointEditor } from './components/WaypointEditor.jsx';
|
|||||||
// Bandwidth monitor (issue #287)
|
// Bandwidth monitor (issue #287)
|
||||||
import { BandwidthMonitor } from './components/BandwidthMonitor.jsx';
|
import { BandwidthMonitor } from './components/BandwidthMonitor.jsx';
|
||||||
|
|
||||||
|
// Temperature gauge (issue #308)
|
||||||
|
import { TempGauge } from './components/TempGauge.jsx';
|
||||||
|
|
||||||
const TAB_GROUPS = [
|
const TAB_GROUPS = [
|
||||||
{
|
{
|
||||||
label: 'SOCIAL',
|
label: 'SOCIAL',
|
||||||
@ -87,6 +90,7 @@ const TAB_GROUPS = [
|
|||||||
{ id: 'battery', label: 'Battery', },
|
{ id: 'battery', label: 'Battery', },
|
||||||
{ id: 'battery-chart', label: 'Battery History', },
|
{ id: 'battery-chart', label: 'Battery History', },
|
||||||
{ id: 'motors', label: 'Motors', },
|
{ id: 'motors', label: 'Motors', },
|
||||||
|
{ id: 'thermal', label: 'Thermal', },
|
||||||
{ id: 'map', label: 'Map', },
|
{ id: 'map', label: 'Map', },
|
||||||
{ id: 'control', label: 'Control', },
|
{ id: 'control', label: 'Control', },
|
||||||
{ id: 'health', label: 'Health', },
|
{ id: 'health', label: 'Health', },
|
||||||
@ -254,6 +258,7 @@ export default function App() {
|
|||||||
{activeTab === 'battery-chart' && <BatteryChart subscribe={subscribe} />}
|
{activeTab === 'battery-chart' && <BatteryChart subscribe={subscribe} />}
|
||||||
{activeTab === 'motors' && <MotorPanel subscribe={subscribe} />}
|
{activeTab === 'motors' && <MotorPanel subscribe={subscribe} />}
|
||||||
{activeTab === 'motor-current-graph' && <MotorCurrentGraph subscribe={subscribe} />}
|
{activeTab === 'motor-current-graph' && <MotorCurrentGraph subscribe={subscribe} />}
|
||||||
|
{activeTab === 'thermal' && <TempGauge subscribe={subscribe} />}
|
||||||
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
|
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
|
||||||
{activeTab === 'control' && (
|
{activeTab === 'control' && (
|
||||||
<div className="flex flex-col h-full gap-4">
|
<div className="flex flex-col h-full gap-4">
|
||||||
|
|||||||
322
ui/social-bot/src/components/TempGauge.jsx
Normal file
322
ui/social-bot/src/components/TempGauge.jsx
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* TempGauge.jsx — CPU and GPU temperature circular gauge
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Subscribes to /saltybot/thermal_status for CPU and GPU temperatures
|
||||||
|
* - Circular gauge visualization with color zones
|
||||||
|
* - Temperature zones: green <60°C, yellow 60-75°C, red >75°C
|
||||||
|
* - Real-time temperature display with needle pointer
|
||||||
|
* - Fan speed percentage indicator
|
||||||
|
* - Peak temperature tracking
|
||||||
|
* - Thermal alert indicators
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const MIN_TEMP = 0;
|
||||||
|
const MAX_TEMP = 100;
|
||||||
|
const GAUGE_START_ANGLE = Math.PI * 0.7; // 126°
|
||||||
|
const GAUGE_END_ANGLE = Math.PI * 2.3; // 414° (180° + 234°)
|
||||||
|
const GAUGE_RANGE = GAUGE_END_ANGLE - GAUGE_START_ANGLE;
|
||||||
|
|
||||||
|
const TEMP_ZONES = {
|
||||||
|
good: { max: 60, color: '#10b981', label: 'Good' }, // green
|
||||||
|
caution: { max: 75, color: '#f59e0b', label: 'Caution' }, // yellow
|
||||||
|
critical: { max: Infinity, color: '#ef4444', label: 'Critical' }, // red
|
||||||
|
};
|
||||||
|
|
||||||
|
function CircularGauge({ temp, maxTemp, label, color }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height * 0.65;
|
||||||
|
const radius = width * 0.35;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.fillStyle = '#1f2937';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Draw gauge background arcs (color zones)
|
||||||
|
const zoneValues = [60, 75, 100];
|
||||||
|
const zoneColors = ['#10b981', '#f59e0b', '#ef4444'];
|
||||||
|
|
||||||
|
for (let i = 0; i < zoneValues.length; i++) {
|
||||||
|
const startVal = i === 0 ? 0 : zoneValues[i - 1];
|
||||||
|
const endVal = zoneValues[i];
|
||||||
|
const normalizedStart = startVal / MAX_TEMP;
|
||||||
|
const normalizedEnd = endVal / MAX_TEMP;
|
||||||
|
|
||||||
|
const startAngle = GAUGE_START_ANGLE + GAUGE_RANGE * normalizedStart;
|
||||||
|
const endAngle = GAUGE_START_ANGLE + GAUGE_RANGE * normalizedEnd;
|
||||||
|
|
||||||
|
ctx.strokeStyle = zoneColors[i];
|
||||||
|
ctx.lineWidth = 12;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw outer ring
|
||||||
|
ctx.strokeStyle = '#374151';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, radius, GAUGE_START_ANGLE, GAUGE_END_ANGLE);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw tick marks and labels
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.strokeStyle = '#9ca3af';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
const value = (i / 10) * MAX_TEMP;
|
||||||
|
const angle = GAUGE_START_ANGLE + (i / 10) * GAUGE_RANGE;
|
||||||
|
const tickLen = i % 5 === 0 ? 8 : 4;
|
||||||
|
|
||||||
|
const x1 = centerX + Math.cos(angle) * radius;
|
||||||
|
const y1 = centerY + Math.sin(angle) * radius;
|
||||||
|
const x2 = centerX + Math.cos(angle) * (radius + tickLen);
|
||||||
|
const y2 = centerY + Math.sin(angle) * (radius + tickLen);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
const labelX = centerX + Math.cos(angle) * (radius + 18);
|
||||||
|
const labelY = centerY + Math.sin(angle) * (radius + 18);
|
||||||
|
ctx.fillText(`${Math.round(value)}°`, labelX, labelY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw needle
|
||||||
|
const normalizedTemp = Math.max(0, Math.min(1, temp / MAX_TEMP));
|
||||||
|
const needleAngle = GAUGE_START_ANGLE + normalizedTemp * GAUGE_RANGE;
|
||||||
|
|
||||||
|
// Needle base circle
|
||||||
|
ctx.fillStyle = '#1f2937';
|
||||||
|
ctx.strokeStyle = '#9ca3af';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, 8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Needle line
|
||||||
|
ctx.strokeStyle = color || '#06b6d4';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(centerX, centerY);
|
||||||
|
const needleLen = radius * 0.85;
|
||||||
|
ctx.lineTo(
|
||||||
|
centerX + Math.cos(needleAngle) * needleLen,
|
||||||
|
centerY + Math.sin(needleAngle) * needleLen
|
||||||
|
);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Needle tip circle
|
||||||
|
ctx.fillStyle = color || '#06b6d4';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(
|
||||||
|
centerX + Math.cos(needleAngle) * needleLen,
|
||||||
|
centerY + Math.sin(needleAngle) * needleLen,
|
||||||
|
4,
|
||||||
|
0,
|
||||||
|
Math.PI * 2
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Draw temperature display
|
||||||
|
ctx.fillStyle = color || '#06b6d4';
|
||||||
|
ctx.font = 'bold 32px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(`${Math.round(temp)}°`, centerX, centerY - 20);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.font = 'bold 12px monospace';
|
||||||
|
ctx.fillText(label, centerX, centerY + 25);
|
||||||
|
}, [temp, label, color]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} width={200} height={180} className="inline-block" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemperatureRow({ label, temp, fanSpeed, maxTemp = MAX_TEMP }) {
|
||||||
|
// Determine color zone
|
||||||
|
let zoneColor = '#10b981'; // green
|
||||||
|
let zoneLabel = 'Good';
|
||||||
|
if (temp >= 60 && temp < 75) {
|
||||||
|
zoneColor = '#f59e0b'; // yellow
|
||||||
|
zoneLabel = 'Caution';
|
||||||
|
} else if (temp >= 75) {
|
||||||
|
zoneColor = '#ef4444'; // red
|
||||||
|
zoneLabel = 'Critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 p-4 bg-gray-900 rounded border border-gray-800">
|
||||||
|
<CircularGauge temp={temp} maxTemp={maxTemp} label={label} color={zoneColor} />
|
||||||
|
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-gray-600">STATUS</span>
|
||||||
|
<span className={`font-bold ${zoneColor === '#10b981' ? 'text-green-400' : zoneColor === '#f59e0b' ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||||
|
{zoneLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fanSpeed !== undefined && (
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-gray-600">FAN SPEED</span>
|
||||||
|
<span className="text-cyan-300 font-mono">{Math.round(fanSpeed)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-gray-600">MAX REACHED</span>
|
||||||
|
<span className="text-amber-300 font-mono">{Math.round(maxTemp)}°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TempGauge({ subscribe }) {
|
||||||
|
const [cpuTemp, setCpuTemp] = useState(0);
|
||||||
|
const [gpuTemp, setGpuTemp] = useState(0);
|
||||||
|
const [cpuFanSpeed, setCpuFanSpeed] = useState(0);
|
||||||
|
const [gpuFanSpeed, setGpuFanSpeed] = useState(0);
|
||||||
|
const [cpuMaxTemp, setCpuMaxTemp] = useState(0);
|
||||||
|
const [gpuMaxTemp, setGpuMaxTemp] = useState(0);
|
||||||
|
const maxTempRef = useRef({ cpu: 0, gpu: 0 });
|
||||||
|
|
||||||
|
// Subscribe to thermal status
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = subscribe(
|
||||||
|
'/saltybot/thermal_status',
|
||||||
|
'saltybot_msgs/ThermalStatus',
|
||||||
|
(msg) => {
|
||||||
|
try {
|
||||||
|
const cpu = msg.cpu_temp || 0;
|
||||||
|
const gpu = msg.gpu_temp || 0;
|
||||||
|
const cpuFan = msg.cpu_fan_speed || 0;
|
||||||
|
const gpuFan = msg.gpu_fan_speed || 0;
|
||||||
|
|
||||||
|
setCpuTemp(cpu);
|
||||||
|
setGpuTemp(gpu);
|
||||||
|
setCpuFanSpeed(cpuFan);
|
||||||
|
setGpuFanSpeed(gpuFan);
|
||||||
|
|
||||||
|
// Track max temperatures
|
||||||
|
if (cpu > maxTempRef.current.cpu) {
|
||||||
|
maxTempRef.current.cpu = cpu;
|
||||||
|
setCpuMaxTemp(cpu);
|
||||||
|
}
|
||||||
|
if (gpu > maxTempRef.current.gpu) {
|
||||||
|
maxTempRef.current.gpu = gpu;
|
||||||
|
setGpuMaxTemp(gpu);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing thermal status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
const overallCritical = cpuTemp >= 75 || gpuTemp >= 75;
|
||||||
|
const overallCaution = cpuTemp >= 60 || gpuTemp >= 60;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
{/* Summary Header */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
||||||
|
THERMAL STATUS
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs font-bold px-2 py-1 rounded ${
|
||||||
|
overallCritical ? 'bg-red-950 text-red-400 border border-red-800' :
|
||||||
|
overallCaution ? 'bg-yellow-950 text-yellow-400 border border-yellow-800' :
|
||||||
|
'bg-green-950 text-green-400 border border-green-800'
|
||||||
|
}`}>
|
||||||
|
{overallCritical ? 'CRITICAL' : overallCaution ? 'CAUTION' : 'NORMAL'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="bg-gray-900 rounded p-2">
|
||||||
|
<div className="text-gray-600">CPU</div>
|
||||||
|
<div className="text-lg font-mono text-cyan-300">{Math.round(cpuTemp)}°C</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded p-2">
|
||||||
|
<div className="text-gray-600">GPU</div>
|
||||||
|
<div className="text-lg font-mono text-cyan-300">{Math.round(gpuTemp)}°C</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gauge Cards */}
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-3 overflow-y-auto">
|
||||||
|
<TemperatureRow
|
||||||
|
label="CPU"
|
||||||
|
temp={cpuTemp}
|
||||||
|
fanSpeed={cpuFanSpeed}
|
||||||
|
maxTemp={cpuMaxTemp}
|
||||||
|
/>
|
||||||
|
<TemperatureRow
|
||||||
|
label="GPU"
|
||||||
|
temp={gpuTemp}
|
||||||
|
fanSpeed={gpuFanSpeed}
|
||||||
|
maxTemp={gpuMaxTemp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temperature zones legend */}
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 space-y-2">
|
||||||
|
<div className="text-xs text-gray-600 font-bold tracking-widest">TEMPERATURE ZONES</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||||
|
<span className="text-gray-400"><60°C</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||||
|
<span className="text-gray-400">60-75°C</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||||
|
<span className="text-gray-400">>75°C</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Topic info */}
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Topic:</span>
|
||||||
|
<span className="text-gray-500">/saltybot/thermal_status</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Type:</span>
|
||||||
|
<span className="text-gray-500">saltybot_msgs/ThermalStatus</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user