diff --git a/README.md b/README.md index 920857d..992add2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Docker Architectures Caddy - Version + Version

Why Site Gateway · diff --git a/ROADMAP.md b/ROADMAP.md index d41839e..d10153a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,3 +156,5 @@ Roughly in priority order: `v0.16.14` fixes the Create button (Create user / Create group) disappearing or showing the wrong label after switching Administration tabs. Root cause: a leftover click handler on the admin tabs bar, written before Groups had a Create button at all, still hard-coded "hide the shared Create button unless the tab is Users" and manually poked tab-active/panel-visibility classes directly -- completely independent of and out of sync with the real logic added in v0.16.13's `render()`. Since that same handler also fires when the app restores your last-viewed tab on page load/refresh, it would immediately stomp the button back to the wrong state. Replaced both old handlers with one that simply updates state and calls the real `render()`, so there's a single source of truth for tab switching instead of two handlers disagreeing with each other. `v0.16.15` cleans up the Groups tab's layout: removed the redundant "Groups / Organize users for Access List permissions." heading, since the tab button and admin panel description already say what the tab is, and it was adding a bare, boxless line of text found nowhere else in Administration once the tab's own Create button moved to the shared header. The Enabled/Disabled stat bar is now the first thing in the panel, structurally matching how the Users tab's own stat bar is positioned. Also added top spacing between the Administration page's subtitle and the row of tab buttons (System, Users, Groups, ...) below it -- that gap had never been set, so the tabs bar sat flush against the subtitle text. + +`v0.16.16` ships a batch of fixes found in live use: the Backup type picker (in both the scheduled-backup form and the manual "Create a backup" dialog) no longer shows a long wrapped sentence as the selected value -- it now shows a short "Complete (Recommended)" / "Configuration only" label with the detail moved into the helper text beneath it, and the in-app documentation now explicitly names the "Backup type" field so it's easy to find by search. The Performance page's "Outliers / Slowest requests" section has been removed, along with the per-row error-count badge in the "Throughput by domain" table -- both added noise without being worth the space for most setups. That table's column headers now stay pinned while scrolling instead of scrolling out of view. Rows for domains with no matching Hosted Site, Proxy Host, or Redirect Host are now badged "Not configured" -- that table is built from Caddy's raw access log, so it always included every hostname a request was ever seen for (including scanner/bot traffic hitting made-up subdomains that fall through to the Default Site handler), not just domains you've actually configured; the badge makes that distinction visible instead of leaving it to guesswork. Finally, the Administration Users tab no longer flashes "No users found." for a moment before the user list has actually loaded. diff --git a/package.json b/package.json index f4375b5..d464371 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.16.15", + "version": "0.16.16", "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 15ddc9d..b333ce0 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -8,7 +8,7 @@ // --- Shared DOM shortcut and app state ---------------------------------------- const $ = selector => document.querySelector(selector); -const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] }; +const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], usersLoaded: false, user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] }; // One-time DOM patches: move the Access List field into the create/settings // forms (features.js owns the Access List data, this file owns these forms). @@ -29,7 +29,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 showLogin(message = "") { state.user = null; state.users = []; state.usersLoaded = false; 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, 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; } @@ -334,7 +334,6 @@ function renderPerformance() { $("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · Checked ${escapeHtml(formatTime(data.checkedAt))}`; const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours"; $("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`; - $("#performance-slowest-title").textContent = `Slowest requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`; const points = data.trend || []; state.performancePoints = points; const max = Math.max(1, ...points.map(point => point.count)); @@ -394,17 +393,14 @@ function renderPerformance() { const routes = (data.routes || []).filter(route => !selected || route.host === selected); state.performanceErrorBreakdowns = {}; state.performanceTopPaths = {}; - 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()} · `; }; + const countCell = count => `${count.toLocaleString()}`; const pathsCell = route => { if (!route.topPaths?.length) return "—"; state.performanceTopPaths[route.host] = route.topPaths; return ``; }; - $("#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)}${formatLatency(route.dayAvgMs)}${formatLatency(route.dayP95Ms)}${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}${(route.dayVisitors || 0).toLocaleString()}${pathsCell(route)}`).join("") : 'No requests have been logged yet.'; + // Requests are logged for any Host header Caddy ever saw, including ones with no matching + // Hosted Site / Proxy Host / Redirect Host -- those fall through to the Default Site handler + // instead of a real backend. Badge those rows so they read as log history, not live config. + const configuredDomains = new Set([...state.sites, ...state.proxies, ...state.redirects].flatMap(item => [item.domain, ...(item.domains || [])]).filter(Boolean).map(domain => domain.toLowerCase())); + $("#performance-rows").innerHTML = routes.length ? routes.map(route => { const unconfigured = !configuredDomains.has((route.host || "").toLowerCase()); return `${escapeHtml(route.host)}${unconfigured ? ' Not configured' : ""}${countCell(route.hourRequests)}${countCell(route.dayRequests)}${formatLatency(route.dayAvgMs)}${formatLatency(route.dayP95Ms)}${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}${(route.dayVisitors || 0).toLocaleString()}${pathsCell(route)}`; }).join("") : 'No requests have been logged yet.'; if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" }); - renderSlowestRequests(data.slowest || []); -} - -// --- Performance: slowest individual requests ------------------------------------------- -function renderSlowestRequests(entries) { - const list = $("#performance-slowest"); if (!list) return; - list.innerHTML = entries.length ? entries.map(entry => `

${escapeHtml(entry.method || "GET")} ${escapeHtml(entry.uri || "/")}${escapeHtml(entry.host || "—")} · ${entry.status ?? "—"} · ${escapeHtml(formatTime(entry.at))}${escapeHtml(formatLatency(entry.durationMs))}
`).join("") : '

No timed requests in this window yet.

'; } // --- Performance: hover tooltip on the request-trend chart ------------------------------- @@ -459,7 +455,7 @@ function renderUsers() { const statusToggle = user.status === "archived" ? "" : ``; const menu = ``; 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"}
${lifecycle}
`; - }).join("") : '

No users found.

'; + }).join("") : state.usersLoaded ? '

No users found.

' : '

Loading users…

'; 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, "error"); } }); old.replaceWith(select); }); } @@ -485,7 +481,7 @@ async function loadFeatureView() { if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); } if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); } if (state.view === "performance") { state.performance = await api(`/api/performance?host=${encodeURIComponent($("#performance-host").value)}&hours=${encodeURIComponent($("#performance-range").value || "6")}`); renderPerformance(); } - if (state.view === "administration") { [state.users, state.settings, state.backups] = await Promise.all([api("/api/users"), api("/api/settings"), api("/api/backups")]); renderUsers(); window.renderExtendedViews?.(); } + if (state.view === "administration") { [state.users, state.settings, state.backups] = await Promise.all([api("/api/users"), api("/api/settings"), api("/api/backups")]); state.usersLoaded = true; renderUsers(); window.renderExtendedViews?.(); } if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.(); restoreAdminTab(); } @@ -570,7 +566,7 @@ async function boot() { $("#login-copy").textContent = session.installationSetupPending ? "Sign in using the administrator credentials you configured during installation." : "Sign in to manage your sites."; if (!session.authenticated) return showLogin(); if (session.setupRequired) { $("#login").classList.add("hidden"); $("#dashboard").classList.add("hidden"); $("#setup-form [name=username]").value = session.user.username; if (!$("#setup-dialog").open) $("#setup-dialog").showModal(); return; } - state.view = location.hash.slice(1) || "overview"; state.users = []; showDashboard(); state.user = session.user; $("#user-label").textContent = session.user?.displayName || session.username; document.querySelectorAll(".admin-only").forEach(element => element.classList.toggle("hidden", !canAdmin())); render(); state.config = await api("/api/config"); + state.view = location.hash.slice(1) || "overview"; state.users = []; state.usersLoaded = false; showDashboard(); state.user = session.user; $("#user-label").textContent = session.user?.displayName || session.username; document.querySelectorAll(".admin-only").forEach(element => element.classList.toggle("hidden", !canAdmin())); render(); state.config = await api("/api/config"); $("#version-label").textContent = `v${state.config.version || "unknown"}`; 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}`; diff --git a/src/public/features.js b/src/public/features.js index cef89ae..ca6cf1d 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -307,7 +307,7 @@ if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) { } // Manual "Create backup" button. -document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Backup & restore

Create a backup

Choose what to include. Site Gateway saves a copy in /data/backups and downloads a copy to your computer.

Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.If provided, this password is required to restore the downloaded archive.
'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true); +document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Backup & restore

Create a backup

Choose what to include. Site Gateway saves a copy in /data/backups and downloads a copy to your computer.

Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups include settings and metadata, but not uploaded Hosted Site files.If provided, this password is required to restore the downloaded archive.
'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true); // --- Logs & Retention admin panel (Administration > Logs & Retention) ------------ diff --git a/src/public/index.html b/src/public/index.html index a915088..4b6fb34 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -8,7 +8,7 @@ Site Gateway - + - + @@ -315,7 +310,7 @@ - +