commit 8a0e1e83ed544f734114b63409e22fc81b6abb01 Author: Yamaray Date: Tue Jun 2 17:31:57 2026 +0200 Version inicial monitor RPi diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de8e2c8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +data +node_modules +.git +*.log diff --git a/DOCUMENTACION.md b/DOCUMENTACION.md new file mode 100644 index 0000000..8b58937 --- /dev/null +++ b/DOCUMENTACION.md @@ -0,0 +1,257 @@ +# Monitor RPi Rack + +Aplicacion web local para monitorizar un rack de Raspberry Pi mediante SSH. Corre en un contenedor Docker dedicado y expone un dashboard accesible por navegador. + +## Arquitectura + +```text +Navegador + dashboard.html / config.html + | + | HTTP API + v +Contenedor monitor-rpi + | + | SSH con usuario/password + v +Raspberry Pi activas +``` + +El backend mantiene el ultimo estado en memoria. El dashboard consulta ese estado, pero no abre conexiones SSH directamente. + +## Rutas Principales + +- Dashboard: `http://192.168.0.53:8787/dashboard.html` +- Configuracion: `http://192.168.0.53:8787/config.html` +- API estado: `http://192.168.0.53:8787/api/status` + +Credenciales HTTP actuales: + +```text +usuario: admin +password: monitor-rpi-2026 +``` + +## Configuracion Persistente + +La configuracion vive en: + +```text +/home/yamaray/docker/monitorRPi/data/config.json +``` + +Dentro del contenedor se lee como: + +```text +/data/config.json +``` + +## Parametros Generales + +### `mockMode` + +Si es `true`, el sistema genera datos simulados. Si es `false`, consulta las Raspberry reales por SSH. + +### `refreshIntervalSeconds` + +Intervalo activo de escaneo cuando hay al menos un dashboard visible. Por defecto: `30`. + +### `idleScanIntervalSeconds` + +Intervalo lento de escaneo cuando no hay dashboards visibles. Por defecto: `300`. + +### `sshTimeoutSeconds` + +Tiempo maximo permitido para completar cada consulta SSH. Por defecto: `8`. + +### `temperatureThresholdsC` + +Umbrales de color para temperaturas: + +```json +{ + "warning": 60, + "hot": 70, + "critical": 80 +} +``` + +### `metricThresholdsPercent` + +Umbrales por metrica para barras de CPU, RAM y disco. + +Valores actuales recomendados: + +```json +{ + "cpu": { "warning": 60, "hot": 75, "critical": 90 }, + "memory": { "warning": 70, "hot": 85, "critical": 95 }, + "disk": { "warning": 70, "hot": 85, "critical": 95 } +} +``` + +## Parametros Por Dispositivo + +### `active` + +Si es `true`, el backend consulta ese dispositivo. Si es `false`, aparece como inactivo y no se abre SSH. + +### `id` + +Identificador interno unico del dispositivo. Se usa para tracking interno y calculos como tasas de red. + +### `name` + +Nombre visible en el dashboard. + +### `host` + +IP o nombre DNS usado para conectar por SSH. + +### `port` + +Puerto SSH. Normalmente `22`; en Home Assistant puede ser `2222` si se usa un add-on SSH. + +### `username` + +Usuario SSH. + +### `password` + +Password SSH. Se guarda en `config.json`, por lo que el archivo debe permanecer protegido. + +### `model` + +Modelo visible: `RPi 3`, `RPi 4`, `RPi 5`. + +### `role` + +Funcion del dispositivo: Docker, Home Assistant, MQTT, Nginx, Gitea, etc. + +### `location` + +Ubicacion fisica o logica. Actualmente se muestra en la tarjeta. + +## Metricas Recogidas + +Por SSH se leen datos de solo lectura: + +- Temperatura CPU +- Uso CPU +- Uso RAM +- Uso disco raiz `/` +- Load average +- Uptime +- Docker instalado +- Contenedores Docker activos +- Contenedores Docker detenidos +- Contenedores Docker unhealthy +- Interfaz de red principal +- Tipo de conexion: Cable/WiFi +- Velocidad nominal si existe +- Trafico RX/TX calculado entre scans + +## Clientes Activos + +El dashboard envia heartbeat al backend cuando esta visible. + +Comportamiento: + +```text +0 clientes visibles -> escaneo lento idleScanIntervalSeconds +1+ clientes visibles -> escaneo activo refreshIntervalSeconds +``` + +El navegador integrado de Codex se ignora para no alterar el conteo de clientes reales. + +## API + +Todas las llamadas requieren autenticacion HTTP Basic. + +### `GET /api/status` + +Devuelve ultimo estado conocido. + +Incluye: + +- `mockMode` +- `refreshIntervalSeconds` +- `idleScanIntervalSeconds` +- `activeClients` +- `currentScanIntervalSeconds` +- `temperatureThresholdsC` +- `metricThresholdsPercent` +- `summary` +- `devices` +- `clients` + +Ejemplo PowerShell: + +```powershell +$pair = 'admin:monitor-rpi-2026' +$token = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair)) +Invoke-RestMethod http://192.168.0.53:8787/api/status -Headers @{Authorization="Basic $token"} +``` + +### `GET /api/config` + +Devuelve la configuracion actual. + +Nota: actualmente devuelve tambien passwords porque `config.html` permite editarlas. + +### `POST /api/config` + +Guarda la configuracion completa y reinicia el ciclo de escaneo. + +### `POST /api/reload` + +Recarga `config.json` desde disco. + +### `POST /api/scan-now` + +Fuerza un scan inmediato de todos los dispositivos activos. + +### `POST /api/client-heartbeat` + +Usado por el dashboard para avisar que hay un cliente visible. + +Body: + +```json +{ + "clientId": "client-id" +} +``` + +### `POST /api/clients/clear` + +Limpia la lista de clientes activos en memoria. + +## Docker + +Ruta en la RPi: + +```text +/home/yamaray/docker/monitorRPi/ +``` + +Stack: + +```text +/home/yamaray/docker/monitorRPi/docker-compose.yml +``` + +Reiniciar: + +```bash +cd /home/yamaray/docker/monitorRPi +docker compose restart monitor-rpi +``` + +Reconstruir: + +```bash +cd /home/yamaray/docker/monitorRPi +docker compose up -d --build +``` + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db89377 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache openssh-client sshpass + +COPY server.js dashboard.html config.html config.json ./ + +ENV PORT=8787 +ENV CONFIG_PATH=/data/config.json + +EXPOSE 8787 + +CMD ["node", "server.js"] diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..d356e7d --- /dev/null +++ b/config.example.json @@ -0,0 +1,131 @@ +{ + "mockMode": true, + "refreshIntervalSeconds": 30, + "idleScanIntervalSeconds": 300, + "sshTimeoutSeconds": 8, + "temperatureThresholdsC": { + "warning": 60, + "hot": 70, + "critical": 80 + }, + "metricThresholdsPercent": { + "cpu": { + "warning": 60, + "hot": 75, + "critical": 90 + }, + "memory": { + "warning": 70, + "hot": 85, + "critical": 95 + }, + "disk": { + "warning": 70, + "hot": 85, + "critical": 95 + }, + "swap": { + "warning": 50, + "hot": 75, + "critical": 90 + } + }, + "devices": [ + { + "active": true, + "name": "Home Assistant Casa", + "host": "192.168.0.46", + "username": "pi", + "password": "", + "role": "Home Assistant Local", + "port": 22, + "id": "rpi5-ha-main", + "model": "RPi 5", + "location": "Rack" + }, + { + "active": true, + "name": "Home Assistant Carabanes", + "host": "192.168.0.57", + "username": "pi", + "password": "", + "role": "Home Assistant Cabaña", + "port": 22, + "id": "rpi4-ha-second", + "model": "RPi 4", + "location": "Rack" + }, + { + "active": true, + "name": "RPi5 - Jose", + "host": "192.168.0.53", + "username": "yamaray", + "password": "", + "role": "Docker", + "port": 22, + "id": "rpi5-jose-docker", + "model": "RPi 5", + "location": "Rack" + }, + { + "active": false, + "name": "RPi5 - Daniel", + "host": "192.168.0.130", + "username": "pi", + "password": "", + "role": "Docker - Daniel", + "port": 22, + "id": "rpi5-dani-docker", + "model": "RPi 5", + "location": "Rack" + }, + { + "active": true, + "name": "RPi EdgePi", + "host": "192.168.0.254", + "username": "yamaray", + "password": "", + "role": "nginx - wireguard ", + "port": 22, + "id": "rpi3-nginx", + "model": "RPi 3", + "location": "Rack" + }, + { + "active": true, + "name": "RPi - Pi3Home", + "host": "192.168.0.37", + "username": "pi", + "password": "", + "role": "MQTT - varios", + "port": 22, + "id": "rpi3-pi3home", + "model": "RPi 3", + "location": "Rack" + }, + { + "active": true, + "name": "RPi - MeshCore", + "host": "192.168.0.60", + "username": "yamaray", + "password": "", + "role": "Meshcore-Interface", + "port": 22, + "id": "rpi3-meshcore", + "model": "RPi 3", + "location": "Rack" + }, + { + "active": false, + "name": "RPi3 Nodo 3", + "host": "192.168.1.47", + "username": "pi", + "password": "", + "role": "Reserva", + "port": 22, + "id": "rpi3-node-3", + "model": "RPi 3", + "location": "Rack" + } + ] +} diff --git a/config.html b/config.html new file mode 100644 index 0000000..39d1aa2 --- /dev/null +++ b/config.html @@ -0,0 +1,739 @@ + + + + + + Configuracion Monitor RPi + + + +
+

Configuracion

+
+ Dashboard + Documentacion + + +
+ + + + +
+
+
+ +
+
+

General

+
+ + + + +
+
+
+
Temperatura
+ + + +
+
+
CPU
+ + + +
+
+
RAM
+ + + +
+
+
Disco
+ + + +
+
+
Swap
+ + + +
+
+
+ +
+

Dispositivos

+
+
+ +

+
+ + + + diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..24c0e5d --- /dev/null +++ b/dashboard.html @@ -0,0 +1,1069 @@ + + + + + + Monitor RPi Rack + + + +
+
+

Monitor RPi Rack

+

Cargando estado...

+
+
+ + + + Configuracion + Documentacion +
+ + + + +
+
+
+ +
+
+
+
+
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fbb4624 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + monitor-rpi: + build: + context: /home/yamaray/docker/monitorRPi/app + container_name: monitor-rpi + restart: unless-stopped + working_dir: /app + command: ["node", "server.js"] + ports: + - "8787:8787" + environment: + PORT: "8787" + CONFIG_PATH: "/data/config.json" + MONITOR_USERNAME: "admin" + MONITOR_PASSWORD: "monitor-rpi-2026" + volumes: + - /home/yamaray/docker/monitorRPi/app:/app:ro + - /home/yamaray/docker/monitorRPi/data:/data diff --git a/docs.html b/docs.html new file mode 100644 index 0000000..bba619a --- /dev/null +++ b/docs.html @@ -0,0 +1,126 @@ + + + + + + Documentacion Monitor RPi + + + +
+

Documentacion Monitor RPi

+ +
+
+

Resumen

+

Aplicacion web para monitorizar Raspberry Pi mediante SSH desde un contenedor dedicado. El dashboard muestra el ultimo estado calculado por el backend; el navegador no abre conexiones SSH.

+ +

Rutas

+ + +

Configuracion

+

La configuracion persistente esta en /home/yamaray/docker/monitorRPi/data/config.json, montada dentro del contenedor como /data/config.json.

+ +

Generales

+ + +

Dispositivos

+ + +

API

+

GET /api/status

+

Devuelve el ultimo estado: resumen, dispositivos, umbrales, clientes activos e intervalo actual.

+ +

GET /api/config

+

Devuelve la configuracion completa.

+ +

POST /api/config

+

Guarda la configuracion completa y reinicia el ciclo de scan.

+ +

POST /api/reload

+

Recarga la configuracion desde disco.

+ +

POST /api/scan-now

+

Fuerza un scan SSH inmediato.

+ +

POST /api/client-heartbeat

+

Usado por el dashboard visible para activar el intervalo rapido.

+ +

POST /api/clients/clear

+

Limpia los clientes activos en memoria.

+ +

Escaneo Adaptativo

+
0 clientes visibles  -> idleScanIntervalSeconds
+1+ clientes visibles -> refreshIntervalSeconds
+ +

Docker

+
cd /home/yamaray/docker/monitorRPi
+docker compose up -d --build
+
+ + diff --git a/server.js b/server.js new file mode 100644 index 0000000..85026c8 --- /dev/null +++ b/server.js @@ -0,0 +1,766 @@ +const http = require("node:http"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const url = require("node:url"); +const { spawn } = require("node:child_process"); + +const ROOT = __dirname; +const CONFIG_PATH = process.env.CONFIG_PATH || path.join(ROOT, "config.json"); +const DEFAULT_CONFIG_PATH = path.join(ROOT, "config.json"); +const PORT = Number(process.env.PORT || 8787); +const AUTH_USERNAME = process.env.MONITOR_USERNAME || ""; +const AUTH_PASSWORD = process.env.MONITOR_PASSWORD || ""; + +let config = null; +let latestStatus = null; +let scanTimer = null; +const previousNetworkSamples = new Map(); +const dashboardClients = new Map(); +let scanInProgress = false; + +const contentTypes = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8" +}; + +async function ensureConfigFile() { + try { + await fs.access(CONFIG_PATH); + } catch { + await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true }); + const defaultConfig = await fs.readFile(DEFAULT_CONFIG_PATH, "utf8"); + await fs.writeFile(CONFIG_PATH, defaultConfig, "utf8"); + } +} + +async function readConfig() { + await ensureConfigFile(); + const raw = await fs.readFile(CONFIG_PATH, "utf8"); + const parsed = JSON.parse(raw); + parsed.refreshIntervalSeconds = Number(parsed.refreshIntervalSeconds || 30); + parsed.idleScanIntervalSeconds = Number(parsed.idleScanIntervalSeconds || 300); + parsed.sshTimeoutSeconds = Number(parsed.sshTimeoutSeconds || 8); + parsed.temperatureThresholdsC = normalizeTemperatureThresholds(parsed.temperatureThresholdsC); + parsed.metricThresholdsPercent = normalizeMetricThresholds(parsed.metricThresholdsPercent); + parsed.devices = Array.isArray(parsed.devices) ? parsed.devices : []; + return parsed; +} + +async function writeConfig(nextConfig) { + const normalized = { + ...nextConfig, + refreshIntervalSeconds: Number(nextConfig.refreshIntervalSeconds || 30), + idleScanIntervalSeconds: Number(nextConfig.idleScanIntervalSeconds || 300), + sshTimeoutSeconds: Number(nextConfig.sshTimeoutSeconds || 8), + temperatureThresholdsC: normalizeTemperatureThresholds(nextConfig.temperatureThresholdsC), + metricThresholdsPercent: normalizeMetricThresholds(nextConfig.metricThresholdsPercent), + devices: Array.isArray(nextConfig.devices) ? nextConfig.devices : [] + }; + await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true }); + await fs.writeFile(CONFIG_PATH, JSON.stringify(normalized, null, 2) + "\n", "utf8"); + config = normalized; + restartScanner(); +} + +function classifyTemp(tempC) { + const thresholds = config.temperatureThresholdsC || {}; + if (tempC >= (thresholds.critical || 80)) return "critical"; + if (tempC >= (thresholds.hot || 70)) return "hot"; + if (tempC >= (thresholds.warning || 60)) return "warning"; + return "normal"; +} + +function normalizeTemperatureThresholds(thresholds = {}) { + return { + warning: Number(thresholds.warning ?? 60), + hot: Number(thresholds.hot ?? 70), + critical: Number(thresholds.critical ?? 80) + }; +} + +function normalizeMetricThresholds(thresholds = {}) { + const defaults = { + cpu: { warning: 60, hot: 75, critical: 90 }, + memory: { warning: 70, hot: 85, critical: 95 }, + disk: { warning: 70, hot: 85, critical: 95 }, + swap: { warning: 50, hot: 75, critical: 90 } + }; + + return Object.fromEntries( + Object.entries(defaults).map(([key, value]) => [ + key, + { + warning: Number(thresholds[key]?.warning ?? value.warning), + hot: Number(thresholds[key]?.hot ?? value.hot), + critical: Number(thresholds[key]?.critical ?? value.critical) + } + ]) + ); +} + +function seededNumber(seed, min, max) { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + const wave = (Math.sin(Date.now() / 45000 + hash) + 1) / 2; + return min + wave * (max - min); +} + +function mockMetrics(device) { + const baseTemp = device.model === "RPi 5" ? 50 : device.model === "RPi 4" ? 47 : 44; + const temp = seededNumber(`${device.id}:temp`, baseTemp - 4, baseTemp + 18); + const dockerRole = /docker|gitea|nginx/i.test(device.role || ""); + const haRole = /home assistant/i.test(device.role || ""); + + return { + cpuTempC: Number(temp.toFixed(1)), + tempLevel: classifyTemp(temp), + cpuCores: device.model === "RPi 3" ? 4 : 4, + cpuUsagePercent: Math.round(seededNumber(`${device.id}:cpu`, 6, dockerRole || haRole ? 62 : 38)), + memoryTotalBytes: device.model === "RPi 5" ? 8 * 1024 ** 3 : device.model === "RPi 4" ? 4 * 1024 ** 3 : 1024 ** 3, + memoryUsedPercent: Math.round(seededNumber(`${device.id}:mem`, 24, dockerRole || haRole ? 78 : 58)), + swapTotalBytes: 100 * 1024 ** 2, + swapUsedPercent: Math.round(seededNumber(`${device.id}:swap`, 0, dockerRole || haRole ? 34 : 18)), + diskTotalBytes: 32 * 1024 ** 3, + diskUsedPercent: Math.round(seededNumber(`${device.id}:disk`, 18, dockerRole ? 84 : 66)), + loadAverage: [ + Number(seededNumber(`${device.id}:load1`, 0.05, 1.8).toFixed(2)), + Number(seededNumber(`${device.id}:load5`, 0.05, 1.4).toFixed(2)), + Number(seededNumber(`${device.id}:load15`, 0.05, 1.1).toFixed(2)) + ], + uptime: `${Math.round(seededNumber(`${device.id}:uptime`, 2, 64))} dias`, + docker: dockerRole + ? { + installed: true, + running: Math.round(seededNumber(`${device.id}:docker-running`, 3, 12)), + stopped: Math.round(seededNumber(`${device.id}:docker-stopped`, 0, 2)), + unhealthy: 0 + } + : { installed: false, running: 0, stopped: 0, unhealthy: 0 }, + homeAssistant: haRole ? { detected: true, status: "running" } : { detected: false, status: "n/a" }, + network: { + interface: device.model === "RPi 3" ? "wlan0" : "eth0", + type: device.model === "RPi 3" ? "WiFi" : "Cable", + state: "up", + ipAddress: device.host, + 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)) + } + }; +} + +const REMOTE_METRICS_SCRIPT = ` +set +e +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 }")" +else + temp_c="" +fi + +read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat +idle_a=$((idle + iowait)) +total_a=$((user + nice + system + idle + iowait + irq + softirq + steal)) +sleep 0.4 +read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat +idle_b=$((idle + iowait)) +total_b=$((user + nice + system + idle + iowait + irq + softirq + steal)) +total_delta=$((total_b - total_a)) +idle_delta=$((idle_b - idle_a)) +if [ "$total_delta" -gt 0 ]; then + cpu_usage="$(awk "BEGIN { printf \\"%.0f\\", (100 * ($total_delta - $idle_delta)) / $total_delta }")" +else + cpu_usage="" +fi + +cpu_cores="$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || grep -c '^processor' /proc/cpuinfo 2>/dev/null)" +memory_total_kb="$(awk '/MemTotal/ { print $2 }' /proc/meminfo)" +memory_usage="$(awk '/MemTotal/ { total=$2 } /MemAvailable/ { available=$2 } END { if (total > 0) printf "%.0f", (100 * (total - available)) / total }' /proc/meminfo)" +swap_total_kb="$(awk '/SwapTotal/ { print $2 }' /proc/meminfo)" +swap_usage="$(awk '/SwapTotal/ { total=$2 } /SwapFree/ { free=$2 } END { if (total > 0) printf "%.0f", (100 * (total - free)) / total; else printf "0" }' /proc/meminfo)" +disk_usage="$(df -P / 2>/dev/null | awk 'NR==2 { gsub(/%/, "", $5); print $5 }')" +disk_total_kb="$(df -P / 2>/dev/null | awk 'NR==2 { print $2 }')" +load_average="$(cut -d ' ' -f 1-3 /proc/loadavg 2>/dev/null)" +uptime_text="$(uptime -p 2>/dev/null | sed 's/^up //')" + +default_iface="$(ip route show default 2>/dev/null | awk 'NR==1 { print $5 }')" +if [ -z "$default_iface" ]; then + default_iface="$(ls /sys/class/net 2>/dev/null | grep -v '^lo$' | head -n 1)" +fi +net_type="" +net_state="" +net_carrier="" +net_speed="" +net_rx="" +net_tx="" +net_ip="" +if [ -n "$default_iface" ] && [ -d "/sys/class/net/$default_iface" ]; then + if [ -d "/sys/class/net/$default_iface/wireless" ] || echo "$default_iface" | grep -Eq '^(wl|wlan)'; then + net_type="WiFi" + else + net_type="Cable" + fi + net_state="$(cat "/sys/class/net/$default_iface/operstate" 2>/dev/null)" + net_carrier="$(cat "/sys/class/net/$default_iface/carrier" 2>/dev/null)" + net_speed="$(cat "/sys/class/net/$default_iface/speed" 2>/dev/null)" + net_rx="$(cat "/sys/class/net/$default_iface/statistics/rx_bytes" 2>/dev/null)" + net_tx="$(cat "/sys/class/net/$default_iface/statistics/tx_bytes" 2>/dev/null)" + net_ip="$(ip -4 addr show "$default_iface" 2>/dev/null | awk '/inet / { sub(/\/.*/, "", $2); print $2; exit }')" +fi + +docker_installed=0 +docker_running=0 +docker_stopped=0 +docker_unhealthy=0 +if command -v docker >/dev/null 2>&1; then + docker_installed=1 + docker_running="$(docker ps -q 2>/dev/null | wc -l | tr -d ' ')" + docker_stopped="$(docker ps -aq -f status=exited -f status=created -f status=dead 2>/dev/null | wc -l | tr -d ' ')" + docker_unhealthy="$(docker ps -q -f health=unhealthy 2>/dev/null | wc -l | tr -d ' ')" +fi + +ha_status="n/a" +if command -v systemctl >/dev/null 2>&1; then + for svc in home-assistant@homeassistant home-assistant homeassistant hassio-supervisor; do + svc_state="$(systemctl is-active "$svc" 2>/dev/null)" + if [ "$svc_state" = "active" ]; then + ha_status="running" + break + elif [ "$svc_state" = "failed" ]; then + ha_status="$svc_state" + fi + done +fi +if [ "$ha_status" = "n/a" ] && [ "$docker_installed" = "1" ]; then + if docker ps --format '{{.Names}}' 2>/dev/null | grep -Eiq 'homeassistant|home-assistant|hassio'; then + ha_status="running" + elif docker ps -a --format '{{.Names}}' 2>/dev/null | grep -Eiq 'homeassistant|home-assistant|hassio'; then + ha_status="stopped" + fi +fi + +printf 'cpuTempC=%s\\n' "$temp_c" +printf 'cpuCores=%s\\n' "$cpu_cores" +printf 'cpuUsagePercent=%s\\n' "$cpu_usage" +printf 'memoryTotalBytes=%s\\n' "$((memory_total_kb * 1024))" +printf 'memoryUsedPercent=%s\\n' "$memory_usage" +printf 'swapTotalBytes=%s\\n' "$((swap_total_kb * 1024))" +printf 'swapUsedPercent=%s\\n' "$swap_usage" +printf 'diskTotalBytes=%s\\n' "$((disk_total_kb * 1024))" +printf 'diskUsedPercent=%s\\n' "$disk_usage" +printf 'loadAverage=%s\\n' "$load_average" +printf 'uptime=%s\\n' "$uptime_text" +printf 'networkInterface=%s\\n' "$default_iface" +printf 'networkType=%s\\n' "$net_type" +printf 'networkState=%s\\n' "$net_state" +printf 'networkCarrier=%s\\n' "$net_carrier" +printf 'networkSpeedMbps=%s\\n' "$net_speed" +printf 'networkRxBytes=%s\\n' "$net_rx" +printf 'networkTxBytes=%s\\n' "$net_tx" +printf 'networkIpAddress=%s\\n' "$net_ip" +printf 'dockerInstalled=%s\\n' "$docker_installed" +printf 'dockerRunning=%s\\n' "$docker_running" +printf 'dockerStopped=%s\\n' "$docker_stopped" +printf 'dockerUnhealthy=%s\\n' "$docker_unhealthy" +printf 'homeAssistantStatus=%s\\n' "$ha_status" +`; + +function parseKeyValueOutput(output) { + const values = {}; + for (const line of output.split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator === -1) continue; + values[line.slice(0, separator)] = line.slice(separator + 1); + } + return values; +} + +function toNumber(value, fallback = 0) { + if (value === "" || value === null || value === undefined) return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function networkRates(device, values) { + const sampleKey = device.id || `${device.host}:${device.port || 22}`; + const now = Date.now(); + const rxBytes = toNumber(values.networkRxBytes, 0); + const txBytes = toNumber(values.networkTxBytes, 0); + const previous = previousNetworkSamples.get(sampleKey); + previousNetworkSamples.set(sampleKey, { rxBytes, txBytes, at: now }); + + let rxBytesPerSecond = null; + let txBytesPerSecond = null; + if (previous) { + const seconds = (now - previous.at) / 1000; + if (seconds > 0 && rxBytes >= previous.rxBytes && txBytes >= previous.txBytes) { + rxBytesPerSecond = Math.round((rxBytes - previous.rxBytes) / seconds); + txBytesPerSecond = Math.round((txBytes - previous.txBytes) / seconds); + } + } + + return { + interface: values.networkInterface || "--", + type: values.networkType || "--", + state: values.networkState || "--", + carrier: values.networkCarrier === "1", + ipAddress: values.networkIpAddress || "", + speedMbps: toNumber(values.networkSpeedMbps, null), + rxBytes, + txBytes, + rxBytesPerSecond, + txBytesPerSecond + }; +} + +function runCommandWithInput(command, args, input, timeoutMs) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGTERM"); + reject(new Error(`Timeout SSH despues de ${Math.round(timeoutMs / 1000)}s`)); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + if (stdout.length > 128 * 1024) stdout = stdout.slice(-128 * 1024); + }); + + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + if (stderr.length > 32 * 1024) stderr = stderr.slice(-32 * 1024); + }); + + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error((stderr || `Comando SSH termino con codigo ${code}`).trim())); + } + }); + + child.stdin.end(input); + }); +} + +async function sshMetrics(device) { + if (!device.password) { + throw new Error("Password SSH no configurada"); + } + + const timeoutMs = Math.max(2, config.sshTimeoutSeconds || 8) * 1000; + const port = Number(device.port || 22); + const destination = `${device.username || "pi"}@${device.host}`; + const args = [ + "-p", + device.password, + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/tmp/monitor-rpi-known-hosts", + "-o", + "ConnectTimeout=5", + "-p", + String(port), + destination, + "sh", + "-s" + ]; + + const { stdout } = await runCommandWithInput("sshpass", args, REMOTE_METRICS_SCRIPT, timeoutMs); + const values = parseKeyValueOutput(stdout); + const temp = toNumber(values.cpuTempC, 0); + const loadAverage = String(values.loadAverage || "") + .split(/\s+/) + .filter(Boolean) + .map((value) => toNumber(value, 0)); + while (loadAverage.length < 3) loadAverage.push(0); + + return { + cpuTempC: Number(temp.toFixed(1)), + tempLevel: classifyTemp(temp), + cpuCores: Math.round(toNumber(values.cpuCores, 0)), + cpuUsagePercent: Math.round(toNumber(values.cpuUsagePercent, 0)), + memoryTotalBytes: toNumber(values.memoryTotalBytes, 0), + memoryUsedPercent: Math.round(toNumber(values.memoryUsedPercent, 0)), + swapTotalBytes: toNumber(values.swapTotalBytes, 0), + swapUsedPercent: Math.round(toNumber(values.swapUsedPercent, 0)), + diskTotalBytes: toNumber(values.diskTotalBytes, 0), + diskUsedPercent: Math.round(toNumber(values.diskUsedPercent, 0)), + loadAverage: loadAverage.slice(0, 3), + uptime: values.uptime || "--", + docker: { + installed: values.dockerInstalled === "1", + running: Math.round(toNumber(values.dockerRunning, 0)), + stopped: Math.round(toNumber(values.dockerStopped, 0)), + unhealthy: Math.round(toNumber(values.dockerUnhealthy, 0)) + }, + homeAssistant: { + detected: values.homeAssistantStatus && values.homeAssistantStatus !== "n/a", + status: values.homeAssistantStatus || "n/a" + }, + network: networkRates(device, values) + }; +} + +async function scanDevice(device) { + if (!device.active) { + return { + ...publicDeviceConfig(device), + online: false, + status: "disabled", + metrics: null, + checkedAt: null, + error: null + }; + } + + if (config.mockMode) { + return { + ...publicDeviceConfig(device), + online: true, + status: "online", + metrics: mockMetrics(device), + checkedAt: new Date().toISOString(), + error: null + }; + } + + try { + return { + ...publicDeviceConfig(device), + online: true, + status: "online", + metrics: await sshMetrics(device), + checkedAt: new Date().toISOString(), + error: null + }; + } catch (error) { + return { + ...publicDeviceConfig(device), + online: false, + status: "ssh-error", + metrics: null, + checkedAt: new Date().toISOString(), + error: error.message + }; + } +} + +function publicDeviceConfig(device) { + const { password, ...safeDevice } = device; + return safeDevice; +} + +function buildSummary(devices) { + const active = devices.filter((device) => device.active); + const online = active.filter((device) => device.online); + const temps = online + .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 alerts = []; + + for (const device of devices) { + if (!device.active) continue; + if (!device.online) alerts.push(`${device.name}: sin conexion`); + if (device.metrics?.tempLevel === "critical") alerts.push(`${device.name}: temperatura critica`); + if (device.metrics?.diskUsedPercent >= 85) alerts.push(`${device.name}: disco alto`); + if (device.metrics?.swapUsedPercent >= (config.metricThresholdsPercent?.swap?.critical || 90)) alerts.push(`${device.name}: swap alto`); + if (device.metrics?.docker?.unhealthy > 0) alerts.push(`${device.name}: contenedores unhealthy`); + } + + return { + total: devices.length, + active: active.length, + online: online.length, + offline: active.length - online.length, + hottest, + alerts + }; +} + +function activeClientCount() { + const now = Date.now(); + const maxAgeMs = Math.max(30, config?.refreshIntervalSeconds || 30) * 1500; + for (const [clientId, client] of dashboardClients) { + if (now - client.lastSeen > maxAgeMs) dashboardClients.delete(clientId); + } + return dashboardClients.size; +} + +function clientSnapshot() { + const now = Date.now(); + activeClientCount(); + return [...dashboardClients.entries()].map(([clientId, client]) => ({ + clientId, + remoteAddress: client.remoteAddress || "", + userAgent: client.userAgent || "", + lastSeen: new Date(client.lastSeen).toISOString(), + ageSeconds: Math.round((now - client.lastSeen) / 1000) + })); +} + +function currentScanIntervalSeconds() { + return activeClientCount() > 0 + ? Math.max(5, config.refreshIntervalSeconds || 30) + : Math.max(60, config.idleScanIntervalSeconds || 300); +} + +function registerClient(clientId, req) { + const userAgent = req.headers["user-agent"] || ""; + if (userAgent.includes("Codex/")) { + return null; + } + + const hadClients = activeClientCount() > 0; + const id = clientId || `client-${Date.now()}-${Math.random().toString(36).slice(2)}`; + dashboardClients.set(id, { + lastSeen: Date.now(), + remoteAddress: req.socket.remoteAddress || "", + userAgent + }); + if (!hadClients) { + scheduleNextScan(1000); + } + return id; +} + +async function scanAll() { + if (scanInProgress) return latestStatus; + scanInProgress = true; + if (!config) config = await readConfig(); + try { + const devices = await Promise.all(config.devices.map(scanDevice)); + latestStatus = { + mockMode: Boolean(config.mockMode), + refreshIntervalSeconds: config.refreshIntervalSeconds, + idleScanIntervalSeconds: config.idleScanIntervalSeconds, + activeClients: activeClientCount(), + currentScanIntervalSeconds: currentScanIntervalSeconds(), + temperatureThresholdsC: config.temperatureThresholdsC, + metricThresholdsPercent: config.metricThresholdsPercent, + lastUpdated: new Date().toISOString(), + summary: buildSummary(devices), + devices + }; + return latestStatus; + } finally { + scanInProgress = false; + } +} + +function scheduleNextScan(delayMs = currentScanIntervalSeconds() * 1000) { + if (scanTimer) clearTimeout(scanTimer); + scanTimer = setTimeout(runScheduledScan, delayMs); +} + +async function runScheduledScan() { + try { + await scanAll(); + } catch (error) { + latestStatus = { + error: error.message, + lastUpdated: new Date().toISOString(), + devices: [] + }; + } finally { + scheduleNextScan(); + } +} + +function restartScanner() { + if (scanTimer) clearTimeout(scanTimer); + runScheduledScan(); +} + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload, null, 2); + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" + }); + res.end(body); +} + +function isAuthEnabled() { + return Boolean(AUTH_USERNAME && AUTH_PASSWORD); +} + +function isAuthorized(req) { + if (!isAuthEnabled()) return true; + const header = req.headers.authorization || ""; + const [scheme, encoded] = header.split(" "); + if (scheme !== "Basic" || !encoded) return false; + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + const separatorIndex = decoded.indexOf(":"); + const username = decoded.slice(0, separatorIndex); + const password = decoded.slice(separatorIndex + 1); + return username === AUTH_USERNAME && password === AUTH_PASSWORD; +} + +function requestAuth(res) { + res.writeHead(401, { + "WWW-Authenticate": 'Basic realm="Monitor RPi"', + "Content-Type": "text/plain; charset=utf-8" + }); + res.end("Authentication required"); +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +async function serveStatic(res, pathname) { + const safePath = pathname === "/" ? "/dashboard.html" : pathname; + const filePath = path.normalize(path.join(ROOT, safePath)); + if (!filePath.startsWith(ROOT)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + try { + const data = await fs.readFile(filePath); + res.writeHead(200, { + "Content-Type": contentTypes[path.extname(filePath)] || "application/octet-stream", + "Cache-Control": "no-store" + }); + res.end(data); + } catch { + res.writeHead(404); + res.end("Not found"); + } +} + +async function handleRequest(req, res) { + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" + }); + res.end(); + return; + } + + const { pathname } = url.parse(req.url); + + try { + if (!isAuthorized(req)) { + requestAuth(res); + return; + } + + if (req.method === "GET" && pathname === "/api/status") { + if (!latestStatus) await scanAll(); + latestStatus.activeClients = activeClientCount(); + latestStatus.currentScanIntervalSeconds = currentScanIntervalSeconds(); + latestStatus.clients = clientSnapshot(); + sendJson(res, 200, latestStatus); + return; + } + + if (req.method === "POST" && pathname === "/api/client-heartbeat") { + const body = await readBody(req); + const payload = body ? JSON.parse(body) : {}; + const clientId = registerClient(payload.clientId, req); + sendJson(res, 200, { + ok: true, + ignored: clientId === null, + clientId, + activeClients: activeClientCount(), + currentScanIntervalSeconds: currentScanIntervalSeconds() + }); + return; + } + + if (req.method === "POST" && pathname === "/api/clients/clear") { + dashboardClients.clear(); + scheduleNextScan(); + sendJson(res, 200, { + ok: true, + activeClients: activeClientCount(), + currentScanIntervalSeconds: currentScanIntervalSeconds() + }); + return; + } + + if (req.method === "GET" && pathname === "/api/config") { + sendJson(res, 200, config); + return; + } + + if (req.method === "POST" && pathname === "/api/config") { + const body = await readBody(req); + await writeConfig(JSON.parse(body)); + sendJson(res, 200, { ok: true, config }); + return; + } + + if (req.method === "POST" && pathname === "/api/reload") { + config = await readConfig(); + restartScanner(); + sendJson(res, 200, { ok: true, config }); + return; + } + + if (req.method === "POST" && pathname === "/api/scan-now") { + await scanAll(); + scheduleNextScan(); + sendJson(res, 200, { ok: true, status: latestStatus }); + return; + } + + if (req.method === "GET") { + await serveStatic(res, pathname); + return; + } + + sendJson(res, 405, { error: "Method not allowed" }); + } catch (error) { + sendJson(res, 500, { error: error.message }); + } +} + +async function main() { + config = await readConfig(); + restartScanner(); + http.createServer(handleRequest).listen(PORT, () => { + console.log(`Monitor RPi disponible en http://localhost:${PORT}`); + console.log(`API de estado: http://localhost:${PORT}/api/status`); + console.log(`Configuracion: ${CONFIG_PATH}`); + console.log(`Autenticacion: ${isAuthEnabled() ? "activa" : "desactivada"}`); + }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});