Compare commits

...

13 Commits

Author SHA1 Message Date
marvin 964b1dd2de Fix error-breakdown popup: remove close X, fix broken data handoff (v0.11.116) 2026-09-16 15:34:48 -04:00
marvin 26e2660a03 Use design tokens for spacing/radius/font-size in error-breakdown popup (v0.11.115) 2026-09-16 15:23:25 -04:00
marvin 9c20253fa4 Replace inline error-breakdown expand with a themed popup (v0.11.114) 2026-09-16 15:21:54 -04:00
marvin 49d8bd83e6 Click-to-expand error breakdown on Performance table (v0.11.113) 2026-09-16 15:11:59 -04:00
marvin 7109967f67 Update README/ROADMAP for v0.11.112 and current feature set 2026-09-16 15:11:47 -04:00
marvin e26c1a3dec Hide misleading preview caption for Abort/Redirect modes (v0.11.112) 2026-09-16 14:57:18 -04:00
marvin f51067bceb Fix blank page caused by unguarded download-support listener (v0.11.110) 2026-09-16 12:34:41 -04:00
marvin 31e50d0e14 Fix blank page caused by unguarded download-support listener (v0.11.110) 2026-09-16 12:26:10 -04:00
marvin 98b79042bf Hide Gateway diagnostics from view, keep referenceable (v0.11.109) 2026-09-16 12:21:32 -04:00
marvin 72afecd539 Restyle Gateway diagnostics as a proper card (v0.11.108) 2026-09-16 12:08:39 -04:00
marvin 180280791a Fix Gateway diagnostics showing on every admin tab (v0.11.107) 2026-09-16 11:57:37 -04:00
marvin 753fb33d52 Update Documentation manual to match Security & updates consolidation (v0.11.106) 2026-09-16 11:43:49 -04:00
marvin 7846342412 Consolidate Security & updates into Certificates and Backup & restore (v0.11.105) 2026-09-16 11:30:39 -04:00
7 changed files with 31 additions and 26 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
<img alt="Version" src="https://img.shields.io/badge/version-0.11.101-62E6A7">
<img alt="Version" src="https://img.shields.io/badge/version-0.11.112-62E6A7">
</p>
<p>
<a href="#why-site-gateway">Why Site Gateway</a> ·
+4 -2
View File
@@ -2,7 +2,7 @@
## Current release status
`v0.11.101` is a stable, day-to-day release. The product has moved well past the original alpha creation flow described in earlier versions of this document — Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts are all implemented, along with authentication, access control, certificates, backups, and full dashboard reporting. This document reflects what's actually shipped and what's genuinely still ahead.
`v0.11.112` is a stable, day-to-day release. The product has moved well past the original alpha creation flow described in earlier versions of this document — Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts are all implemented, along with authentication, access control, certificates, backups, and full dashboard reporting. This document reflects what's actually shipped and what's genuinely still ahead.
## Product direction
@@ -39,6 +39,7 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
- Performance view with request throughput, response times, and per-route breakdowns.
- Rotating access and activity logs.
- Update-available banner when a newer image is deployed.
- A redacted support-report export exists (version, config health, certificate readiness, upstream checks, recent events) but its UI entry point is currently hidden pending a readability rewrite of the report's output format.
### Data and operations
@@ -49,6 +50,7 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
### Brand and docs
- Current icon and wordmark (v0.11.99) used consistently across the login screen, sidebar, themed default pages, and this README.
- A sitewide design-token system (colors, spacing, radius, and type scale defined once and reused everywhere) underpins the interface, so new UI stays visually consistent by default.
- Integrated, searchable in-app documentation covering every configurable field, including 2FA and the update-notification banner.
- Companion marketing site with an installation guide covering Docker Compose, plain `docker run`, and Unraid.
@@ -56,7 +58,7 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
Roughly in priority order:
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain, plus a redacted support-report export.
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain.
- **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships.
- **Browsable backup/restore history** — today a restore validates and rolls back safely, but there's no UI history of past backups beyond what's on disk.
- **Container picker for Proxy/Streaming targets** — letting a target be selected from a list of running Docker containers instead of typed as an IP/hostname, gated behind an opt-in Docker-socket mount since it needs real access to the Engine API. Also needs a shared Docker network between Site Gateway and the target container to actually be reachable, not just discoverable.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
"version": "0.11.104",
"version": "0.11.116",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
+10 -5
View File
@@ -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 };
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: {} };
// 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).
@@ -338,8 +338,9 @@ function renderPerformance() {
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />` : "";
if (!points.length) $("#performance-sparkline-labels").innerHTML = '<span class="axis-label" style="left:0;width:100%;top:45%;text-align:center">No request data for this window yet.</span>';
const routes = data.routes || [];
const countCell = (count, errors, breakdown) => { const title = breakdown?.length ? ` title="${escapeHtml(breakdown.map(item => `${item.status}: ${item.count.toLocaleString()}`).join(" · "))}"` : ""; return `${count.toLocaleString()}${errors ? ` <span class="count-divider">·</span> <span class="http-status bad"${title}>${errors.toLocaleString()}</span>` : ""}`; };
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown)}</td><td>${route.dayAvgMs == null ? "—" : `${route.dayAvgMs} ms`}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
state.performanceErrorBreakdowns = {};
const countCell = (count, errors, breakdown, host) => { if (!errors) return `${count.toLocaleString()}`; if (!breakdown?.length) return `${count.toLocaleString()} <span class="count-divider">·</span> <span class="http-status bad">${errors.toLocaleString()}</span>`; state.performanceErrorBreakdowns[host] = { total: errors, breakdown }; return `${count.toLocaleString()} <span class="count-divider">·</span> <button type="button" class="http-status bad count-link-button" data-error-host="${escapeHtml(host)}">${errors.toLocaleString()}</button>`; };
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}</td><td>${route.dayAvgMs == null ? "—" : `${route.dayAvgMs} ms`}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
}
@@ -413,7 +414,7 @@ function render() {
return;
}
if (!management) {
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
$("#open-create").textContent = state.view === "administration" ? " Create user" : state.view === "streaming" ? " New streaming host" : state.view === "redirects" ? " New redirect host" : state.view === "access" ? " New Access List" : $("#open-create").textContent;
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
@@ -500,7 +501,7 @@ $("#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"; } });
$("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; });
$("#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)); } });
// --- Primary navigation (sidebar view switching) -------------------------------------------
@@ -513,6 +514,10 @@ $("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error
$("#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)));
// --- 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 => `<div class="error-breakdown-row"><span>${escapeHtml(item.status)}</span><span>${item.count.toLocaleString()}</span></div>`).join(""); dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.</p><div class="error-breakdown-list">${rows}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`; dialog.showModal(); }
$("#performance-rows").addEventListener("click", event => { const button = event.target.closest("[data-error-host]"); if (!button) return; const entry = state.performanceErrorBreakdowns[button.dataset.errorHost]; if (!entry) return; showErrorBreakdown(button.dataset.errorHost, entry.total, entry.breakdown); });
$("#log-status").addEventListener("change", renderLogs);
$("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs);
+5 -5
View File
@@ -113,12 +113,12 @@ function defaultSiteThemedHtml(title, message) {
// match the currently selected response mode (themed 404 / welcome / abort /
// redirect / custom HTML).
function renderDefaultSitePreview() {
const form = document.querySelector("#default-site-form"), frame = document.querySelector("#default-site-preview-frame"), note = document.querySelector("#default-site-preview-note");
const form = document.querySelector("#default-site-form"), frame = document.querySelector("#default-site-preview-frame"), note = document.querySelector("#default-site-preview-note"), caption = document.querySelector("#default-site-preview-caption");
if (!form || !frame || !note) return;
const mode = form.elements.mode.value;
if (mode === "abort") { frame.closest(".default-site-preview-frame-wrap").classList.add("hidden"); note.classList.remove("hidden"); note.textContent = "No page is shown for this mode \u2014 the connection is closed immediately."; return; }
if (mode === "redirect") { frame.closest(".default-site-preview-frame-wrap").classList.add("hidden"); note.classList.remove("hidden"); const destination = form.elements.redirectUrl.value.trim(); note.textContent = destination ? `No page is shown for this mode \u2014 visitors are redirected to ${destination}.` : "No page is shown for this mode \u2014 enter a redirect destination above."; return; }
frame.closest(".default-site-preview-frame-wrap").classList.remove("hidden"); note.classList.add("hidden");
if (mode === "abort") { frame.closest(".default-site-preview-frame-wrap").classList.add("hidden"); note.classList.remove("hidden"); note.textContent = "No page is shown for this mode \u2014 the connection is closed immediately."; caption?.classList.add("hidden"); return; }
if (mode === "redirect") { frame.closest(".default-site-preview-frame-wrap").classList.add("hidden"); note.classList.remove("hidden"); const destination = form.elements.redirectUrl.value.trim(); note.textContent = destination ? `No page is shown for this mode \u2014 visitors are redirected to ${destination}.` : "No page is shown for this mode \u2014 enter a redirect destination above."; caption?.classList.add("hidden"); return; }
frame.closest(".default-site-preview-frame-wrap").classList.remove("hidden"); note.classList.add("hidden"); caption?.classList.remove("hidden");
if (mode === "custom") { frame.srcdoc = form.elements.customHtml.value || "<p style=\"font:14px sans-serif;color:#95a4ba;padding:20px\">Enter Custom HTML above to preview it here.</p>"; return; }
const title = form.elements.title.value || (mode === "welcome" ? "Gateway ready" : "Route not found");
const message = form.elements.message.value || "The gateway is responding, but this address has not been configured.";
@@ -275,7 +275,7 @@ document.addEventListener("click", event => { if (event.target.closest(".create-
function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = "<span></span>"; actions.append(toggle); }); }
function decorateGroupCards() { document.querySelectorAll('[data-admin-panel="groups"] .group-card').forEach(card => { const group = state.groups.find(value => value.id === card.querySelector("[data-group-action]")?.dataset.groupId); if (!group) return; const icon = card.querySelector(".site-icon"); if (icon && icon.textContent.trim() === "GR") icon.innerHTML = featureIcon(group, "GR"); const menu = card.querySelector(".menu"); if (menu && !menu.querySelector("[data-group-action=icon]")) { const button = document.createElement("button"); button.dataset.groupAction = "icon"; button.dataset.groupId = group.id; button.textContent = "Change icon"; menu.prepend(button); } }); }
document.addEventListener("click", event => { const button = event.target.closest("[data-group-action=icon]"); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); openIconPicker("groups", button.dataset.groupId); }, true);
function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","security","retention","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","retention","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); });
// --- Backup encryption password field: placeholder/visibility polish -------------
+8 -10
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -35,7 +35,7 @@ main{padding-top:24px}.utility-bar{display:flex;align-items:center;justify-conte
.performance-sparkline-labels .time-label{position:absolute;bottom:0;color:var(--muted);font-size:.68rem;white-space:nowrap}
.performance-sparkline-labels .time-label.time-label-end{transform:translateX(-100%)}.row-highlight{background:rgba(98,230,167,.08)}
.user-head-actions{display:flex;align-items:center;gap:10px}
[data-admin-panel="security"]>h2{margin-bottom:18px}[data-admin-panel="security"]>.dashboard-panel+.dashboard-panel,[data-admin-panel="security"]>.dashboard-panel+form{margin-top:18px}
[data-admin-panel="backups"]>.dashboard-panel{margin-top:24px}
.user-role-select{appearance:none!important;-webkit-appearance:none!important;height:44px;min-height:44px;width:100%;box-sizing:border-box;padding:0 42px 0 12px;line-height:42px;border:1px solid var(--line);border-radius:9px;background-color:var(--panel);color:var(--text);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23f4f7fb' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;background-size:16px}
:root[data-theme="light"] .user-role-select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23132033' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E")}
@media(max-width:760px){.data-row{grid-template-columns:auto 1fr}.data-row>div:nth-of-type(n+2){grid-column:2}.feature-summary{gap:8px}.feature-summary>div{padding:13px}.log-toolbar{align-items:stretch;flex-direction:column}.log-toolbar label{min-width:0}}
@@ -277,7 +277,7 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:23%;text-align:center}
.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:23%;text-align:center}
.performance-table .count-divider{color:var(--muted);margin:0 2px}
.performance-table td .http-status.bad[title]{cursor:help;text-decoration:underline dotted;text-underline-offset:3px}
.performance-table .count-link-button{border:0;background:none;padding:0;font:inherit;color:var(--danger);cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px}.performance-table .count-link-button:hover{text-decoration-style:solid}.error-breakdown-list{margin-top:var(--space-4);border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(8,16,29,.28);padding:0 var(--space-4)}.error-breakdown-row{display:flex;justify-content:space-between;gap:var(--space-4);padding:var(--space-3) 0;border-top:1px solid var(--line);font-size:var(--font-size-md)}.error-breakdown-row:first-child{border-top:0}
@media(max-width:760px){.performance-table th:nth-child(2),.performance-table td:nth-child(2){display:none}.performance-table th:nth-child(1),.performance-table td:nth-child(1){width:40%}.performance-table th:nth-child(3),.performance-table td:nth-child(3){width:30%}.performance-table th:nth-child(4),.performance-table td:nth-child(4){width:30%}}
.mfa-qr{display:flex;justify-content:center;padding:16px;background:#fff;border-radius:12px;margin:16px 0}
.mfa-qr svg{width:200px;height:200px}