Files
monitorRPi/dashboard.html
2026-06-02 17:31:57 +02:00

1070 lines
36 KiB
HTML

<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Monitor RPi Rack</title>
<style>
:root {
color-scheme: dark;
--bg: #101214;
--panel: #191d21;
--panel-2: #20262b;
--text: #f4f7f9;
--muted: #a7b0b8;
--line: #323a41;
--ok: #2fd17c;
--warn: #f0c34e;
--hot: #f28b42;
--bad: #ef5b5b;
--info: #64b5f6;
--header: #13171a;
--input: #0f1215;
--bar-bg: #0f1215;
--alert-bg: #2a1d15;
--alert-text: #ffd2a6;
--alert-line: #6d4933;
--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;
--button-summary: #7c5cff;
--button-summary-text: #ffffff;
}
:root[data-mode="dark"] { color-scheme: dark; }
:root[data-mode="light"] {
color-scheme: light;
--alert-bg: #fff4df;
--alert-text: #80501f;
--alert-line: #e1b36d;
--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; --bar-bg: #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; --bar-bg: #e2e8ec; --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; --bar-bg: #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; --bar-bg: #dce7e0; --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; --bar-bg: #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; --bar-bg: #d8e7eb; --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; --bar-bg: #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; --bar-bg: #e8e2d5; --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; --bar-bg: #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; --bar-bg: #e4ead9; --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; --bar-bg: #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; --bar-bg: #eadfce; --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;
font-family: "Segoe UI", system-ui, sans-serif;
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);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 28px;
border-bottom: 1px solid var(--line);
background: var(--header);
}
h1, h2, h3, p { margin: 0; }
h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 14px;
flex-wrap: wrap;
justify-content: flex-end;
}
a, button {
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;
}
a:hover, button:hover { border-color: var(--info); }
#mode {
color: var(--info);
font-weight: 650;
}
#scanNow,
.header-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;
}
#summaryView {
background: linear-gradient(135deg, var(--button-summary), color-mix(in srgb, var(--button-summary) 78%, var(--info)));
border-color: color-mix(in srgb, var(--button-summary) 72%, var(--text));
color: var(--button-summary-text);
font-weight: 750;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--button-summary) 18%, transparent);
}
#scanNow:hover,
#summaryView:hover,
.header-actions > a:hover {
filter: brightness(1.08);
}
#scanNow.action-busy,
#summaryView.action-busy,
.header-actions > a.action-busy {
filter: brightness(1.18);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--button-primary) 26%, transparent);
}
#scanNow.action-ok,
.header-actions > a.action-ok {
background: var(--online-bg);
border-color: var(--online-line);
color: var(--ok);
filter: none;
}
#summaryView.action-ok {
background: #f28b42;
border-color: #ffc07c;
color: #15100b;
filter: none;
box-shadow: 0 0 0 3px rgba(242, 139, 66, 0.28);
}
.theme-controls {
position: relative;
display: flex;
align-items: center;
gap: 6px;
padding-left: 4px;
}
.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));
}
main {
padding: 22px 28px 32px;
display: grid;
gap: 18px;
}
.summary {
display: grid;
grid-template-columns: repeat(5, minmax(130px, 1fr));
gap: 12px;
}
.summary-item, .device {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
}
.summary-item {
padding: 15px;
min-height: 88px;
}
.label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
}
.value {
margin-top: 7px;
font-size: 27px;
font-weight: 750;
}
.summary-ok { color: var(--ok); }
.summary-warn { color: var(--warn); }
.summary-hot { color: var(--hot); }
.summary-bad { color: var(--bad); }
.value-with-note {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.summary-note {
color: var(--muted);
font-size: 13px;
line-height: 1.25;
overflow-wrap: anywhere;
text-align: right;
}
.alerts {
display: flex;
gap: 8px;
flex-wrap: wrap;
min-height: 32px;
}
.alert {
border: 1px solid var(--alert-line);
color: var(--alert-text);
background: var(--alert-bg);
border-radius: 999px;
padding: 7px 10px;
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(240px, 1fr));
gap: 14px;
}
.grid.table-mode {
display: block;
}
.device {
overflow: hidden;
min-height: 345px;
display: flex;
flex-direction: column;
}
.device.disabled { opacity: 0.55; }
.device-head {
padding: 15px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
min-height: 94px;
}
.device-title {
display: grid;
gap: 5px;
min-width: 0;
}
.device-title h3 {
font-size: 18px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.device-side {
display: grid;
gap: 9px;
justify-items: end;
text-align: right;
min-width: 104px;
}
.network-mini {
display: grid;
gap: 3px;
color: var(--muted);
font-size: 12px;
line-height: 1.25;
}
.network-mini strong {
color: var(--text);
font-size: 13px;
font-weight: 650;
}
.meta {
color: var(--muted);
font-size: 13px;
line-height: 1.35;
}
.badge {
border-radius: 999px;
padding: 6px 9px;
font-size: 12px;
border: 1px solid var(--line);
white-space: nowrap;
}
.online { color: var(--ok); border-color: var(--online-line); background: var(--online-bg); }
.offline { color: var(--bad); border-color: var(--offline-line); background: var(--offline-bg); }
.disabled-badge { color: var(--muted); background: var(--disabled-bg); }
.temperature {
padding: 16px 15px 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.temperature .number {
font-size: 44px;
font-weight: 800;
line-height: 1;
}
.normal { color: var(--ok); }
.warning { color: var(--warn); }
.hot { color: var(--hot); }
.critical { color: var(--bad); }
.metrics {
padding: 8px 15px 16px;
display: grid;
gap: 10px;
}
.metric-row {
display: grid;
grid-template-columns: 48px 58px minmax(48px, 1fr) 42px;
align-items: center;
gap: 8px;
font-size: 13px;
}
.metric-capacity {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.bar {
height: 8px;
border-radius: 999px;
background: var(--bar-bg);
overflow: hidden;
border: 1px solid var(--line);
}
.fill {
display: block;
height: 100%;
background: var(--info);
}
.fill-normal { background: var(--ok); }
.fill-warning { background: var(--warn); }
.fill-hot { background: var(--hot); }
.fill-critical { background: var(--bad); }
.summary-table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: color-mix(in srgb, var(--panel) 94%, transparent);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.14);
}
.summary-table {
width: 100%;
min-width: 1040px;
border-collapse: collapse;
}
.summary-table th {
position: sticky;
top: 0;
z-index: 1;
padding: 13px 14px;
color: var(--muted);
background: color-mix(in srgb, var(--panel-2) 90%, var(--panel));
border-bottom: 1px solid var(--line);
font-size: 12px;
text-align: left;
text-transform: uppercase;
white-space: nowrap;
}
.summary-table td {
padding: 13px 14px;
border-bottom: 1px solid color-mix(in srgb, var(--line) 76%, transparent);
vertical-align: middle;
font-size: 14px;
}
.summary-table tr:last-child td {
border-bottom: 0;
}
.summary-table tbody tr {
background: color-mix(in srgb, var(--panel) 96%, transparent);
}
.summary-table tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--panel-2) 38%, var(--panel));
}
.summary-table tbody tr:hover {
background: color-mix(in srgb, var(--info) 12%, var(--panel));
}
.table-device-name {
display: grid;
gap: 4px;
min-width: 160px;
font-weight: 720;
}
.table-device-name span {
color: var(--muted);
font-size: 12px;
font-weight: 500;
}
.table-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 76px;
border-radius: 999px;
padding: 6px 10px;
border: 1px solid var(--line);
font-size: 12px;
font-weight: 750;
}
.table-status.online { color: var(--ok); border-color: var(--online-line); background: var(--online-bg); }
.table-status.offline { color: var(--bad); border-color: var(--offline-line); background: var(--offline-bg); }
.table-status.disabled-badge { color: var(--muted); background: var(--disabled-bg); }
.table-temp {
font-size: 18px;
font-weight: 800;
white-space: nowrap;
}
.table-meter {
display: grid;
grid-template-columns: minmax(70px, 1fr) 88px;
gap: 10px;
align-items: center;
min-width: 150px;
}
.table-meter strong {
text-align: right;
white-space: nowrap;
}
.details {
margin-top: auto;
padding: 13px 15px;
border-top: 1px solid var(--line);
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
}
.empty {
padding: 20px;
color: var(--muted);
border: 1px dashed var(--line);
border-radius: 12px;
}
@media (max-width: 1180px) {
.grid { grid-template-columns: repeat(2, minmax(240px, 1fr)); }
.summary { grid-template-columns: repeat(3, minmax(130px, 1fr)); }
}
@media (max-width: 680px) {
header { align-items: flex-start; flex-direction: column; padding: 18px; }
main { padding: 16px; }
.grid, .summary { grid-template-columns: 1fr; }
.header-actions { justify-content: flex-start; }
.summary-table-wrap { border-radius: 16px; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Monitor RPi Rack</h1>
<p class="meta" id="subtitle">Cargando estado...</p>
</div>
<div class="header-actions">
<span id="mode"></span>
<button id="scanNow" type="button">Actualizar</button>
<button id="summaryView" type="button">Resumen</button>
<a id="configLink" href="/config.html">Configuracion</a>
<a href="/docs.html">Documentacion</a>
<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 class="summary" id="summary"></section>
<section class="alerts" id="alerts"></section>
<section class="grid" id="devices"></section>
</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 els = {
subtitle: document.querySelector("#subtitle"),
mode: document.querySelector("#mode"),
summary: document.querySelector("#summary"),
alerts: document.querySelector("#alerts"),
devices: document.querySelector("#devices"),
scanNow: document.querySelector("#scanNow"),
summaryView: document.querySelector("#summaryView"),
configLink: document.querySelector("#configLink")
};
let refreshTimer = null;
let heartbeatTimer = null;
let latestData = null;
let viewMode = localStorage.getItem("monitorRpiViewMode") || "cards";
function makeClientId() {
return `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function getClientId() {
try {
const stored = window.localStorage?.getItem("monitorRpiClientId");
const next = stored || makeClientId();
window.localStorage?.setItem("monitorRpiClientId", next);
return next;
} catch {
return makeClientId();
}
}
const clientId = getClientId();
function fmtDate(value) {
if (!value) return "sin datos";
return new Intl.DateTimeFormat("es-ES", {
dateStyle: "short",
timeStyle: "medium"
}).format(new Date(value));
}
function summaryItem(label, value, className = "") {
return `<article class="summary-item"><div class="label">${label}</div><div class="value ${className}">${value}</div></article>`;
}
function summaryItemWithNote(label, value, note, className = "") {
return `
<article class="summary-item">
<div class="label">${label}</div>
<div class="value value-with-note ${className}">
<span>${value}</span>
<span class="summary-note">${note}</span>
</div>
</article>
`;
}
function hottestSummaryClass(value, thresholds = {}) {
if (!Number.isFinite(value)) return "";
if (value >= (thresholds.critical ?? 80)) return "summary-bad";
if (value >= (thresholds.hot ?? 70)) return "summary-hot";
if (value >= (thresholds.warning ?? 60)) return "summary-warn";
return "summary-ok";
}
function metricLevel(value, thresholds = {}) {
const safe = Number(value || 0);
if (safe >= (thresholds.critical ?? 90)) return "critical";
if (safe >= (thresholds.hot ?? 75)) return "hot";
if (safe >= (thresholds.warning ?? 60)) return "warning";
return "normal";
}
function formatCapacity(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return "--";
if (value >= 1024 ** 3) return `${Math.round(value / 1024 ** 3)} GB`;
if (value >= 1024 ** 2) return `${Math.round(value / 1024 ** 2)} MB`;
return `${Math.round(value / 1024)} KB`;
}
function formatUsedCapacity(totalBytes, usedPercent) {
const total = Number(totalBytes || 0);
const percent = Number(usedPercent || 0);
if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(percent)) return "--";
return formatCapacity(total * Math.max(0, Math.min(100, percent)) / 100);
}
function metricRow(label, capacity, value, thresholds) {
const safe = Math.max(0, Math.min(100, Number(value || 0)));
const level = metricLevel(safe, thresholds);
return `
<div class="metric-row">
<span>${label}</span>
<span class="metric-capacity">${capacity}</span>
<span class="bar"><span class="fill fill-${level}" style="width:${safe}%"></span></span>
<strong>${safe}%</strong>
</div>
`;
}
function formatRate(bytesPerSecond) {
if (bytesPerSecond === null || bytesPerSecond === undefined) return "--";
const value = Number(bytesPerSecond);
if (!Number.isFinite(value)) return "--";
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB/s`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB/s`;
return `${Math.round(value)} B/s`;
}
function renderDevice(device) {
const disabled = !device.active;
const metrics = device.metrics;
const thresholds = window.metricThresholdsPercent || {};
const statusClass = disabled ? "disabled-badge" : device.online ? "online" : "offline";
const statusText = disabled ? "Inactivo" : device.online ? "Online" : "Offline";
const tempLevel = metrics?.tempLevel || "normal";
const tempText = metrics ? `${metrics.cpuTempC.toFixed(1)} C` : "-- C";
const dockerText = metrics?.docker?.installed
? `${metrics.docker.running} activos, ${metrics.docker.stopped} parados`
: "no detectado";
const haText = metrics?.homeAssistant?.detected ? metrics.homeAssistant.status : "no detectado";
const net = metrics?.network;
const networkTitle = net
? `${net.type || "--"} · ${net.interface || "--"}`
: "Red --";
const networkTraffic = net
? `RX ${formatRate(net.rxBytesPerSecond)} · TX ${formatRate(net.txBytesPerSecond)}`
: "RX -- · TX --";
const networkDetail = net
? `${net.ipAddress || device.host || net.state || "--"}${net.speedMbps ? ` · ${net.speedMbps} Mbps` : ""}`
: "--";
return `
<article class="device ${disabled ? "disabled" : ""}">
<div class="device-head">
<div class="device-title">
<h3>${device.name}</h3>
<div class="meta">${device.model || "Modelo sin definir"} · ${device.role || "Sin rol"}</div>
<div class="meta">${device.host}:${device.port || 22}</div>
</div>
<div class="device-side">
<span class="badge ${statusClass}">${statusText}</span>
<div class="network-mini">
<strong>${networkTitle}</strong>
<span>${networkTraffic}</span>
<span>${networkDetail}</span>
</div>
</div>
</div>
<div class="temperature">
<div>
<div class="label">CPU temp</div>
<div class="number ${tempLevel}">${tempText}</div>
</div>
<div class="meta">${device.location || ""}</div>
</div>
<div class="metrics">
${metrics ? metricRow("CPU", `${metrics.cpuCores || "--"}c`, metrics.cpuUsagePercent, thresholds.cpu) : ""}
${metrics ? metricRow("RAM", formatCapacity(metrics.memoryTotalBytes), metrics.memoryUsedPercent, thresholds.memory) : ""}
${metrics ? metricRow("Swap", formatCapacity(metrics.swapTotalBytes), metrics.swapUsedPercent, thresholds.swap) : ""}
${metrics ? metricRow("Disco", formatCapacity(metrics.diskTotalBytes), metrics.diskUsedPercent, thresholds.disk) : ""}
${!metrics ? `<p class="empty">${disabled ? "No se escanea por configuracion." : "Sin metricas disponibles."}</p>` : ""}
</div>
<div class="details">
<span>Uptime: ${metrics?.uptime || "--"}</span>
<span>Load: ${metrics?.loadAverage?.join(", ") || "--"}</span>
<span>Docker: ${dockerText}</span>
<span>Home Assistant: ${haText}</span>
<span>Ultima lectura: ${fmtDate(device.checkedAt)}</span>
</div>
</article>
`;
}
function tableMeter(totalBytes, usedPercent, thresholds) {
const safe = Math.max(0, Math.min(100, Number(usedPercent || 0)));
const level = metricLevel(safe, thresholds);
const total = formatCapacity(totalBytes);
const used = formatUsedCapacity(totalBytes, safe);
return `
<div class="table-meter" title="Total ${total}. Ocupado ${used}, ${safe}%.">
<span class="bar"><span class="fill fill-${level}" style="width:${safe}%"></span></span>
<strong>${total} - ${safe}%</strong>
</div>
`;
}
function cpuTableMeter(metrics, thresholds) {
const safe = Math.max(0, Math.min(100, Number(metrics.cpuUsagePercent || 0)));
const level = metricLevel(safe, thresholds);
const cores = metrics.cpuCores ? `${metrics.cpuCores}c` : "--";
return `
<div class="table-meter" title="${metrics.cpuCores || "--"} nucleos. Uso CPU ${safe}%.">
<span class="bar"><span class="fill fill-${level}" style="width:${safe}%"></span></span>
<strong>${cores} - ${safe}%</strong>
</div>
`;
}
function renderDeviceSummaryRow(device) {
const disabled = !device.active;
const metrics = device.metrics;
const thresholds = window.metricThresholdsPercent || {};
const statusClass = disabled ? "disabled-badge" : device.online ? "online" : "offline";
const statusText = disabled ? "Inactivo" : device.online ? "Online" : "Offline";
const tempLevel = metrics?.tempLevel || "normal";
const tempText = metrics?.cpuTempC !== undefined ? `${metrics.cpuTempC.toFixed(1)} C` : "--";
return `
<tr>
<td>
<div class="table-device-name">
${device.name || device.id || "--"}
<span>${device.role || device.location || device.host || ""}</span>
</div>
</td>
<td>${device.model || "--"}</td>
<td><span class="table-status ${statusClass}">${statusText}</span></td>
<td>${metrics ? cpuTableMeter(metrics, thresholds.cpu) : "--"}</td>
<td><span class="table-temp ${tempLevel}">${tempText}</span></td>
<td>${metrics ? tableMeter(metrics.memoryTotalBytes, metrics.memoryUsedPercent, thresholds.memory) : "--"}</td>
<td>${metrics ? tableMeter(metrics.swapTotalBytes, metrics.swapUsedPercent, thresholds.swap) : "--"}</td>
<td>${metrics ? tableMeter(metrics.diskTotalBytes, metrics.diskUsedPercent, thresholds.disk) : "--"}</td>
</tr>
`;
}
function renderDeviceSummaryTable(devices) {
if (!devices.length) return `<p class="empty">No hay RPi configuradas.</p>`;
return `
<div class="summary-table-wrap">
<table class="summary-table">
<thead>
<tr>
<th>Nombre del dispositivo</th>
<th>Modelo</th>
<th>Estado</th>
<th>CPU</th>
<th>Temperatura</th>
<th>Memoria RAM</th>
<th>Swap</th>
<th>Disco</th>
</tr>
</thead>
<tbody>
${devices.map(renderDeviceSummaryRow).join("")}
</tbody>
</table>
</div>
`;
}
function renderDevicesArea(devices) {
const list = Array.isArray(devices) ? devices : [];
els.devices.classList.toggle("table-mode", viewMode === "summary");
els.summaryView.textContent = viewMode === "summary" ? "Detalles" : "Resumen";
els.summaryView.classList.toggle("action-ok", viewMode === "summary");
els.devices.innerHTML = viewMode === "summary"
? renderDeviceSummaryTable(list)
: list.map(renderDevice).join("");
}
function render(data) {
latestData = data;
const summary = data.summary || {};
const temperatureThresholds = data.temperatureThresholdsC || {};
window.metricThresholdsPercent = data.metricThresholdsPercent || {};
els.subtitle.textContent = `Ultima actualizacion: ${fmtDate(data.lastUpdated)} · scan cada ${data.currentScanIntervalSeconds || data.refreshIntervalSeconds || 30}s · clientes ${data.activeClients ?? 0}`;
els.mode.textContent = data.mockMode ? "Modo mock" : "SSH real";
els.summary.innerHTML = [
summaryItem("RPi activas", `${summary.online || 0}/${summary.active || 0}`),
summaryItem("Offline", summary.offline || 0, summary.offline > 0 ? "summary-hot" : ""),
summary.hottest
? summaryItemWithNote(
"Mas caliente",
`${summary.hottest.value.toFixed(1)} C`,
summary.hottest.name,
hottestSummaryClass(summary.hottest.value, temperatureThresholds)
)
: summaryItem("Mas caliente", "--"),
summaryItem("Total RPi configuradas", summary.total || 0),
summaryItem("Alertas", summary.alerts?.length || 0, summary.alerts?.length > 0 ? "summary-hot" : "")
].join("");
els.alerts.innerHTML = summary.alerts?.length
? summary.alerts.map((alert) => `<span class="alert">${alert}</span>`).join("")
: `<span class="meta">Sin alertas activas.</span>`;
renderDevicesArea(data.devices || []);
}
async function loadStatus() {
const res = await fetch("/api/status", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
render(data);
clearInterval(refreshTimer);
refreshTimer = setInterval(loadStatus, Math.max(5, data.refreshIntervalSeconds || 30) * 1000);
}
async function sendHeartbeat() {
if (document.visibilityState !== "visible") return;
try {
await fetch("/api/client-heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId })
});
} catch {
// El dashboard seguira intentando en el siguiente pulso.
}
}
function startHeartbeat() {
sendHeartbeat();
clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(sendHeartbeat, 15000);
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
sendHeartbeat();
loadStatus().catch(() => {});
}
});
els.scanNow.addEventListener("click", async () => {
els.scanNow.disabled = true;
els.scanNow.classList.add("action-busy");
els.scanNow.textContent = "Actualizando...";
try {
await fetch("/api/scan-now", { method: "POST" });
await loadStatus();
els.scanNow.classList.remove("action-busy");
els.scanNow.classList.add("action-ok");
els.scanNow.textContent = "Actualizado";
setTimeout(() => {
els.scanNow.classList.remove("action-ok");
els.scanNow.textContent = "Actualizar";
}, 1200);
} finally {
els.scanNow.disabled = false;
els.scanNow.classList.remove("action-busy");
}
});
els.summaryView.addEventListener("click", () => {
viewMode = viewMode === "summary" ? "cards" : "summary";
localStorage.setItem("monitorRpiViewMode", viewMode);
els.summaryView.classList.add("action-busy");
if (latestData) renderDevicesArea(latestData.devices || []);
setTimeout(() => els.summaryView.classList.remove("action-busy"), 450);
});
els.configLink.addEventListener("click", () => {
els.configLink.classList.add("action-busy");
els.configLink.textContent = "Abriendo...";
});
startHeartbeat();
loadStatus().catch((error) => {
els.subtitle.textContent = `Error cargando estado: ${error.message}`;
});
</script>
</body>
</html>