Version inicial monitor RPi
This commit is contained in:
+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>
|
||||
Reference in New Issue
Block a user