/* ─── 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",
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 =
``;
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);
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 = `
`;
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)}
${escHtml(summary.short_summary)}
${cpuUsed}%
Usage
User ${perf.cpu_user_percent}%
Sys ${perf.cpu_system_percent}%
Idle ${perf.cpu_idle_percent}%
${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)}
${storage.disk_usage_percent.toFixed(1)}%
Usage
Used ${fmtGB(storage.disk_used_bytes)}
Total ${fmtGB(storage.disk_total_bytes)}
= 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.running}
Running
${containers.stopped}
Stopped
Total: ${containers.total_containers}
Images: ${containers.total_images}
Ports: ${(containers.exposed_ports ?? []).join(", ") || "—"}
${packages.total_installed}
Installed
${packages.upgradeable_count}
Upgradeable
${riskBadge(packages.risk_level)}
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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 = ` |
`;
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();
}