From 5eb7964a7bf38cc347e3c992d14f82e279cb2fb4 Mon Sep 17 00:00:00 2001 From: Yamaray Date: Fri, 5 Jun 2026 23:13:19 +0200 Subject: [PATCH] Agregar temperatura Top-Rack --- DOCUMENTACION.md | 21 +++++++++++++ config.example.json | 8 +++++ config.html | 9 ++++-- dashboard.html | 10 ++++++- docs.html | 10 +++++++ server.js | 73 +++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 123 insertions(+), 8 deletions(-) diff --git a/DOCUMENTACION.md b/DOCUMENTACION.md index c1b5b79..a14d198 100644 --- a/DOCUMENTACION.md +++ b/DOCUMENTACION.md @@ -248,6 +248,7 @@ Ubicacion fisica o logica. Actualmente se muestra en la tarjeta. Por SSH se leen datos de solo lectura: - Temperatura CPU +- Temperatura Top-Rack opcional desde archivo JSON escrito por Node-RED - Uso CPU - Uso RAM - Uso disco raiz `/` @@ -262,6 +263,26 @@ Por SSH se leen datos de solo lectura: - Velocidad nominal si existe - Trafico RX/TX calculado entre scans +### Temperatura Top-Rack + +Si una RPi tiene un sensor externo de temperatura del rack, Node-RED puede escribir la lectura en un archivo local del mismo equipo: + +```json +{ + "temperatureC": 23.7, + "sensor": "rack-top", + "updatedAt": "2026-06-05T21:12:18.997Z" +} +``` + +En el configurador, coloca la ruta en el dispositivo que tiene el sensor: + +```text +/home/yamaray/monitor-rpi/rack-temp.json +``` + +El backend lee ese archivo durante el mismo scan SSH y muestra el valor como `Temperatura Top-Rack` en la primera linea del dashboard. + ## Clientes Activos El dashboard envia heartbeat al backend cuando esta visible. diff --git a/config.example.json b/config.example.json index 357849c..86a8d8f 100644 --- a/config.example.json +++ b/config.example.json @@ -41,6 +41,7 @@ "password": "", "authMethod": "password", "privateKeyPath": "", + "rackTemperatureFile": "", "role": "Home Assistant Local", "port": 22, "id": "rpi5-ha-main", @@ -55,6 +56,7 @@ "password": "", "authMethod": "password", "privateKeyPath": "", + "rackTemperatureFile": "", "role": "Home Assistant CabaƱa", "port": 22, "id": "rpi4-ha-second", @@ -69,6 +71,7 @@ "password": "", "authMethod": "password", "privateKeyPath": "", + "rackTemperatureFile": "", "role": "Docker", "port": 22, "id": "rpi5-jose-docker", @@ -83,6 +86,7 @@ "password": "", "authMethod": "password", "privateKeyPath": "", + "rackTemperatureFile": "", "role": "Docker - Daniel", "port": 22, "id": "rpi5-dani-docker", @@ -97,6 +101,7 @@ "password": "", "authMethod": "password", "privateKeyPath": "", + "rackTemperatureFile": "", "role": "nginx - wireguard ", "port": 22, "id": "rpi3-nginx", @@ -111,6 +116,7 @@ "password": "", "authMethod": "password", "privateKeyPath": "", + "rackTemperatureFile": "", "role": "MQTT - varios", "port": 22, "id": "rpi3-pi3home", @@ -125,6 +131,7 @@ "password": "", "authMethod": "password", "privateKeyPath": "", + "rackTemperatureFile": "/home/yamaray/monitor-rpi/rack-temp.json", "role": "Meshcore-Interface", "port": 22, "id": "rpi3-meshcore", @@ -139,6 +146,7 @@ "password": "", "authMethod": "password", "privateKeyPath": "", + "rackTemperatureFile": "", "role": "Reserva", "port": 22, "id": "rpi3-node-3", diff --git a/config.html b/config.html index 07ee4e5..e44602c 100644 --- a/config.html +++ b/config.html @@ -233,6 +233,7 @@ .field-role { grid-column: span 3; } .field-model { grid-column: span 2; } .field-location { grid-column: span 2; } + .field-rack-temp { grid-column: span 4; } .field-host { grid-column: span 3; } .field-port { grid-column: span 1; } .field-username { grid-column: span 2; } @@ -445,7 +446,7 @@ @media (max-width: 1180px) { .device { grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: start; } - .field-name, .field-role, .field-host, .password-credential { grid-column: span 3; } + .field-name, .field-role, .field-host, .password-credential, .field-rack-temp { grid-column: span 3; } .field-id, .field-model, .field-location, .field-username, .field-auth, .field-active, .field-delete { grid-column: span 2; } .field-port { grid-column: span 1; } .credential-note { grid-column: span 4; } @@ -460,7 +461,7 @@ .top-grid, .device { grid-template-columns: 1fr; } .field-active, .field-name, .field-id, .field-role, .field-model, .field-location, .field-host, .field-port, .field-username, .field-auth, .password-credential, - .credential-note, .field-delete { grid-column: span 1; } + .credential-note, .field-rack-temp, .field-delete { grid-column: span 1; } .threshold-row { grid-template-columns: 1fr; } } @@ -702,6 +703,9 @@
Metodo activo: ${authMethod === "key" ? "Clave asimetrica: copiar clave publica del monitor en la RPi" : "Password"}
+ @@ -848,6 +852,7 @@ password: "", privateKeyPath: "", authMethod: "password", + rackTemperatureFile: "", model: "RPi 4", role: "", location: "Rack", diff --git a/dashboard.html b/dashboard.html index 24c0e5d..0e8fbfd 100644 --- a/dashboard.html +++ b/dashboard.html @@ -282,7 +282,7 @@ .summary { display: grid; - grid-template-columns: repeat(5, minmax(130px, 1fr)); + grid-template-columns: repeat(6, minmax(120px, 1fr)); gap: 12px; } @@ -981,6 +981,14 @@ hottestSummaryClass(summary.hottest.value, temperatureThresholds) ) : summaryItem("Mas caliente", "--"), + summary.rackTemperature + ? summaryItemWithNote( + "Temperatura Top-Rack", + `${summary.rackTemperature.temperatureC.toFixed(1)} C`, + summary.rackTemperature.deviceName || summary.rackTemperature.sensor || "", + hottestSummaryClass(summary.rackTemperature.temperatureC, temperatureThresholds) + ) + : summaryItem("Temperatura Top-Rack", "--"), summaryItem("Total RPi configuradas", summary.total || 0), summaryItem("Alertas", summary.alerts?.length || 0, summary.alerts?.length > 0 ? "summary-hot" : "") ].join(""); diff --git a/docs.html b/docs.html index b8369a1..828ac93 100644 --- a/docs.html +++ b/docs.html @@ -149,6 +149,16 @@ chmod 600 ~/.ssh/authorized_keys
0 clientes visibles  -> idleScanIntervalSeconds
 1+ clientes visibles -> refreshIntervalSeconds
+

Temperatura Top-Rack

+

Si una RPi tiene un sensor externo del rack, Node-RED puede escribir la lectura en un archivo JSON local.

+
{
+  "temperatureC": 23.7,
+  "sensor": "rack-top",
+  "updatedAt": "2026-06-05T21:12:18.997Z"
+}
+

En el configurador, en ese dispositivo, usa Archivo temp rack con una ruta como:

+
/home/yamaray/monitor-rpi/rack-temp.json
+

Docker

cd /home/yamaray/docker/monitorRPi
 docker compose up -d --build
diff --git a/server.js b/server.js index 84ffa07..78ec852 100644 --- a/server.js +++ b/server.js @@ -165,12 +165,22 @@ function mockMetrics(device) { speedMbps: device.model === "RPi 3" ? null : 1000, rxBytesPerSecond: Math.round(seededNumber(`${device.id}:rx`, 1200, 95000)), txBytesPerSecond: Math.round(seededNumber(`${device.id}:tx`, 700, 55000)) - } + }, + rackTemperature: device.rackTemperatureFile + ? { + temperatureC: Number(seededNumber(`${device.id}:rack-temp`, 25, 37).toFixed(1)), + sensor: "rack-top", + updatedAt: new Date().toISOString(), + ageSeconds: 0, + sourceFile: device.rackTemperatureFile + } + : null }; } const REMOTE_METRICS_SCRIPT = ` set +e +rack_temperature_file="$RACK_TEMPERATURE_FILE" temp_raw="$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null)" if [ -n "$temp_raw" ]; then temp_c="$(awk "BEGIN { printf \\"%.1f\\", $temp_raw / 1000 }")" @@ -259,6 +269,27 @@ if [ "$ha_status" = "n/a" ] && [ "$docker_installed" = "1" ]; then fi fi +rack_temperature_c="" +rack_temperature_sensor="" +rack_temperature_updated_at="" +rack_temperature_age_seconds="" +if [ -n "$rack_temperature_file" ] && [ -r "$rack_temperature_file" ]; then + rack_payload="$(cat "$rack_temperature_file" 2>/dev/null)" + rack_temperature_c="$(printf '%s' "$rack_payload" | awk -F '[:,}]' '/temperatureC/ { gsub(/[^0-9.\\-]/, "", $2); print $2; exit }')" + if [ -z "$rack_temperature_c" ]; then + rack_temperature_c="$(printf '%s' "$rack_payload" | awk 'NR==1 { gsub(/[^0-9.\\-]/, "", $1); print $1; exit }')" + fi + rack_temperature_sensor="$(printf '%s' "$rack_payload" | sed -n 's/.*"sensor"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -n 1)" + rack_temperature_updated_at="$(printf '%s' "$rack_payload" | sed -n 's/.*"updatedAt"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -n 1)" + if [ -n "$rack_temperature_updated_at" ]; then + rack_updated_epoch="$(date -d "$rack_temperature_updated_at" +%s 2>/dev/null)" + now_epoch="$(date +%s 2>/dev/null)" + if [ -n "$rack_updated_epoch" ] && [ -n "$now_epoch" ] && [ "$now_epoch" -ge "$rack_updated_epoch" ]; then + rack_temperature_age_seconds="$((now_epoch - rack_updated_epoch))" + fi + fi +fi + printf 'cpuTempC=%s\\n' "$temp_c" printf 'cpuCores=%s\\n' "$cpu_cores" printf 'cpuUsagePercent=%s\\n' "$cpu_usage" @@ -283,6 +314,10 @@ printf 'dockerRunning=%s\\n' "$docker_running" printf 'dockerStopped=%s\\n' "$docker_stopped" printf 'dockerUnhealthy=%s\\n' "$docker_unhealthy" printf 'homeAssistantStatus=%s\\n' "$ha_status" +printf 'rackTemperatureC=%s\\n' "$rack_temperature_c" +printf 'rackTemperatureSensor=%s\\n' "$rack_temperature_sensor" +printf 'rackTemperatureUpdatedAt=%s\\n' "$rack_temperature_updated_at" +printf 'rackTemperatureAgeSeconds=%s\\n' "$rack_temperature_age_seconds" `; function parseKeyValueOutput(output) { @@ -301,6 +336,10 @@ function toNumber(value, fallback = 0) { return Number.isFinite(parsed) ? parsed : fallback; } +function shellQuote(value) { + return `'${String(value || "").replace(/'/g, `'\\''`)}'`; +} + function networkRates(device, values) { const sampleKey = device.id || `${device.host}:${device.port || 22}`; const now = Date.now(); @@ -333,9 +372,12 @@ function networkRates(device, values) { }; } -function runCommandWithInput(command, args, input, timeoutMs) { +function runCommandWithInput(command, args, input, timeoutMs, env = {}) { return new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, ...env } + }); let stdout = ""; let stderr = ""; let settled = false; @@ -428,9 +470,11 @@ async function sshMetrics(device) { ]; } - const { stdout } = await runCommandWithInput(command, args, REMOTE_METRICS_SCRIPT, timeoutMs); + const remoteScript = `RACK_TEMPERATURE_FILE=${shellQuote(device.rackTemperatureFile || "")}\n${REMOTE_METRICS_SCRIPT}`; + const { stdout } = await runCommandWithInput(command, args, remoteScript, timeoutMs); const values = parseKeyValueOutput(stdout); const temp = toNumber(values.cpuTempC, 0); + const rackTemperatureC = toNumber(values.rackTemperatureC, null); const loadAverage = String(values.loadAverage || "") .split(/\s+/) .filter(Boolean) @@ -460,7 +504,16 @@ async function sshMetrics(device) { detected: values.homeAssistantStatus && values.homeAssistantStatus !== "n/a", status: values.homeAssistantStatus || "n/a" }, - network: networkRates(device, values) + network: networkRates(device, values), + rackTemperature: Number.isFinite(rackTemperatureC) + ? { + temperatureC: Number(rackTemperatureC.toFixed(1)), + sensor: values.rackTemperatureSensor || "", + updatedAt: values.rackTemperatureUpdatedAt || "", + ageSeconds: toNumber(values.rackTemperatureAgeSeconds, null), + sourceFile: device.rackTemperatureFile || "" + } + : null }; } @@ -520,6 +573,15 @@ function buildSummary(devices) { .filter((device) => device.metrics && Number.isFinite(device.metrics.cpuTempC)) .map((device) => ({ name: device.name, value: device.metrics.cpuTempC })); const hottest = temps.sort((a, b) => b.value - a.value)[0] || null; + const rackTemperature = online + .map((device) => { + const rack = device.metrics?.rackTemperature; + return rack && Number.isFinite(rack.temperatureC) + ? { ...rack, deviceName: device.name } + : null; + }) + .filter(Boolean) + .sort((a, b) => (a.ageSeconds ?? Number.MAX_SAFE_INTEGER) - (b.ageSeconds ?? Number.MAX_SAFE_INTEGER))[0] || null; const alerts = []; for (const device of devices) { @@ -537,6 +599,7 @@ function buildSummary(devices) { online: online.length, offline: active.length - online.length, hottest, + rackTemperature, alerts }; }