/* ─── Constants ──────────────────────────────────────────────────── */ const WEBHOOK_URL = "https://n8n.derez.ai/webhook/4ad0c89f-ab8c-45ee-bad1-9321ce94dd64"; const INSTANCES_URL = "https://n8n.derez.ai/webhook/bb369f27-244c-4f8a-869b-f787050619a2"; const BACKUP_URL = "https://n8n.derez.ai/webhook/69c0c632-df3b-457f-affe-b725f217f9a2"; const SERVERS_URL = "https://n8n.derez.ai/webhook/78c0c2b8-e497-4d44-8f26-fec34217513c"; const KEYS_URL = "https://n8n.derez.ai/webhook/a001374f-d8c0-4430-9b47-9f1e9ab134c3"; const AUTH_URL = "https://n8n.derez.ai/webhook/e256310a-6627-45ba-a221-599751943fe6"; /* ─── State ──────────────────────────────────────────────────────── */ let dnsRecords = []; let instancesRecords = []; let instancesEditTarget = null; let backupRecords = []; let serversData = []; let currentServerIdx = 0; let keysData = []; let currentSession = null; let currentEmail = null; /* ─── Cookie helpers ─────────────────────────────────────────────── */ function setCookie(name, value, days) { const expires = new Date(Date.now() + days * 864e5).toUTCString(); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Strict`; } function getCookie(name) { return document.cookie.split("; ").reduce((acc, c) => { const [k, ...rest] = c.split("="); return k === name ? decodeURIComponent(rest.join("=")) : acc; }, null); } function deleteCookie(name) { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Strict`; } /* ─── Authenticated fetch wrapper ────────────────────────────────── */ async function apiFetch(url, options = {}) { const headers = { ...(options.headers || {}), ...(currentSession ? { "X-Session-Id": currentSession } : {}), }; const res = await fetch(url, { ...options, headers }); if (res.status === 401) { deleteCookie("al_session"); deleteCookie("al_email"); localStorage.removeItem("al_email"); location.reload(); } return res; } /* ─── API helpers ────────────────────────────────────────────────── */ function getApiUrl() { return localStorage.getItem("al_url") || WEBHOOK_URL; } function apiHeaders() { return { "Content-Type": "application/json" }; } /* ─── Show / hide app shell ──────────────────────────────────────── */ function showAuth(message) { const el = document.getElementById("login-error"); if (message) { el.textContent = message; el.style.display = "block"; } else { el.style.display = "none"; } document.getElementById("login-backdrop").classList.remove("hidden"); document.getElementById("login-email").value = ""; document.getElementById("login-password").value = ""; setTimeout(() => document.getElementById("login-email").focus(), 80); } function showApp() { document.getElementById("login-backdrop").classList.add("hidden"); updateSidebarUser(); } function loadData() { dnsLoadRecords(); } /* ─── Email validation ───────────────────────────────────────────── */ function isValidEmail(s) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); } /* ─── Sign In ────────────────────────────────────────────────────── */ async function doSignIn() { const email = document.getElementById("login-email").value.trim(); const password = document.getElementById("login-password").value; if (!isValidEmail(email)) { showAuth("Please enter a valid email address."); return; } if (!password) { showAuth("Password is required."); return; } const btn = document.getElementById("login-btn"); btn.disabled = true; btn.innerHTML = `
Signing in…`; try { const res = await fetch(AUTH_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (!res.ok) throw new Error("Account not found or incorrect password."); const data = await res.json(); const raw = Array.isArray(data) ? data[0] : data; const item = raw && "json" in raw ? raw.json : raw; const sessionId = item?.sessionid; if (!sessionId) throw new Error("Authentication failed. Please try again."); currentSession = sessionId; currentEmail = email; setCookie("al_session", sessionId, 30); setCookie("al_email", email, 30); showApp(); loadData(); } catch (err) { showAuth(err.message); } finally { btn.disabled = false; btn.innerHTML = "Sign In"; } } /* ─── Sign Out ───────────────────────────────────────────────────── */ function doSignOut() { if (currentSession) { fetch(AUTH_URL, { method: "DELETE", headers: { "Content-Type": "application/json", "X-Session-Id": currentSession, }, body: JSON.stringify({ sessionid: currentSession }), }).catch(() => {}); } deleteCookie("al_session"); deleteCookie("al_email"); localStorage.removeItem("al_email"); currentSession = null; currentEmail = null; showAuth(); toast("Signed out", "info"); } function updateSidebarUser() { document.getElementById("sidebar-username").textContent = currentEmail || "Not signed in"; const settingsEl = document.getElementById("settings-session-email"); if (settingsEl) settingsEl.textContent = currentEmail || "—"; } /* ─── Navigation ─────────────────────────────────────────────────── */ document.querySelectorAll(".nav-item[data-section]").forEach((el) => { el.addEventListener("click", () => { const key = el.dataset.section; document .querySelectorAll(".nav-item") .forEach((n) => n.classList.remove("active")); el.classList.add("active"); document .querySelectorAll(".section") .forEach((s) => s.classList.remove("active")); document.getElementById("section-" + key)?.classList.add("active"); const labels = { dns: "DNS Records", instances: "Instances", backup: "Backup", servers: "Servers", settings: "Settings", "coming-soon-firewall": "Firewall", "coming-soon-ssl": "SSL / TLS", keys: "Keys", }; document.getElementById("page-title").textContent = labels[key] || key; // Show refresh button for sections that have live data const topbar = document.getElementById("topbar-actions"); const btnRefresh = document.getElementById("btn-refresh"); if (key === "dns") { topbar.style.display = ""; btnRefresh.onclick = dnsLoadRecords; } else if (key === "instances") { topbar.style.display = ""; btnRefresh.onclick = instancesLoadRecords; } else if (key === "backup") { topbar.style.display = ""; btnRefresh.onclick = backupLoadRecords; } else if (key === "servers") { topbar.style.display = ""; btnRefresh.onclick = serversLoadData; } else if (key === "keys") { topbar.style.display = ""; btnRefresh.onclick = keysLoadRecords; } else { topbar.style.display = "none"; } // Load data when switching to a section for the first time if (key === "instances" && instancesRecords.length === 0) { instancesLoadRecords(); } if (key === "backup" && backupRecords.length === 0) { backupLoadRecords(); } if (key === "servers" && serversData.length === 0) { serversLoadData(); } if (key === "keys" && keysData.length === 0) { keysLoadRecords(); } }); }); /* ─── Toast ──────────────────────────────────────────────────────── */ function toast(msg, type = "info", duration = 3500) { const el = document.createElement("div"); el.className = `toast toast-${type}`; el.textContent = msg; document.getElementById("toast-container").appendChild(el); setTimeout(() => el.remove(), duration); } /* ─── Confirm dialog ─────────────────────────────────────────────── */ let confirmResolve = null; function confirmDialog(title, message, okLabel = "Delete") { document.getElementById("confirm-title").textContent = title; document.getElementById("confirm-message").textContent = message; document.getElementById("confirm-ok-btn").textContent = okLabel; document.getElementById("confirm-backdrop").classList.add("open"); return new Promise((res) => { confirmResolve = res; }); } function confirmClose(result = false) { document.getElementById("confirm-backdrop").classList.remove("open"); if (confirmResolve) { confirmResolve(result); confirmResolve = null; } } document .getElementById("confirm-ok-btn") .addEventListener("click", () => confirmClose(true)); document.getElementById("confirm-backdrop").addEventListener("click", (e) => { if (e.target === e.currentTarget) confirmClose(false); }); /* ─── DNS — Load ─────────────────────────────────────────────────── */ async function dnsLoadRecords() { const tbody = document.getElementById("dns-table-body"); tbody.innerHTML = `

Loading…

`; try { const res = await apiFetch(getApiUrl(), { headers: apiHeaders(), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); const json = await res.json(); // Support both array-wrapped and direct response shapes const data = Array.isArray(json) ? (json[0]?.response ?? json) : (json?.response ?? []); dnsRecords = Array.isArray(data) ? data : []; dnsRender(); toast( `Loaded ${dnsRecords.length} record${dnsRecords.length !== 1 ? "s" : ""}`, "success", ); } catch (err) { dnsRecords = []; tbody.innerHTML = `

${err.message}

`; toast("Failed to load DNS records: " + err.message, "error"); } } /* ─── DNS — Render table ─────────────────────────────────────────── */ function dnsRender() { const search = document.getElementById("dns-search").value.toLowerCase(); const typeFilter = document .getElementById("dns-filter-type") .value.toUpperCase(); const HIDDEN_NAMES = ["fr", "n8n", "admin", "app", "@"]; const filtered = dnsRecords.filter((r) => { const name = r.name.toLowerCase(); if (HIDDEN_NAMES.includes(name)) return false; const matchType = !typeFilter || r.type === typeFilter; const content = (r.records || []) .map((x) => x.content) .join(" ") .toLowerCase(); const matchSearch = !search || name.includes(search) || content.includes(search) || r.type.toLowerCase().includes(search); return matchType && matchSearch; }); document.getElementById("dns-record-count").textContent = filtered.length === dnsRecords.length ? `${dnsRecords.length} record${dnsRecords.length !== 1 ? "s" : ""}` : `${filtered.length} / ${dnsRecords.length} records`; const tbody = document.getElementById("dns-table-body"); if (filtered.length === 0) { tbody.innerHTML = `

No records found

`; return; } tbody.innerHTML = filtered .map((r) => { const records = r.records || []; const content = records .map((x) => `${escHtml(x.content)}`) .join("
"); const disabled = records.some((x) => x.is_disabled); const statusHtml = disabled ? `Disabled` : `Enabled`; return ` ${escHtml(r.name)} ${escHtml(r.type)} ${content} ${formatTTL(r.ttl)} ${statusHtml} `; }) .join(""); } /* ─── DNS — Delete ─────────────────────────────────────────────────── */ async function dnsDeleteRecord(btn) { const record = { name: btn.dataset.name, type: btn.dataset.type, }; const ok = await confirmDialog( "Delete DNS Record", `Delete the ${record.type} record "${record.name}"? This cannot be undone.`, ); if (!ok) return; try { const res = await apiFetch(getApiUrl(), { method: "DELETE", headers: apiHeaders(), body: JSON.stringify({ name: record.name, type: record.type, }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); toast(`Record "${record.name}" (${record.type}) deleted`, "success"); await dnsLoadRecords(); } catch (err) { toast("Delete failed: " + err.message, "error"); } } /* ─── DNS — Add record (inline form) ────────────────────────────── */ async function dnsAddRecord() { const name = document.getElementById("add-name").value.trim(); const type = document.getElementById("add-type").value; const content = document.getElementById("add-content").value.trim(); const ttl = parseInt(document.getElementById("add-ttl").value, 10) || 3600; if (!name) { toast("Name is required", "warning"); return; } if (!content) { toast("Content is required", "warning"); return; } const btn = document.getElementById("add-submit-btn"); btn.disabled = true; const orig = btn.innerHTML; btn.innerHTML = `
Adding…`; try { const res = await apiFetch(getApiUrl(), { method: "POST", headers: apiHeaders(), body: JSON.stringify({ action: "create", name, type, content, ttl, is_disabled: false, }), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); toast(`Record "${name}" (${type}) added`, "success"); document.getElementById("add-name").value = ""; document.getElementById("add-content").value = ""; document.getElementById("add-ttl").value = "3600"; await dnsLoadRecords(); } catch (err) { toast("Add failed: " + err.message, "error"); } finally { btn.disabled = false; btn.innerHTML = orig; } } /* ─── Settings ───────────────────────────────────────────────────── */ function settingsSave() { const url = document.getElementById("setting-url").value.trim(); if (url) { localStorage.setItem("al_url", url); toast("Webhook URL saved", "success"); } else toast("Enter a URL to save", "warning"); } async function settingsTest() { try { const res = await apiFetch(getApiUrl(), { headers: apiHeaders(), }); if (res.ok) toast("Connection OK — HTTP " + res.status, "success"); else toast("Reachable but HTTP " + res.status, "warning"); } catch (err) { toast("Connection failed: " + err.message, "error"); } } function settingsLoad() { const url = localStorage.getItem("al_url"); if (url) document.getElementById("setting-url").value = url; } /* ─── Helpers ────────────────────────────────────────────────────── */ function escHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function escAttr(s) { return `'${String(s).replace(/'/g, "'")}'`; } function formatTTL(s) { if (!s && s !== 0) return "—"; if (s < 60) return `${s}s`; if (s < 3600) return `${Math.round(s / 60)}m`; if (s < 86400) return `${Math.round(s / 3600)}h`; return `${Math.round(s / 86400)}d`; } /* ─── Add form: Enter key on content submits ─────────────────────── */ document.getElementById("add-content").addEventListener("keydown", (e) => { if (e.key === "Enter") dnsAddRecord(); }); document.getElementById("add-name").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("add-content").focus(); }); /* ─── Login: submit on Enter ─────────────────────────────────────── */ document.getElementById("login-password").addEventListener("keydown", (e) => { if (e.key === "Enter") doSignIn(); }); document.getElementById("login-email").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("login-password").focus(); }); /* ─── Instances — Helpers ────────────────────────────────────── */ // The API stores some values wrapped in literal quotes, e.g. '"Hermes"'. // Strip them for clean display and editing. function stripQuotes(s) { if (typeof s !== "string") return String(s ?? ""); return s.replace(/^"|"$/g, "").trim(); } /* ─── Instances — Load ───────────────────────────────────────── */ async function instancesLoadRecords() { const tbody = document.getElementById("inst-table-body"); tbody.innerHTML = `

Loading…

`; try { const res = await apiFetch(INSTANCES_URL, { headers: apiHeaders() }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); const json = await res.json(); // Normalise: [{data:[...]}, ...], plain array, {data:[...]}, or single object const data = Array.isArray(json) ? (json[0]?.data ?? json) : (json?.instances ?? json?.data ?? (json?.uuid ? [json] : [])); instancesRecords = Array.isArray(data) ? data : []; instancesRender(); toast( `Loaded ${instancesRecords.length} instance${instancesRecords.length !== 1 ? "s" : ""}`, "success", ); } catch (err) { instancesRecords = []; tbody.innerHTML = `

${err.message}

`; toast("Failed to load instances: " + err.message, "error"); } } /* ─── Instances — Render table ────────────────────────────────── */ function instancesRender() { const search = document.getElementById("inst-search").value.toLowerCase(); const filtered = instancesRecords.filter((r) => { if (!search) return true; return ( stripQuotes(r.uuid).toLowerCase().includes(search) || stripQuotes(r.agent).toLowerCase().includes(search) || stripQuotes(r.domain).toLowerCase().includes(search) || stripQuotes(r.server ?? "") .toLowerCase() .includes(search) ); }); document.getElementById("inst-record-count").textContent = filtered.length === instancesRecords.length ? `${instancesRecords.length} instance${instancesRecords.length !== 1 ? "s" : ""}` : `${filtered.length} / ${instancesRecords.length} instances`; const tbody = document.getElementById("inst-table-body"); if (filtered.length === 0) { tbody.innerHTML = `

No instances found

`; return; } tbody.innerHTML = filtered .map((r) => { const uuid = escHtml(r.uuid); const agent = escHtml(stripQuotes(r.agent)); const domain = escHtml(stripQuotes(r.domain)); const sshPort = escHtml(stripQuotes(r.ssh_port)); const ram = escHtml(stripQuotes(r.ram)); const cpu = escHtml(stripQuotes(r.cpu)); const server = escHtml(stripQuotes(r.server ?? "")); const created = escHtml(stripQuotes(r.created ?? "")); return ` ${uuid} ${agent} ${domain} ${sshPort} ${ram} ${cpu} ${server} ${created} `; }) .join(""); } /* ─── Instances — Copy SSH ────────────────────────────────────── */ function instancesCopySSH(btn) { const domain = btn.dataset.domain; const port = btn.dataset.port; const cmd = `ssh root@${domain} -p ${port}`; navigator.clipboard .writeText(cmd) .then(() => { const orig = btn.innerHTML; btn.innerHTML = ` Copied!`; setTimeout(() => { btn.innerHTML = orig; }, 1800); toast(`Copied: ${cmd}`, "success"); }) .catch(() => { toast("Clipboard access denied", "error"); }); } /* ─── Instances — Delete ───────────────────────────────────────── */ async function instancesDelete(btn) { const uuid = btn.dataset.uuid; const agent = btn.dataset.agent; const ok = await confirmDialog( "Delete Instance", `Delete instance “${agent}” (${uuid})? This cannot be undone.`, ); if (!ok) return; try { const res = await apiFetch(INSTANCES_URL, { method: "DELETE", headers: apiHeaders(), body: JSON.stringify({ uuid }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); toast(`Instance “${agent}” deleted`, "success"); await instancesLoadRecords(); } catch (err) { toast("Delete failed: " + err.message, "error"); } } /* ─── Instances — Edit modal ──────────────────────────────────── */ function instancesOpenEdit(btn) { const uuid = btn.dataset.uuid; const record = instancesRecords.find((r) => r.uuid === uuid); if (!record) return; instancesEditTarget = record; document.getElementById("inst-edit-uuid").value = record.uuid; document.getElementById("inst-edit-agent").value = stripQuotes(record.agent); document.getElementById("inst-edit-domain").value = stripQuotes( record.domain, ); document.getElementById("inst-edit-ssh-port").value = stripQuotes( record.ssh_port, ); document.getElementById("inst-edit-ram").value = stripQuotes(record.ram); document.getElementById("inst-edit-cpu").value = stripQuotes(record.cpu); document.getElementById("inst-edit-backdrop").classList.add("open"); setTimeout(() => document.getElementById("inst-edit-ssh-port").focus(), 80); } function instancesCloseEdit() { document.getElementById("inst-edit-backdrop").classList.remove("open"); instancesEditTarget = null; } async function instancesSaveEdit() { if (!instancesEditTarget) return; const ssh_port = document.getElementById("inst-edit-ssh-port").value.trim(); const ram = document.getElementById("inst-edit-ram").value.trim(); const cpu = document.getElementById("inst-edit-cpu").value.trim(); if (!ssh_port || !ram || !cpu) { toast("SSH Port, RAM and CPU are required", "warning"); return; } const btn = document.getElementById("inst-save-btn"); btn.disabled = true; const orig = btn.innerHTML; btn.innerHTML = `
Saving…`; try { const res = await apiFetch(INSTANCES_URL, { method: "PUT", headers: apiHeaders(), body: JSON.stringify({ uuid: instancesEditTarget.uuid, ssh_port, ram, cpu, }), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); toast(`Instance “${instancesEditTarget.uuid}” updated`, "success"); instancesCloseEdit(); await instancesLoadRecords(); } catch (err) { toast("Save failed: " + err.message, "error"); } finally { btn.disabled = false; btn.innerHTML = orig; } } // Close edit modal on backdrop click document.getElementById("inst-edit-backdrop").addEventListener("click", (e) => { if (e.target === e.currentTarget) instancesCloseEdit(); }); /* ─── Backup — Load ────────────────────────────────────────── */ async function backupLoadRecords() { const tbody = document.getElementById("backup-table-body"); tbody.innerHTML = `

Loading…

`; try { const res = await apiFetch(BACKUP_URL, { headers: apiHeaders() }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); const json = await res.json(); // Normalise: // Shape: [{data:{repoList:[...]}}] const raw = Array.isArray(json) ? json[0] : json; backupRecords = raw?.data?.repoList ?? []; backupRender(); toast( `Loaded ${backupRecords.length} repositor${backupRecords.length !== 1 ? "ies" : "y"}`, "success", ); } catch (err) { backupRecords = []; tbody.innerHTML = `

${err.message}

`; toast("Failed to load backups: " + err.message, "error"); } } /* ─── Backup — Render table ────────────────────────────── */ const BACKUP_HIDDEN = ["4server", "notebook", "office"]; function backupRender() { const search = ( document.getElementById("backup-search")?.value ?? "" ).toLowerCase(); const filtered = backupRecords.filter((r) => { const name = (r.name ?? "").toLowerCase(); if (BACKUP_HIDDEN.some((h) => name.includes(h))) return false; if (!search) return true; return name.includes(search) || (r.id ?? "").toLowerCase().includes(search); }); document.getElementById("backup-record-count").textContent = filtered.length === backupRecords.length ? `${filtered.length} repositor${filtered.length !== 1 ? "ies" : "y"}` : `${filtered.length} / ${backupRecords.length} repositories`; const tbody = document.getElementById("backup-table-body"); if (filtered.length === 0) { tbody.innerHTML = `

No repositories found

`; return; } tbody.innerHTML = filtered .map((r) => { const region = escHtml(r.region ?? ""); const regionClass = ["eu", "us"].includes(region) ? `badge-${region}` : "badge-region"; return ` ${escHtml(r.name)} ${escHtml(r.id)} ${region.toUpperCase()} ${formatBytes(r.currentUsage)} ${formatDate(r.lastModified)} ${formatDate(r.createdAt)} `; }) .join(""); } /* ─── Backup — Delete ──────────────────────────────────────── */ async function backupDelete(btn) { const id = btn.dataset.id; const name = btn.dataset.name; const ok = await confirmDialog( "Delete Repository", `Delete backup repository “${name}” (${id})? This cannot be undone.`, ); if (!ok) return; try { const res = await apiFetch(BACKUP_URL, { method: "DELETE", headers: apiHeaders(), body: JSON.stringify({ id }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); toast(`Repository “${name}” deleted`, "success"); await backupLoadRecords(); } catch (err) { toast("Delete failed: " + err.message, "error"); } } /* ─── Backup — Helpers ─────────────────────────────────────── */ // currentUsage is in MB function formatBytes(mb) { if (mb == null) return "—"; if (mb < 1024) return `${mb.toFixed(1)} MB`; return `${(mb / 1024).toFixed(2)} GB`; } function formatDate(iso) { if (!iso) return "—"; const d = new Date(iso); return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", }); } /* ─── Servers — Load ───────────────────────────────────────── */ async function serversLoadData() { document.getElementById("server-tabs-strip").innerHTML = ""; document.getElementById("server-dashboard").innerHTML = `

Loading…

`; try { const res = await apiFetch(SERVERS_URL, { headers: apiHeaders() }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); const json = await res.json(); const raw = Array.isArray(json) ? json : [json]; serversData = raw .map((item) => { try { return typeof item.text === "string" ? JSON.parse(item.text) : item; } catch { return null; } }) .filter(Boolean); if (serversData.length === 0) throw new Error("No server data in response"); currentServerIdx = 0; serversRenderTabs(); serversRenderDashboard(0); toast( `Loaded ${serversData.length} server${serversData.length !== 1 ? "s" : ""}`, "success", ); } catch (err) { serversData = []; document.getElementById("server-tabs-strip").innerHTML = ""; document.getElementById("server-dashboard").innerHTML = `

${err.message}

`; toast("Failed to load server data: " + err.message, "error"); } } /* ─── Servers — Render tabs ────────────────────────────────── */ function serversRenderTabs() { const dotColor = { green: "var(--success)", yellow: "var(--warning)", red: "var(--danger)", }; document.getElementById("server-tabs-strip").innerHTML = serversData .map((s, i) => { const status = s.summary?.overall_status ?? "unknown"; const color = dotColor[status] ?? "var(--text-muted)"; const name = escHtml(s.server?.name ?? `Server ${i + 1}`); return `
${name}
`; }) .join(""); } function serversSelectTab(idx) { currentServerIdx = idx; serversRenderTabs(); serversRenderDashboard(idx); } /* ─── Servers — Render dashboard ────────────────────────────── */ function serversRenderDashboard(idx) { const s = serversData[idx]; if (!s) return; const { server, performance: perf, storage, containers, packages, security, podman, action_advice, summary, kpis, } = s; /* helpers */ const pc = (p) => (p < 60 ? "prog-low" : p < 80 ? "prog-mid" : "prog-high"); // usage: high = bad const ps = (p) => (p >= 80 ? "prog-low" : p >= 60 ? "prog-mid" : "prog-high"); // score: high = good const fmtGB = (b) => { if (b == null) return "—"; if (b >= 1e12) return (b / 1e12).toFixed(2) + " TB"; if (b >= 1e9) return (b / 1e9).toFixed(1) + " GB"; if (b >= 1e6) return (b / 1e6).toFixed(0) + " MB"; return (b / 1e3).toFixed(0) + " KB"; }; const statusColors = { green: "var(--success)", yellow: "var(--warning)", red: "var(--danger)", }; const sc = statusColors[summary.overall_status] ?? "var(--text-muted)"; const riskBadge = (lvl) => { const l = (lvl ?? "").toLowerCase(); const cls = l === "low" ? "badge-low" : l === "medium" ? "badge-medium" : l === "high" ? "badge-high" : "badge-other"; return `${escHtml((lvl ?? "—").toUpperCase())}`; }; const secScore = kpis?.kpi_19_security_score ?? "—"; /* CPU */ const cpuUsed = +(100 - (perf.cpu_idle_percent ?? 100)).toFixed(2); /* Security checks */ const checks = [ { label: "SSH password auth", ok: !security.ssh_password_auth, warn: security.ssh_password_auth, }, { label: "SSH root login", ok: !security.ssh_root_login, suffix: security.ssh_root_login ? "enabled" : "disabled", }, { label: "Seccomp", ok: security.seccomp_enabled }, { label: "AppArmor", ok: security.apparmor_enabled }, { label: "SELinux", ok: security.selinux_enabled }, ]; const secChecksHtml = checks .map( (c) => `
${escHtml(c.label)}${c.suffix ? ` (${c.suffix})` : ""}
`, ) .join(""); /* Exposed ports */ const portsHtml = (security.firewall_exposed_services ?? []).length ? security.firewall_exposed_services .map( (p) => `${p}`, ) .join("") : ``; /* Action advice */ const actions = [ ...(action_advice.priority_actions ?? []), action_advice.upgrade_needed ? "Package upgrades available — consider updating." : null, action_advice.ram_upgrade_recommended ? "RAM upgrade is recommended." : null, action_advice.container_cleanup_needed ? "Container cleanup needed." : null, ].filter(Boolean); const warnIcon = ``; const checkIcon = ``; const actionsHtml = actions.length ? actions .map((a) => `
${warnIcon}${escHtml(a)}
`) .join("") : `
${checkIcon} All clear — no priority actions.
`; document.getElementById("server-dashboard").innerHTML = `
${escHtml(server.name)} ${summary.overall_status.toUpperCase()}
${escHtml(server.os)}  ·  Kernel ${escHtml(server.kernel)}  ·  Up ${escHtml(server.uptime)}
${summary.score}
Score

${escHtml(summary.short_summary)}

CPU
${cpuUsed}%
Usage
User ${perf.cpu_user_percent}% Sys ${perf.cpu_system_percent}% Idle ${perf.cpu_idle_percent}%
Memory
${perf.memory_usage_percent.toFixed(1)}%
Usage
Used ${fmtGB(perf.memory_used_bytes)} Free ${fmtGB(perf.memory_free_bytes)} Total ${fmtGB(perf.memory_total_bytes)}
Disk
${storage.disk_usage_percent.toFixed(1)}%
Usage
Used ${fmtGB(storage.disk_used_bytes)} Total ${fmtGB(storage.disk_total_bytes)}
Security
= 80 ? "var(--success)" : secScore >= 60 ? "var(--warning)" : "var(--danger)") : "var(--text)"}">${secScore}
Sec. Score
${typeof secScore === "number" ? `
` : ""}
${secChecksHtml}
Exposed Ports
${portsHtml}
Vuln score: ${security.vulnerability_score ?? "—"}
${ security.risk_flags?.length ? `
${security.risk_flags.map((f) => `${escHtml(f)}`).join("")}
` : "" }
Containers
${containers.running}
Running
${containers.stopped}
Stopped

Total: ${containers.total_containers} Images: ${containers.total_images}
Ports: ${(containers.exposed_ports ?? []).join(", ") || "—"}
Packages
${packages.total_installed}
Installed
${packages.upgradeable_count}
Upgradeable
${riskBadge(packages.risk_level)}
Podman
Network${escHtml(podman.network_backend)}
cgroup${escHtml(podman.cgroup_version)}
Storage${escHtml(podman.storage_driver)}
Rootless${podman.rootless ? "Yes" : "No"}
Action Advice
${actionsHtml}
`; } /* ─── Keys — Load ───────────────────────────────────────────────── */ async function keysLoadRecords() { const tbody = document.getElementById("keys-table-body"); tbody.innerHTML = `

Loading…

`; try { const res = await apiFetch(KEYS_URL, { headers: apiHeaders() }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); // Support both [{data:[...]}, ...] and {data:[...]} and flat array const first = Array.isArray(json) ? json[0] : json; keysData = first && Array.isArray(first.data) ? first.data : Array.isArray(json) ? json : []; const count = document.getElementById("keys-record-count"); if (count) count.textContent = `${keysData.length} key${keysData.length !== 1 ? "s" : ""}`; keysRender(); } catch (err) { tbody.innerHTML = `

Failed to load keys: ${escHtml(err.message)}

`; } } function keysRender() { const tbody = document.getElementById("keys-table-body"); if (!keysData.length) { tbody.innerHTML = `

No keys found.

`; return; } tbody.innerHTML = keysData .map((k) => { const statusBadge = k.disabled ? `Disabled` : `Active`; const limit = k.limit != null ? `$${Number(k.limit).toFixed(2)}` : `—`; const remaining = k.limit_remaining != null ? `$${Number(k.limit_remaining).toFixed(2)}` : `—`; const reset = k.limit_reset ? k.limit_reset.charAt(0).toUpperCase() + k.limit_reset.slice(1) : `—`; const usage = k.usage_monthly != null ? `$${Number(k.usage_monthly).toFixed(4)}` : `—`; const created = k.created_at ? formatDate(k.created_at) : `—`; const name = escHtml(k.name); return ` ${name} ${escHtml(k.label)} ${statusBadge} ${limit} ${remaining} ${reset} ${usage} ${created} `; }) .join(""); } /* ─── Keys — Delete ──────────────────────────────────────────── */ async function keysDelete(btn) { const name = btn.dataset.name; const ok = await confirmDialog( "Delete API Key", `Delete key "${name}"? This cannot be undone.`, ); if (!ok) return; try { const res = await apiFetch(KEYS_URL, { method: "DELETE", headers: apiHeaders(), body: JSON.stringify({ name }), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); toast(`Key "${name}" deleted`, "success"); await keysLoadRecords(); } catch (err) { toast("Delete failed: " + err.message, "error"); } } /* ─── Init ────────────────────────────────────────────────────────── */ settingsLoad(); const _boot = getCookie("al_session"); if (_boot) { currentSession = _boot; currentEmail = getCookie("al_email") || ""; showApp(); loadData(); } else { showAuth(); }