diff --git a/package.json b/package.json index bd74dfe..dffb4fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.11.116", + "version": "0.11.117", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/app.js b/src/public/app.js index 21c47d6..5819c73 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -44,7 +44,7 @@ async function api(url, options = {}) { // --- Login/dashboard shell, toast, and small formatting helpers ------------------ function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); } function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); } -function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); } +function toast(message, type = "success") { const el = $("#toast"); el.textContent = message; el.classList.toggle("toast-error", type === "error"); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); } function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; } function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; } @@ -337,10 +337,11 @@ function renderPerformance() { $("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`); $("#performance-sparkline").innerHTML = points.length ? `${gridLines}` : ""; if (!points.length) $("#performance-sparkline-labels").innerHTML = 'No request data for this window yet.'; - const routes = data.routes || []; + const routes = (data.routes || []).filter(route => !selected || route.host === selected); state.performanceErrorBreakdowns = {}; const countCell = (count, errors, breakdown, host) => { if (!errors) return `${count.toLocaleString()}`; if (!breakdown?.length) return `${count.toLocaleString()} · ${errors.toLocaleString()}`; state.performanceErrorBreakdowns[host] = { total: errors, breakdown }; return `${count.toLocaleString()} · `; }; - $("#performance-rows").innerHTML = routes.length ? routes.map(route => `${escapeHtml(route.host)}${countCell(route.hourRequests, route.hourErrors)}${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}${route.dayAvgMs == null ? "—" : `${route.dayAvgMs} ms`}`).join("") : 'No requests have been logged yet.'; + const formatAvgMs = ms => ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; + $("#performance-rows").innerHTML = routes.length ? routes.map(route => `${escapeHtml(route.host)}${countCell(route.hourRequests, route.hourErrors)}${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}${formatAvgMs(route.dayAvgMs)}`).join("") : 'No requests have been logged yet.'; if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" }); } @@ -362,7 +363,7 @@ function renderUsers() { return `
${escapeHtml(initials(user.displayName))}
${escapeHtml(user.status)}${menu}

${escapeHtml(user.displayName)}${isSelf ? ' You' : ""}

${escapeHtml(user.username)}

${roleLabel}${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}
`; }).join("") : '

No users found.

'; document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); }); - document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = ''; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); }); + document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = ''; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message, "error"); } }); old.replaceWith(select); }); } @@ -475,7 +476,7 @@ async function boot() { if (!state.loadedVersion) state.loadedVersion = state.config.version; $("#port-range").textContent = `${state.config.minPort}–${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`; $("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView(); - if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000); + if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message, "error")); }, 30000); if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000); } @@ -500,20 +501,20 @@ $("#logout").addEventListener("click", async () => { await fetch("/api/logout", // --- Dashboard actions: run certificate check, download support report, jump to // an attention item's view ------------------------------------------------------------- -$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message); } finally { button.disabled = false; button.textContent = "Run certificate check"; } }); +$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; button.textContent = "Run certificate check"; } }); $("#download-support")?.addEventListener("click", () => { location.href = "/api/support-report"; }); -$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message)); } }); +$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message, "error")); } }); // --- Primary navigation (sidebar view switching) ------------------------------------------- function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); } -document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } })); -$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); }); +document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message, "error")); } })); +$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message, "error")); }); // --- Logs & Performance filter controls ----------------------------------------------------- -$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message))); -$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message))); -$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message))); -$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message))); +$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error"))); +$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); +$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); +$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error"))); // --- Performance: themed error-breakdown popup, replacing the old hover tooltip ------------ function showErrorBreakdown(host, total, breakdown) { let dialog = document.querySelector("#error-breakdown-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "error-breakdown-dialog"; document.body.append(dialog); } const rows = breakdown.map(item => `
${escapeHtml(item.status)}${item.count.toLocaleString()}
`).join(""); dialog.innerHTML = `

Performance · Last 24h

${escapeHtml(host)}

${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.

${rows}
`; dialog.showModal(); } @@ -539,7 +540,7 @@ document.addEventListener("keydown", event => { if (event.key === "Escape") clos document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); })); // --- Hosted Sites & Proxy Hosts: create form submit handlers -------------------------------- -$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message))); +$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message, "error"))); $("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } }); $("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } }); @@ -575,7 +576,7 @@ $("#site-grid").addEventListener("click", async event => { const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind; if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return; closeMenus(); - if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status."); } } + if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } } if (action === "settings") openSettings(kind, card.dataset.id); if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); } if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); } @@ -592,7 +593,7 @@ document.querySelector("#redirect-list")?.addEventListener("click", event => { // --- Delete confirmation dialog and replace-files handler ------------------------------------ $("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; }); -$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message); } event.target.value = ""; state.pendingReplace = null; }); +$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; }); // --- Icon picker dialog: search, upload, URL, and reset-to-fallback ------------------------- @@ -659,7 +660,7 @@ $("#user-list").addEventListener("click", async event => { closeMenus(); if (!await themedUserConfirm(`Permanently delete user “${user.username}”? This cannot be undone.`, "Delete user")) return; button.disabled = true; - try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message); } finally { button.disabled = false; } + try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; } return; } button.disabled = true; @@ -667,7 +668,7 @@ $("#user-list").addEventListener("click", async event => { const body = button.dataset.userAction === "role" ? { role: button.dataset.value } : { status: button.dataset.value }; await api(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); await loadFeatureView(); toast("User updated."); - } catch (error) { toast(error.message); } + } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; } }); @@ -688,10 +689,10 @@ window.addEventListener("hashchange", () => { if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`); state.view = location.hash.slice(1) || "overview"; render(); - loadFeatureView().catch(error => toast(error.message)); + loadFeatureView().catch(error => toast(error.message, "error")); }); -boot().catch(error => toast(error.message)); +boot().catch(error => toast(error.message, "error")); // --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------ @@ -767,7 +768,7 @@ $("#account-mfa-enable").addEventListener("click", async () => { $("#mfa-setup-error").textContent = ""; $("#mfa-setup-confirm-form").reset(); $("#mfa-setup-dialog").showModal(); - } catch (error) { toast(error.message); } + } catch (error) { toast(error.message, "error"); } }); $("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); }); $("#mfa-setup-confirm-form").addEventListener("submit", async event => { @@ -796,7 +797,7 @@ $("#account-mfa-disable").addEventListener("click", async () => { state.user.mfaEnabled = false; renderAccount(); toast("Two-factor authentication disabled."); - } catch (error) { toast(error.message); } + } catch (error) { toast(error.message, "error"); } }); $("#account-mfa-recovery").addEventListener("click", async () => { const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue"); @@ -806,5 +807,5 @@ $("#account-mfa-recovery").addEventListener("click", async () => { $("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n"); $("#mfa-recovery-dialog").showModal(); toast("Recovery codes regenerated. Your old codes no longer work."); - } catch (error) { toast(error.message); } + } catch (error) { toast(error.message, "error"); } }); diff --git a/src/public/index.html b/src/public/index.html index d8e205b..005b14c 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -7,7 +7,7 @@ Site Gateway - + - + diff --git a/src/public/styles.css b/src/public/styles.css index f0b7919..cfe500a 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -1,13 +1,13 @@ -:root{--bg:#08101d;--panel:#101a2b;--panel2:#142136;--line:#23334d;--text:#f4f7fb;--muted:#91a0b6;--green:#62e6a7;--blue:#79a9ff;--danger:#ff7185;--warning:#ffbf69;--shadow:0 24px 70px rgba(0,0,0,.35);--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-5:24px;--space-6:32px;--space-7:40px;--radius-sm:9px;--radius-md:12px;--radius-lg:16px;--radius-xl:18px;--font-size-xs:.68rem;--font-size-sm:.78rem;--font-size-md:.82rem;--font-size-base:1rem;--empty-border:#30415e;--toggle-track-off:#344259;--toggle-thumb:#d7deea;--toast-bg:#eef6ff;--toast-text:#0b1626;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:dark} -*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 75% -10%,#172b51 0,transparent 35%),var(--bg);background-repeat:no-repeat;background-attachment:fixed;color:var(--text);min-height:100vh}button,input{font:inherit}.hidden{display:none!important}.app-shell{display:grid;grid-template-columns:240px 1fr;min-height:100vh}aside{border-right:1px solid var(--line);padding:28px 20px;display:flex;flex-direction:column;background:rgba(8,16,29,.78);backdrop-filter:blur(20px)}.brand{display:flex;flex-direction:column;align-items:center;gap:12px;font-weight:750;letter-spacing:-.02em;border:0;background:none;padding:0;margin:0;width:100%;cursor:pointer;border-radius:10px;transition:opacity .15s ease}.brand:hover,.brand:focus-visible{opacity:.82}.brand:focus-visible{outline:2px solid var(--green);outline-offset:4px}.brand-icon{width:64px;height:64px;object-fit:contain}.brand-wordmark{width:190px;max-width:100%;height:auto;object-fit:contain;margin-top:0}.brand-stack{display:flex;flex-direction:column;align-items:center;gap:16px}.brand-icon-lg{width:88px;height:88px;object-fit:contain}.brand-wordmark-lg{width:290px;max-width:100%;height:auto;object-fit:contain;margin-top:0}.brand-mark{width:52px;height:52px;border-radius:15px;display:grid;place-items:center;background:linear-gradient(145deg,var(--green),#2fbf91);color:#06251a;font-weight:900;box-shadow:0 10px 30px rgba(98,230,167,.18)}.brand-mark.small{width:34px;height:34px;border-radius:10px}.eyebrow{text-transform:uppercase;letter-spacing:.16em;font-size:.7rem;color:var(--green);font-weight:800;margin:0 0 9px}nav{margin-top:42px}nav button{width:100%;border:0;border-radius:10px;padding:12px;text-align:left;background:transparent;color:var(--muted);display:flex;justify-content:space-between}nav .nav-active{background:var(--panel2);color:var(--text)}nav span{color:var(--green)}.aside-footer{margin-top:auto;padding:16px 10px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;color:var(--muted);font-size:.85rem}.text-button{border:0;background:none;color:var(--blue);cursor:pointer}main{padding:58px clamp(28px,6vw,88px);max-width:1500px;width:100%;margin:auto}header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px}h1{font-size:clamp(2rem,4vw,3.2rem);letter-spacing:-.045em;margin:0 0 8px;line-height:1}h2{letter-spacing:-.025em}.muted{color:var(--muted);margin:0;line-height:1.6}.button{border:1px solid transparent;border-radius:10px;padding:11px 16px;color:var(--text);font-weight:720;cursor:pointer}.button:disabled{opacity:.55;cursor:wait}.primary{background:var(--green);color:#05251a;box-shadow:0 10px 30px rgba(98,230,167,.13)}.secondary{background:var(--panel2);border-color:var(--line)}.danger{background:var(--danger);color:#2b0710}.wide{width:100%}.summary{display:flex;align-items:center;gap:28px;margin:40px 0 28px;padding:17px 20px;background:rgba(16,26,43,.75);border:1px solid var(--line);border-radius:14px}.summary>div{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:.88rem}.summary strong{color:var(--text)}.port-note{margin-left:auto}.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#61708a}.status-dot.running{background:var(--green);box-shadow:0 0 0 4px rgba(98,230,167,.1)}.status-dot.disabled{background:var(--danger);box-shadow:0 0 0 4px rgba(255,113,133,.09)}.status-dot.error,.status-dot.idle{background:var(--warning);box-shadow:0 0 0 4px rgba(255,191,105,.09)}.site-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:18px}.site-card{position:relative;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));border:1px solid var(--line);border-radius:16px;padding:20px;min-height:220px;box-shadow:0 14px 40px rgba(0,0,0,.14);transition:.2s}.site-card:hover{transform:translateY(-2px);border-color:#344b6e}.card-top,.card-footer,.card-actions{display:flex;align-items:center;justify-content:space-between}.site-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:#1c3050;color:var(--blue);font-size:.78rem;font-weight:850}.site-card h2{margin:27px 0 5px;font-size:1.15rem}.address{margin:0;color:var(--muted);font-family:ui-monospace,monospace;font-size:.82rem}.card-footer{position:absolute;left:20px;right:20px;bottom:20px}.status-pill{display:flex;align-items:center;gap:8px;text-transform:capitalize;font-size:.78rem;color:var(--muted)}.icon-button,.launch{width:34px;height:34px;border-radius:9px;border:1px solid var(--line);display:grid;place-items:center;background:#101b2e;color:var(--muted);cursor:pointer;text-decoration:none}.menu-wrap{position:relative}.menu{display:none;position:absolute;right:0;top:40px;width:145px;background:#17243a;border:1px solid var(--line);border-radius:10px;padding:6px;box-shadow:var(--shadow);z-index:3}.menu-open .menu{display:block}.menu button{border:0;background:none;color:var(--text);display:block;width:100%;text-align:left;padding:9px;border-radius:7px;cursor:pointer}.menu button:hover{background:#21314b}.menu .danger-text{color:var(--danger)}.toggle{width:39px;height:22px;border:0;border-radius:20px;background:#344259;padding:3px;cursor:pointer;transition:background-color .2s ease}.toggle:disabled{opacity:.7;cursor:wait}.toggle span{display:block;width:16px;height:16px;border-radius:50%;background:#d7deea;transition:transform .2s ease,background-color .2s ease}.toggle.on{background:#278764}.toggle.on span{transform:translateX(17px);background:white}.card-actions{gap:8px}.empty{text-align:center;border:1px dashed #30415e;border-radius:18px;padding:64px 24px}.empty-icon{font-size:2rem;color:var(--green)}.empty h2{margin:14px 0 8px}.empty p{color:var(--muted);margin:0 auto 24px;max-width:430px}.login-shell{min-height:100vh;display:grid;place-items:center;padding:20px}.login-card,.dialog-card{width:min(440px,100%);background:rgba(16,26,43,.96);border:1px solid var(--line);border-radius:18px;padding:30px;box-shadow:var(--shadow)}.login-card .brand-stack{margin-bottom:40px}.login-card h1{font-size:2.25rem}.login-card>.muted{margin-bottom:24px}label{display:block;color:#c7d0de;font-size:.82rem;font-weight:650;margin:16px 0 0}input{display:block;width:100%;margin-top:7px;border:1px solid var(--line);border-radius:9px;padding:12px;background:#0b1525;color:var(--text);outline:none}input:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(98,230,167,.1)}.login-card .button{margin-top:10px}.login-card .error{margin:0;min-height:.4em}.error{min-height:1.2em;color:var(--danger);font-size:.82rem}dialog{border:0;padding:0;background:transparent;color:var(--text);width:min(500px,calc(100% - 28px))}dialog::backdrop{background:rgba(2,7,14,.76);backdrop-filter:blur(5px)}.dialog-card{width:100%}.dialog-card.compact{padding:26px}.dialog-heading{display:flex;justify-content:space-between}.dialog-heading h2,.compact h2{margin:0;font-size:1.65rem}.dropzone{border:1px dashed #3a4d6b;border-radius:12px;padding:24px;text-align:center;cursor:pointer}.dropzone input{display:none}.dropzone span,.dropzone strong,.dropzone small{display:block}.upload-icon{font-size:1.5rem;color:var(--green);margin-bottom:7px}.dropzone small,label small{font-weight:400;color:var(--muted);margin-top:5px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:24px}#account-password-form .error{margin:0;min-height:.3em}#account-password-form .dialog-actions{margin-top:8px}.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:#eef6ff;color:#0b1626;padding:11px 16px;border-radius:9px;box-shadow:var(--shadow);transition:.2s;pointer-events:none}.toast.show{opacity:1;transform:translate(-50%,0)}.update-banner{position:fixed;left:50%;bottom:30px;transform:translate(-50%,0);display:flex;align-items:center;gap:16px;background:#17243a;border:1px solid var(--line);border-radius:12px;padding:14px 16px 14px 20px;box-shadow:var(--shadow);z-index:5;color:var(--text);font-size:.88rem}.update-banner-actions{display:flex;align-items:center;gap:8px}.update-banner .button{padding:8px 14px} +:root{--bg:#08101d;--panel:#101a2b;--panel2:#142136;--line:#23334d;--text:#f4f7fb;--muted:#91a0b6;--green:#62e6a7;--blue:#79a9ff;--danger:#ff7185;--warning:#ffbf69;--shadow:0 24px 70px rgba(0,0,0,.35);--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-5:24px;--space-6:32px;--space-7:40px;--radius-sm:9px;--radius-md:12px;--radius-lg:16px;--radius-xl:18px;--font-size-xs:.68rem;--font-size-sm:.78rem;--font-size-md:.82rem;--font-size-base:1rem;--empty-border:#30415e;--toggle-track-off:#344259;--toggle-thumb:#d7deea;--toast-bg:#eef6ff;--toast-text:#0b1626;--toast-error-bg:#ffe1e5;--toast-error-text:#5c0f1a;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:dark} +*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 75% -10%,#172b51 0,transparent 35%),var(--bg);background-repeat:no-repeat;background-attachment:fixed;color:var(--text);min-height:100vh}button,input{font:inherit}.hidden{display:none!important}.app-shell{display:grid;grid-template-columns:240px 1fr;min-height:100vh}aside{border-right:1px solid var(--line);padding:28px 20px;display:flex;flex-direction:column;background:rgba(8,16,29,.78);backdrop-filter:blur(20px)}.brand{display:flex;flex-direction:column;align-items:center;gap:12px;font-weight:750;letter-spacing:-.02em;border:0;background:none;padding:0;margin:0;width:100%;cursor:pointer;border-radius:10px;transition:opacity .15s ease}.brand:hover,.brand:focus-visible{opacity:.82}.brand:focus-visible{outline:2px solid var(--green);outline-offset:4px}.brand-icon{width:64px;height:64px;object-fit:contain}.brand-wordmark{width:190px;max-width:100%;height:auto;object-fit:contain;margin-top:0}.brand-stack{display:flex;flex-direction:column;align-items:center;gap:16px}.brand-icon-lg{width:88px;height:88px;object-fit:contain}.brand-wordmark-lg{width:290px;max-width:100%;height:auto;object-fit:contain;margin-top:0}.brand-mark{width:52px;height:52px;border-radius:15px;display:grid;place-items:center;background:linear-gradient(145deg,var(--green),#2fbf91);color:#06251a;font-weight:900;box-shadow:0 10px 30px rgba(98,230,167,.18)}.brand-mark.small{width:34px;height:34px;border-radius:10px}.eyebrow{text-transform:uppercase;letter-spacing:.16em;font-size:.7rem;color:var(--green);font-weight:800;margin:0 0 9px}nav{margin-top:42px}nav button{width:100%;border:0;border-radius:10px;padding:12px;text-align:left;background:transparent;color:var(--muted);display:flex;justify-content:space-between}nav .nav-active{background:var(--panel2);color:var(--text)}nav span{color:var(--green)}.aside-footer{margin-top:auto;padding:16px 10px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;color:var(--muted);font-size:.85rem}.text-button{border:0;background:none;color:var(--blue);cursor:pointer}main{padding:58px clamp(28px,6vw,88px);max-width:1500px;width:100%;margin:auto}header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px}h1{font-size:clamp(2rem,4vw,3.2rem);letter-spacing:-.045em;margin:0 0 8px;line-height:1}h2{letter-spacing:-.025em}.muted{color:var(--muted);margin:0;line-height:1.6}.button{border:1px solid transparent;border-radius:10px;padding:11px 16px;color:var(--text);font-weight:720;cursor:pointer}.button:disabled{opacity:.55;cursor:wait}.primary{background:var(--green);color:#05251a;box-shadow:0 10px 30px rgba(98,230,167,.13)}.secondary{background:var(--panel2);border-color:var(--line)}.danger{background:var(--danger);color:#2b0710}.wide{width:100%}.summary{display:flex;align-items:center;gap:28px;margin:40px 0 28px;padding:17px 20px;background:rgba(16,26,43,.75);border:1px solid var(--line);border-radius:14px}.summary>div{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:.88rem}.summary strong{color:var(--text)}.port-note{margin-left:auto}.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#61708a}.status-dot.running{background:var(--green);box-shadow:0 0 0 4px rgba(98,230,167,.1)}.status-dot.disabled{background:var(--danger);box-shadow:0 0 0 4px rgba(255,113,133,.09)}.status-dot.error,.status-dot.idle{background:var(--warning);box-shadow:0 0 0 4px rgba(255,191,105,.09)}.site-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:18px}.site-card{position:relative;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));border:1px solid var(--line);border-radius:16px;padding:20px;min-height:220px;box-shadow:0 14px 40px rgba(0,0,0,.14);transition:.2s}.site-card:hover{transform:translateY(-2px);border-color:#344b6e}.card-top,.card-footer,.card-actions{display:flex;align-items:center;justify-content:space-between}.site-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:#1c3050;color:var(--blue);font-size:.78rem;font-weight:850}.site-card h2{margin:27px 0 5px;font-size:1.15rem}.address{margin:0;color:var(--muted);font-family:ui-monospace,monospace;font-size:.82rem}.card-footer{position:absolute;left:20px;right:20px;bottom:20px}.status-pill{display:flex;align-items:center;gap:8px;text-transform:capitalize;font-size:.78rem;color:var(--muted)}.icon-button,.launch{width:34px;height:34px;border-radius:9px;border:1px solid var(--line);display:grid;place-items:center;background:#101b2e;color:var(--muted);cursor:pointer;text-decoration:none}.menu-wrap{position:relative}.menu{display:none;position:absolute;right:0;top:40px;width:145px;background:#17243a;border:1px solid var(--line);border-radius:10px;padding:6px;box-shadow:var(--shadow);z-index:3}.menu-open .menu{display:block}.menu button{border:0;background:none;color:var(--text);display:block;width:100%;text-align:left;padding:9px;border-radius:7px;cursor:pointer}.menu button:hover{background:#21314b}.menu .danger-text{color:var(--danger)}.toggle{width:39px;height:22px;border:0;border-radius:20px;background:#344259;padding:3px;cursor:pointer;transition:background-color .2s ease}.toggle:disabled{opacity:.7;cursor:wait}.toggle span{display:block;width:16px;height:16px;border-radius:50%;background:#d7deea;transition:transform .2s ease,background-color .2s ease}.toggle.on{background:#278764}.toggle.on span{transform:translateX(17px);background:white}.card-actions{gap:8px}.empty{text-align:center;border:1px dashed #30415e;border-radius:18px;padding:64px 24px}.empty-icon{font-size:2rem;color:var(--green)}.empty h2{margin:14px 0 8px}.empty p{color:var(--muted);margin:0 auto 24px;max-width:430px}.login-shell{min-height:100vh;display:grid;place-items:center;padding:20px}.login-card,.dialog-card{width:min(440px,100%);background:rgba(16,26,43,.96);border:1px solid var(--line);border-radius:18px;padding:30px;box-shadow:var(--shadow)}.login-card .brand-stack{margin-bottom:40px}.login-card h1{font-size:2.25rem}.login-card>.muted{margin-bottom:24px}label{display:block;color:#c7d0de;font-size:.82rem;font-weight:650;margin:16px 0 0}input{display:block;width:100%;margin-top:7px;border:1px solid var(--line);border-radius:9px;padding:12px;background:#0b1525;color:var(--text);outline:none}input:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(98,230,167,.1)}.login-card .button{margin-top:10px}.login-card .error{margin:0;min-height:.4em}.error{min-height:1.2em;color:var(--danger);font-size:.82rem}dialog{border:0;padding:0;background:transparent;color:var(--text);width:min(500px,calc(100% - 28px))}dialog::backdrop{background:rgba(2,7,14,.76);backdrop-filter:blur(5px)}.dialog-card{width:100%}.dialog-card.compact{padding:26px}.dialog-heading{display:flex;justify-content:space-between}.dialog-heading h2,.compact h2{margin:0;font-size:1.65rem}.dropzone{border:1px dashed #3a4d6b;border-radius:12px;padding:24px;text-align:center;cursor:pointer}.dropzone input{display:none}.dropzone span,.dropzone strong,.dropzone small{display:block}.upload-icon{font-size:1.5rem;color:var(--green);margin-bottom:7px}.dropzone small,label small{font-weight:400;color:var(--muted);margin-top:5px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:24px}#account-password-form .error{margin:0;min-height:.3em}#account-password-form .dialog-actions{margin-top:8px}.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:var(--toast-bg);color:var(--toast-text);padding:11px 16px;border-radius:var(--radius-sm);box-shadow:var(--shadow);transition:.2s;pointer-events:none}.toast.show{opacity:1;transform:translate(-50%,0)}.toast.toast-error{background:var(--toast-error-bg);color:var(--toast-error-text)}.update-banner{position:fixed;left:50%;bottom:30px;transform:translate(-50%,0);display:flex;align-items:center;gap:16px;background:#17243a;border:1px solid var(--line);border-radius:12px;padding:14px 16px 14px 20px;box-shadow:var(--shadow);z-index:5;color:var(--text);font-size:.88rem}.update-banner-actions{display:flex;align-items:center;gap:8px}.update-banner .button{padding:8px 14px} @media(max-width:760px){.app-shell{display:block}aside{display:none}main{padding:32px 18px}header{align-items:flex-start;flex-direction:column}.summary{gap:14px;flex-wrap:wrap}.port-note{width:100%;margin:0;padding-top:10px;border-top:1px solid var(--line)}.site-grid{grid-template-columns:1fr}} /* Header account controls and state-aware summary indicators */ .header-actions{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap}.theme-control{display:flex;align-items:center;gap:8px;margin:0;padding:7px 9px 7px 12px;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--muted);font-size:.78rem}.theme-control select{border:0;background:transparent;color:var(--text);font:inherit;font-weight:700;outline:none;cursor:pointer}.account-control{display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--muted);font-size:.78rem}.account-control strong{color:var(--text)}.status-dot.inactive{background:#718096;box-shadow:0 0 0 4px rgba(113,128,150,.09)} /* Light theme */ -:root[data-theme="light"]{color-scheme:light;--bg:#f3f6fa;--panel:#fff;--panel2:#e8eef6;--line:#d6dfeb;--text:#132033;--muted:#637188;--green:#138a5b;--blue:#2563ad;--danger:#cf334d;--warning:#c57a08;--shadow:0 24px 70px rgba(34,54,80,.14);--empty-border:#c3d0e0;--toggle-track-off:#aab5c4;--toggle-thumb:#ffffff;--toast-bg:#132033;--toast-text:#f5f8fc} -:root[data-theme="light"] body{background:radial-gradient(circle at 75% -10%,#dfeaff 0,transparent 36%),var(--bg);background-repeat:no-repeat;background-attachment:fixed}:root[data-theme="light"] aside{background:rgba(255,255,255,.82)}:root[data-theme="light"] .site-card{background:linear-gradient(145deg,#fff,#f6f9fc);box-shadow:0 14px 40px rgba(49,71,99,.08)}:root[data-theme="light"] .summary{background:rgba(255,255,255,.82)}:root[data-theme="light"] .site-icon{background:#e5eefb;color:#275c9c}:root[data-theme="light"] .icon-button,:root[data-theme="light"] .launch{background:#f5f8fc}:root[data-theme="light"] .menu{background:#fff}:root[data-theme="light"] .menu button:hover{background:#edf2f8}:root[data-theme="light"] input{background:#fff}:root[data-theme="light"] .login-card,:root[data-theme="light"] .dialog-card{background:rgba(255,255,255,.97)}:root[data-theme="light"] .toggle{background:#aab5c4}:root[data-theme="light"] .toast{background:#132033;color:#f5f8fc} +:root[data-theme="light"]{color-scheme:light;--bg:#f3f6fa;--panel:#fff;--panel2:#e8eef6;--line:#d6dfeb;--text:#132033;--muted:#637188;--green:#138a5b;--blue:#2563ad;--danger:#cf334d;--warning:#c57a08;--shadow:0 24px 70px rgba(34,54,80,.14);--empty-border:#c3d0e0;--toggle-track-off:#aab5c4;--toggle-thumb:#ffffff;--toast-bg:#132033;--toast-text:#f5f8fc;--toast-error-bg:#4a1420;--toast-error-text:#ffd9de} +:root[data-theme="light"] body{background:radial-gradient(circle at 75% -10%,#dfeaff 0,transparent 36%),var(--bg);background-repeat:no-repeat;background-attachment:fixed}:root[data-theme="light"] aside{background:rgba(255,255,255,.82)}:root[data-theme="light"] .site-card{background:linear-gradient(145deg,#fff,#f6f9fc);box-shadow:0 14px 40px rgba(49,71,99,.08)}:root[data-theme="light"] .summary{background:rgba(255,255,255,.82)}:root[data-theme="light"] .site-icon{background:#e5eefb;color:#275c9c}:root[data-theme="light"] .icon-button,:root[data-theme="light"] .launch{background:#f5f8fc}:root[data-theme="light"] .menu{background:#fff}:root[data-theme="light"] .menu button:hover{background:#edf2f8}:root[data-theme="light"] input{background:#fff}:root[data-theme="light"] .login-card,:root[data-theme="light"] .dialog-card{background:rgba(255,255,255,.97)}:root[data-theme="light"] .toggle{background:#aab5c4}:root[data-theme="light"] @media(max-width:1050px){header{align-items:flex-start;flex-direction:column}.header-actions{justify-content:flex-start}}@media(max-width:760px){.header-actions{width:100%}.account-control{order:3;width:100%;justify-content:space-between}.summary>div:not(.port-note){min-width:calc(50% - 10px)}}