Version inicial monitor RPi
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
data
|
||||
node_modules
|
||||
.git
|
||||
*.log
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user