Compare commits

...

2 Commits

Author SHA1 Message Date
yamaray c4af121597 Configurar Top-Rack como ajuste global 2026-06-05 23:23:30 +02:00
yamaray 5eb7964a7b Agregar temperatura Top-Rack 2026-06-05 23:13:19 +02:00
6 changed files with 166 additions and 6 deletions
+29
View File
@@ -248,6 +248,7 @@ Ubicacion fisica o logica. Actualmente se muestra en la tarjeta.
Por SSH se leen datos de solo lectura: Por SSH se leen datos de solo lectura:
- Temperatura CPU - Temperatura CPU
- Temperatura Top-Rack opcional desde archivo JSON escrito por Node-RED
- Uso CPU - Uso CPU
- Uso RAM - Uso RAM
- Uso disco raiz `/` - Uso disco raiz `/`
@@ -262,6 +263,34 @@ Por SSH se leen datos de solo lectura:
- Velocidad nominal si existe - Velocidad nominal si existe
- Trafico RX/TX calculado entre scans - 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, usa la seccion general `Temperatura Top-Rack`:
```text
RPi origen: RPi - MeshCore · 192.168.0.60
Path JSON temperatura: /home/yamaray/monitor-rpi/rack-temp.json
```
La configuracion se guarda como:
```text
rackTemperatureSourceDeviceId: rpi3-meshcore
rackTemperatureFile: /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 ## Clientes Activos
El dashboard envia heartbeat al backend cuando esta visible. El dashboard envia heartbeat al backend cuando esta visible.
+2
View File
@@ -5,6 +5,8 @@
"sshTimeoutSeconds": 8, "sshTimeoutSeconds": 8,
"sshPrivateKeyPath": "/ssh/monitor_rpi_ed25519", "sshPrivateKeyPath": "/ssh/monitor_rpi_ed25519",
"sshPublicKeyPath": "/ssh/monitor_rpi_ed25519.pub", "sshPublicKeyPath": "/ssh/monitor_rpi_ed25519.pub",
"rackTemperatureSourceDeviceId": "rpi3-meshcore",
"rackTemperatureFile": "/home/yamaray/monitor-rpi/rack-temp.json",
"temperatureThresholdsC": { "temperatureThresholdsC": {
"warning": 60, "warning": 60,
"hot": 70, "hot": 70,
+23
View File
@@ -502,6 +502,17 @@
<input id="sshTimeoutSeconds" type="number" min="2" step="1"> <input id="sshTimeoutSeconds" type="number" min="2" step="1">
</label> </label>
</div> </div>
<div class="ssh-key-panel">
<h2>Temperatura Top-Rack</h2>
<div class="ssh-key-fields">
<label title="RPi desde el que el monitor leera el archivo JSON de temperatura. Se muestra nombre e IP para identificarlo rapido.">RPi origen
<select id="rackTemperatureSourceDeviceId"></select>
</label>
<label title="Path del archivo JSON en el RPi origen. Node-RED debe escribir ahi la ultima temperatura.">Path JSON temperatura
<input id="rackTemperatureFile" placeholder="/home/yamaray/monitor-rpi/rack-temp.json">
</label>
</div>
</div>
<div class="ssh-key-panel"> <div class="ssh-key-panel">
<h2>Clave SSH del monitor</h2> <h2>Clave SSH del monitor</h2>
<div class="ssh-key-fields"> <div class="ssh-key-fields">
@@ -716,6 +727,16 @@
document.querySelector("#refreshIntervalSeconds").value = state.config.refreshIntervalSeconds || 30; document.querySelector("#refreshIntervalSeconds").value = state.config.refreshIntervalSeconds || 30;
document.querySelector("#idleScanIntervalSeconds").value = state.config.idleScanIntervalSeconds || 300; document.querySelector("#idleScanIntervalSeconds").value = state.config.idleScanIntervalSeconds || 300;
document.querySelector("#sshTimeoutSeconds").value = state.config.sshTimeoutSeconds || 8; document.querySelector("#sshTimeoutSeconds").value = state.config.sshTimeoutSeconds || 8;
const rackSource = document.querySelector("#rackTemperatureSourceDeviceId");
rackSource.innerHTML = [
`<option value="">Sin sensor Top-Rack</option>`,
...state.config.devices.map((device) => {
const value = device.id || device.host || device.name || "";
const label = `${device.name || value} · ${device.host || "sin IP"}`;
return `<option value="${value}" ${state.config.rackTemperatureSourceDeviceId === value ? "selected" : ""}>${label}</option>`;
})
].join("");
document.querySelector("#rackTemperatureFile").value = state.config.rackTemperatureFile || "";
document.querySelector("#sshPrivateKeyPath").value = state.config.sshPrivateKeyPath || "/ssh/monitor_rpi_ed25519"; document.querySelector("#sshPrivateKeyPath").value = state.config.sshPrivateKeyPath || "/ssh/monitor_rpi_ed25519";
document.querySelector("#sshPublicKeyPath").value = state.config.sshPublicKeyPath || `${document.querySelector("#sshPrivateKeyPath").value}.pub`; document.querySelector("#sshPublicKeyPath").value = state.config.sshPublicKeyPath || `${document.querySelector("#sshPrivateKeyPath").value}.pub`;
document.querySelector("#warningTemp").value = state.config.temperatureThresholdsC?.warning || 60; document.querySelector("#warningTemp").value = state.config.temperatureThresholdsC?.warning || 60;
@@ -741,6 +762,8 @@
state.config.refreshIntervalSeconds = Number(document.querySelector("#refreshIntervalSeconds").value || 30); state.config.refreshIntervalSeconds = Number(document.querySelector("#refreshIntervalSeconds").value || 30);
state.config.idleScanIntervalSeconds = Number(document.querySelector("#idleScanIntervalSeconds").value || 300); state.config.idleScanIntervalSeconds = Number(document.querySelector("#idleScanIntervalSeconds").value || 300);
state.config.sshTimeoutSeconds = Number(document.querySelector("#sshTimeoutSeconds").value || 8); state.config.sshTimeoutSeconds = Number(document.querySelector("#sshTimeoutSeconds").value || 8);
state.config.rackTemperatureSourceDeviceId = document.querySelector("#rackTemperatureSourceDeviceId").value || "";
state.config.rackTemperatureFile = document.querySelector("#rackTemperatureFile").value || "";
state.config.sshPrivateKeyPath = document.querySelector("#sshPrivateKeyPath").value || "/ssh/monitor_rpi_ed25519"; state.config.sshPrivateKeyPath = document.querySelector("#sshPrivateKeyPath").value || "/ssh/monitor_rpi_ed25519";
state.config.sshPublicKeyPath = document.querySelector("#sshPublicKeyPath").value || `${state.config.sshPrivateKeyPath}.pub`; state.config.sshPublicKeyPath = document.querySelector("#sshPublicKeyPath").value || `${state.config.sshPrivateKeyPath}.pub`;
state.config.temperatureThresholdsC = { state.config.temperatureThresholdsC = {
+9 -1
View File
@@ -282,7 +282,7 @@
.summary { .summary {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(130px, 1fr)); grid-template-columns: repeat(6, minmax(120px, 1fr));
gap: 12px; gap: 12px;
} }
@@ -981,6 +981,14 @@
hottestSummaryClass(summary.hottest.value, temperatureThresholds) hottestSummaryClass(summary.hottest.value, temperatureThresholds)
) )
: summaryItem("Mas caliente", "--"), : 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("Total RPi configuradas", summary.total || 0),
summaryItem("Alertas", summary.alerts?.length || 0, summary.alerts?.length > 0 ? "summary-hot" : "") summaryItem("Alertas", summary.alerts?.length || 0, summary.alerts?.length > 0 ? "summary-hot" : "")
].join(""); ].join("");
+11
View File
@@ -149,6 +149,17 @@ chmod 600 ~/.ssh/authorized_keys</code></pre>
<pre><code>0 clientes visibles -> idleScanIntervalSeconds <pre><code>0 clientes visibles -> idleScanIntervalSeconds
1+ clientes visibles -> refreshIntervalSeconds</code></pre> 1+ clientes visibles -> refreshIntervalSeconds</code></pre>
<h2>Temperatura Top-Rack</h2>
<p>Si una RPi tiene un sensor externo del rack, Node-RED puede escribir la lectura en un archivo JSON local.</p>
<pre><code>{
"temperatureC": 23.7,
"sensor": "rack-top",
"updatedAt": "2026-06-05T21:12:18.997Z"
}</code></pre>
<p>En el configurador, usa la seccion general <code>Temperatura Top-Rack</code>:</p>
<pre><code>RPi origen: RPi - MeshCore · 192.168.0.60
Path JSON temperatura: /home/yamaray/monitor-rpi/rack-temp.json</code></pre>
<h2>Docker</h2> <h2>Docker</h2>
<pre><code>cd /home/yamaray/docker/monitorRPi <pre><code>cd /home/yamaray/docker/monitorRPi
docker compose up -d --build</code></pre> docker compose up -d --build</code></pre>
+92 -5
View File
@@ -49,6 +49,7 @@ async function readConfig() {
parsed.temperatureThresholdsC = normalizeTemperatureThresholds(parsed.temperatureThresholdsC); parsed.temperatureThresholdsC = normalizeTemperatureThresholds(parsed.temperatureThresholdsC);
parsed.metricThresholdsPercent = normalizeMetricThresholds(parsed.metricThresholdsPercent); parsed.metricThresholdsPercent = normalizeMetricThresholds(parsed.metricThresholdsPercent);
parsed.devices = Array.isArray(parsed.devices) ? parsed.devices.map(normalizeDeviceConfig) : []; parsed.devices = Array.isArray(parsed.devices) ? parsed.devices.map(normalizeDeviceConfig) : [];
normalizeRackTemperatureConfig(parsed);
return parsed; return parsed;
} }
@@ -65,6 +66,7 @@ async function writeConfig(nextConfig) {
metricThresholdsPercent: normalizeMetricThresholds(nextConfig.metricThresholdsPercent), metricThresholdsPercent: normalizeMetricThresholds(nextConfig.metricThresholdsPercent),
devices: Array.isArray(nextConfig.devices) ? nextConfig.devices.map(normalizeDeviceConfig) : [] devices: Array.isArray(nextConfig.devices) ? nextConfig.devices.map(normalizeDeviceConfig) : []
}; };
normalizeRackTemperatureConfig(normalized);
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true }); await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
await fs.writeFile(CONFIG_PATH, JSON.stringify(normalized, null, 2) + "\n", "utf8"); await fs.writeFile(CONFIG_PATH, JSON.stringify(normalized, null, 2) + "\n", "utf8");
config = normalized; config = normalized;
@@ -80,6 +82,19 @@ function normalizeDeviceConfig(device = {}) {
}; };
} }
function normalizeRackTemperatureConfig(nextConfig) {
nextConfig.rackTemperatureSourceDeviceId = String(nextConfig.rackTemperatureSourceDeviceId || "");
nextConfig.rackTemperatureFile = String(nextConfig.rackTemperatureFile || "");
if (!nextConfig.rackTemperatureSourceDeviceId || !nextConfig.rackTemperatureFile) {
const legacyDevice = nextConfig.devices.find((device) => device.rackTemperatureFile);
if (legacyDevice) {
nextConfig.rackTemperatureSourceDeviceId = legacyDevice.id || "";
nextConfig.rackTemperatureFile = legacyDevice.rackTemperatureFile || "";
}
}
}
function classifyTemp(tempC) { function classifyTemp(tempC) {
const thresholds = config.temperatureThresholdsC || {}; const thresholds = config.temperatureThresholdsC || {};
if (tempC >= (thresholds.critical || 80)) return "critical"; if (tempC >= (thresholds.critical || 80)) return "critical";
@@ -130,6 +145,7 @@ function mockMetrics(device) {
const temp = seededNumber(`${device.id}:temp`, baseTemp - 4, baseTemp + 18); const temp = seededNumber(`${device.id}:temp`, baseTemp - 4, baseTemp + 18);
const dockerRole = /docker|gitea|nginx/i.test(device.role || ""); const dockerRole = /docker|gitea|nginx/i.test(device.role || "");
const haRole = /home assistant/i.test(device.role || ""); const haRole = /home assistant/i.test(device.role || "");
const rackTemperatureFile = rackTemperatureFileForDevice(device);
return { return {
cpuTempC: Number(temp.toFixed(1)), cpuTempC: Number(temp.toFixed(1)),
@@ -165,12 +181,22 @@ function mockMetrics(device) {
speedMbps: device.model === "RPi 3" ? null : 1000, speedMbps: device.model === "RPi 3" ? null : 1000,
rxBytesPerSecond: Math.round(seededNumber(`${device.id}:rx`, 1200, 95000)), rxBytesPerSecond: Math.round(seededNumber(`${device.id}:rx`, 1200, 95000)),
txBytesPerSecond: Math.round(seededNumber(`${device.id}:tx`, 700, 55000)) txBytesPerSecond: Math.round(seededNumber(`${device.id}:tx`, 700, 55000))
} },
rackTemperature: rackTemperatureFile
? {
temperatureC: Number(seededNumber(`${device.id}:rack-temp`, 25, 37).toFixed(1)),
sensor: "rack-top",
updatedAt: new Date().toISOString(),
ageSeconds: 0,
sourceFile: rackTemperatureFile
}
: null
}; };
} }
const REMOTE_METRICS_SCRIPT = ` const REMOTE_METRICS_SCRIPT = `
set +e set +e
rack_temperature_file="$RACK_TEMPERATURE_FILE"
temp_raw="$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null)" temp_raw="$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null)"
if [ -n "$temp_raw" ]; then if [ -n "$temp_raw" ]; then
temp_c="$(awk "BEGIN { printf \\"%.1f\\", $temp_raw / 1000 }")" temp_c="$(awk "BEGIN { printf \\"%.1f\\", $temp_raw / 1000 }")"
@@ -259,6 +285,27 @@ if [ "$ha_status" = "n/a" ] && [ "$docker_installed" = "1" ]; then
fi fi
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 'cpuTempC=%s\\n' "$temp_c"
printf 'cpuCores=%s\\n' "$cpu_cores" printf 'cpuCores=%s\\n' "$cpu_cores"
printf 'cpuUsagePercent=%s\\n' "$cpu_usage" printf 'cpuUsagePercent=%s\\n' "$cpu_usage"
@@ -283,6 +330,10 @@ printf 'dockerRunning=%s\\n' "$docker_running"
printf 'dockerStopped=%s\\n' "$docker_stopped" printf 'dockerStopped=%s\\n' "$docker_stopped"
printf 'dockerUnhealthy=%s\\n' "$docker_unhealthy" printf 'dockerUnhealthy=%s\\n' "$docker_unhealthy"
printf 'homeAssistantStatus=%s\\n' "$ha_status" 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) { function parseKeyValueOutput(output) {
@@ -301,6 +352,17 @@ function toNumber(value, fallback = 0) {
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
} }
function shellQuote(value) {
return `'${String(value || "").replace(/'/g, `'\\''`)}'`;
}
function rackTemperatureFileForDevice(device) {
const sourceId = config.rackTemperatureSourceDeviceId || "";
const sourceMatches = sourceId && [device.id, device.host, device.name].includes(sourceId);
if (sourceMatches) return config.rackTemperatureFile || "";
return device.rackTemperatureFile || "";
}
function networkRates(device, values) { function networkRates(device, values) {
const sampleKey = device.id || `${device.host}:${device.port || 22}`; const sampleKey = device.id || `${device.host}:${device.port || 22}`;
const now = Date.now(); const now = Date.now();
@@ -333,9 +395,12 @@ function networkRates(device, values) {
}; };
} }
function runCommandWithInput(command, args, input, timeoutMs) { function runCommandWithInput(command, args, input, timeoutMs, env = {}) {
return new Promise((resolve, reject) => { 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 stdout = "";
let stderr = ""; let stderr = "";
let settled = false; let settled = false;
@@ -428,9 +493,12 @@ async function sshMetrics(device) {
]; ];
} }
const { stdout } = await runCommandWithInput(command, args, REMOTE_METRICS_SCRIPT, timeoutMs); const rackTemperatureFile = rackTemperatureFileForDevice(device);
const remoteScript = `RACK_TEMPERATURE_FILE=${shellQuote(rackTemperatureFile)}\n${REMOTE_METRICS_SCRIPT}`;
const { stdout } = await runCommandWithInput(command, args, remoteScript, timeoutMs);
const values = parseKeyValueOutput(stdout); const values = parseKeyValueOutput(stdout);
const temp = toNumber(values.cpuTempC, 0); const temp = toNumber(values.cpuTempC, 0);
const rackTemperatureC = toNumber(values.rackTemperatureC, null);
const loadAverage = String(values.loadAverage || "") const loadAverage = String(values.loadAverage || "")
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
@@ -460,7 +528,16 @@ async function sshMetrics(device) {
detected: values.homeAssistantStatus && values.homeAssistantStatus !== "n/a", detected: values.homeAssistantStatus && values.homeAssistantStatus !== "n/a",
status: 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: rackTemperatureFile
}
: null
}; };
} }
@@ -520,6 +597,15 @@ function buildSummary(devices) {
.filter((device) => device.metrics && Number.isFinite(device.metrics.cpuTempC)) .filter((device) => device.metrics && Number.isFinite(device.metrics.cpuTempC))
.map((device) => ({ name: device.name, value: 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 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 = []; const alerts = [];
for (const device of devices) { for (const device of devices) {
@@ -537,6 +623,7 @@ function buildSummary(devices) {
online: online.length, online: online.length,
offline: active.length - online.length, offline: active.length - online.length,
hottest, hottest,
rackTemperature,
alerts alerts
}; };
} }