Version inicial monitor RPi

This commit is contained in:
2026-06-02 17:31:57 +02:00
commit 8a0e1e83ed
9 changed files with 3124 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
data
node_modules
.git
*.log
+257
View File
@@ -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
```
+14
View File
@@ -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"]
+131
View File
@@ -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"
}
]
}
+739
View File
@@ -0,0 +1,739 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Configuracion Monitor RPi</title>
<style>
:root {
color-scheme: dark;
--bg: #101214;
--panel: #191d21;
--panel-2: #20262b;
--text: #f4f7f9;
--muted: #a7b0b8;
--line: #323a41;
--ok: #2fd17c;
--bad: #ef5b5b;
--info: #64b5f6;
--warn: #f0c34e;
--header: #13171a;
--input: #0f1215;
--bar-bg: #0f1215;
--online-bg: #10261b;
--online-line: #2f7654;
--offline-bg: #2a1515;
--offline-line: #773939;
--disabled-bg: #202326;
--bg-accent: #1c242a;
--bg-accent-2: #232019;
--button-primary: #2f7fb8;
--button-primary-text: #f8fbfd;
}
:root[data-mode="dark"] { color-scheme: dark; }
:root[data-mode="light"] {
color-scheme: light;
--online-bg: #e8f7ee;
--online-line: #8ac9a4;
--offline-bg: #fdeaea;
--offline-line: #e0a0a0;
--disabled-bg: #eef1f3;
}
:root[data-style="slate"][data-mode="dark"] {
--bg: #101214; --panel: #191d21; --panel-2: #20262b; --text: #f4f7f9; --muted: #a7b0b8; --line: #323a41; --header: #13171a; --input: #0f1215; --info: #64b5f6;
}
:root[data-style="slate"][data-mode="light"] {
--bg: #f4f6f7; --panel: #ffffff; --panel-2: #eef2f4; --text: #182127; --muted: #66727a; --line: #d4dce1; --header: #ffffff; --input: #ffffff; --info: #2f7fb8;
}
:root[data-style="pine"][data-mode="dark"] {
--bg: #0f1512; --panel: #17211c; --panel-2: #1f2b25; --text: #f0f6f2; --muted: #a8b8ad; --line: #33443a; --header: #121b17; --input: #0d1411; --info: #70b7a6; --ok: #4ed48b;
}
:root[data-style="pine"][data-mode="light"] {
--bg: #f2f6f1; --panel: #ffffff; --panel-2: #e9f0eb; --text: #17241d; --muted: #647369; --line: #d2ded6; --header: #fbfdfb; --input: #ffffff; --info: #3b8f82; --ok: #26875b;
}
:root[data-style="ocean"][data-mode="dark"] {
--bg: #0e1417; --panel: #172026; --panel-2: #1f2b31; --text: #f0f7f8; --muted: #a7b8bd; --line: #33454d; --header: #111a1f; --input: #0c1215; --info: #6dbbd1; --ok: #55d49c;
}
:root[data-style="ocean"][data-mode="light"] {
--bg: #eef6f7; --panel: #ffffff; --panel-2: #e4eff2; --text: #162429; --muted: #63767d; --line: #ccdde2; --header: #fbfefe; --input: #ffffff; --info: #2f87a1; --ok: #288866;
}
:root[data-style="cloud"][data-mode="dark"] {
--bg: #121316; --panel: #1d2025; --panel-2: #252a31; --text: #f5f5f2; --muted: #b4b4aa; --line: #3b3f46; --header: #171a1f; --input: #101216; --info: #a7b7d9;
}
:root[data-style="cloud"][data-mode="light"] {
--bg: #f7f5ef; --panel: #ffffff; --panel-2: #f0eee7; --text: #252521; --muted: #747066; --line: #ded9cc; --header: #fffdf7; --input: #ffffff; --info: #687faa;
}
:root[data-style="sage"][data-mode="dark"] {
--bg: #111411; --panel: #1c211b; --panel-2: #252b23; --text: #f3f6ef; --muted: #b0b8a8; --line: #3d4639; --header: #171b16; --input: #10140f; --info: #9cb88c; --ok: #78c77a;
}
:root[data-style="sage"][data-mode="light"] {
--bg: #f4f6ee; --panel: #ffffff; --panel-2: #edf1e5; --text: #20271d; --muted: #6e7665; --line: #d9dfce; --header: #fefff9; --input: #ffffff; --info: #6d8f5f; --ok: #4d8f4c;
}
:root[data-style="linen"][data-mode="dark"] {
--bg: #141210; --panel: #211d19; --panel-2: #2b2620; --text: #f7f2ec; --muted: #b9afa5; --line: #483f35; --header: #1b1714; --input: #120f0d; --info: #d0a46e; --ok: #7ac784;
}
:root[data-style="linen"][data-mode="light"] {
--bg: #f7f3ec; --panel: #fffdfa; --panel-2: #f0e8dd; --text: #2a231d; --muted: #786d61; --line: #e2d7c9; --header: #fffaf3; --input: #ffffff; --info: #a3743e; --ok: #588d53;
}
:root[data-style="slate"][data-mode="dark"] { --bg-accent: #1b2830; --button-primary: #2f7fb8; }
:root[data-style="slate"][data-mode="light"] { --bg-accent: #d8edf8; --bg-accent-2: #f2e4d7; --button-primary: #2f7fb8; }
:root[data-style="pine"][data-mode="dark"] { --bg-accent: #16241d; --bg-accent-2: #221d16; --button-primary: #3b9f87; }
:root[data-style="pine"][data-mode="light"] { --bg-accent: #cfecdc; --bg-accent-2: #eef0d2; --button-primary: #3b8f82; }
:root[data-style="ocean"][data-mode="dark"] { --bg-accent: #13252c; --bg-accent-2: #211d2a; --button-primary: #2d95ad; }
:root[data-style="ocean"][data-mode="light"] { --bg-accent: #caedf5; --bg-accent-2: #e1e7ff; --button-primary: #2f87a1; }
:root[data-style="cloud"][data-mode="dark"] { --bg-accent: #22242a; --bg-accent-2: #292318; --button-primary: #7c8fb8; }
:root[data-style="cloud"][data-mode="light"] { --bg-accent: #e8e4f5; --bg-accent-2: #f1e2c7; --button-primary: #687faa; }
:root[data-style="sage"][data-mode="dark"] { --bg-accent: #20281d; --bg-accent-2: #242414; --button-primary: #789b66; }
:root[data-style="sage"][data-mode="light"] { --bg-accent: #d8edca; --bg-accent-2: #f0ebcf; --button-primary: #6d8f5f; }
:root[data-style="linen"][data-mode="dark"] { --bg-accent: #292017; --bg-accent-2: #202326; --button-primary: #b47b3a; }
:root[data-style="linen"][data-mode="light"] { --bg-accent: #ead3b7; --bg-accent-2: #f3e8cf; --button-primary: #a3743e; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 10% 0%, color-mix(in srgb, var(--bg-accent) 92%, transparent), transparent 34rem),
radial-gradient(circle at 88% 8%, color-mix(in srgb, var(--bg-accent-2) 80%, transparent), transparent 30rem),
linear-gradient(135deg, color-mix(in srgb, var(--bg-accent) 28%, var(--bg)), var(--bg) 42%, color-mix(in srgb, var(--bg-accent-2) 35%, var(--bg)));
color: var(--text);
font-family: "Segoe UI", system-ui, sans-serif;
}
header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 22px 28px;
border-bottom: 1px solid var(--line);
background: var(--header);
}
h1, h2, p { margin: 0; }
h1 { font-size: 24px; }
h2 { font-size: 17px; margin-bottom: 12px; }
main {
padding: 22px 28px 34px;
display: grid;
gap: 16px;
}
section {
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
padding: 16px;
}
.top-grid {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 12px;
}
.threshold-groups {
margin-top: 16px;
display: grid;
gap: 10px;
}
.threshold-row {
display: grid;
grid-template-columns: 110px repeat(3, minmax(120px, 1fr));
gap: 10px;
align-items: end;
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel-2);
}
.threshold-title {
color: var(--text);
font-size: 14px;
font-weight: 700;
align-self: center;
}
.devices {
display: grid;
gap: 12px;
}
.device {
display: grid;
grid-template-columns: 76px 1.35fr 1fr 1fr 1fr 1fr;
gap: 10px;
align-items: end;
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
background: var(--panel-2);
}
.field-name { grid-column: span 2; }
.field-id { grid-column: span 2; }
.field-role { grid-column: span 2; }
.field-location { grid-column: span 2; }
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
input, select {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--input);
color: var(--text);
padding: 9px 10px;
font: inherit;
font-size: 14px;
}
input[type="checkbox"] {
width: 22px;
height: 22px;
accent-color: var(--ok);
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
button, a {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-2);
color: var(--text);
padding: 9px 12px;
text-decoration: none;
font: inherit;
cursor: pointer;
}
button:hover, a:hover { border-color: var(--info); }
.danger:hover { border-color: var(--bad); }
#save,
#add,
.actions > a {
background: var(--button-primary);
border-color: color-mix(in srgb, var(--button-primary) 72%, var(--text));
color: var(--button-primary-text);
font-weight: 650;
}
#save:hover,
#add:hover,
.actions > a:hover {
filter: brightness(1.08);
}
#add.action-ok,
.actions > a.action-busy {
filter: brightness(1.18);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--button-primary) 26%, transparent);
}
#add.action-ok {
background: var(--online-bg);
border-color: var(--online-line);
color: var(--ok);
filter: none;
}
#save.save-ok {
background: var(--online-bg);
border-color: var(--online-line);
color: var(--ok);
}
#save.save-error {
background: var(--offline-bg);
border-color: var(--offline-line);
color: var(--bad);
}
.theme-controls {
position: relative;
display: flex;
align-items: center;
gap: 6px;
}
.icon-button {
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
padding: 0;
border-radius: 16px;
font-size: 17px;
line-height: 1;
}
.icon-button.active {
border-color: var(--info);
box-shadow: inset 0 0 0 1px var(--info);
}
.style-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: 220px;
padding: 8px;
display: grid;
gap: 6px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
}
.style-menu[hidden] { display: none; }
.style-option {
display: grid;
grid-template-columns: 28px 1fr;
gap: 9px;
align-items: center;
width: 100%;
text-align: left;
padding: 8px;
}
.style-swatch {
width: 26px;
height: 26px;
border-radius: 7px;
border: 1px solid var(--line);
background: linear-gradient(135deg, var(--swatch-a), var(--swatch-b));
}
.save-ok {
border-color: #2f7654;
background: var(--online-bg);
color: var(--ok);
}
.save-error {
border-color: #773939;
background: var(--offline-bg);
color: var(--bad);
}
.status {
color: var(--muted);
min-height: 20px;
font-size: 14px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 12px;
}
@media (max-width: 1180px) {
.device { grid-template-columns: repeat(3, 1fr); align-items: start; }
.top-grid { grid-template-columns: repeat(2, 1fr); }
.threshold-row { grid-template-columns: 90px repeat(3, minmax(100px, 1fr)); }
}
@media (max-width: 680px) {
header { flex-direction: column; align-items: flex-start; padding: 18px; }
main { padding: 16px; }
.top-grid, .device { grid-template-columns: 1fr; }
.threshold-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<h1>Configuracion</h1>
<div class="actions">
<a id="dashboardLink" href="/dashboard.html">Dashboard</a>
<a href="/docs.html">Documentacion</a>
<button id="save" type="button">Guardar</button>
<button id="add" type="button">Agregar RPi</button>
<div class="theme-controls">
<button class="icon-button" id="lightMode" type="button" title="Modo claro"></button>
<button class="icon-button" id="darkMode" type="button" title="Modo oscuro"></button>
<button class="icon-button" id="styleMenuButton" type="button" title="Estilos"></button>
<div class="style-menu" id="styleMenu" hidden></div>
</div>
</div>
</header>
<main>
<section>
<h2>General</h2>
<div class="top-grid">
<label title="Si esta activo, el dashboard usa datos simulados y no abre SSH hacia las Raspberry.">Modo mock
<select id="mockMode">
<option value="true">Activo</option>
<option value="false">SSH real</option>
</select>
</label>
<label title="Frecuencia de escaneo SSH cuando hay al menos un dashboard visible.">Intervalo scan segundos
<input id="refreshIntervalSeconds" type="number" min="5" step="1">
</label>
<label title="Frecuencia de escaneo cuando no hay dashboards visibles. Reduce carga en la red y en las RPi.">Intervalo sin clientes segundos
<input id="idleScanIntervalSeconds" type="number" min="60" step="1">
</label>
<label title="Tiempo maximo permitido para que responda cada consulta SSH.">Timeout SSH segundos
<input id="sshTimeoutSeconds" type="number" min="2" step="1">
</label>
</div>
<div class="threshold-groups">
<div class="threshold-row">
<div class="threshold-title">Temperatura</div>
<label title="Desde esta temperatura la lectura pasa a amarillo.">Amarillo C
<input id="warningTemp" type="number" min="30" step="1">
</label>
<label title="Desde esta temperatura la lectura pasa a naranja.">Naranja C
<input id="hotTemp" type="number" min="40" step="1">
</label>
<label title="Desde esta temperatura la lectura pasa a rojo y se considera critica.">Rojo C
<input id="criticalTemp" type="number" min="50" step="1">
</label>
</div>
<div class="threshold-row">
<div class="threshold-title">CPU</div>
<label title="Desde este porcentaje la barra de CPU pasa a amarillo.">Amarillo %
<input id="cpuWarning" type="number" min="1" max="100" step="1">
</label>
<label title="Desde este porcentaje la barra de CPU pasa a naranja.">Naranja %
<input id="cpuHot" type="number" min="1" max="100" step="1">
</label>
<label title="Desde este porcentaje la barra de CPU pasa a rojo.">Rojo %
<input id="cpuCritical" type="number" min="1" max="100" step="1">
</label>
</div>
<div class="threshold-row">
<div class="threshold-title">RAM</div>
<label title="Desde este porcentaje la barra de RAM pasa a amarillo.">Amarillo %
<input id="memoryWarning" type="number" min="1" max="100" step="1">
</label>
<label title="Desde este porcentaje la barra de RAM pasa a naranja.">Naranja %
<input id="memoryHot" type="number" min="1" max="100" step="1">
</label>
<label title="Desde este porcentaje la barra de RAM pasa a rojo.">Rojo %
<input id="memoryCritical" type="number" min="1" max="100" step="1">
</label>
</div>
<div class="threshold-row">
<div class="threshold-title">Disco</div>
<label title="Desde este porcentaje la barra de disco pasa a amarillo.">Amarillo %
<input id="diskWarning" type="number" min="1" max="100" step="1">
</label>
<label title="Desde este porcentaje la barra de disco pasa a naranja.">Naranja %
<input id="diskHot" type="number" min="1" max="100" step="1">
</label>
<label title="Desde este porcentaje la barra de disco pasa a rojo.">Rojo %
<input id="diskCritical" type="number" min="1" max="100" step="1">
</label>
</div>
<div class="threshold-row">
<div class="threshold-title">Swap</div>
<label>Amarillo %
<input id="swapWarning" type="number" min="1" max="100" step="1">
</label>
<label>Naranja %
<input id="swapHot" type="number" min="1" max="100" step="1">
</label>
<label>Rojo %
<input id="swapCritical" type="number" min="1" max="100" step="1">
</label>
</div>
</div>
</section>
<section>
<h2>Dispositivos</h2>
<div class="devices" id="devices"></div>
</section>
<p class="status" id="status"></p>
</main>
<script>
const themeStyles = [
{ id: "slate", name: "Pizarra", colors: ["#20262b", "#64b5f6"] },
{ id: "pine", name: "Pino", colors: ["#1f2b25", "#70b7a6"] },
{ id: "ocean", name: "Costa", colors: ["#1f2b31", "#6dbbd1"] },
{ id: "cloud", name: "Nube", colors: ["#f0eee7", "#687faa"] },
{ id: "sage", name: "Salvia", colors: ["#edf1e5", "#6d8f5f"] },
{ id: "linen", name: "Lino", colors: ["#f0e8dd", "#a3743e"] }
];
function storedTheme() {
try {
return JSON.parse(localStorage.getItem("monitorRpiTheme")) || {};
} catch {
return {};
}
}
function applyTheme(next = {}) {
const current = { style: "slate", mode: "dark", ...storedTheme(), ...next };
if (!themeStyles.some((style) => style.id === current.style)) current.style = "slate";
if (!["dark", "light"].includes(current.mode)) current.mode = "dark";
document.documentElement.dataset.style = current.style;
document.documentElement.dataset.mode = current.mode;
localStorage.setItem("monitorRpiTheme", JSON.stringify(current));
document.querySelector("#lightMode")?.classList.toggle("active", current.mode === "light");
document.querySelector("#darkMode")?.classList.toggle("active", current.mode === "dark");
document.querySelectorAll("[data-theme-style]").forEach((button) => {
button.classList.toggle("active", button.dataset.themeStyle === current.style);
});
}
function setupThemeControls() {
const menu = document.querySelector("#styleMenu");
menu.innerHTML = themeStyles.map((style) => `
<button class="style-option" type="button" data-theme-style="${style.id}">
<span class="style-swatch" style="--swatch-a:${style.colors[0]};--swatch-b:${style.colors[1]}"></span>
<span>${style.name}</span>
</button>
`).join("");
document.querySelector("#lightMode").addEventListener("click", () => applyTheme({ mode: "light" }));
document.querySelector("#darkMode").addEventListener("click", () => applyTheme({ mode: "dark" }));
document.querySelector("#styleMenuButton").addEventListener("click", (event) => {
event.stopPropagation();
menu.hidden = !menu.hidden;
});
menu.addEventListener("click", (event) => {
const button = event.target.closest("[data-theme-style]");
if (!button) return;
applyTheme({ style: button.dataset.themeStyle });
menu.hidden = true;
});
document.addEventListener("click", () => {
menu.hidden = true;
});
applyTheme();
}
setupThemeControls();
const state = { config: null };
const devicesEl = document.querySelector("#devices");
const statusEl = document.querySelector("#status");
const dashboardLink = document.querySelector("#dashboardLink");
function deviceTemplate(device, index) {
return `
<div class="device" data-index="${index}">
<label title="Si esta activo, este dispositivo se consulta por SSH. Si esta inactivo, se muestra deshabilitado y no se escanea.">Activo
<input data-field="active" type="checkbox" ${device.active ? "checked" : ""}>
</label>
<label class="field-name" title="Nombre visible de la tarjeta en el dashboard.">Nombre
<input data-field="name" value="${device.name || ""}">
</label>
<label title="IP o nombre DNS usado para conectar por SSH.">IP / host
<input data-field="host" value="${device.host || ""}">
</label>
<label title="Usuario SSH para este dispositivo.">Usuario
<input data-field="username" value="${device.username || ""}">
</label>
<label title="Password SSH. Se guarda en config.json dentro del volumen persistente.">Password
<input data-field="password" type="password" value="${device.password || ""}">
</label>
<label class="field-id" title="Identificador interno unico. Se usa para tracking de metricas como red.">ID
<input data-field="id" value="${device.id || ""}">
</label>
<label title="Funcion del equipo: Docker, Home Assistant, MQTT, Nginx, Gitea, etc.">Rol
<input data-field="role" value="${device.role || ""}">
</label>
<label title="Puerto SSH. Normalmente 22; en Home Assistant con add-on puede ser 2222.">Puerto
<input data-field="port" type="number" min="1" value="${device.port || 22}">
</label>
<label title="Modelo de Raspberry usado como informacion visual.">Modelo
<select data-field="model">
${["RPi 3", "RPi 4", "RPi 5"].map((model) => `<option ${device.model === model ? "selected" : ""}>${model}</option>`).join("")}
</select>
</label>
<label class="field-location" title="Ubicacion fisica o logica del dispositivo.">Ubicacion
<input data-field="location" value="${device.location || ""}">
</label>
<button class="danger" data-remove="${index}" type="button">Eliminar</button>
</div>
`;
}
function render() {
const thresholds = state.config.metricThresholdsPercent || {};
document.querySelector("#mockMode").value = String(Boolean(state.config.mockMode));
document.querySelector("#refreshIntervalSeconds").value = state.config.refreshIntervalSeconds || 30;
document.querySelector("#idleScanIntervalSeconds").value = state.config.idleScanIntervalSeconds || 300;
document.querySelector("#sshTimeoutSeconds").value = state.config.sshTimeoutSeconds || 8;
document.querySelector("#warningTemp").value = state.config.temperatureThresholdsC?.warning || 60;
document.querySelector("#hotTemp").value = state.config.temperatureThresholdsC?.hot || 70;
document.querySelector("#criticalTemp").value = state.config.temperatureThresholdsC?.critical || 80;
document.querySelector("#cpuWarning").value = thresholds.cpu?.warning ?? 60;
document.querySelector("#cpuHot").value = thresholds.cpu?.hot ?? 75;
document.querySelector("#cpuCritical").value = thresholds.cpu?.critical ?? 90;
document.querySelector("#memoryWarning").value = thresholds.memory?.warning ?? 70;
document.querySelector("#memoryHot").value = thresholds.memory?.hot ?? 85;
document.querySelector("#memoryCritical").value = thresholds.memory?.critical ?? 95;
document.querySelector("#diskWarning").value = thresholds.disk?.warning ?? 70;
document.querySelector("#diskHot").value = thresholds.disk?.hot ?? 85;
document.querySelector("#diskCritical").value = thresholds.disk?.critical ?? 95;
document.querySelector("#swapWarning").value = thresholds.swap?.warning ?? 50;
document.querySelector("#swapHot").value = thresholds.swap?.hot ?? 75;
document.querySelector("#swapCritical").value = thresholds.swap?.critical ?? 90;
devicesEl.innerHTML = state.config.devices.map(deviceTemplate).join("");
}
function collect() {
state.config.mockMode = document.querySelector("#mockMode").value === "true";
state.config.refreshIntervalSeconds = Number(document.querySelector("#refreshIntervalSeconds").value || 30);
state.config.idleScanIntervalSeconds = Number(document.querySelector("#idleScanIntervalSeconds").value || 300);
state.config.sshTimeoutSeconds = Number(document.querySelector("#sshTimeoutSeconds").value || 8);
state.config.temperatureThresholdsC = {
...(state.config.temperatureThresholdsC || {}),
warning: Number(document.querySelector("#warningTemp").value || 60),
hot: Number(document.querySelector("#hotTemp").value || 70),
critical: Number(document.querySelector("#criticalTemp").value || 80)
};
state.config.metricThresholdsPercent = {
cpu: {
warning: Number(document.querySelector("#cpuWarning").value || 60),
hot: Number(document.querySelector("#cpuHot").value || 75),
critical: Number(document.querySelector("#cpuCritical").value || 90)
},
memory: {
warning: Number(document.querySelector("#memoryWarning").value || 70),
hot: Number(document.querySelector("#memoryHot").value || 85),
critical: Number(document.querySelector("#memoryCritical").value || 95)
},
disk: {
warning: Number(document.querySelector("#diskWarning").value || 70),
hot: Number(document.querySelector("#diskHot").value || 85),
critical: Number(document.querySelector("#diskCritical").value || 95)
},
swap: {
warning: Number(document.querySelector("#swapWarning").value || 50),
hot: Number(document.querySelector("#swapHot").value || 75),
critical: Number(document.querySelector("#swapCritical").value || 90)
}
};
state.config.devices = [...devicesEl.querySelectorAll(".device")].map((row) => {
const device = {};
row.querySelectorAll("[data-field]").forEach((input) => {
const field = input.dataset.field;
if (input.type === "checkbox") device[field] = input.checked;
else if (input.type === "number") device[field] = Number(input.value);
else device[field] = input.value;
});
return device;
});
}
async function load() {
const res = await fetch("/api/config", { cache: "no-store" });
state.config = await res.json();
render();
}
async function save() {
const saveButton = document.querySelector("#save");
saveButton.disabled = true;
statusEl.className = "status";
statusEl.textContent = "Guardando cambios...";
collect();
try {
const res = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state.config)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
saveButton.classList.add("save-ok");
statusEl.className = "status save-ok";
statusEl.textContent = `Guardado correctamente a las ${new Date().toLocaleTimeString("es-ES")}`;
setTimeout(() => saveButton.classList.remove("save-ok"), 1200);
} catch (error) {
statusEl.className = "status save-error";
statusEl.textContent = `Error guardando: ${error.message}`;
throw error;
} finally {
saveButton.disabled = false;
}
}
document.querySelector("#save").addEventListener("click", () => {
save().catch(() => {});
});
document.querySelector("#add").addEventListener("click", () => {
const addButton = document.querySelector("#add");
collect();
state.config.devices.push({
id: `rpi-${state.config.devices.length + 1}`,
name: "Nueva RPi",
host: "",
port: 22,
username: "pi",
password: "",
model: "RPi 4",
role: "",
location: "Rack",
active: false
});
render();
addButton.classList.add("action-ok");
addButton.textContent = "RPi agregada";
statusEl.className = "status save-ok";
statusEl.textContent = "Nueva RPi agregada. Revisa sus datos y pulsa Guardar.";
setTimeout(() => {
addButton.classList.remove("action-ok");
addButton.textContent = "Agregar RPi";
}, 1400);
});
dashboardLink.addEventListener("click", () => {
dashboardLink.classList.add("action-busy");
dashboardLink.textContent = "Abriendo...";
});
devicesEl.addEventListener("click", (event) => {
const index = event.target.dataset.remove;
if (index === undefined) return;
collect();
state.config.devices.splice(Number(index), 1);
render();
});
load().catch((error) => {
statusEl.textContent = `Error cargando configuracion: ${error.message}`;
});
</script>
</body>
</html>
+1069
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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
+126
View File
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Documentacion Monitor RPi</title>
<style>
body {
margin: 0;
font-family: "Segoe UI", system-ui, sans-serif;
background: #f4f6f7;
color: #172127;
line-height: 1.55;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 28px;
border-bottom: 1px solid #d4dce1;
background: #ffffff;
}
main {
max-width: 980px;
margin: 0 auto;
padding: 28px;
}
h1 { margin: 0; font-size: 24px; }
h2 { margin-top: 30px; border-bottom: 1px solid #d4dce1; padding-bottom: 8px; }
h3 { margin-top: 20px; }
code, pre { background: #eef2f4; border-radius: 8px; }
code { padding: 2px 5px; }
pre { padding: 14px; overflow: auto; }
a {
color: #2f7fb8;
text-decoration: none;
font-weight: 650;
}
.nav {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
</style>
</head>
<body>
<header>
<h1>Documentacion Monitor RPi</h1>
<nav class="nav">
<a href="/dashboard.html">Dashboard</a>
<a href="/config.html">Configuracion</a>
</nav>
</header>
<main>
<h2>Resumen</h2>
<p>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.</p>
<h2>Rutas</h2>
<ul>
<li><code>/dashboard.html</code>: vista principal.</li>
<li><code>/config.html</code>: configuracion.</li>
<li><code>/api/status</code>: estado actual.</li>
</ul>
<h2>Configuracion</h2>
<p>La configuracion persistente esta en <code>/home/yamaray/docker/monitorRPi/data/config.json</code>, montada dentro del contenedor como <code>/data/config.json</code>.</p>
<h3>Generales</h3>
<ul>
<li><code>mockMode</code>: usa datos simulados si es true.</li>
<li><code>refreshIntervalSeconds</code>: scan activo con clientes visibles.</li>
<li><code>idleScanIntervalSeconds</code>: scan lento sin clientes visibles.</li>
<li><code>sshTimeoutSeconds</code>: timeout por consulta SSH.</li>
<li><code>temperatureThresholdsC</code>: umbrales de temperatura.</li>
<li><code>metricThresholdsPercent</code>: umbrales de CPU, RAM y disco.</li>
</ul>
<h3>Dispositivos</h3>
<ul>
<li><code>active</code>: incluye o excluye el dispositivo del scan.</li>
<li><code>id</code>: identificador interno unico.</li>
<li><code>name</code>: nombre visible.</li>
<li><code>host</code>: IP o DNS.</li>
<li><code>port</code>: puerto SSH.</li>
<li><code>username/password</code>: credenciales SSH.</li>
<li><code>model</code>: modelo visible.</li>
<li><code>role</code>: funcion del equipo.</li>
<li><code>location</code>: ubicacion.</li>
</ul>
<h2>API</h2>
<h3>GET /api/status</h3>
<p>Devuelve el ultimo estado: resumen, dispositivos, umbrales, clientes activos e intervalo actual.</p>
<h3>GET /api/config</h3>
<p>Devuelve la configuracion completa.</p>
<h3>POST /api/config</h3>
<p>Guarda la configuracion completa y reinicia el ciclo de scan.</p>
<h3>POST /api/reload</h3>
<p>Recarga la configuracion desde disco.</p>
<h3>POST /api/scan-now</h3>
<p>Fuerza un scan SSH inmediato.</p>
<h3>POST /api/client-heartbeat</h3>
<p>Usado por el dashboard visible para activar el intervalo rapido.</p>
<h3>POST /api/clients/clear</h3>
<p>Limpia los clientes activos en memoria.</p>
<h2>Escaneo Adaptativo</h2>
<pre><code>0 clientes visibles -> idleScanIntervalSeconds
1+ clientes visibles -> refreshIntervalSeconds</code></pre>
<h2>Docker</h2>
<pre><code>cd /home/yamaray/docker/monitorRPi
docker compose up -d --build</code></pre>
</main>
</body>
</html>
+766
View File
@@ -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;
});