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 = `

Loading…

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

${err.message}

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

No records found

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

Loading…

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

${err.message}

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

No instances found

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

Loading…

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

${err.message}

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

No repositories found

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

Loading…

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

${err.message}

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

+ ${escHtml(summary.short_summary)} +

+
+ + +
+ +
+
+ + + + + CPU +
+
${cpuUsed}%
+
Usage
+
+
+ User ${perf.cpu_user_percent}% + Sys ${perf.cpu_system_percent}% + Idle ${perf.cpu_idle_percent}% +
+
+ +
+
+ + + + + Memory +
+
${perf.memory_usage_percent.toFixed(1)}%
+
Usage
+
+
+ Used ${fmtGB(perf.memory_used_bytes)} + Free ${fmtGB(perf.memory_free_bytes)} + Total ${fmtGB(perf.memory_total_bytes)} +
+
+ +
+
+ + + + + + Disk +
+
${storage.disk_usage_percent.toFixed(1)}%
+
Usage
+
+
+ Used ${fmtGB(storage.disk_used_bytes)} + Total ${fmtGB(storage.disk_total_bytes)} +
+
+
+ + +
+ +
+
+ + + + Security +
+
+
+
= 80 ? "var(--success)" : secScore >= 60 ? "var(--warning)" : "var(--danger)") : "var(--text)"}">${secScore}
+
Sec. Score
+ ${typeof secScore === "number" ? `
` : ""} +
+
${secChecksHtml}
+
+
Exposed Ports
+
${portsHtml}
+
Vuln score: ${security.vulnerability_score ?? "—"}
+
+
+ ${ + security.risk_flags?.length + ? ` +
+ ${security.risk_flags.map((f) => `${escHtml(f)}`).join("")} +
` + : "" + } +
+ +
+
+ + + + + Containers +
+
+
+
${containers.running}
+
Running
+
+
+
${containers.stopped}
+
Stopped
+
+
+
+
+ Total: ${containers.total_containers} + Images: ${containers.total_images} +
+
+ Ports: ${(containers.exposed_ports ?? []).join(", ") || "—"} +
+
+
+ + +
+ +
+
+ + + + + Packages +
+
+
+
${packages.total_installed}
+
Installed
+
+
+
${packages.upgradeable_count}
+
Upgradeable
+
+
+ ${riskBadge(packages.risk_level)} +
+ +
+
+ + + + + Podman +
+
+
Network${escHtml(podman.network_backend)}
+
cgroup${escHtml(podman.cgroup_version)}
+
Storage${escHtml(podman.storage_driver)}
+
Rootless${podman.rootless ? "Yes" : "No"}
+
+
+ +
+
+ + + + + Action Advice +
+ ${actionsHtml} +
+
+ `; +} + +/* ─── Keys — Load ───────────────────────────────────────────────── */ +async function keysLoadRecords() { + const tbody = document.getElementById("keys-table-body"); + tbody.innerHTML = `

Loading…

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

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

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

No keys found.

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

+ Loading records… +

+
+
+
+
+ + +
+
+ + + + + + + Add New Record + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+
+ +
+
+
+ +
+
+ + + + + + + Instances + + +
+
+ + + + + + + + + + + + + + + + + + + +
UUIDAgentDomainSSH PortRAMCPUServerCreatedActions
+
+
+

+ Loading instances… +

+
+
+
+
+
+ + +
+ +
+
+
+ +
+
+
+ +
+
+ + + + + + + Repositories + + +
+
+ + + + + + + + + + + + + + + + + +
NameIDRegion + Size + Last ModifiedCreatedActions
+
+
+

+ Loading repositories… +

+
+
+
+
+
+ + +
+
+
+ API Connection +
+
+
+ + +
+
+
+ + +
+
+
+
+ Current Session +
+

+ Signed in as + . + Sign out to switch accounts. +

+
+ +
+
+
+ + +
+
+
+ + + + + API Keys + + +
+
+ + + + + + + + + + + + + + + + + + + +
NameKeyStatusLimitRemainingResetUsage (monthly)CreatedActions
+
+
+

+ Loading keys… +

+
+
+
+
+
+ + +
+
+
+ + + +

Firewall management — coming soon

+
+
+
+ +
+
+
+
+
+

Loading servers…

+
+
+
+ +
+
+
+ + + + +

SSL / TLS management — coming soon

+
+
+
+
+ +
+ +
+ + + + + + + + + +
+
+ +
+ + +
+
+ + +
+ + + + 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; +}