/* ─── Constants ──────────────────────────────────────────────────── */ const API_BASE = "https://n8n.derez.ai/webhook"; /* ─── API route paths ────────────────────────────────────────────── */ const ROUTES = { auth: "/account/login", dns: "/admin/dns", instances: "/admin/instance", backup: "/admin/backup-repo", servers: "/admin/server", seats: "/server", keys: "/admin/key", crm: "/crm", }; /* ─── State ──────────────────────────────────────────────────────── */ let dnsRecords = []; let instancesRecords = []; let instancesEditTarget = null; let backupRecords = []; let serversData = []; let currentServerIdx = 0; let keysData = []; let crmRecords = []; let crmSortKey = null; let crmSortDir = 1; 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 getApiBase() { return (localStorage.getItem("al_url") || API_BASE).replace(/\/$/, ""); } function apiUrl(route) { return getApiBase() + route; } 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(apiUrl(ROUTES.auth), { 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(apiUrl(ROUTES.auth), { 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"; } /* ─── 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", keys: "Keys", sales: "Sales", crm: "CRM", }; 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 if (key === "crm") { topbar.style.display = ""; btnRefresh.onclick = crmLoadRecords; } 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(); } if (key === "crm" && crmRecords.length === 0) { crmLoadRecords(); } }); }); /* ─── 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(apiUrl(ROUTES.dns), { 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", "usa", "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(apiUrl(ROUTES.dns), { 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(apiUrl(ROUTES.dns), { 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("API base URL saved", "success"); } else { localStorage.removeItem("al_url"); document.getElementById("setting-url").value = ""; toast("Reverted to default: " + API_BASE, "info"); } } async function settingsTest() { try { const res = await apiFetch(apiUrl(ROUTES.dns), { 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(apiUrl(ROUTES.instances), { 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(apiUrl(ROUTES.instances), { 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(apiUrl(ROUTES.instances), { 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(apiUrl(ROUTES.backup), { 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(apiUrl(ROUTES.backup), { 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(apiUrl(ROUTES.servers), { 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); /* ─── Fetch seat data ─────────────────────────────────────── */ let seatMap = {}; try { const seatRes = await fetch("https://n8n.derez.ai/webhook/server"); if (seatRes.ok) { const seatJson = await seatRes.json(); const seatArr = Array.isArray(seatJson?.data) ? seatJson.data : Array.isArray(seatJson) ? seatJson : []; for (const s of seatArr) { if (s.server) seatMap[s.server.toLowerCase().trim()] = s; } } } catch (_) { /* seat data is optional */ } /* ─── Merge seats into server data ─────────────────────────── */ for (const sv of serversData) { const key = ( typeof sv.server === "string" ? sv.server : (sv.server?.name ?? "") ) .toLowerCase() .trim(); sv.seats = seatMap[key] || null; } 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
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 ?? "—"}
Seats
${ s.seats ? `
${s.seats.Seats ?? "—"}
Total
${s.seats["Seats taken"] ?? "—"}
Taken
${s.seats["Seats Available"] ?? "—"}
Available
` : `
No seat data available
` }
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(apiUrl(ROUTES.keys), { 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(apiUrl(ROUTES.keys), { 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"); } } /* ─── CRM — Load ────────────────────────────────────────────────── */ async function crmLoadRecords() { const tbody = document.getElementById("crm-table-body"); tbody.innerHTML = `

Loading…

`; try { const res = await apiFetch(apiUrl(ROUTES.crm), { headers: apiHeaders(), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); const json = await res.json(); // Shape: {data: [{...}, ...]} let data = json?.data ?? json?.response ?? json; // If the outer result wraps in an extra array like [{data:[...]}] handle that too if (Array.isArray(data) && data.length > 0 && data[0]?.data) { data = data[0].data; } crmRecords = Array.isArray(data) ? data : []; crmRender(); toast( `Loaded ${crmRecords.length} contact${crmRecords.length !== 1 ? "s" : ""}`, "success", ); } catch (err) { crmRecords = []; tbody.innerHTML = `

${err.message}

`; toast("Failed to load CRM contacts: " + err.message, "error"); } } /* ─── CRM — Render table ────────────────────────────────────────── */ function crmRender() { const searchName = ( document.getElementById("crm-search-name")?.value ?? "" ).toLowerCase(); const searchCompany = ( document.getElementById("crm-search-company")?.value ?? "" ).toLowerCase(); const searchEmail = ( document.getElementById("crm-search-email")?.value ?? "" ).toLowerCase(); const searchStage = ( document.getElementById("crm-search-stage")?.value ?? "" ).toLowerCase(); const searchStrategy = ( document.getElementById("crm-search-strategy")?.value ?? "" ).toLowerCase(); const searchCategory = ( document.getElementById("crm-search-category")?.value ?? "" ).toLowerCase(); const searchAffiliate = ( document.getElementById("crm-search-affiliate")?.value ?? "" ).toLowerCase(); let filtered = crmRecords.filter((r) => { const name = (r.Name ?? "").toLowerCase(); const company = (r.Company ?? "").toLowerCase(); const email = (r.email ?? "").toLowerCase(); const stage = (r.Stage ?? "").toLowerCase(); const strategy = (r.strategy ?? "").toLowerCase(); const category = (r.category ?? "").toLowerCase(); const affiliate = (r.affiliate ?? "").toLowerCase(); return ( (!searchName || name.includes(searchName)) && (!searchCompany || company.includes(searchCompany)) && (!searchEmail || email.includes(searchEmail)) && (!searchStage || stage.includes(searchStage)) && (!searchStrategy || strategy.includes(searchStrategy)) && (!searchCategory || category.includes(searchCategory)) && (!searchAffiliate || affiliate.includes(searchAffiliate)) ); }); // Sort if (crmSortKey) { filtered.sort((a, b) => { const va = (a[crmSortKey] ?? "").toString().toLowerCase(); const vb = (b[crmSortKey] ?? "").toString().toLowerCase(); if (va < vb) return -1 * crmSortDir; if (va > vb) return 1 * crmSortDir; return 0; }); } // Update sort arrows document.querySelectorAll(".sort-arrow").forEach((el) => { el.textContent = el.dataset.sort === crmSortKey ? (crmSortDir === 1 ? " ▲" : " ▼") : ""; }); const total = crmRecords.length; const countEl = document.getElementById("crm-record-count"); countEl.textContent = filtered.length === total ? `${total} contact${total !== 1 ? "s" : ""}` : `${filtered.length} / ${total} contacts`; // Reset check-all state const checkAll = document.getElementById("crm-check-all"); if (checkAll) checkAll.checked = false; const tbody = document.getElementById("crm-table-body"); if (filtered.length === 0) { tbody.innerHTML = `

No contacts found

`; document.getElementById("crm-delete-selected-btn").disabled = true; return; } tbody.innerHTML = filtered .map((r) => { const id = r.id; const name = escHtml(r.Name ?? ""); const company = escHtml(r.Company ?? ""); const email = escHtml(r.email ?? ""); const stage = escHtml(r.Stage ?? ""); const strategy = escHtml(r.strategy ?? ""); const category = escHtml(r.category ?? ""); const affiliate = escHtml(r.affiliate ?? ""); const dateNext = r.date_next_contact ? formatDate(r.date_next_contact) : "—"; // Prevent row click when clicking the checkbox return ` ${name || "—"} ${company || "—"} ${email || "—"} ${stage || "—"} ${strategy || "—"} ${category ? escHtml(category) : "—"} ${affiliate || "—"} ${dateNext} `; }) .join(""); crmUpdateDeleteBtn(); } /* ─── CRM — Clear all filters ──────────────────────────────────── */ function crmClearFilters() { [ "name", "company", "email", "stage", "strategy", "category", "affiliate", ].forEach((f) => { const el = document.getElementById("crm-search-" + f); if (el) el.value = ""; }); crmRender(); } /* ─── CRM — Sort ───────────────────────────────────────────────── */ function crmToggleSort(key) { if (crmSortKey === key) { crmSortDir *= -1; } else { crmSortKey = key; crmSortDir = 1; } crmRender(); } /* ─── CRM — Checkbox helpers ────────────────────────────────────── */ function crmUpdateDeleteBtn() { const checked = document.querySelectorAll(".crm-select:checked").length; const hasSel = checked > 0; document.getElementById("crm-delete-selected-btn").disabled = !hasSel; document.getElementById("crm-hold-btn").disabled = !hasSel; document.getElementById("crm-cold-btn").disabled = !hasSel; const label = hasSel ? `Delete Checked (${checked})` : "Delete Checked"; document.getElementById("crm-delete-selected-btn").innerHTML = ` ${label}`; document.getElementById("crm-hold-btn").textContent = hasSel ? `Set Hold (${checked})` : "Set Hold"; document.getElementById("crm-cold-btn").textContent = hasSel ? `Set Cold (${checked})` : "Set Cold"; } function crmToggleAll() { const checked = document.getElementById("crm-check-all").checked; document.querySelectorAll(".crm-select").forEach((cb) => { cb.checked = checked; }); crmUpdateDeleteBtn(); } /* ─── CRM — Delete single contact ──────────────────────────────── */ async function crmDeleteContact(btn) { const id = btn.dataset.id; const name = btn.dataset.name || `#${id}`; const ok = await confirmDialog( "Delete Contact", `Delete contact “${name}” (id: ${id})? This cannot be undone.`, ); if (!ok) return; try { const res = await apiFetch(apiUrl(ROUTES.crm), { method: "DELETE", headers: apiHeaders(), body: JSON.stringify({ id: Number(id) }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); toast(`Contact “${name}” deleted`, "success"); await crmLoadRecords(); } catch (err) { toast("Delete failed: " + err.message, "error"); } } /* ─── CRM — Delete selected contacts ────────────────────────────── */ async function crmDeleteSelected() { const ids = Array.from(document.querySelectorAll(".crm-select:checked")).map( (cb) => Number(cb.dataset.id), ); if (ids.length === 0) return; const ok = await confirmDialog( "Delete Selected Contacts", `Delete ${ids.length} contact${ids.length !== 1 ? "s" : ""}? This cannot be undone.`, ); if (!ok) return; // Delete sequentially let failed = 0; for (const id of ids) { try { const res = await apiFetch(apiUrl(ROUTES.crm), { method: "DELETE", headers: apiHeaders(), body: JSON.stringify({ id }), }); if (!res.ok) failed++; } catch { failed++; } } if (failed === 0) { toast( `Deleted ${ids.length} contact${ids.length !== 1 ? "s" : ""}`, "success", ); } else { toast( `Deleted ${ids.length - failed} contact${ids.length - failed !== 1 ? "s" : ""} (${failed} failed)`, "warning", ); } await crmLoadRecords(); } /* ─── CRM — Set stage for checked contacts ──────────────────────── */ async function crmSetStageSelected(stage) { const ids = Array.from(document.querySelectorAll(".crm-select:checked")).map( (cb) => Number(cb.dataset.id), ); if (ids.length === 0) return; const ok = await confirmDialog( `Set Stage to ${stage}`, `Set stage to “${stage}” for ${ids.length} contact${ids.length !== 1 ? "s" : ""}?`, stage, ); if (!ok) return; let failed = 0; for (const id of ids) { const r = crmRecords.find((c) => c.id === id); if (!r) { failed++; continue; } const payload = { id, Name: r.Name ?? "", Company: r.Company ?? "", email: r.email ?? "", Stage: stage, strategy: r.strategy ?? "", category: r.category ?? "", affiliate: r.affiliate ?? "", Number: r.Number ?? "", date_next_contact: r.date_next_contact ?? null, History: r.History ?? "", }; try { const res = await apiFetch(apiUrl(ROUTES.crm), { method: "POST", headers: apiHeaders(), body: JSON.stringify(payload), }); if (!res.ok) failed++; } catch { failed++; } } if (failed === 0) { toast( `Stage set to “${stage}” for ${ids.length} contact${ids.length !== 1 ? "s" : ""}`, "success", ); } else { toast( `Updated ${ids.length - failed} contact${ids.length - failed !== 1 ? "s" : ""} (${failed} failed)`, "warning", ); } await crmLoadRecords(); } /* ─── CRM — Edit modal ──────────────────────────────────────────── */ function crmOpenEdit(id) { const r = crmRecords.find((c) => c.id === id); if (!r) return; document.getElementById("crm-edit-id").value = r.id; document.getElementById("crm-edit-name").value = r.Name ?? ""; document.getElementById("crm-edit-company").value = r.Company ?? ""; document.getElementById("crm-edit-email").value = r.email ?? ""; document.getElementById("crm-edit-stage").value = r.Stage ?? ""; document.getElementById("crm-edit-strategy").value = r.strategy ?? ""; document.getElementById("crm-edit-category").value = r.category ?? ""; document.getElementById("crm-edit-affiliate").value = r.affiliate ?? ""; document.getElementById("crm-edit-number").value = r.Number ?? ""; document.getElementById("crm-edit-date-next").value = r.date_next_contact ? r.date_next_contact.split("T")[0] : ""; document.getElementById("crm-edit-history").value = r.History ?? ""; document.getElementById("crm-edit-title").textContent = `Edit Contact — ${r.Name || r.email || `#${r.id}`}`; document.getElementById("crm-edit-backdrop").classList.add("open"); setTimeout(() => document.getElementById("crm-edit-name").focus(), 80); } function crmCloseEdit() { document.getElementById("crm-edit-backdrop").classList.remove("open"); } document.getElementById("crm-edit-backdrop")?.addEventListener("click", (e) => { if (e.target === e.currentTarget) crmCloseEdit(); }); async function crmSaveEdit() { const id = Number(document.getElementById("crm-edit-id").value); const payload = { id, Name: document.getElementById("crm-edit-name").value.trim(), Company: document.getElementById("crm-edit-company").value.trim(), email: document.getElementById("crm-edit-email").value.trim(), Stage: document.getElementById("crm-edit-stage").value, strategy: document.getElementById("crm-edit-strategy").value.trim(), category: document.getElementById("crm-edit-category").value.trim(), affiliate: document.getElementById("crm-edit-affiliate").value.trim(), Number: document.getElementById("crm-edit-number").value.trim(), date_next_contact: document.getElementById("crm-edit-date-next").value || null, History: document.getElementById("crm-edit-history").value.trim(), }; const btn = document.getElementById("crm-save-btn"); btn.disabled = true; const orig = btn.innerHTML; btn.innerHTML = `
Saving…`; try { const res = await apiFetch(apiUrl(ROUTES.crm), { method: "POST", headers: apiHeaders(), body: JSON.stringify(payload), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); toast( `Contact “${payload.Name || payload.email || `#${id}`}” updated`, "success", ); crmCloseEdit(); await crmLoadRecords(); } catch (err) { toast("Save failed: " + err.message, "error"); } finally { btn.disabled = false; btn.innerHTML = orig; } } /* ─── Init ────────────────────────────────────────────────────────── */ settingsLoad(); const _boot = getCookie("al_session"); if (_boot) { currentSession = _boot; currentEmail = getCookie("al_email") || ""; showApp(); loadData(); } else { showAuth(); }