diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx
index ed6bbc6..e6aafdb 100644
--- a/ui/social-bot/src/App.jsx
+++ b/ui/social-bot/src/App.jsx
@@ -65,6 +65,9 @@ import { WaypointEditor } from './components/WaypointEditor.jsx';
// Bandwidth monitor (issue #287)
import { BandwidthMonitor } from './components/BandwidthMonitor.jsx';
+// Temperature gauge (issue #308)
+import { TempGauge } from './components/TempGauge.jsx';
+
const TAB_GROUPS = [
{
label: 'SOCIAL',
@@ -87,6 +90,7 @@ const TAB_GROUPS = [
{ id: 'battery', label: 'Battery', },
{ id: 'battery-chart', label: 'Battery History', },
{ id: 'motors', label: 'Motors', },
+ { id: 'thermal', label: 'Thermal', },
{ id: 'map', label: 'Map', },
{ id: 'control', label: 'Control', },
{ id: 'health', label: 'Health', },
@@ -254,6 +258,7 @@ export default function App() {
{activeTab === 'battery-chart' && }
{activeTab === 'motors' && }
{activeTab === 'motor-current-graph' && }
+ {activeTab === 'thermal' && }
{activeTab === 'map' && }
{activeTab === 'control' && (
diff --git a/ui/social-bot/src/components/TempGauge.jsx b/ui/social-bot/src/components/TempGauge.jsx
new file mode 100644
index 0000000..8e78ee2
--- /dev/null
+++ b/ui/social-bot/src/components/TempGauge.jsx
@@ -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
;
+}
+
+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 (
+
+
+
+
+
+ STATUS
+
+ {zoneLabel}
+
+
+
+ {fanSpeed !== undefined && (
+
+ FAN SPEED
+ {Math.round(fanSpeed)}%
+
+ )}
+
+
+ MAX REACHED
+ {Math.round(maxTemp)}°
+
+
+
+ );
+}
+
+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 (
+
+ {/* Summary Header */}
+
+
+
+ THERMAL STATUS
+
+
+ {overallCritical ? 'CRITICAL' : overallCaution ? 'CAUTION' : 'NORMAL'}
+
+
+
+ {/* Quick stats */}
+
+
+
CPU
+
{Math.round(cpuTemp)}°C
+
+
+
GPU
+
{Math.round(gpuTemp)}°C
+
+
+
+
+ {/* Gauge Cards */}
+
+
+
+
+
+ {/* Temperature zones legend */}
+
+
+ {/* Topic info */}
+
+
+ Topic:
+ /saltybot/thermal_status
+
+
+ Type:
+ saltybot_msgs/ThermalStatus
+
+
+
+ );
+}