1070 lines
36 KiB
HTML
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>
|