diff --git a/app.js b/app.js
new file mode 100644
index 0000000..9f4db35
--- /dev/null
+++ b/app.js
@@ -0,0 +1,1361 @@
+/* ─── 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 = ` |
`;
+ 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 = ` |
`;
+ 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", "@"];
+
+ 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 = ` |
`;
+ 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 = ` |
`;
+ 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 = ` |
`;
+ 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 = ` |
`;
+ 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 =
+ ``;
+ 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 = `
+ `;
+ 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)}
+
+
+
+
+
+
+
+
+
${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(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 = ` |
`;
+ 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();
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..8d65980
--- /dev/null
+++ b/index.html
@@ -0,0 +1,1071 @@
+
+
+
+
+
+ Agent Loft — Admin
+
+
+
+
+
+
+
+
+
+
+
+
DNS Records
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ Type |
+ Content |
+ TTL |
+ Status |
+ Actions |
+
+
+
+
+
+
+
+
+ Loading records…
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | UUID |
+ Agent |
+ Domain |
+ SSH Port |
+ RAM |
+ CPU |
+ Server |
+ Created |
+ Actions |
+
+
+
+
+
+
+
+
+ Loading instances…
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ ID |
+ Region |
+
+ Size
+ |
+ Last Modified |
+ Created |
+ Actions |
+
+
+
+
+
+
+
+
+ Loading repositories…
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Signed in as
+ —.
+ Sign out to switch accounts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ Key |
+ Status |
+ Limit |
+ Remaining |
+ Reset |
+ Usage (monthly) |
+ Created |
+ Actions |
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
Firewall management — coming soon
+
+
+
+
+
+
+
+
+
+
+
SSL / TLS management — coming soon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Agent Loft Admin
+
Sign in with your Agent Loft account
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/start b/start
new file mode 100755
index 0000000..a1db1d4
--- /dev/null
+++ b/start
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+
+#!/bin/bash
+
+# Simple static file server with live reload
+# Requires Node.js and live-server
+
+# Check if live-server is installed
+if ! command -v live-server &> /dev/null
+then
+ echo "Installing live-server..."
+ npm install -g live-server
+fi
+
+# Serve the current directory with auto-reload
+echo "Starting live-server on http://localhost:8080 ..."
+live-server .
+
+
+
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..ee5f354
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,797 @@
+/* ─── Reset & Base ──────────────────────────────────────────── */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+:root {
+ --bg: #0f1117;
+ --bg-card: #1a1d27;
+ --bg-hover: #22263a;
+ --bg-input: #12151f;
+ --border: #2a2f45;
+ --accent: #4f8ef7;
+ --accent-dim: #2a4a8a;
+ --danger: #e05252;
+ --danger-dim: #5a1f1f;
+ --success: #3fc97e;
+ --warning: #f0a04b;
+ --text: #e4e8f5;
+ --text-muted: #7a82a0;
+ --sidebar-w: 230px;
+ --header-h: 54px;
+ --radius: 8px;
+ --shadow: 0 4px 24px rgba(0, 0, 0, 0.45);
+}
+
+html,
+body {
+ height: 100%;
+ font-family: "Segoe UI", system-ui, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 14px;
+}
+
+/* ─── Layout ────────────────────────────────────────────────── */
+.layout {
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+/* ─── Sidebar ───────────────────────────────────────────────── */
+.sidebar {
+ width: var(--sidebar-w);
+ background: var(--bg-card);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ overflow-y: auto;
+}
+
+.sidebar-brand {
+ height: var(--header-h);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 18px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 700;
+ font-size: 15px;
+ letter-spacing: 0.3px;
+ color: var(--accent);
+ flex-shrink: 0;
+}
+.sidebar-brand svg {
+ flex-shrink: 0;
+}
+
+.sidebar-section-label {
+ padding: 18px 18px 6px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+ font-weight: 600;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 18px;
+ cursor: pointer;
+ border-radius: 0;
+ color: var(--text-muted);
+ transition:
+ background 0.15s,
+ color 0.15s;
+ user-select: none;
+ border-left: 3px solid transparent;
+}
+.nav-item:hover {
+ background: var(--bg-hover);
+ color: var(--text);
+}
+.nav-item.active {
+ background: var(--bg-hover);
+ color: var(--accent);
+ border-left-color: var(--accent);
+}
+.nav-item svg {
+ flex-shrink: 0;
+}
+
+.sidebar-footer {
+ margin-top: auto;
+ padding: 14px 18px;
+ border-top: 1px solid var(--border);
+}
+.sidebar-footer .nav-item {
+ padding: 9px 0;
+ border-left: none;
+}
+
+/* ─── Main ──────────────────────────────────────────────────── */
+.main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.topbar {
+ height: var(--header-h);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 28px;
+ flex-shrink: 0;
+ gap: 14px;
+}
+.topbar-title {
+ font-size: 16px;
+ font-weight: 600;
+}
+.topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 28px;
+}
+
+/* ─── Section visibility ────────────────────────────────────── */
+.section {
+ display: none;
+}
+.section.active {
+ display: block;
+}
+
+/* ─── Cards ─────────────────────────────────────────────────── */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 20px 24px;
+ margin-bottom: 20px;
+}
+.card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 18px;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.card-title {
+ font-size: 15px;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* ─── Buttons ───────────────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 14px;
+ border-radius: 6px;
+ border: none;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 500;
+ transition:
+ opacity 0.15s,
+ background 0.15s;
+ white-space: nowrap;
+}
+.btn:hover {
+ opacity: 0.85;
+}
+.btn:active {
+ opacity: 0.7;
+}
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+}
+.btn-danger {
+ background: var(--danger);
+ color: #fff;
+}
+.btn-ghost {
+ background: var(--bg-hover);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+.btn-success {
+ background: var(--success);
+ color: #000;
+}
+.btn-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+}
+.btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* ─── Inputs ────────────────────────────────────────────────── */
+.form-row {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ align-items: flex-end;
+}
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ flex: 1;
+ min-width: 130px;
+}
+.form-group label {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-weight: 500;
+}
+.form-group input,
+.form-group select,
+.form-group textarea {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ padding: 8px 10px;
+ font-size: 13px;
+ outline: none;
+ transition: border-color 0.15s;
+ width: 100%;
+}
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus {
+ border-color: var(--accent);
+}
+.form-group select option {
+ background: var(--bg-card);
+}
+
+/* ─── Table ─────────────────────────────────────────────────── */
+.table-wrap {
+ overflow-x: auto;
+}
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+thead tr {
+ border-bottom: 2px solid var(--border);
+}
+tbody tr {
+ border-bottom: 1px solid var(--border);
+ transition: background 0.1s;
+}
+tbody tr:hover {
+ background: var(--bg-hover);
+}
+th {
+ padding: 9px 12px;
+ text-align: left;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+td {
+ padding: 11px 12px;
+ vertical-align: middle;
+}
+td:last-child {
+ white-space: nowrap;
+}
+
+.badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 600;
+}
+.badge-a {
+ background: #1a3a5c;
+ color: #5fb3f5;
+}
+.badge-aaaa {
+ background: #1a2f4c;
+ color: #8ac8f8;
+}
+.badge-cname {
+ background: #2a1f4a;
+ color: #a888f8;
+}
+.badge-mx {
+ background: #1f3a2a;
+ color: #5fd89a;
+}
+.badge-txt {
+ background: #3a2a1a;
+ color: #f0b060;
+}
+.badge-ns {
+ background: #3a1a2a;
+ color: #f080b0;
+}
+.badge-other {
+ background: #2a2a2a;
+ color: #9090a0;
+}
+.badge-eu {
+ background: #1a2f4c;
+ color: #8ac8f8;
+}
+.badge-us {
+ background: #1f3a2a;
+ color: #5fd89a;
+}
+.badge-region {
+ background: #2a2a2a;
+ color: #9090a0;
+}
+
+.status-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ margin-right: 5px;
+}
+.dot-enabled {
+ background: var(--success);
+}
+.dot-disabled {
+ background: var(--danger);
+}
+
+/* ─── Toast ─────────────────────────────────────────────────── */
+#toast-container {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 9999;
+}
+.toast {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-left: 4px solid var(--accent);
+ border-radius: var(--radius);
+ padding: 12px 18px;
+ min-width: 260px;
+ max-width: 380px;
+ box-shadow: var(--shadow);
+ animation: slideIn 0.2s ease;
+ font-size: 13px;
+}
+.toast.toast-error {
+ border-left-color: var(--danger);
+}
+.toast.toast-success {
+ border-left-color: var(--success);
+}
+.toast.toast-warning {
+ border-left-color: var(--warning);
+}
+@keyframes slideIn {
+ from {
+ transform: translateX(60px);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* ─── Modal ─────────────────────────────────────────────────── */
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s;
+}
+.modal-backdrop.open {
+ opacity: 1;
+ pointer-events: all;
+}
+.modal {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ width: 100%;
+ max-width: 500px;
+ box-shadow: var(--shadow);
+ transform: translateY(20px);
+ transition: transform 0.2s;
+}
+.modal-backdrop.open .modal {
+ transform: translateY(0);
+}
+.modal-header {
+ padding: 18px 22px 16px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.modal-title {
+ font-size: 15px;
+ font-weight: 600;
+}
+.modal-close {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--text-muted);
+ font-size: 20px;
+ line-height: 1;
+ padding: 2px 6px;
+ border-radius: 4px;
+}
+.modal-close:hover {
+ background: var(--bg-hover);
+ color: var(--text);
+}
+.modal-body {
+ padding: 22px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.modal-footer {
+ padding: 14px 22px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+/* ─── Confirm dialog ────────────────────────────────────────── */
+#confirm-backdrop .modal {
+ max-width: 380px;
+}
+
+/* ─── Login modal ───────────────────────────────────────────── */
+#login-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.75);
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ backdrop-filter: blur(4px);
+}
+#login-backdrop.hidden {
+ display: none;
+}
+#login-modal {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ width: 100%;
+ max-width: 360px;
+ box-shadow: var(--shadow);
+}
+.login-logo {
+ text-align: center;
+ padding: 28px 22px 6px;
+}
+.login-logo svg {
+ color: var(--accent);
+}
+.login-logo h2 {
+ margin-top: 10px;
+ font-size: 17px;
+ font-weight: 700;
+ color: var(--accent);
+}
+.login-logo p {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+#login-error {
+ margin: 0 22px;
+ padding: 8px 12px;
+ background: var(--danger-dim);
+ border: 1px solid var(--danger);
+ border-radius: 6px;
+ font-size: 12px;
+ color: var(--danger);
+ display: none;
+}
+
+/* ─── Settings ──────────────────────────────────────────────── */
+.settings-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+@media (max-width: 600px) {
+ .settings-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ─── Empty state ───────────────────────────────────────────── */
+.empty-state {
+ text-align: center;
+ padding: 48px 20px;
+ color: var(--text-muted);
+}
+.empty-state svg {
+ margin-bottom: 14px;
+ opacity: 0.4;
+}
+.empty-state p {
+ font-size: 14px;
+}
+
+/* ─── Spinner ───────────────────────────────────────────────── */
+.spinner {
+ width: 18px;
+ height: 18px;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ display: inline-block;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ─── Misc ──────────────────────────────────────────────────── */
+.flex-gap {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.mono {
+ font-family: "Cascadia Code", "Fira Code", monospace;
+ font-size: 12px;
+}
+.text-muted {
+ color: var(--text-muted);
+}
+hr.sep {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 10px 0;
+}
+
+/* ─── Server notebook tabs ────────────────────────────────── */
+.server-tabs {
+ display: flex;
+ gap: 2px;
+ padding: 0 2px;
+ border-bottom: 2px solid var(--border);
+ margin-bottom: 20px;
+ overflow-x: auto;
+}
+.server-tab {
+ padding: 8px 20px 10px;
+ cursor: pointer;
+ border: 1px solid transparent;
+ border-bottom: 2px solid transparent;
+ border-radius: 8px 8px 0 0;
+ color: var(--text-muted);
+ font-size: 13px;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ white-space: nowrap;
+ position: relative;
+ bottom: -2px;
+ user-select: none;
+ transition:
+ background 0.12s,
+ color 0.12s,
+ border-color 0.12s;
+}
+.server-tab:hover {
+ background: var(--bg-hover);
+ color: var(--text);
+ border-color: var(--border);
+ border-bottom-color: var(--bg-hover);
+}
+.server-tab.active {
+ background: var(--bg);
+ border-color: var(--border);
+ border-bottom-color: var(--bg);
+ color: var(--text);
+}
+.server-tab-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+/* ─── Dashboard grid ──────────────────────────────────────── */
+.dash-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+ margin-bottom: 16px;
+}
+@media (max-width: 900px) {
+ .dash-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .dash-span-2 {
+ grid-column: span 1;
+ }
+}
+@media (max-width: 600px) {
+ .dash-grid {
+ grid-template-columns: 1fr;
+ }
+}
+.dash-span-2 {
+ grid-column: span 2;
+}
+.dash-span-3 {
+ grid-column: 1 / -1;
+}
+.dash-grid .card {
+ margin-bottom: 0;
+}
+
+/* ─── Progress bars ───────────────────────────────────────── */
+.prog-wrap {
+ background: var(--bg-input);
+ border-radius: 4px;
+ height: 6px;
+ margin-top: 6px;
+ overflow: hidden;
+}
+.prog-bar {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.4s ease;
+ min-width: 2px;
+}
+.prog-low {
+ background: var(--success);
+}
+.prog-mid {
+ background: var(--warning);
+}
+.prog-high {
+ background: var(--danger);
+}
+
+/* ─── Dashboard stat display ──────────────────────────────── */
+.dash-stat {
+ font-size: 26px;
+ font-weight: 700;
+ line-height: 1.1;
+}
+.dash-stat-sub {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+.dash-row {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-top: 10px;
+}
+.dash-kv {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 13px;
+ padding: 3px 0;
+}
+.dash-kv + .dash-kv {
+ border-top: 1px solid var(--border);
+}
+
+/* ─── Status / risk badges (dashboard) ──────────────────────── */
+.badge-green {
+ background: #1c3a26;
+ color: #5fd89a;
+}
+.badge-yellow {
+ background: #372900;
+ color: #f0b060;
+}
+.badge-red {
+ background: #3a1515;
+ color: #f07070;
+}
+.badge-low {
+ background: #1c3a26;
+ color: #5fd89a;
+}
+.badge-medium {
+ background: #372900;
+ color: #f0b060;
+}
+.badge-high {
+ background: #3a1515;
+ color: #f07070;
+}
+
+/* ─── Action advice items ─────────────────────────────────── */
+.action-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--danger-dim);
+ border: 1px solid var(--danger);
+ border-radius: 6px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+.action-item + .action-item {
+ margin-top: 8px;
+}
+.action-item > svg {
+ flex-shrink: 0;
+ color: var(--danger);
+ margin-top: 2px;
+}
+.action-ok {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--success);
+}
+
+/* ─── Security check rows ────────────────────────────────── */
+.sec-check {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ padding: 3px 0;
+}