Files

809 lines
32 KiB
HTML

<!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: repeat(12, minmax(0, 1fr));
gap: 10px;
align-items: end;
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
background: var(--panel-2);
}
.field-active { grid-column: span 2; }
.field-name { grid-column: span 3; }
.field-id { grid-column: span 2; }
.field-role { grid-column: span 3; }
.field-model { grid-column: span 2; }
.field-location { grid-column: span 2; }
.field-host { grid-column: span 3; }
.field-port { grid-column: span 1; }
.field-username { grid-column: span 2; }
.field-auth { grid-column: span 2; }
.password-credential, .key-credential { grid-column: span 4; }
.field-delete { grid-column: span 2; }
.device[data-auth-method="password"] .key-credential { display: none; }
.device[data-auth-method="key"] .password-credential { display: none; }
.credential-note {
grid-column: span 8;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
align-self: center;
}
.device[data-auth-method="key"] .credential-note strong { color: var(--info); }
.device[data-auth-method="password"] .credential-note strong { color: var(--ok); }
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);
}
.field-active input[type="checkbox"] {
align-self: center;
}
.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 {
background: var(--offline-bg);
border-color: var(--offline-line);
color: var(--bad);
font-weight: 650;
}
.danger:hover {
background: var(--bad);
border-color: color-mix(in srgb, var(--bad) 72%, var(--text));
color: #ffffff;
}
#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(6, minmax(0, 1fr)); align-items: start; }
.field-name, .field-role, .field-host, .password-credential, .key-credential { grid-column: span 3; }
.field-id, .field-model, .field-location, .field-username, .field-auth, .field-active, .field-delete { grid-column: span 2; }
.field-port { grid-column: span 1; }
.credential-note { grid-column: span 4; }
.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; }
.field-active, .field-name, .field-id, .field-role, .field-model, .field-location,
.field-host, .field-port, .field-username, .field-auth, .password-credential,
.key-credential, .credential-note, .field-delete { grid-column: span 1; }
.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) {
const authMethod = device.privateKeyPath ? "key" : "password";
return `
<div class="device" data-index="${index}" data-auth-method="${authMethod}">
<label class="field-name" title="Nombre visible de la tarjeta en el dashboard.">Nombre
<input data-field="name" maxlength="35" value="${device.name || ""}">
</label>
<label class="field-id" title="Identificador interno unico. Se usa para tracking de metricas como red.">ID
<input data-field="id" maxlength="30" value="${device.id || ""}">
</label>
<label class="field-role" title="Funcion del equipo: Docker, Home Assistant, MQTT, Nginx, Gitea, etc.">Rol
<input data-field="role" value="${device.role || ""}">
</label>
<label class="field-model" 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" maxlength="20" value="${device.location || ""}">
</label>
<label class="field-host" title="IP o nombre DNS usado para conectar por SSH.">IP / host
<input data-field="host" value="${device.host || ""}">
</label>
<label class="field-port" title="Puerto SSH. Normalmente 22; en Home Assistant con add-on puede ser 2222.">Puerto
<input data-field="port" type="number" min="1" max="65535" maxlength="10" value="${device.port || 22}">
</label>
<label class="field-username" title="Usuario SSH para este dispositivo.">Usuario
<input data-field="username" maxlength="30" value="${device.username || ""}">
</label>
<label class="field-auth" title="Metodo de autenticacion SSH que usara el monitor para este dispositivo.">Auth SSH
<select data-auth-method>
<option value="password" ${authMethod === "password" ? "selected" : ""}>Password</option>
<option value="key" ${authMethod === "key" ? "selected" : ""}>Clave privada</option>
</select>
</label>
<label class="password-credential" title="Password SSH. Se guarda en config.json dentro del volumen persistente. Si eliges clave privada, se guardara vacio.">Password
<input data-field="password" type="password" value="${device.password || ""}">
</label>
<label class="key-credential" title="Ruta de la clave privada SSH dentro del contenedor. Ejemplo: /ssh/carabanes_monitor_ed25519. Si eliges password, se guardara vacia.">Clave privada
<input data-field="privateKeyPath" value="${device.privateKeyPath || ""}">
</label>
<div class="credential-note">
Metodo activo: <strong>${authMethod === "key" ? "Clave privada" : "Password"}</strong>
</div>
<label class="field-active" 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>
<button class="danger field-delete" 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;
});
const authMethod = row.querySelector("[data-auth-method]")?.value || "password";
if (authMethod === "key") device.password = "";
else device.privateKeyPath = "";
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: "",
privateKeyPath: "",
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();
});
devicesEl.addEventListener("change", (event) => {
const selector = event.target.closest("[data-auth-method]");
if (!selector) return;
const row = selector.closest(".device");
row.dataset.authMethod = selector.value;
const note = row.querySelector(".credential-note strong");
if (note) note.textContent = selector.value === "key" ? "Clave privada" : "Password";
});
load().catch((error) => {
statusEl.textContent = `Error cargando configuracion: ${error.message}`;
});
</script>
</body>
</html>