diff --git a/internal/views/layouts/base.gohtml b/internal/views/layouts/base.gohtml index fefd91b..e1398a7 100644 --- a/internal/views/layouts/base.gohtml +++ b/internal/views/layouts/base.gohtml @@ -11,107 +11,14 @@ + - - {{if eq .Shell "auth"}} -
-
-
-
-
-
-
-
- Self-hosted fleet control -
-
- -
-
-
Maintainarr
-
VM operations dashboard
-
-
-

Operate Linux nodes without leaving the browser.

-

Registration, OTP-backed sign-in, fleet health, remote actions, SSH access, and automation hooks in one self-hosted build.

-
-
-
- - Role-based access with OTP 2FA -
-
-
-
- - Ubuntu, Debian, Arch and more -
-
-
-
- - Web SSH console -
-
-
-
- - Recurring automation jobs -
-
-
-
-
-
-
- {{template "content" .}} -
-
-
-
-
-
-
-
- {{else}} -
- -
-
- {{template "content" .}} -
-
-
- {{end}} + + {{template "shell" .}} + + diff --git a/web/static/css/app.css b/web/static/css/app.css index 5889272..4a4f30c 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -15,13 +15,104 @@ --bs-link-color-rgb: var(--color-primary-600); --bs-link-hover-color-rgb: var(--color-primary-700); --ma-border: rgba(15, 23, 42, 0.08); + --ma-surface-sidebar: #16181d; + --ma-surface-base: #1d2026; + --ma-surface-1: #22262d; + --ma-surface-2: #272c34; + --ma-surface-3: #2d333c; + --ma-surface-overlay: #343b45; +} + +body.theme-dark, +body.theme-light, +body.theme-blue { + --color-primary-50: 239 246 255; + --color-primary-100: 219 234 254; + --color-primary-200: 191 219 254; + --color-primary-300: 147 197 253; + --color-primary-400: 96 165 250; + --color-primary-500: 59 130 246; + --color-primary-600: 37 99 235; + --color-primary-700: 29 78 216; + --color-primary-800: 30 64 175; + --color-primary-900: 30 58 138; + --color-primary-950: 23 37 84; +} + +body.theme-green { + --color-primary-50: 236 253 245; + --color-primary-100: 209 250 229; + --color-primary-200: 167 243 208; + --color-primary-300: 110 231 183; + --color-primary-400: 52 211 153; + --color-primary-500: 16 185 129; + --color-primary-600: 5 150 105; + --color-primary-700: 4 120 87; + --color-primary-800: 6 95 70; + --color-primary-900: 6 78 59; + --color-primary-950: 2 44 34; +} + +body.theme-red { + --color-primary-50: 254 242 242; + --color-primary-100: 254 226 226; + --color-primary-200: 254 202 202; + --color-primary-300: 252 165 165; + --color-primary-400: 248 113 113; + --color-primary-500: 239 68 68; + --color-primary-600: 220 38 38; + --color-primary-700: 185 28 28; + --color-primary-800: 153 27 27; + --color-primary-900: 127 29 29; + --color-primary-950: 69 10 10; +} + +body.theme-blue, +body.theme-green, +body.theme-red, +body.theme-dark, +body.theme-light { + --bs-primary: rgb(var(--color-primary-600)); + --bs-primary-rgb: var(--color-primary-600); + --bs-link-color-rgb: var(--color-primary-600); + --bs-link-hover-color-rgb: var(--color-primary-700); } body { font-family: "Inter", system-ui, sans-serif; + overflow: hidden; +} + +html, +body { + height: 100%; +} + +body[data-bs-theme="dark"] { + --ma-border: rgba(255, 255, 255, 0.06); + --ma-surface-sidebar: #191b20; + --ma-surface-base: #21252b; + --ma-surface-1: #242930; + --ma-surface-2: #282e36; + --ma-surface-3: #2c333c; + --ma-surface-overlay: #313944; background: - radial-gradient(circle at top left, rgba(var(--color-primary-100), 0.55), transparent 28%), - linear-gradient(180deg, #f8fbff 0%, #f4f7fb 55%, #eef3f8 100%); + radial-gradient(circle at top left, rgba(var(--color-primary-700), 0.28), transparent 28%), + linear-gradient(180deg, #171a1f 0%, #1b1f26 55%, #20252c 100%); + color: #e5eefb; +} + +body[data-bs-theme="light"] { + --ma-border: rgba(15, 23, 42, 0.08); + --ma-surface-sidebar: #e2e5ea; + --ma-surface-base: #eceff3; + --ma-surface-1: #e9ecf0; + --ma-surface-2: #e6e9ee; + --ma-surface-3: #e3e7ec; + --ma-surface-overlay: #dde2e8; + background: + radial-gradient(circle at top left, rgba(var(--color-primary-200), 0.55), transparent 28%), + linear-gradient(180deg, #f7f9fc 0%, #f0f3f7 55%, #eaedf2 100%); color: #0f172a; } @@ -30,7 +121,6 @@ a { } .auth-card, -.sidebar, .node-chip, .sidebar-status, .hero-stat, @@ -41,11 +131,21 @@ a { } .auth-card { - background: rgba(255, 255, 255, 0.92); + background: color-mix(in srgb, var(--ma-surface-overlay) 92%, transparent); backdrop-filter: blur(14px); } +body[data-bs-theme="light"] .auth-card { + background: color-mix(in srgb, white 58%, var(--ma-surface-base)); +} + .auth-brand { + background: + radial-gradient(circle at top, rgba(var(--color-primary-600), 0.32), transparent 35%), + linear-gradient(155deg, #111827 0%, #0f172a 70%, #172554 100%); +} + +body[data-bs-theme="light"] .auth-brand { background: radial-gradient(circle at top, rgba(var(--color-primary-200), 0.55), transparent 35%), linear-gradient(155deg, #ffffff 0%, #eef5ff 70%, #e4ecff 100%); @@ -57,11 +157,16 @@ a { align-items: center; padding: 0.85rem 1rem; border-radius: 1rem; - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(var(--color-primary-200), 0.7); + background: color-mix(in srgb, var(--ma-surface-2) 88%, transparent); + border: 1px solid rgba(var(--color-primary-500), 0.35); font-weight: 500; } +body[data-bs-theme="light"] .auth-feature { + background: color-mix(in srgb, white 55%, var(--ma-surface-1)); + border: 1px solid rgba(var(--color-primary-200), 0.7); +} + .brand-mark, .brand-mark-lg, .stat-icon, @@ -94,52 +199,196 @@ a { font-size: 1.4rem; } -.sidebar { - width: 300px; - min-height: 100vh; - background: rgba(255, 255, 255, 0.82); +.content { + min-width: 0; + min-height: 0; + background: color-mix(in srgb, var(--ma-surface-base) 96%, transparent); + overflow: hidden; +} + +.app-header, +.app-footer { + background: color-mix(in srgb, var(--ma-surface-2) 94%, transparent); backdrop-filter: blur(14px); } -.sidebar-nav .list-group-item { - border: 0; - border-radius: 0.95rem !important; - margin-bottom: 0.35rem; - color: #475569; - font-weight: 600; - background: transparent; +body[data-bs-theme="light"] .app-header, +body[data-bs-theme="light"] .app-footer { + background: color-mix(in srgb, white 42%, var(--ma-surface-2)); } -.sidebar-nav .list-group-item:hover { - background: rgba(var(--color-primary-100), 0.8); - color: #0f172a; +.app-sidebar { + width: 280px; + background: color-mix(in srgb, var(--ma-surface-sidebar) 97%, transparent); + backdrop-filter: blur(14px); + overflow: auto; } -.sidebar-nav .list-group-item.active { - background: linear-gradient(135deg, rgba(var(--color-primary-500), 0.12), rgba(var(--color-primary-700), 0.18)); - color: rgb(var(--color-primary-800)); +body[data-bs-theme="light"] .app-sidebar { + background: color-mix(in srgb, white 22%, var(--ma-surface-sidebar)); } -.sidebar-status { - background: rgba(var(--color-primary-50), 0.9); +.app-sidebar-nav .btn { + justify-content: flex-start; + align-items: center; + gap: 0.4rem; } -.content { +.sidebar-logo { + width: 3rem; + height: 3rem; + object-fit: cover; + border-radius: 0.95rem; + box-shadow: 0 0.75rem 2rem rgba(var(--color-primary-700), 0.18); +} + +.min-w-0 { min-width: 0; } +.app-shell { + height: 100vh; + overflow: hidden; +} + +.container-fluid { + min-height: 0; +} + +.content > .container-fluid { + height: 100%; + overflow: auto; +} + .node-chip { transition: transform 0.18s ease, box-shadow 0.18s ease; } .node-chip:hover { transform: translateY(-3px); - box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.08); + box-shadow: 0 1rem 2rem rgba(2, 6, 23, 0.35); +} + +.dashboard-loader { + min-height: 220px; +} + +.dashboard-spinner { + display: grid; + place-items: center; + min-height: 160px; +} + +.kpi-card { + overflow: hidden; + position: relative; +} + +.kpi-graph { + position: absolute; + inset: 0; + opacity: 0.55; + pointer-events: none; +} + +.kpi-graph svg { + width: 100%; + height: 100%; +} + +.node-tile { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + min-height: 118px; +} + +.node-tile-icon { + display: flex; + align-items: center; + justify-content: center; + padding: 0.65rem; + border-right: 1px solid var(--ma-border); +} + +.node-tile-main { + padding: 0.7rem 0.8rem 0.7rem 0.75rem; + min-width: 0; + position: relative; +} + +.node-tile-metrics { + display: grid; + gap: 0.45rem; +} + +.node-metric { + display: grid; + grid-template-columns: 2rem minmax(0, 1fr) 2.4rem; + align-items: center; + gap: 0.45rem; + width: 100%; +} + +.node-metric .progress { + --bs-progress-height: 0.35rem; + margin: 0; +} + +.node-tile .chip-icon { + width: 2.35rem; + height: 2.35rem; + font-size: 1rem; +} + +.node-tile-main .badge { + font-size: 0.65rem; + padding: 0.35rem 0.45rem; +} + +.node-tile-main h3 { + font-size: 0.95rem; +} + +.node-tile-main .small { + font-size: 0.74rem; +} + +.node-status { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + display: inline-block; + position: absolute; + top: 0.75rem; + right: 0.8rem; +} + +.node-status.is-online { + background: #22c55e; + box-shadow: 0 0 0 0.2rem rgba(34, 197, 94, 0.15); +} + +.node-status.is-offline { + background: #64748b; + box-shadow: 0 0 0 0.2rem rgba(100, 116, 139, 0.12); +} + +.node-metric-label, +.node-metric-value { + font-size: 0.72rem; + font-weight: 600; + line-height: 1; +} + +.node-metric-value { + text-align: right; + font-variant-numeric: tabular-nums; + justify-self: end; } .progress { --bs-progress-height: 0.6rem; - background: #e7eef8; + background: color-mix(in srgb, black 18%, var(--ma-surface-1)); } .card, @@ -155,6 +404,121 @@ a { border-color: var(--ma-border); } +.card, +.list-group-item, +.table, +.modal-content, +.dropdown-menu, +.sidebar-status, +.theme-card, +.add-vm-panel, +.auth-toggle-option, +.option-check { + background: var(--ma-surface-1); +} + +.stat-card, +.node-chip, +.dashboard-loader > .card, +.preview-card.dim { + background: var(--ma-surface-2); +} + +.modal-content { + background: var(--ma-surface-overlay); +} + +body[data-bs-theme="light"] .card, +body[data-bs-theme="light"] .list-group-item, +body[data-bs-theme="light"] .table, +body[data-bs-theme="light"] .modal-content, +body[data-bs-theme="light"] .dropdown-menu, +body[data-bs-theme="light"] .sidebar-status, +body[data-bs-theme="light"] .theme-card, +body[data-bs-theme="light"] .add-vm-panel, +body[data-bs-theme="light"] .auth-toggle-option, +body[data-bs-theme="light"] .option-check { + background: var(--ma-surface-1); +} + +.add-vm-form { + display: grid; + gap: 1rem; +} + +.add-vm-panel { + border: 1px solid var(--ma-border); + border-radius: 1.1rem; + padding: 1rem; +} + +body[data-bs-theme="light"] .add-vm-panel { + background: var(--ma-surface-1); +} + +.add-vm-panel-title { + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-secondary-color); + margin-bottom: 0.9rem; +} + +.auth-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.auth-toggle-option { + display: grid; + gap: 0.2rem; + padding: 0.85rem 0.95rem; + border: 1px solid var(--ma-border); + border-radius: 0.95rem; + cursor: pointer; + transition: border-color 0.18s ease, background-color 0.18s ease, transform 0.18s ease; +} + +body[data-bs-theme="light"] .auth-toggle-option { + background: var(--ma-surface-1); +} + +.auth-toggle-label { + font-weight: 600; + color: var(--bs-emphasis-color); +} + +.auth-toggle-text { + font-size: 0.78rem; + color: var(--bs-secondary-color); +} + +.btn-check:checked + .auth-toggle-option { + border-color: rgba(var(--color-primary-500), 0.7); + background: rgba(var(--color-primary-500), 0.12); + transform: translateY(-1px); +} + +.auth-mode-panel { + min-height: 74px; +} + +.option-check { + display: flex; + align-items: center; + gap: 0.7rem; + min-height: calc(1.5em + 0.75rem + 2px); + padding: 0.7rem 0.85rem; + border: 1px solid var(--ma-border); + border-radius: 0.95rem; +} + +body[data-bs-theme="light"] .option-check { + background: var(--ma-surface-1); +} + .run-pre, .code-block, .console-output { @@ -166,7 +530,7 @@ a { .run-pre, .code-block { - background: #0f172a; + background: #13161b; color: #dbeafe; padding: 1rem; border-radius: 1rem; @@ -176,15 +540,42 @@ a { min-height: 420px; max-height: 60vh; overflow: auto; - background: #0b1220; + background: #151920; color: #dbeafe; - padding: 1rem; + padding: 0; +} + +.console-shell { + position: relative; +} + +.console-terminal { + min-height: 420px; + height: 60vh; + overflow: hidden; +} + +.console-panel { + min-height: calc(100vh - 12rem); +} + +.console-panel .console-terminal { + height: calc(100vh - 12rem); +} + +.console-terminal .xterm { + height: 100%; + padding: 0.9rem 1rem; +} + +.console-terminal .xterm-viewport { + border-radius: 1.25rem; } .preview-card { min-height: 120px; - border: 1px solid rgba(var(--color-primary-200), 0.9); - background: linear-gradient(135deg, rgba(var(--color-primary-100), 0.9), rgba(var(--color-primary-300), 0.9)); + border: 1px solid rgba(var(--color-primary-700), 0.55); + background: linear-gradient(135deg, rgba(var(--color-primary-900), 0.95), rgba(var(--color-primary-700), 0.8)); } .preview-card.accent { @@ -192,6 +583,15 @@ a { } .preview-card.dim { + background: linear-gradient(135deg, #0f172a, #1e293b); +} + +body[data-bs-theme="light"] .preview-card { + background: linear-gradient(135deg, rgba(var(--color-primary-100), 0.9), rgba(var(--color-primary-300), 0.9)); + border: 1px solid rgba(var(--color-primary-200), 0.9); +} + +body[data-bs-theme="light"] .preview-card.dim { background: linear-gradient(135deg, #ffffff, #e2e8f0); } @@ -200,9 +600,69 @@ a { height: auto; } +.theme-card { + padding: 1rem; + border: 1px solid var(--ma-border); + border-radius: 1rem; + cursor: pointer; +} + +body[data-bs-theme="light"] .theme-card { + background: var(--ma-surface-1); +} + +.theme-card strong, +.theme-card small { + display: block; +} + +.theme-card small { + color: var(--bs-secondary-color); +} + +.theme-swatch { + display: block; + height: 88px; + border-radius: 0.85rem; + margin-bottom: 0.85rem; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.swatch-dark { + background: linear-gradient(135deg, #0f172a, #1e293b); +} + +.swatch-light { + background: linear-gradient(135deg, #ffffff, #e2e8f0); +} + +.swatch-green { + background: linear-gradient(135deg, rgb(16 185 129), rgb(4 120 87)); +} + +.swatch-red { + background: linear-gradient(135deg, rgb(239 68 68), rgb(185 28 28)); +} + +.swatch-blue { + background: linear-gradient(135deg, rgb(59 130 246), rgb(29 78 216)); +} + +.btn-check:checked + .theme-card { + border-color: rgb(var(--color-primary-500)); + box-shadow: 0 0 0 0.2rem rgba(var(--color-primary-500), 0.2); +} + @media (max-width: 991.98px) { - .sidebar { + .app-shell { + flex-direction: column; + } + + .app-sidebar { width: 100%; - min-height: auto; + } + + .auth-toggle { + grid-template-columns: 1fr; } } diff --git a/web/static/img/maintainarr_banner.png b/web/static/img/maintainarr_banner.png new file mode 100644 index 0000000..35742c7 Binary files /dev/null and b/web/static/img/maintainarr_banner.png differ diff --git a/web/static/img/maintainarr_logo.png b/web/static/img/maintainarr_logo.png new file mode 100644 index 0000000..dbc7717 Binary files /dev/null and b/web/static/img/maintainarr_logo.png differ diff --git a/web/static/js/app.js b/web/static/js/app.js index 5c4e60b..06ac4be 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -1,34 +1,282 @@ document.addEventListener("DOMContentLoaded", () => { - const form = document.getElementById("console-form"); const output = document.getElementById("console"); - const input = document.getElementById("console-input"); + const triggerType = document.getElementById("trigger_type"); + const scheduleKind = document.getElementById("schedule_kind"); + const addCommandButton = document.getElementById("add-command-step"); + const commandSteps = document.getElementById("command-steps"); + const nodeLive = document.querySelector(".node-live"); + const dashboardNodes = document.getElementById("dashboard-nodes"); + const authToggle = document.querySelector("[data-auth-toggle]"); - if (!form || !output || !input) { - return; - } - - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const socket = new WebSocket(`${protocol}//${window.location.host}${form.dataset.ws}`); - - socket.addEventListener("message", (event) => { - output.textContent += event.data; - output.scrollTop = output.scrollHeight; - }); - - socket.addEventListener("open", () => { - output.textContent += "Connected to remote shell.\n"; - }); - - socket.addEventListener("close", () => { - output.textContent += "\nSession closed.\n"; - }); - - form.addEventListener("submit", (event) => { - event.preventDefault(); - if (socket.readyState !== WebSocket.OPEN) { + const attachXtermConsole = (consoleOutput) => { + const wsPath = consoleOutput.dataset.ws; + if (!wsPath || typeof Terminal === "undefined") { return; } - socket.send(`${input.value}\n`); - input.value = ""; + + const term = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: '"Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace', + fontSize: 14, + lineHeight: 1.25, + theme: { + background: "#0b1220", + foreground: "#dbeafe", + cursor: "#93c5fd", + cursorAccent: "#0b1220", + selectionBackground: "rgba(147, 197, 253, 0.28)", + }, + }); + const fitAddon = typeof FitAddon !== "undefined" ? new FitAddon.FitAddon() : null; + if (fitAddon) { + term.loadAddon(fitAddon); + } + term.open(consoleOutput); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${window.location.host}${wsPath}`); + const fit = () => { + fitAddon?.fit(); + term.focus(); + }; + + window.setTimeout(fit, 0); + window.addEventListener("resize", fit); + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(fit); + observer.observe(consoleOutput); + } + + socket.addEventListener("message", (event) => { + term.write(event.data); + }); + + socket.addEventListener("open", () => { + fit(); + }); + + socket.addEventListener("close", () => { + term.write("\r\n[session closed]\r\n"); + }); + + socket.addEventListener("error", () => { + term.write("\r\n[connection error]\r\n"); + }); + + term.onData((value) => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(value); + } + }); + }; + + if (output?.dataset.xterm === "true") { + attachXtermConsole(output); + } + + const syncScheduleUi = () => { + if (!triggerType || !scheduleKind) { + return; + } + + const schedulePanel = document.querySelector(".schedule-panel"); + const weeklyRows = document.querySelectorAll(".weekly-only"); + const intervalRows = document.querySelectorAll(".interval-only"); + const showSchedule = triggerType.value !== "triggered"; + const showWeekly = showSchedule && scheduleKind.value === "weekly"; + const showInterval = showSchedule && scheduleKind.value === "interval"; + + if (schedulePanel) { + schedulePanel.classList.toggle("opacity-50", !showSchedule); + } + weeklyRows.forEach((row) => row.classList.toggle("d-none", !showWeekly)); + intervalRows.forEach((row) => row.classList.toggle("d-none", !showInterval)); + }; + + triggerType?.addEventListener("change", syncScheduleUi); + scheduleKind?.addEventListener("change", syncScheduleUi); + syncScheduleUi(); + + addCommandButton?.addEventListener("click", () => { + if (!commandSteps) { + return; + } + + const nextIndex = commandSteps.querySelectorAll(".command-step").length + 1; + const wrapper = document.createElement("div"); + wrapper.className = "input-group command-step"; + wrapper.innerHTML = ` + ${nextIndex} + + + `; + commandSteps.appendChild(wrapper); }); + + commandSteps?.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement) || !target.classList.contains("remove-command-step")) { + return; + } + + const steps = commandSteps.querySelectorAll(".command-step"); + if (steps.length <= 1) { + return; + } + + target.closest(".command-step")?.remove(); + commandSteps.querySelectorAll(".command-step").forEach((step, index) => { + const badge = step.querySelector(".input-group-text"); + if (badge) { + badge.textContent = String(index + 1); + } + }); + }); + + if (nodeLive instanceof HTMLElement) { + const nodeId = nodeLive.dataset.nodeId; + const history = { + cpu: [], + ram: [], + disk: [], + uptime: [], + }; + + const uptimeLabel = (seconds) => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + return `${hours}h ${minutes}m`; + }; + + const renderSparkline = (values) => { + if (values.length === 0) { + return ""; + } + const max = Math.max(...values, 1); + const points = values.map((value, index) => { + const x = (index / Math.max(values.length - 1, 1)) * 100; + const y = 100 - (value / max) * 80 - 10; + return `${x},${y}`; + }).join(" "); + + return ` + + + + `; + }; + + const pushValue = (key, value) => { + history[key].push(value); + if (history[key].length > 18) { + history[key].shift(); + } + const card = nodeLive.querySelector(`.kpi-card[data-kpi="${key}"] .kpi-graph`); + if (card) { + card.innerHTML = renderSparkline(history[key]); + } + }; + + const updateNodeStats = async () => { + if (!nodeId) { + return; + } + try { + const response = await fetch(`/nodes/${nodeId}/stats`, { headers: { Accept: "application/json" } }); + if (!response.ok) { + return; + } + const stats = await response.json(); + const cpu = Number(stats.cpu_usage || 0); + const ram = Number(stats.ram_usage || 0); + const disk = Number(stats.disk_usage || 0); + const uptime = Number(stats.uptime_seconds || 0); + + const cpuValue = nodeLive.querySelector('[data-kpi-value="cpu"]'); + const ramValue = nodeLive.querySelector('[data-kpi-value="ram"]'); + const diskValue = nodeLive.querySelector('[data-kpi-value="disk"]'); + const uptimeValue = nodeLive.querySelector('[data-kpi-value="uptime"]'); + + if (cpuValue) cpuValue.textContent = `${cpu.toFixed(1)}%`; + if (ramValue) ramValue.textContent = `${ram.toFixed(1)}%`; + if (diskValue) diskValue.textContent = `${disk.toFixed(1)}%`; + if (uptimeValue) uptimeValue.textContent = uptimeLabel(uptime); + + pushValue("cpu", cpu); + pushValue("ram", ram); + pushValue("disk", disk); + pushValue("uptime", Math.min(uptime / 3600, 100)); + } catch (_) { + // Ignore transient poll failures. + } + }; + + updateNodeStats(); + window.setInterval(updateNodeStats, 5000); + } + + if (dashboardNodes instanceof HTMLElement) { + const url = dashboardNodes.dataset.dashboardNodesUrl; + if (url) { + fetch(url, { headers: { Accept: "text/html" } }) + .then((response) => { + if (!response.ok) { + throw new Error("failed"); + } + return response.text(); + }) + .then((html) => { + dashboardNodes.innerHTML = html; + dashboardNodes.classList.add("is-loaded"); + }) + .catch(() => { + dashboardNodes.innerHTML = ` +
+
+ Failed to load nodes. +
+
+ `; + }); + } + } + + if (authToggle instanceof HTMLElement) { + const passwordRadio = document.getElementById("authModePassword"); + const keyRadio = document.getElementById("authModeKey"); + const passwordPanel = document.querySelector('[data-auth-panel="password"]'); + const keyPanel = document.querySelector('[data-auth-panel="key"]'); + const passwordInput = document.querySelector('[data-auth-input="password"]'); + const keyInput = document.querySelector('[data-auth-input="key"]'); + + const syncAuthMode = () => { + const passwordMode = passwordRadio instanceof HTMLInputElement && passwordRadio.checked; + + passwordPanel?.classList.toggle("d-none", !passwordMode); + keyPanel?.classList.toggle("d-none", passwordMode); + + if (passwordInput instanceof HTMLInputElement) { + passwordInput.disabled = !passwordMode; + passwordInput.required = passwordMode; + if (!passwordMode) { + passwordInput.value = ""; + } + } + + if (keyInput instanceof HTMLInputElement) { + keyInput.disabled = passwordMode; + keyInput.required = !passwordMode; + if (passwordMode) { + keyInput.value = ""; + } + } + }; + + passwordRadio?.addEventListener("change", syncAuthMode); + keyRadio?.addEventListener("change", syncAuthMode); + syncAuthMode(); + } });