Import Site Gateway app and clean up for standalone release

This commit is contained in:
2026-09-12 17:58:54 +00:00
commit bbeeb633de
24 changed files with 4214 additions and 0 deletions
+464
View File
@@ -0,0 +1,464 @@
const $ = selector => document.querySelector(selector);
const summaryBar = document.querySelector("#management-summary");
const redirectView = document.querySelector("#redirects-view");
if (summaryBar && redirectView) redirectView.parentElement.insertBefore(summaryBar, redirectView);
const state = { sites: [], proxies: [], redirects: [], 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 };
document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain ");
if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this hosted site and all of its domains.</small>'; anchor.before(label); } }
if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select id="settings-access-list" name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this route and all of its domains.</small>'; anchor.after(label); } }
const proxyAccessLabel = document.querySelector("#proxy-form [name=accessListId]")?.closest("label"); const proxyTlsLabel = document.querySelector("#proxy-form [name=tls]")?.closest("label"); if (proxyAccessLabel && proxyTlsLabel) proxyTlsLabel.before(proxyAccessLabel);
const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel);
document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove();
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
function applyTheme(preference) {
const effective = preference === "system" ? (systemTheme.matches ? "dark" : "light") : preference;
document.documentElement.dataset.theme = effective;
document.querySelector('meta[name="theme-color"]').content = effective === "dark" ? "#08101d" : "#f3f6fa";
}
const savedTheme = localStorage.getItem("webserver-theme") || "system";
$("#theme-select").value = savedTheme; applyTheme(savedTheme);
$("#theme-select").addEventListener("change", event => { localStorage.setItem("webserver-theme", event.target.value); applyTheme(event.target.value); });
systemTheme.addEventListener("change", () => { if ($("#theme-select").value === "system") applyTheme("system"); });
async function api(url, options = {}) {
const response = await fetch(url, options);
if (response.status === 401) { showLogin(); throw new Error("Please sign in again."); }
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); }
return response.status === 204 ? null : response.json();
}
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; }
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 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}`; }
function formatBytes(value) {
if (!Number.isFinite(value)) return "Unavailable";
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB", "TB"]; let size = value / 1024, unit = units[0];
for (let index = 1; size >= 1024 && index < units.length; index++) { size /= 1024; unit = units[index]; }
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${unit}`;
}
function formatDuration(seconds) {
if (!Number.isFinite(seconds)) return "Unavailable";
const days = Math.floor(seconds / 86400), hours = Math.floor(seconds % 86400 / 3600), minutes = Math.floor(seconds % 3600 / 60);
if (days) return `${days}d ${hours}h`; if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`;
}
function formatTime(value) {
if (!value) return "Just now";
const date = new Date(value); return Number.isNaN(date.getTime()) ? "Recently" : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
}
function certificateStatusLabel(status) { return ({ healthy:"Healthy", warning:"Renewal due soon", critical:"Renewal required urgently", expired:"Expired", pending:"Awaiting Caddy / ACME certificate", mismatch:"Certificate does not cover this domain" }[status] || String(status || "Unknown")).replaceAll("-", " "); }
function parseHeaderLines(value) { return String(value || "").split("\n").map(line => { const index = line.indexOf(":"); return index > 0 ? { name:line.slice(0,index).trim(), value:line.slice(index+1).trim() } : null; }).filter(Boolean); }
function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; return Boolean(form.querySelector(`${scope} [name="healthEnabled"]`)?.checked); }
function scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; }
function advancedFormBody(form, body) {
body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
body.hsts = form.has("hsts"); body.hstsSubdomains = form.has("hstsSubdomains"); body.healthEnabled = body.healthEnabled === true || body.healthEnabled === "on"; body.upstreamTlsInsecure = form.has("upstreamTlsInsecure");
body.requestHeaders = parseHeaderLines(form.get("requestHeadersText")); body.responseHeaders = parseHeaderLines(form.get("responseHeadersText")); body.compression = form.get("compression") || "automatic"; body.customConfig = form.get("customConfig") || "";
body.locations = String(form.get("customLocationsText") || "").split("\n").map(line => { const [path, target, behavior] = line.split("|").map(value => value.trim()); return path && target ? { path, target, stripPrefix:behavior.toLowerCase() === "strip" } : null; }).filter(Boolean);
body.upstreams = String(form.get("upstreamsText") || "").split("\n").map(value => value.trim()).filter(Boolean);
body.healthPath = form.get("healthPath") || "/"; body.healthMethod = form.get("healthMethod") || "GET"; body.healthExpected = form.get("healthExpected") || "200-499"; body.healthTimeoutSeconds = Number(form.get("healthTimeoutSeconds") || 4); body.healthRetries = Number(form.get("healthRetries") || 0);
delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText;
return body;
}
// Single capture-path for monitoring settings: unchecked checkboxes must be sent as false.
document.addEventListener("submit", async event => {
if (event.target?.id !== "settings-form" || !state.editing) return;
event.preventDefault(); event.stopImmediatePropagation();
const form = new FormData(event.target), button = event.submitter;
let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile;
if (state.editing.kind === "proxy") body = advancedFormBody(form, body);
else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; }
button.disabled = true;
try { await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); }
catch (error) { $("#settings-error").textContent = error.message; }
finally { button.disabled = false; }
}, true);
function healthCopy(group, label) {
if (!group.total) return "Nothing configured";
if (group.errors) return `${group.errors} ${group.errors === 1 ? label.replace(/s$/, "") : label} need attention`;
if (group.running) return `${group.running} running${group.disabled ? ` · ${group.disabled} disabled` : ""}`;
return `${group.disabled} disabled`;
}
function probeClass(service) { return service.status === "ready" ? "running" : service.status === "error" ? "error" : service.status === "checking" ? "idle" : "inactive"; }
function probeCopy(service, ready, error, unconfigured = "Not configured") {
if (service.status === "checking") return "Checking again before reporting a problem";
if (service.status === "unconfigured") return unconfigured;
return service.status === "ready" ? ready : error;
}
function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
function renderDashboard() {
const data = state.dashboard; if (!data) return;
if (data.system) renderDashboardJobsSafe(data.system);
$("#dash-hosted-total").textContent = data.hosted.total;
$("#dash-hosted-detail").textContent = healthCopy(data.hosted, "sites");
$("#dash-proxy-total").textContent = data.proxies.total;
$("#dash-proxy-detail").textContent = healthCopy(data.proxies, "routes");
$("#dash-tls-total").textContent = data.tlsDomains;
$("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains";
$("#dash-attention-total").textContent = data.attention.length;
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues";
const hasErrors = data.attention.length > 0, isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
const overall = $("#overall-health");
overall.className = `health-badge ${hasErrors ? "error" : isChecking || hasNothingRunning ? "warning" : "healthy"}`;
overall.textContent = hasErrors ? "Needs attention" : isChecking ? "Checking" : hasNothingRunning ? "Idle" : "Healthy";
$("#gateway-health-dot").className = `status-dot ${probeClass(data.gateway)}`;
$("#gateway-health-copy").textContent = probeCopy(data.gateway, data.gateway.lastReload ? `Ready · reloaded ${formatTime(data.gateway.lastReload)}` : "Ready and responding", "Caddy is not responding");
$("#http-health-dot").className = `status-dot ${probeClass(data.services.http)}`;
$("#http-health-copy").textContent = probeCopy(data.services.http, "Ready and responding", "Not responding");
$("#https-health-dot").className = `status-dot ${probeClass(data.services.https)}`;
$("#https-health-copy").textContent = probeCopy(data.services.https, `Ready and responding · ${data.services.https.activeDomains} TLS domain${data.services.https.activeDomains === 1 ? "" : "s"}`, "Not responding", "Not configured · no TLS domains enabled");
$("#storage-health-dot").className = `status-dot ${data.services.storage.healthy ? "running" : "error"}`;
$("#storage-health-copy").textContent = data.services.storage.healthy ? "Ready · /data is readable and writable" : "Permission error · check /data";
$("#health-checked").textContent = `Last checked ${formatTime(data.checkedAt)}`;
updateDashboardUptime(data.system.uptimeSeconds);
$("#system-memory").textContent = formatBytes(data.system.memoryBytes);
$("#system-data").textContent = formatBytes(data.system.dataBytes);
$("#system-disk").textContent = formatBytes(data.system.diskFreeBytes);
$("#system-disk").title = `${formatBytes(data.system.diskFreeBytes)} available of ${formatBytes(data.system.diskTotalBytes)} on the /data volume`;
$("#system-app-version").textContent = `v${data.system.appVersion}`;
$("#system-caddy-version").textContent = data.system.caddyVersion;
$("#system-database").textContent = `${data.system.databaseEngine} · ${data.system.databaseStatus}`;
$("#system-database-detail").textContent = `${formatBytes(data.system.databaseBytes)} configuration database`;
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="dashboard-list-item issue ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`).join("") : '<p class="quiet-state">Everything looks good.</p>';
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="dashboard-list-item"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(formatTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
}
setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000);
function initials(name) {
const words = String(name || "").trim().split(/\s+/).map(word => word.replace(/[^a-z0-9]/gi, "")).filter(Boolean);
if (!words.length) return "??";
return (words.length > 1 ? words[0][0] + words[1][0] : words[0].slice(0, 2).padEnd(2, words[0][0])).toUpperCase();
}
function iconMarkup(item) { return item.icon ? `<img src="${escapeHtml(item.icon)}" alt="">` : escapeHtml(initials(item.name)); }
document.addEventListener('error', event => { const image = event.target; if (!(image instanceof HTMLImageElement) || !image.closest('.site-icon') || image.dataset.fallback) return; image.dataset.fallback = 'true'; const fallback = document.createElement('span'); fallback.textContent = initials(image.closest('[data-id]')?.querySelector('h2')?.textContent || '?'); image.replaceWith(fallback); }, true);
function canManage() { return ["administrator", "standard"].includes(state.user?.role); }
function canAdmin() { return state.user?.role === "administrator"; }
function hostedCard(site) {
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : "";
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${site.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
}
function proxyCard(proxy) {
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : "";
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
}
function renderCertificates() {
const data = state.certificates; if (!data) return;
$("#certificate-count").textContent = data.summary.total;
$("#cert-healthy").textContent = data.summary.healthy; $("#cert-30").textContent = data.summary.within30Days; $("#cert-7").textContent = data.summary.within7Days; $("#cert-warning").textContent = data.summary.warning + data.summary.critical + data.summary.expired + data.summary.mismatch; $("#cert-pending").textContent = data.summary.pending;
const ageMinutes = (Date.now() - new Date(data.checkedAt).getTime()) / 60000, stale = ageMinutes > (data.thresholds?.staleMinutes || 10);
$("#cert-last-checked").textContent = `Last checked ${formatTime(data.checkedAt)} · ${stale ? "data may be stale" : "current"}`;
$("#certificate-list").innerHTML = data.certificates.length ? data.certificates.map(cert => `<details class="certificate-row"><summary><span class="status-dot ${cert.status === "healthy" ? "running" : cert.status === "pending" ? "idle" : "error"}"></span><span><strong>${escapeHtml(cert.domain)}</strong><small>${escapeHtml(cert.kind)} · ${escapeHtml(cert.name)} · ${escapeHtml(cert.source)}</small></span><span><strong>${cert.expiresAt ? `${cert.daysRemaining} days remaining` : cert.status === "mismatch" ? "Domain mismatch" : "Not detected"}</strong><small>${cert.expiresAt ? `Expires ${formatTime(cert.expiresAt)}` : cert.mismatch ? `Covers: ${(cert.coveredNames || []).map(escapeHtml).join(", ") || "no DNS names"}` : "No stored certificate was found"}</small></span></summary><dl class="certificate-details"><div><dt>Status</dt><dd>${escapeHtml(cert.status)}</dd></div><div><dt>Valid from</dt><dd>${cert.validFrom ? escapeHtml(formatTime(cert.validFrom)) : "—"}</dd></div><div><dt>Issuer</dt><dd>${escapeHtml(cert.issuer || "—")}</dd></div><div><dt>Covered domains</dt><dd>${escapeHtml((cert.coveredNames || []).join(", ") || "—")}</dd></div><div><dt>Serial number</dt><dd>${escapeHtml(cert.serialNumber || "—")}</dd></div><div><dt>SHA-256 fingerprint</dt><dd>${escapeHtml(cert.fingerprint || "—")}</dd></div><div><dt>Last detected update</dt><dd>${cert.updatedAt ? escapeHtml(formatTime(cert.updatedAt)) : "—"}</dd></div></dl></details>`).join("") : '<p class="quiet-state padded">No HTTPS domains are configured.</p>';
renderReadiness();
}
function renderReadiness() {
const routes = state.readiness?.routes || [];
$("#readiness-list").innerHTML = routes.length ? routes.map(item => {
const dnsOk = item.dns.healthy, portsOk = item.ports.http && item.ports.https !== false;
const tlsOk = ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status);
const upstreamOk = !item.upstream || item.upstream.status === "healthy";
const check = item.upstream;
const message = !dnsOk ? `DNS failed${item.dns.error ? ` · ${item.dns.error}` : ""}` : !item.ports.http ? "HTTP port 80 is not responding inside the container" : item.ports.https === false ? "HTTPS port 443 is not responding inside the container" : !tlsOk ? `TLS ${item.tls.status.replaceAll("-", " ")}` : !upstreamOk ? `Upstream ${check?.error || "unavailable"}` : `Ready · DNS ${item.dns.addresses.join(", ")}${check ? ` · upstream ${check.httpStatus || "responding"}` : ""}`;
return `<div class="dashboard-list-item readiness-row" role="button" tabindex="0" data-readiness-id="${escapeHtml(item.id)}" aria-label="View diagnostics for ${escapeHtml(item.domain)}"><span class="status-dot ${dnsOk && portsOk && tlsOk && upstreamOk ? "running" : "error"}"></span><span><strong>${escapeHtml(item.domain)}</strong><small>${escapeHtml(message)}</small><span class="readiness-hint">Click to view diagnostics</span></span></div>`;
}).join("") : '<p class="quiet-state">No configured domains to check.</p>';
}
function showReadinessDetails(item) {
const check = item.upstream;
const upstream = check ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${check.httpStatus ?? "no response"}${check.responseMs != null ? ` · ${check.responseMs} ms` : ""} · ${check.attempts || 1} attempt${(check.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(check.checkedAt))}</dd></div>${check.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(check.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>";
$("#readiness-title").textContent = item.domain;
$("#readiness-detail-content").innerHTML = `<dl class="readiness-detail-grid"><div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${upstream}</dl>`;
$("#readiness-dialog").showModal();
}
$("#readiness-list").addEventListener("click", event => { const row = event.target.closest("[data-readiness-id]"); const item = state.readiness?.routes?.find(route => route.id === row?.dataset.readinessId); if (item) showReadinessDetails(item); });
$("#readiness-list").addEventListener("keydown", event => { if (event.key !== "Enter" && event.key !== " ") return; const row = event.target.closest("[data-readiness-id]"); if (row) { event.preventDefault(); row.click(); } });
function renderLogs() {
const data = state.logs; if (!data) return;
const selected = $("#log-host").value; $("#log-host").innerHTML = '<option value="">All domains</option>' + data.hosts.map(host => `<option value="${escapeHtml(host)}">${escapeHtml(host)}</option>`).join(""); $("#log-host").value = selected;
const statusClass = $("#log-status").value, entries = statusClass ? data.entries.filter(entry => String(entry.status || "").startsWith(statusClass)) : data.entries;
const errors = entries.filter(entry => entry.status >= 400).length, measured = entries.filter(entry => entry.durationMs != null), average = measured.length ? Math.round(measured.reduce((sum,entry) => sum + entry.durationMs,0) / measured.length) : null;
$("#log-summary").innerHTML = `${entries.length} request${entries.length === 1 ? "" : "s"} · ${errors} error response${errors === 1 ? "" : "s"} · ${average == null ? "no latency data" : `${average} ms average`} · <span id="log-last-checked">Checked ${escapeHtml(formatTime(new Date().toISOString()))}</span>`;
$("#log-rows").innerHTML = entries.length ? entries.map(entry => `<tr><td>${escapeHtml(formatTime(entry.at))}</td><td>${escapeHtml(entry.host || "—")}</td><td><code>${escapeHtml(entry.method || "")} ${escapeHtml(entry.uri || "")}</code></td><td><span class="http-status ${entry.status >= 500 ? "bad" : ""}">${entry.status ?? "—"}</span></td><td>${entry.durationMs == null ? "—" : `${entry.durationMs} ms`}</td></tr>`).join("") : '<tr><td colspan="5" class="quiet-state">No matching requests have been logged yet.</td></tr>';
const categoryOf = message => /cert|tls|https/i.test(message) ? "certificate" : /health|upstream|response|fetch/i.test(message) ? "health" : /login|user|password|access/i.test(message) ? "authentication" : /backup|restore/i.test(message) ? "backup" : /config|route|host|gateway|reload/i.test(message) ? "configuration" : "system";
const severity = $("#event-severity").value, category = $("#event-category").value;
const activity = data.activity.filter(item => (!severity || item.status === severity) && (!category || categoryOf(item.message) === category));
$("#gateway-log-list").innerHTML = activity.length ? activity.map(item => { const eventCategory = categoryOf(item.message); const indicatorClass = item.status === "error" ? "disabled" : item.status === "warning" ? "error" : "running"; return `<div class="event-row"><span class="status-dot ${indicatorClass}" aria-label="${escapeHtml(item.status || "ok")}"></span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(eventCategory)} · ${escapeHtml(formatTime(item.at))}</small></span></div>`; }).join("") : '<div class="gateway-empty-state"><span class="status-dot"></span><strong>No matching gateway events</strong><small>Try a different severity or category filter.</small></div>';
}
function renderUsers() {
const counts = { administrator: 0, standard: 0, viewer: 0, disabled: 0, archived: 0 };
state.users.forEach(user => { if (user.status === "active") counts[user.role] = (counts[user.role] || 0) + 1; else if (counts[user.status] !== undefined) counts[user.status] += 1; });
const summary = $("#user-summary");
if (summary) summary.innerHTML = [["Administrators", counts.administrator, "#62e6a7"], ["Standard Users", counts.standard, "#6ea8ff"], ["Viewers", counts.viewer, "#b58cff"], ["Disabled", counts.disabled, "#ff7185"], ["Archived", counts.archived, "#e6a04f"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("");
$("#user-list").innerHTML = state.users.length ? state.users.map(user => {
const isSelf = user.id === state.user?.id;
const statusClass = user.status === "active" ? "running" : user.status === "disabled" ? "disabled" : "inactive";
const roleAction = user.role === "administrator" ? "standard" : user.role === "standard" ? "viewer" : "administrator";
const roleLabel = user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User";
const lifecycle = user.status === "archived" ? `<button class="button secondary" data-user-action="status" data-value="active">Restore</button>` : `<button class="button secondary danger-text" data-user-action="status" data-value="archived">Archive</button>`;
const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`;
const deleteAction = !isSelf ? `<button class="button secondary danger-text" data-user-action="delete">Delete</button>` : "";
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}${deleteAction}</div><div class="card-footer">${statusToggle}</div></article>`;
}).join("") : '<p class="quiet-state">No users found.</p>';
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const user = state.users.find(item => item.id === card.dataset.userId); const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!user || !head || !footer) return; if (status) footer.prepend(status); const menu = document.createElement("div"); menu.className = "menu-wrap"; menu.innerHTML = '<button class="icon-button" type="button" aria-label="Change user icon">•••</button>'; menu.querySelector("button").addEventListener("click", () => openIconPicker("users", user.id)); head.append(menu); });
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.style.cssText = "height:44px;min-height:44px;width:100%;box-sizing:border-box;padding:0 42px 0 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);line-height:42px"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; 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); });
}
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 === "administration") { [state.users, state.settings, state.backups] = await Promise.all([api("/api/users"), api("/api/settings"), api("/api/backups")]); renderUsers(); window.renderExtendedViews?.(); }
if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.();
restoreAdminTab();
}
function render() {
const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view;
if (location.hash !== `#${viewHash}`) history.replaceState(null, "", `${location.pathname}${location.search}#${viewHash}`);
$("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = "0"; $("#redirect-count").textContent = state.redirects.length; $("#access-count").textContent = state.accessLists.length; $("#certificate-count").textContent = state.certificates?.summary.total || 0;
document.querySelectorAll("nav [data-view], .aside-utilities [data-view]").forEach(button => button.classList.toggle("nav-active", button.dataset.view === state.view));
const overview = state.view === "overview";
$("#dashboard-view").classList.toggle("hidden", !overview);
const management = state.view === "hosted" || state.view === "proxies" || state.view === "streaming";
$("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "redirects" || state.view === "access"));
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#users-view").classList.toggle("hidden", state.view !== "administration");
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
$("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
if (overview) {
$("#page-title").textContent = "Dashboard";
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
renderDashboard();
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."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], 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."] };
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 === "redirects" ? " New redirect host" : state.view === "access" ? " New Access List" : $("#open-create").textContent;
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
if (state.view === "redirects") { const items = state.redirects; const running = items.filter(item => item.enabled !== false).length, disabled = items.length - running; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = 0; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = "status-dot inactive"; $(".port-note").classList.add("hidden"); }
if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs();
return;
}
const items = state.view === "hosted" ? state.sites : state.view === "proxies" ? state.proxies : [];
$("#site-grid").innerHTML = items.map(state.view === "hosted" ? hostedCard : proxyCard).join("");
$("#empty").classList.toggle("hidden", !state.loaded || items.length > 0);
$("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : state.view === "proxies" ? "Create your first proxy host" : "Create your first streaming host";
$("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : state.view === "proxies" ? "Connect a domain to another container, application, or LAN service." : "Streaming host management is coming soon.";
$("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : state.view === "proxies" ? "Proxy hosts" : "Streaming hosts";
$("#page-subtitle").textContent = state.view === "hosted" ? "Upload and publish websites on a port or domain." : state.view === "proxies" ? "Route domains securely to applications and containers." : "Prepare and monitor streaming services from one place.";
$("#open-create").textContent = state.view === "hosted" ? " New hosted site" : " New proxy host";
$("#open-create").classList.toggle("hidden", state.view === "streaming" || !canManage());
$("#empty .create-trigger").textContent = state.view === "hosted" ? "Create a hosted site" : state.view === "proxies" ? "Create a proxy host" : "Streaming hosts coming soon";
$("#empty .create-trigger").disabled = state.view === "streaming";
$(".port-note").classList.toggle("hidden", state.view === "proxies");
const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length;
$("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors;
$("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues";
$("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`;
}
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } }
async function refreshPendingProxies(ids = []) {
const pending = new Set(ids.map(String));
for (const delay of [1000, 2000, 3000]) {
if (!pending.size) return;
await new Promise(resolve => setTimeout(resolve, delay));
await refresh();
for (const proxy of state.proxies) if (pending.has(String(proxy.id)) && proxy.upstream) pending.delete(String(proxy.id));
}
}
async function refreshDashboard() {
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").textContent = "Checking services…";
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
finally { button.disabled = false; button.classList.remove("spinning"); }
}
function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); }
async function boot() {
const requestedHash = location.hash.slice(1); state.adminTab = requestedHash.startsWith("administration/") ? requestedHash.split("/")[1] || "users" : "users"; if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
const session = await fetch("/api/session").then(response => response.json());
$("#login-title").textContent = session.installationSetupPending ? "Welcome to Site Gateway" : "Welcome back";
$("#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");
$("#version-label").textContent = `v${state.config.version || "unknown"}`;
$("#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);
}
$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); event.target.reset(); await boot(); } catch (error) { $("#login-error").textContent = error.message; } });
$("#setup-form").addEventListener("submit", async event => { event.preventDefault(); $("#setup-error").textContent = ""; try { await api("/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); $("#setup-dialog").close(); event.target.reset(); await boot(); showLogin("Administrator account saved. Sign in with your finalized credentials."); } catch (error) { $("#setup-error").textContent = error.message; } });
$("#setup-dialog").addEventListener("cancel", event => event.preventDefault());
$("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); });
$("#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"; });
$("#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)); } });
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").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)); });
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
$("#log-status").addEventListener("change", renderLogs);
$("#event-severity").addEventListener("change", renderLogs);
$("#event-category").addEventListener("change", renderLogs);
function openCreate() {
if (state.view === "streaming") return toast("Streaming host management is coming soon.");
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
if (state.view === "access") { $("#access-form").reset(); delete $("#access-form").dataset.editing; $("#access-error").textContent = ""; $("#access-form .access-create-guidance")?.remove(); const assignmentSummary = $("#access-assignment-summary"); assignmentSummary?.classList.add("hidden"); if (assignmentSummary) assignmentSummary.innerHTML = ""; window.renderCredentialEditor?.([]); return $("#access-dialog").showModal(); }
if (state.view === "proxies") { $("#proxy-form").reset(); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); }
$("#create-form").reset(); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal();
}
$("#open-create").addEventListener("click", openCreate);
document.addEventListener("click", event => { if (event.target.closest(".create-trigger")) openCreate(); if (event.target.closest(".close-dialog")) event.target.closest("dialog").close(); if (!event.target.closest(".menu-wrap")) closeMenus(); });
document.addEventListener("keydown", event => { if (event.key === "Escape") closeMenus(); });
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message)));
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = event.submitter; 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 = event.submitter; 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"; } });
function ensureHostedHealthFields() { [document.querySelector("#create-form details"), document.querySelector("#settings-hosted-advanced")].forEach(details => { if (!details || details.querySelector("[name=healthEnabled]")) return; const access = details.querySelector("[name=accessListId]")?.closest("label"); if (!access) return; access.insertAdjacentHTML("afterend", '<label>Health-check path<input name="healthPath" value="/"></label><label>Health-check method<select name="healthMethod"><option value="GET">GET — retrieve a response</option><option value="HEAD">HEAD — headers only</option></select></label><label>Expected status<input name="healthExpected" value="200-499"><small>Examples: 200, 200,204, or 200-399.</small></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label>Retries<input name="healthRetries" type="number" min="0" max="3" value="0"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this site</span></label>'); }); }
setInterval(ensureHostedHealthFields, 300);
function openSettings(kind, id) {
if (kind === "hosted") kind = "site";
const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset();
$("#settings-title").textContent = kind === "proxy" ? "Edit proxy host" : "Domain & TLS"; $("#settings-name-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-target-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-advanced").classList.toggle("hidden", kind !== "proxy"); $("#settings-hosted-advanced").classList.toggle("hidden", kind !== "site");
form.elements.name.value = item.name || ""; form.elements.domain.value = item.domain || ""; form.elements.target.value = item.target || ""; form.elements.tls.value = item.tls || "automatic"; form.elements.hsts.checked = Boolean(item.hsts); if (form.elements.settingsAccessListId) form.elements.settingsAccessListId.value = item.accessListId || "";
if (kind === "proxy") { form.elements.accessListId.value = item.accessListId || ""; form.elements.healthPath.value = item.healthPath || "/"; form.elements.healthExpected.value = item.healthExpected || "200-499"; form.elements.healthTimeoutSeconds.value = item.healthTimeoutSeconds || 4; form.elements.healthEnabled.checked = item.healthEnabled !== false; form.elements.compression.value = item.compression || "automatic"; form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n"); form.elements.requestHeadersText.value = (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"); form.elements.responseHeadersText.value = (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"); form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; form.elements.upstreamTlsInsecure.checked = Boolean(item.upstreamTlsInsecure); form.elements.hstsSubdomains.checked = Boolean(item.hstsSubdomains); form.elements.customConfig.value = item.customConfig || ""; }
if (kind === "proxy") form.elements.healthMethod.value = item.healthMethod || "GET";
if (kind === "site") { form.elements.healthPath.value = item.healthPath || "/"; form.elements.healthMethod.value = item.healthMethod || "GET"; form.elements.healthExpected.value = item.healthExpected || "200-499"; form.elements.healthTimeoutSeconds.value = item.healthTimeoutSeconds || 4; form.elements.healthRetries.value = item.healthRetries || 0; form.elements.healthEnabled.checked = item.healthEnabled !== false; }
$("#settings-error").textContent = ""; if (kind === "proxy" && form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); $("#settings-dialog").showModal();
document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom");
}
$("#settings-form").addEventListener("submit", async event => { event.preventDefault(); const button = event.submitter; button.disabled = true; button.textContent = "Applying…"; $("#settings-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"); let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile; body = state.editing.kind === "proxy" ? advancedFormBody(form, body) : { domain:body.domain, tls:body.tls, hsts:form.has("hsts") }; const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size; if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) { const existing = state.proxies.find(item => item.id === state.editing.id); if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Save & apply"; return; } } try { const base = state.editing.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method:"POST", body:files }); } $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { $("#settings-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Save & apply"; } });
$("#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 base = kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); }
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(); }
if (action === "icon") openIconPicker(kind, card.dataset.id);
});
document.querySelector("#redirect-list")?.addEventListener("click", event => {
const card = event.target.closest(".redirect-card"); if (!card) return;
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; }
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
});
$("#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; });
function openIconPicker(kind, id) {
state.iconTarget = { kind, id }; $("#icon-search").value = ""; $("#icon-url").value = ""; $("#icon-upload").value = ""; $("#icon-error").textContent = ""; $("#icon-results").innerHTML = '<p class="quiet-state">Enter at least two characters to search.</p>'; $("#icon-dialog").showModal(); setTimeout(() => $("#icon-search").focus(), 0);
}
let iconSearchTimer;
$("#icon-search").addEventListener("input", event => {
clearTimeout(iconSearchTimer); const query = event.target.value.trim(); $("#icon-error").textContent = "";
if (query.length < 2) { $("#icon-results").innerHTML = '<p class="quiet-state">Enter at least two characters to search.</p>'; return; }
$("#icon-results").innerHTML = '<p class="quiet-state">Searching…</p>';
iconSearchTimer = setTimeout(async () => {
try {
const results = await api(`/api/icons/search?q=${encodeURIComponent(query)}`);
$("#icon-results").innerHTML = results.length ? results.map(icon => `<button type="button" class="icon-choice" data-slug="${escapeHtml(icon.slug)}"><img src="${escapeHtml(icon.preview)}" alt=""><span>${escapeHtml(icon.label)}</span></button>`).join("") : '<p class="quiet-state">No matching icons found.</p>';
} catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; }
}, 280);
});
async function saveIcon(slug) {
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
$("#icon-error").textContent = "";
try {
await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
$("#icon-dialog").close(); await refresh(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored.");
} catch (error) { $("#icon-error").textContent = error.message; }
}
$("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); });
$("#reset-icon").addEventListener("click", event => { event.preventDefault(); saveIcon(""); });
$("#icon-upload").addEventListener("change", async event => {
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = "";
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); }
catch (error) { $("#icon-error").textContent = error.message; }
});
$("#save-icon-url").addEventListener("click", async () => {
const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; }
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); }
catch (error) { $("#icon-error").textContent = error.message; }
});
$("#user-form").addEventListener("submit", async event => {
event.preventDefault(); const button = event.submitter; button.disabled = true; $("#user-error").textContent = "";
try {
await api("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) });
$("#user-dialog").close(); await loadFeatureView(); toast("User created.");
} catch (error) { $("#user-error").textContent = error.message; }
finally { button.disabled = false; }
});
function themedUserConfirm(message, title = "Confirm action") { let dialog = document.querySelector("#user-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "user-confirm-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>${escapeHtml(title)}</h2></div></div><p class="muted">${escapeHtml(message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button danger">Confirm</button></div></form>`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true })); }
$("#user-list").addEventListener("click", async event => {
const button = event.target.closest("[data-user-action]"); if (!button) return;
const card = button.closest("[data-user-id]"); const user = state.users.find(item => item.id === card?.dataset.userId); if (!user) return;
if (button.dataset.userAction === "password") {
state.passwordTarget = user.id; $("#password-form").reset(); $("#password-error").textContent = ""; $("#password-title").textContent = `Reset ${user.username} password`; $("#password-dialog").showModal(); return;
}
if (button.dataset.userAction === "delete") {
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; }
return;
}
button.disabled = true;
try {
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); }
finally { button.disabled = false; }
});
$("#password-form").addEventListener("submit", async event => {
event.preventDefault(); const button = event.submitter; button.disabled = true; $("#password-error").textContent = "";
try {
await api(`/api/users/${state.passwordTarget}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: new FormData(event.target).get("password") }) });
$("#password-dialog").close(); state.passwordTarget = null; await loadFeatureView(); toast("Password reset.");
} catch (error) { $("#password-error").textContent = error.message; }
finally { button.disabled = false; }
});
boot().catch(error => toast(error.message));
function syncUpstreamTlsControls(form) {
if (!form || !form.elements.target) return;
const targets = [form.elements.target.value, form.elements.upstreamsText?.value || ""].join("\n").split(/\n+/).map(value => value.trim()).filter(Boolean);
const https = targets.length > 0 && targets.every(value => /^https:\/\//i.test(value));
const tlsName = form.elements.upstreamTlsServerName, tlsSkip = form.elements.upstreamTlsInsecure;
[tlsName, tlsSkip].forEach(input => { if (!input) return; input.disabled = !https; input.closest("label")?.classList.toggle("control-disabled", !https); });
if (tlsSkip && !https) tlsSkip.checked = false;
const help = tlsSkip?.closest("label")?.querySelector("small");
if (help) help.textContent = https ? "Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate." : "Available only when the upstream uses HTTPS.";
}
document.addEventListener("input", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
document.addEventListener("change", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
document.querySelectorAll("#proxy-form,#settings-form").forEach(form => syncUpstreamTlsControls(form));
document.addEventListener("click", event => { if (event.target.closest(".create-trigger,[data-action=edit],[data-card-action=edit]")) setTimeout(() => { syncUpstreamTlsControls(document.querySelector("#proxy-form")); syncUpstreamTlsControls(document.querySelector("#settings-form")); }, 0); });
document.addEventListener("click", event => { const trigger = event.target.closest("[data-action=settings],[data-card-action=settings]"); if (!trigger) return; setTimeout(() => { const item = (state.editing?.kind === "proxy" ? state.proxies : state.sites).find(value => value.id === state.editing?.id); if (!item) return; const scope = state.editing.kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; const checkbox = document.querySelector(`${scope} [name="healthEnabled"]`); if (checkbox) checkbox.checked = !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false"); }, 0); });
setInterval(() => { if (state.view !== 'access') return; const items = state.accessLists || []; const enabled = items.filter(item => item.enabled !== false).length; const disabled = items.length - enabled; $('#running-count').textContent = enabled; $('#disabled-count').textContent = disabled; $('#error-count').textContent = 0; $('#running-label').textContent = enabled ? 'Enabled' : 'None enabled'; $('#disabled-label').textContent = disabled ? 'Disabled' : 'None disabled'; $('#error-label').textContent = 'No issues'; $('#running-dot').className = `status-dot ${enabled ? 'running' : 'inactive'}`; $('#disabled-dot').className = `status-dot ${disabled ? 'disabled' : 'inactive'}`; $('#error-dot').className = 'status-dot inactive'; $('.port-note').classList.add('hidden'); }, 500);
function renderDashboardJobsSafe(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"); if (!columns) return; const health = document.querySelector("[data-dashboard-health]") || columns.querySelector(".health-list")?.closest("section"); if (health) { health.dataset.dashboardHealth = "true"; if (health.parentElement === columns) columns.parentElement.insertBefore(health, columns); } let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
document.addEventListener("submit", async event => { if (event.target?.id !== "settings-form" || state.editing?.kind !== "site") return; event.preventDefault(); event.stopImmediatePropagation(); const button = event.submitter; button.disabled = true; const form = new FormData(event.target); try { await api(`/api/sites/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain: form.get("domain"), domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: form.get("tls"), hsts: form.has("hsts"), accessListId: form.get("accessListId") || "", healthEnabled: form.has("healthEnabled"), healthPath: form.get("healthPath") || "/", healthMethod: form.get("healthMethod") || "GET", healthExpected: form.get("healthExpected") || "200-499", healthTimeoutSeconds: Number(form.get("healthTimeoutSeconds") || 4), healthRetries: Number(form.get("healthRetries") || 0), compression: form.get("compression") || "automatic", requestHeaders: parseHeaderLines(form.get("requestHeadersText")), responseHeaders: parseHeaderLines(form.get("responseHeadersText")), hstsSubdomains: form.has("hstsSubdomains"), customConfig: form.get("customConfig") || "" }) }); document.querySelector("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { document.querySelector("#settings-error").textContent = error.message; } finally { button.disabled = false; } }, true);
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+276
View File
@@ -0,0 +1,276 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#0b1220">
<title>Site Gateway</title>
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="login" class="login-shell hidden">
<form id="login-form" class="login-card">
<img class="brand-mark product-icon" src="/site-gateway-lockup-approved.png" alt="Site Gateway">
<p class="eyebrow">Host. Proxy. Secure.</p>
<h1 id="login-title">Welcome back</h1>
<p id="login-copy" class="muted">Sign in to manage your sites.</p>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<p id="login-error" class="error" role="alert"></p>
<button class="button primary wide">Sign in</button>
</form>
</div>
<dialog id="setup-dialog" class="setup-dialog">
<form id="setup-form" class="dialog-card setup-card">
<img class="brand-mark product-icon" src="/site-gateway-lockup-approved.png" alt="Site Gateway">
<p class="eyebrow">First-time setup</p>
<h1>Secure your administrator account</h1>
<p class="muted">Confirm or change the administrator details below. The credentials supplied during installation were used only to bootstrap this account.</p>
<label>Display name<input name="displayName" value="Administrator" maxlength="80" autocomplete="name" required></label>
<label>Administrator username<input name="username" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._-]{2,63}" autocomplete="username" required></label>
<label>New password<input name="password" type="password" minlength="8" autocomplete="new-password" required><small>Use at least 8 characters and a password unique to Site Gateway.</small></label>
<label>Confirm password<input name="confirmPassword" type="password" minlength="8" autocomplete="new-password" required></label>
<p id="setup-error" class="error" role="alert"></p>
<button class="button primary wide">Save administrator account</button>
</form>
</dialog>
<div id="dashboard" class="app-shell hidden">
<aside>
<div class="brand"><img class="brand-mark small product-icon" src="/site-gateway-icon-approved.png" alt=""><span>Site Gateway</span></div>
<nav aria-label="Publishing types">
<button class="nav-active" data-view="overview">Dashboard</button>
<button data-view="hosted">Hosted sites <span id="hosted-count">0</span></button>
<button data-view="proxies">Proxy hosts <span id="proxy-count">0</span></button>
<button data-view="streaming">Streaming hosts <span id="streaming-count">0</span></button>
<button data-view="redirects">Redirect hosts <span id="redirect-count">0</span></button>
<button data-view="certificates">Certificates <span id="certificate-count">0</span></button>
<button data-view="access">Access Lists <span id="access-count">0</span></button>
<button data-view="logs">Logs</button>
</nav>
<div class="aside-utilities"><button class="admin-only" data-view="administration">Administration</button><button data-view="documentation">Documentation</button></div>
<div class="aside-footer"><span>Installed version</span><strong id="version-label">v—</strong></div>
</aside>
<main>
<div class="utility-bar" aria-label="Account and appearance">
<label class="theme-control" for="theme-select"><span>Theme</span><select id="theme-select" aria-label="Color theme"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label>
<div class="account-control"><span>Signed in as <strong id="user-label">admin</strong></span><button id="logout" class="text-button">Sign out</button></div>
</div>
<nav class="mobile-nav" aria-label="Dashboard sections">
<button class="nav-active" data-view="overview">Dashboard</button>
<button data-view="hosted">Hosted</button>
<button data-view="proxies">Proxies</button>
<button data-view="redirects">Redirects</button>
<button data-view="certificates">TLS</button>
<button data-view="logs">Logs</button>
<button class="admin-only" data-view="administration">Admin</button><button data-view="documentation">Docs</button>
</nav>
<header>
<div><p class="eyebrow">Gateway control</p><h1 id="page-title">Dashboard</h1><p id="page-subtitle" class="muted">Health, activity, and system status at a glance.</p></div>
<button id="open-create" class="button primary"> New hosted site</button><button id="check-health" class="button primary hidden">Run certificate check</button><button id="refresh-logs" class="button primary hidden">Refresh logs</button>
</header>
<section id="dashboard-view" class="dashboard-view" aria-label="Gateway dashboard">
<div class="metric-grid">
<button class="metric-card" data-target="hosted"><span class="metric-label">Hosted sites</span><strong id="dash-hosted-total">0</strong><span id="dash-hosted-detail">None configured</span></button>
<button class="metric-card" data-target="proxies"><span class="metric-label">Proxy hosts</span><strong id="dash-proxy-total">0</strong><span id="dash-proxy-detail">None configured</span></button>
<button class="metric-card" data-target="certificates"><span class="metric-label">Certificates</span><strong id="dash-tls-total">0</strong><span id="dash-tls-detail">No TLS domains</span></button>
<div class="metric-card attention"><span class="metric-label">Needs attention</span><strong id="dash-attention-total">0</strong><span id="dash-attention-detail">No current issues</span></div>
</div>
<div class="dashboard-columns">
<section class="dashboard-panel">
<div class="panel-heading"><div><p class="eyebrow">Live health</p><h2>Services</h2></div><div class="health-actions"><span id="overall-health" class="health-badge healthy">Healthy</span><button id="refresh-health" class="icon-button" aria-label="Refresh health checks" title="Refresh health checks"></button></div></div>
<div class="health-list">
<div><span id="gateway-health-dot" class="status-dot running"></span><span><strong>Gateway</strong><small id="gateway-health-copy">Configuration valid</small></span></div>
<div><span id="http-health-dot" class="status-dot running"></span><span><strong>HTTP · Port 80</strong><small id="http-health-copy">Ready and responding</small></span></div>
<div><span id="https-health-dot" class="status-dot inactive"></span><span><strong>HTTPS · Port 443</strong><small id="https-health-copy">Not configured</small></span></div>
<div><span id="storage-health-dot" class="status-dot running"></span><span><strong>Persistent storage</strong><small id="storage-health-copy">Data directory writable</small></span></div>
</div>
<p id="health-checked" class="checked-time">Last checked —</p>
</section>
<section class="dashboard-panel">
<div class="panel-heading"><div><p class="eyebrow">Runtime</p><h2>System</h2></div></div>
<dl class="system-grid">
<div><dt>Uptime</dt><dd id="system-uptime"></dd></div>
<div><dt>Memory</dt><dd id="system-memory"></dd></div>
<div><dt>Site Gateway data</dt><dd id="system-data"></dd><small>Used by sites and configuration</small></div>
<div><dt>Storage available</dt><dd id="system-disk"></dd><small>Available on the /data volume</small></div>
<div><dt>Site Gateway</dt><dd id="system-app-version"></dd></div>
<div><dt>Caddy</dt><dd id="system-caddy-version"></dd></div>
<div><dt>Database</dt><dd id="system-database"></dd><small id="system-database-detail">SQLite storage</small></div>
</dl>
</section>
</div>
<div class="dashboard-columns lower">
<section class="dashboard-panel">
<div class="panel-heading"><div><p class="eyebrow">Action required</p><h2>Needs attention</h2></div></div>
<div id="attention-list" class="dashboard-list"><p class="quiet-state">Everything looks good.</p></div>
</section>
<section class="dashboard-panel">
<div class="panel-heading"><div><p class="eyebrow">Recent activity</p><h2>Recent activity</h2></div><button class="text-button" data-view="logs">View all logs →</button></div>
<div id="activity-list" class="dashboard-list"><p class="quiet-state">No recent activity.</p></div>
</section>
</div>
</section>
<section id="certificates-view" class="feature-view hidden">
<p id="cert-last-checked" class="certificate-status muted checked-time">Last checked —</p>
<div class="feature-summary">
<div><strong id="cert-healthy">0</strong><span>Healthy</span></div><div><strong id="cert-30">0</strong><span>Within 30 days</span></div><div><strong id="cert-7">0</strong><span>Within 7 days</span></div><div><strong id="cert-warning">0</strong><span>Needs attention</span></div><div><strong id="cert-pending">0</strong><span>Not detected</span></div>
</div>
<div class="diagnostic-section-heading"><p class="eyebrow">Certificate inventory</p><h2>Certificates</h2><p class="muted">Managed and uploaded certificates assigned to configured domains.</p></div><div id="certificate-list" class="data-list diagnostic-list"><p class="quiet-state">Loading certificates…</p></div>
<section class="dashboard-panel readiness-panel"><div class="panel-heading"><div><p class="eyebrow">Guided diagnostics</p><h2>Domain readiness</h2><p class="muted">DNS, listener, TLS, and upstream checks for every configured domain.</p></div></div><div id="readiness-list" class="dashboard-list diagnostic-list"><p class="quiet-state">Run a check to inspect configured domains.</p></div></section>
</section>
<section id="logs-view" class="feature-view hidden">
<div class="log-toolbar"><div class="log-filters"><label>Domain<select id="log-host"><option value="">All domains</option></select></label><label>Response status<select id="log-status"><option value="">All responses</option><option value="2">Successful · 2xx</option><option value="3">Redirects · 3xx</option><option value="4">Client errors · 4xx</option><option value="5">Server errors · 5xx</option></select></label></div></div>
<p id="log-summary" class="muted feature-note">No requests in the current view. <span id="log-last-checked">Not checked yet.</span></p>
<p class="muted feature-note">Recent requests handled by Caddy. Sensitive request headers are never displayed.</p>
<div class="log-section-heading diagnostic-section-heading"><p class="eyebrow">Access logs</p><h2>Access requests</h2><p class="muted">Requests handled by configured domains. Sensitive headers are never displayed.</p></div><div class="table-wrap log-table-wrap diagnostic-list"><table class="log-table"><thead><tr><th>Time</th><th>Domain</th><th>Request</th><th>Status</th><th>Duration</th></tr></thead><tbody id="log-rows"></tbody></table></div>
<section class="dashboard-panel log-activity"><div class="panel-heading"><div><p class="eyebrow">Gateway events</p><h2>Activity and errors</h2><p class="muted">Configuration, certificate, and health events recorded by Site Gateway.</p></div></div><div class="event-filters"><label>Severity<select id="event-severity"><option value="">All severities</option><option value="ok">Normal</option><option value="warning">Warnings</option><option value="error">Errors</option></select></label><label>Category<select id="event-category"><option value="">All categories</option><option value="configuration">Configuration</option><option value="certificate">Certificates / TLS</option><option value="health">Upstream health</option><option value="authentication">Authentication</option><option value="backup">Backups</option><option value="system">System</option></select></label></div><div id="gateway-log-list" class="dashboard-list event-list diagnostic-list"></div></section>
</section>
<section id="users-view" class="feature-view hidden">
<div class="admin-tabs"><button class="tab-active" data-admin-tab="users">Users</button><button data-admin-tab="defaults">Gateway defaults</button><button data-admin-tab="backups">Backup & restore</button><button data-admin-tab="security">Security & updates</button><button data-admin-tab="danger" class="danger-tab">Danger Zone</button></div>
<section data-admin-panel="users">
<div id="user-summary" class="summary user-summary" aria-label="User summary"></div>
<div id="user-list" class="user-grid"><p class="quiet-state">Loading users…</p></div>
</section>
<section data-admin-panel="defaults" class="hidden settings-panel">
<h2>Default site</h2><p class="muted">Choose what visitors receive when no configured host matches their request.</p>
<div class="callout"><strong>HTTP fallback</strong><span>This response is used for unknown HTTP hostnames. Unknown HTTPS hostnames are rejected unless a matching certificate and route exist, preventing misleading certificate warnings.</span></div>
<form id="default-site-form" class="settings-form"><div class="form-section"><p class="eyebrow">Response</p><label>Response<select name="mode"><option value="themed404">Themed route-not-found page (404)</option><option value="welcome">Gateway ready page (200)</option><option value="abort">No response — close connection</option><option value="redirect">Redirect elsewhere</option><option value="custom">Custom HTML</option></select></label></div><div class="form-section"><p class="eyebrow">Page content</p><label>Page heading<input name="title" maxlength="100" placeholder="Route not found"></label><label>Explanation<textarea name="message" maxlength="500" placeholder="The gateway is responding, but this address has not been configured."></textarea></label></div><div class="form-section"><p class="eyebrow">Redirect behavior</p><label>Redirect destination<input name="redirectUrl" type="url" placeholder="https://www.example.com"></label><label>Redirect code<select name="redirectCode"><option>302</option><option>301</option><option>307</option><option>308</option></select></label><label class="check-control"><input name="preservePath" type="checkbox" checked><span>Preserve the requested path and query</span></label></div><div class="form-section form-section-wide"><p class="eyebrow">Custom response</p><label>Custom HTML<textarea name="customHtml" class="code-input" placeholder="<!doctype html>..."></textarea><small>Administrator-authored HTML only. Used when Custom HTML is selected.</small></label></div><div class="dialog-actions"><button class="button primary">Save & apply</button></div><p id="default-error" class="error"></p></form>
</section>
<section data-admin-panel="backups" class="hidden settings-panel"><div class="panel-heading"><div><h2>Backup & restore</h2><p class="muted">Create a copy, restore a previous version, or schedule automatic backups.</p></div><div class="row-actions"><button id="import-backup" class="button secondary">Import backup</button><button id="create-backup" class="button primary">Create backup</button></div></div><input id="backup-upload" type="file" accept=".sgbackup,application/zip" hidden><form id="backup-settings-form" class="settings-form backup-settings"><div class="form-section form-section-wide"><p class="eyebrow">Scheduled backups</p><div class="form-grid"><label class="check-control"><input name="enabled" type="checkbox"><span>Enable scheduled backups</span></label><label>Backup type<select name="type"><option value="configuration">Configuration only</option><option value="complete">Complete — configuration + hosted files</option></select><small id="backup-type-help">Configuration only includes settings and metadata, not uploaded Hosted Site files.</small></label><label>Schedule<select name="frequency"><option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option></select></label><label>Hour<select name="hour"></select></label><label>Keep<input name="retention" type="number" min="1" max="100" value="7"></label><label class="check-control"><input name="includeLogs" type="checkbox"><span>Include logs</span></label></div></div><div class="form-section form-section-wide"><p class="eyebrow">Encryption</p><div class="form-grid encryption-grid"><label class="backup-password-field"><span class="field-label">Backup encryption password <span class="optional">Optional</span></span><input name="backupPassword" type="password" autocomplete="new-password"><small>Used for manually created backups and required when restoring an encrypted archive. It is not stored by Site Gateway.</small></label><label class="check-control encryption-toggle"><input name="encrypt" type="checkbox"><span>Encrypt scheduled backups<small>Uses the containers <code>BACKUP_PASSWORD</code> value. Enable only after configuring that value.</small></span></label></div></div><div class="dialog-actions"><button class="button secondary">Save schedule</button></div></form><div class="callout"><strong>Storage guidance</strong><span id="backup-path">Backups are stored in /data/backups. Mount /backups separately to protect against appdata disk failure.</span></div><div id="backup-list" class="data-list"></div></section>
<section data-admin-panel="security" class="hidden settings-panel"><h2>Security, health & updates</h2><div class="role-callout"><strong>Configuration safety</strong><span>Site Gateway validates generated Caddy configuration before every reload and retains the active configuration when validation fails.</span><strong>Container updates</strong><span>Updates are installed by pulling a new pinned image. Create a backup before changing versions.</span></div><section class="support-panel"><div><p class="eyebrow">Troubleshooting & support</p><h3>Gateway diagnostics</h3><p class="muted">Run checks and download a redacted report when you need to investigate a gateway issue.</p></div><div class="row-actions"><button id="download-support" class="button secondary admin-only">Download support report</button></div><p class="muted support-note">The report includes version, configuration health, certificate readiness, upstream checks, and recent events. Passwords, private keys, session secrets, cookies, and certificate contents are excluded.</p></section><form id="health-settings-form" class="settings-form"><label>Renewing-soon warning<input name="warningDays" type="number" min="8" max="120" value="30"><small>Days remaining before a certificate is highlighted.</small></label><label>Critical warning<input name="criticalDays" type="number" min="1" max="119" value="7"><small>Must be lower than the renewing-soon threshold.</small></label><label>Stale health data<input name="staleMinutes" type="number" min="2" max="1440" value="10"><small>Minutes before a displayed check is considered old.</small></label><div class="dialog-actions"><button class="button primary">Save health settings</button></div></form></section>
<section data-admin-panel="danger" class="hidden settings-panel danger-zone"><h2>Danger Zone</h2><p class="muted">These actions can permanently remove Site Gateway data. Review each warning carefully before continuing.</p><div class="danger-card"><p class="eyebrow">Restore defaults</p><h3>Reset gateway preferences</h3><p>Restore default site behavior, backup scheduling, certificate thresholds, and interface preferences. Your users, routes, certificates, logs, and backups remain intact.</p><button id="restore-defaults" class="button secondary">Restore default settings</button></div><div class="danger-card destructive"><p class="eyebrow">Permanent action</p><h3>Factory reset</h3><p>Deletes all Site Gateway data under <code>/data</code>, including users, routes, certificates, logs, backups, and settings. Docker-mounted files outside <code>/data</code> are not affected. The container restarts at first-install setup.</p><form id="factory-reset-form" class="danger-form"><label>Administrator username<input name="username" autocomplete="username" required></label><label>Administrator password<input name="password" type="password" autocomplete="current-password" required></label><label>Type <strong>FACTORY RESET</strong> to confirm<input name="confirmation" required autocomplete="off"></label><p id="factory-reset-error" class="error"></p><div class="danger-actions"><button class="button secondary" type="button" id="factory-reset-cancel">Cancel</button><button class="button danger" type="submit">Erase all data and reset</button></div></form></div></section>
</section>
<section id="redirects-view" class="feature-view hidden"><div id="redirect-list" class="site-grid"></div><section id="redirect-empty" class="empty"><div class="empty-icon"></div><h2>Create your first redirect</h2><p>Send an old domain to a new destination while preserving its path if you choose.</p><button class="button primary create-trigger">Create a redirect host</button></section></section>
<section id="access-view" class="feature-view hidden"><div id="access-list" class="data-list"></div></section>
<section id="documentation-view" class="feature-view hidden docs"><div class="docs-intro"><p class="eyebrow">Site Gateway manual</p><h2>Simple routing for homelabs and small teams</h2><p>A complete guide to publishing sites, routing applications, securing domains, and recovering safely. Start with the defaults, then use the advanced controls when you understand the trade-offs.</p><div class="docs-search-panel"><label class="doc-search"><span>Search the complete manual</span><input id="doc-search" type="search" placeholder="Search “upstream TLS”, “Plex”, “CIDR”, “backup”, or any field name"></label><small>Searches purpose, fields, examples, troubleshooting, and expert notes.</small></div></div><div class="docs-layout"><aside class="docs-nav" aria-label="Documentation sections"><p class="eyebrow">Contents</p><button data-doc-jump="introduction">Introduction</button><button data-doc-jump="dashboard">Dashboard</button><button data-doc-jump="hosted">Hosted Sites</button><button data-doc-jump="proxy">Proxy Hosts</button><button data-doc-jump="redirect">Redirect Hosts</button><button data-doc-jump="certificate">Certificates</button><button data-doc-jump="logs">Logs</button><button data-doc-jump="access">Access Lists</button><button data-doc-jump="administration">Administration</button><button data-doc-jump="backup">Backup & Restore</button><button data-doc-jump="danger">Danger Zone</button><button data-doc-jump="common">Common Controls</button></aside><div id="docs-content">
<article data-doc="introduction why built philosophy caddy novice expert"><p class="eyebrow">Introduction</p><h2>Why Site Gateway exists</h2><p>Reverse proxies often expose powerful settings without explaining what they change. Site Gateway provides a visual, Caddy-powered control plane for static sites, proxy routes, redirects, HTTPS, health checks, access control, and recovery.</p><h3>Novice path</h3><p>Create one route, test it locally, then add a domain and TLS. Keep defaults until you have a reason to change them.</p><h3>Expert note</h3><p>Configuration is stored in SQLite under <code>/data</code> and generated Caddy configuration is validated before reload.</p><h3>Example</h3><p>Publish a ZIP on a direct port first, then add <code>www.example.com</code> after DNS and port forwarding are ready.</p></article>
<article data-doc="getting started install first login"><p class="eyebrow">Getting started</p><h2>From installation to your first route</h2><p>Install the container with persistent <code>/data</code> storage, open port 8080, and sign in with the administrator credentials supplied to Docker. Before publishing public domains, make sure DNS points to this server and ports 80 and 443 are free.</p></article>
<article data-doc="hosted static zip index upload"><p class="eyebrow">Hosted sites</p><h2>Publish a static website</h2><ol><li>Open Hosted Sites and choose New hosted site.</li><li>Give the site a name and unused direct-access port.</li><li>Upload an index.html or ZIP whose root contains index.html.</li><li>Add a domain only when DNS is ready; choose Automatic HTTPS for public service.</li></ol><p><strong>Expected behavior:</strong> the files are served immediately on the chosen port and, when configured, through the domain.</p></article>
<article data-doc="proxy jellyfin vaultwarden plex forward upstream"><p class="eyebrow">Proxy hosts</p><h2>Connect a local application</h2><p>For Jellyfin at <code>192.168.1.20:8096</code>, use domain <code>jellyfin.example.com</code> and forward target <code>http://192.168.1.20:8096</code>. Site Gateway checks the upstream and Caddy manages eligible public HTTPS certificates automatically.</p></article>
<article data-doc="certificate tls dns ports pending"><p class="eyebrow">Certificates</p><h2>Automatic HTTPS prerequisites</h2><p>The domain must resolve to your public address, inbound ports 80 and 443 must reach Site Gateway, and another proxy cannot own those ports. “Not detected” means Caddy has not yet stored a certificate; review gateway events and DNS before retrying.</p></article>
<article data-doc="diagnostics readiness check now support report expiration"><p class="eyebrow">Health diagnostics</p><h2>Understand what failed</h2><p>Open Certificates and choose <strong>Check now</strong> to test DNS resolution, the gateway listeners, stored certificate coverage, and proxy upstream health. Expand a certificate for its non-secret details. Administrators can download a redacted support report when asking for help; it intentionally excludes credentials, cookies, private keys, secrets, and raw expert configuration.</p></article>
<article data-doc="access list lan authentication"><p class="eyebrow">Access Lists</p><h2>Protect a route</h2><p>Use <code>private_ranges</code> to allow standard LAN address ranges, or enter exact IP/CIDR values one per line. Add a login when the visitor must also authenticate. Assign the saved list from the Proxy Hosts Advanced section.</p></article>
<article data-doc="redirect host permanent temporary path query"><p class="eyebrow">Redirect Hosts</p><h2>Move an address safely</h2><p>Use 301 or 308 only when the move is intended to be permanent; browsers can cache them. Use 302 or 307 while testing. Enable Preserve path and query when <code>old.example.com/library?id=2</code> should become <code>new.example.com/library?id=2</code>.</p></article>
<article data-doc="default site welcome 404 no response custom html"><p class="eyebrow">Default site</p><h2>Handle unknown addresses</h2><p>The themed 404 is the safest public default. Gateway ready confirms HTTP routing during setup, No response closes unmatched HTTP connections, Redirect sends visitors elsewhere, and Custom HTML serves administrator-provided markup. Unknown HTTPS names still require their own valid route and certificate.</p></article>
<article data-doc="backup restore update rollback sqlite certificates"><p class="eyebrow">Backups</p><h2>Back up before an update</h2><p>A configuration backup contains a consistent SQLite snapshot and portable recovery data. A complete backup also contains hosted files, local assets, and certificate storage. Backups are stored in <code>/data/backups</code>; advanced installations can mount separate storage directly at that path.</p></article>
<article data-doc="backup encryption schedule retention restore troubleshooting"><p class="eyebrow">Restore checklist</p><h2>Recover with confidence</h2><ol><li>Download or import the <code>.sgbackup</code> archive.</li><li>Supply its password if it is encrypted.</li><li>Choose Restore and allow validation to finish.</li><li>Confirm hosts, certificates, and upstream health.</li></ol><p>Site Gateway verifies file checksums and creates a pre-restore safety backup. If the restored Caddy configuration is invalid, it attempts to recover the previous state automatically.</p></article>
<article data-doc="logs access logs gateway events requests response status domain filters"><p class="eyebrow">Logs</p><h2>Investigate requests and gateway events</h2><p>Access Logs show domains, paths, response status, latency, and upstream outcomes. Gateway Events record configuration and operational changes.</p><h3>Example</h3><p>Filter for a 502 or failed upstream event, then compare the target address with a direct LAN request.</p></article>
<article data-doc="users roles administrator standard viewer groups permissions audit"><p class="eyebrow">Administration · Users & Groups</p><h2>Control who can change the gateway</h2><p>Administrators manage users, roles, groups, and audit history. Standard Users perform permitted management tasks; Viewers are read-only.</p><h3>Example</h3><p>Create a Viewer for monitoring and a Standard User for routine route changes.</p></article>
<article data-doc="gateway defaults default site restore factory reset danger zone"><p class="eyebrow">Administration · Gateway Defaults & Danger Zone</p><h2>Preferences and destructive actions</h2><p>Gateway Defaults control unknown HTTP responses, backup scheduling, and certificate thresholds. Restore Defaults changes preferences only. Factory Reset deletes all data under <code>/data</code> and returns to initial setup.</p><h3>Example</h3><p>Keep the themed 404 in production and create a complete backup before any factory reset.</p></article>
<article data-doc="proxy advanced access list health expected status compression custom locations headers upstream tls server name hsts caddy configuration five ws"><p class="eyebrow">Proxy Hosts · Advanced options</p><h2>Why the advanced controls exist</h2><p>Most applications work with only a domain, Forward to target, and TLS choice. Advanced options are for applications with unusual paths, authentication boundaries, response codes, headers, certificates, or performance needs.</p><h3>What each control changes</h3><ul><li><strong>Access List:</strong> applies reusable login and network rules before the upstream is reached.</li><li><strong>Health-check path and method:</strong> tells Site Gateway what request to make when checking the application.</li><li><strong>Expected status:</strong> accepts a code, list, or range such as <code>200</code>, <code>200,204</code>, or <code>200-399</code>.</li><li><strong>Timeout and retries:</strong> control how long a check waits and how many additional attempts are made.</li><li><strong>Compression:</strong> controls whether Caddy negotiates gzip or zstd for responses.</li><li><strong>Custom Locations:</strong> sends paths such as <code>/api/*</code> to a different upstream and can strip or preserve the path.</li><li><strong>Request and response headers:</strong> add metadata required by an application or browser.</li><li><strong>Upstream TLS server name:</strong> supplies the SNI name when the upstream certificate expects a hostname.</li><li><strong>Trust an unverified upstream certificate:</strong> permits internal HTTPS with an untrusted certificate; use only on a trusted network.</li><li><strong>HSTS:</strong> tells browsers to use HTTPS for future requests; enable only after HTTPS is reliable.</li><li><strong>Custom Caddy configuration:</strong> an expert escape hatch for supported Caddy directives, validated before reload.</li></ul><h3>Who, where, when, and why</h3><p><strong>Who:</strong> experts operating applications with documented proxy requirements. <strong>Where:</strong> the Advanced options panel for one Proxy Host. <strong>When:</strong> only after the basic route works. <strong>Why:</strong> to solve a known requirement rather than guessing at settings.</p><h3>Practical example: Jellyfin</h3><p>Use <code>http://192.168.1.20:8096</code> as the upstream, leave the health path at <code>/</code>, keep the default expected range, and enable HSTS only after public HTTPS works. If an internal HTTPS service uses a private certificate, set its upstream SNI name and consider the unverified-certificate option only when the LAN is trusted.</p><h3>How to verify</h3><p>Save one change at a time, watch the cards upstream status, inspect Access Logs, and compare the result with a direct request to the application. If Caddy rejects a custom configuration, Site Gateway retains the last known-good configuration.</p></article>
<article data-doc="danger zone restore defaults factory reset credentials yes countdown setup recovery complete guide"><p class="eyebrow">Administration · Danger Zone</p><h2>Reset preferences or rebuild from zero</h2><p>This page contains the two actions with the greatest impact in Site Gateway. They are intentionally separate so a routine preference correction cannot be confused with a destructive rebuild.</p><h3>Restore Defaults: what it is for</h3><p>Restore Defaults returns gateway preferences to their known starting values: the Default Site response, page heading and explanation, redirect behavior, backup schedule, and certificate-health thresholds. It does not remove hosts, uploaded files, users, groups, Access Lists, certificates, logs, or saved backups.</p><h3>Factory Reset: what it is for</h3><p>Factory Reset removes Site Gateway data under <code>/data</code>, including routes, hosted content, users, groups, Access Lists, certificates, logs, backups, icons, and settings. Files mounted outside <code>/data</code> are not touched. Use it for a lab rebuild, a clean handoff, or recovery from an intentionally abandoned configuration—not to undo one route.</p><h3>Who should use these actions</h3><p>Only an Administrator should use them. Standard Users and Viewers should not see or operate destructive controls. The server verifies the signed-in administrator, the entered username, and the password before showing the final confirmation.</p><h3>What happens when you click the button</h3><p>Validation happens in order: username, password, confirmation phrase, then a second themed dialog requiring <code>YES</code>. Cancel clears every field and changes nothing. Restore Defaults applies immediately and refreshes the page. Factory Reset clears the data, recreates the initial bootstrap state, shows a countdown, and returns to the first-install login/setup flow without requiring a manual container restart.</p><h3>When to use a backup instead</h3><p>If you want to undo a recent change while keeping the rest of the installation, create or restore a complete backup. Factory Reset is not a rollback tool; it intentionally removes the recovery material stored under <code>/data/backups</code>.</p><h3>Practical examples</h3><ul><li>Your Default Site explanation is confusing: use Restore Defaults.</li><li>You are moving the container to a new owner: create a complete backup, verify it, then use Factory Reset.</li><li>A route stopped working: inspect Logs and restore the route or backup; do not factory-reset first.</li></ul><h3>After a Factory Reset</h3><p>Open the management URL, sign in with the installation administrator credentials, and complete the initial administrator setup. Recreate or restore your hosts, certificates, users, groups, and Access Lists only after confirming the empty gateway responds correctly.</p></article>
<article data-doc="common interface controls menus three dots edit disable delete enable icons dashboard icons custom upload initials roles cards"><p class="eyebrow">Common Interface Controls</p><h2>Menus, status controls, and icons</h2><p>The same card language is used throughout Hosted Sites, Proxy Hosts, Redirect Hosts, Access Lists, Groups, and Users so that learning one area transfers to the next.</p><h3>What the three-dot menu is for</h3><p>The three-dot menu contains actions that change or inspect a card. <strong>Edit</strong> opens the full form. <strong>Enable/Disable</strong> changes whether the route or control is active without deleting its saved configuration. <strong>Assignments</strong> shows which hosts use an Access List. <strong>Delete</strong> removes the record after a confirmation.</p><h3>Who can use each action</h3><p>Administrators can manage all cards. Standard Users see only actions allowed by their capability set. Viewers can inspect information but cannot create, edit, disable, assign, or delete configuration. Authorization is enforced by the server, not only by hiding buttons.</p><h3>When to disable instead of delete</h3><p>Disable a route during maintenance or testing when you expect to reuse its settings. Delete only when the route, assignments, and its configuration are no longer needed.</p><h3>Changing a card icon</h3><p>Select the cards icon or choose Icon from its menu to open the icon picker. Search by service name, such as <code>Jellyfin</code>, then select a result. You can also upload a custom PNG, JPEG, WebP, or SVG when the service is not in the catalog. The interface scales icons into the same two-letter tile size and preserves the current initials as a fallback if an icon is removed or unavailable.</p><h3>Practical examples</h3><ul><li>Disable a Proxy Host while upgrading Plex, then enable it after the upstream responds.</li><li>Assign one Access List to several hosts and inspect Assignments before changing its rules.</li><li>Choose a Jellyfin icon for a Proxy Host; if the icon catalog is unavailable, its initials remain visible.</li></ul><h3>Backup and troubleshooting</h3><p>Icons and assignments are included in complete backups. If a custom icon does not appear, verify the upload completed, refresh the card list, and confirm the file format is supported. Changing an icon never changes routing, TLS, or access behavior.</p></article>
<article data-doc="hosted site field reference name primary additional domains upload port tls hsts icon"><p class="eyebrow">Hosted Sites · Field reference</p><h2>What each Hosted Site field means</h2><p><strong>Name</strong> is the label you see in Site Gateway; it does not have to match the domain. <strong>Primary domain</strong> is the main hostname. <strong>Additional domains</strong> are aliases that serve the same files. <strong>Upload</strong> accepts a site folder or ZIP and expects <code>index.html</code> at the web root. <strong>Port</strong> is the direct LAN port and must be inside the configured range. <strong>TLS</strong> controls whether the domain uses automatic public HTTPS. <strong>HSTS</strong> should be enabled only after HTTPS has been tested on every intended client.</p><h3>Novice example</h3><p>Name the route “Family landing page,” use port 9100, upload the ZIP, browse to the LAN address, and add a domain later.</p><h3>Expert example</h3><p>Use additional domains for a canonical and legacy hostname while keeping one file tree. Complete backups preserve both the route metadata and uploaded files.</p></article>
<article data-doc="certificates field reference domain readiness check issuer expiration custom certificate acme"><p class="eyebrow">Certificates · Field reference</p><h2>Read certificate health correctly</h2><p>Each configured hostname receives its own readiness result. DNS shows whether the name resolves, HTTP and HTTPS show listener reachability, and TLS shows certificate coverage and status. Issuer identifies the authority, expiration shows remaining lifetime, and “Waiting for Caddy” means issuance has not completed—not that a certificate was already created.</p><h3>Novice workflow</h3><p>Confirm DNS, forward ports 80 and 443, stop competing proxies, then run the certificate check. Do not troubleshoot an upstream application until the domain and HTTPS checks are healthy.</p><h3>Expert workflow</h3><p>Use custom certificates for externally purchased or wildcard material under the custom certificate area. Keep Caddy-managed ACME material separate and protect private keys.</p></article>
<article data-doc="advanced caddy custom locations headers compression health upstream tls"><p class="eyebrow">Advanced proxy settings</p><h2>Start simple, expand only when needed</h2><p>Custom Locations route selected paths to different upstreams. Request headers are sent upstream; response headers are returned to visitors. Health checks accept individual codes or ranges. Unverified upstream TLS and custom Caddy configuration are expert controls—change one item at a time and rely on validation feedback.</p></article>
<article data-doc="troubleshooting dns ports certificate caddy nginx conflict"><p class="eyebrow">Troubleshooting</p><h2>When HTTPS is not detected</h2><p>Confirm public DNS points to this server, router forwarding reaches ports 80 and 443, and NGINX Proxy Manager or another service is not still using those ports. Then review Certificates and Logs → Gateway events. Site Gateway cannot request a public certificate while another gateway receives the challenge.</p></article>
</div></div><p id="doc-empty" class="quiet-state hidden">No guide matched that search.</p></section>
<section id="management-summary" class="summary hidden" aria-label="Site summary"><div><span id="running-dot" class="status-dot inactive"></span><strong id="running-count">0</strong><span id="running-label">No sites running</span></div><div><span id="disabled-dot" class="status-dot inactive"></span><strong id="disabled-count">0</strong><span id="disabled-label">No disabled sites</span></div><div><span id="error-dot" class="status-dot inactive"></span><strong id="error-count">0</strong><span id="error-label">No issues</span></div><div class="port-note">Ports <strong id="port-range">90009099</strong></div></section>
<div id="management-view" class="hidden">
<section id="empty" class="empty hidden">
<div class="empty-icon"></div><h2>Publish your first site</h2>
<p>Drop in a ZIP containing an <code>index.html</code> and choose a port. Thats it.</p>
<button class="button primary create-trigger">Create a site</button>
</section>
<section id="site-grid" class="site-grid" aria-live="polite"></section>
</div>
</main>
</div>
<dialog id="create-dialog">
<form id="create-form" class="dialog-card">
<div class="dialog-heading"><div><p class="eyebrow">New destination</p><h2>Create a site</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
<label>Site name<input name="name" placeholder="Portfolio" maxlength="80" required></label>
<label>Port<input name="port" type="number" required><small id="port-help"></small></label>
<label>Domain <span class="optional">Optional</span><input name="domain" placeholder="www.example.com"><small>Leave blank for port-only LAN access.</small></label><label>Additional domains <span class="optional">Optional</span><textarea name="domains" placeholder="www.example.com&#10;example.net"></textarea><small>One alias per line. All domains use the same hosted files and TLS settings.</small></label>
<label>TLS<select name="tls"><option value="automatic">Automatic public HTTPS</option><option value="internal">Internal HTTPS for trusted local devices</option><option value="http">HTTP only</option></select></label>
<label class="check-control"><input name="hsts" type="checkbox" value="true"><span>Enable HSTS after HTTPS is verified</span></label>
<details><summary>Advanced options</summary><div class="details-body"><label>Access List<select name="accessListId"><option value="">Public — no Access List</option></select><small>Reusable network or login protection.</small></label><label>Compression<select name="compression"><option value="automatic">Automatic zstd + gzip</option><option value="gzip">gzip only</option><option value="off">Off</option></select></label><label>Request headers<textarea name="requestHeadersText" placeholder="X-Robots-Tag: noindex"></textarea><small>One Name: value pair per line.</small></label><label>Response headers<textarea name="responseHeadersText" placeholder="X-Frame-Options: SAMEORIGIN"></textarea><small>One Name: value pair per line.</small></label><label class="check-control"><input name="hstsSubdomains" type="checkbox"><span>Apply HSTS to subdomains</span></label><label>Custom Caddy configuration<textarea name="customConfig" class="code-input" placeholder="# Expert use only"></textarea><small>Validated before Caddy reload.</small></label></div></details>
<label class="dropzone">Website files<input name="files" type="file" accept=".zip,.html,text/html,application/zip" required><span class="upload-icon"></span><strong>Choose a ZIP or index.html</strong><small>ZIP files must contain index.html · Up to 250 MB</small></label>
<p id="create-error" class="error" role="alert"></p>
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Create & publish</button></div>
</form>
</dialog>
<dialog id="proxy-dialog">
<form id="proxy-form" class="dialog-card">
<div class="dialog-heading"><div><p class="eyebrow">New route</p><h2>Create a proxy host</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
<label>Name<input name="name" placeholder="Home Assistant" maxlength="80" required></label>
<label>Primary domain<input name="domain" placeholder="home.example.com" required></label>
<label>Additional domains <span class="optional">Optional</span><textarea name="domainsText" placeholder="www.home.example.com&#10;home.example.net"></textarea><small>One alias per line. All domains use this proxy hosts upstream and TLS settings.</small></label>
<label>Forward to<input name="target" type="url" placeholder="http://192.168.1.20:8123" required><small>Use the container name, LAN address, or application URL.</small></label>
<label>Upstream pool <span class="optional">Optional</span><textarea name="upstreamsText" placeholder="http://192.168.1.20:53&#10;http://192.168.1.21:53"></textarea><small>One HTTP/HTTPS target per line. Caddy distributes requests across healthy targets.</small></label>
<label>TLS<select name="tls"><option value="automatic">Automatic public HTTPS</option><option value="internal">Internal HTTPS for trusted local devices</option><option value="custom">Custom uploaded certificate</option><option value="http">HTTP only</option></select></label>
<label class="check-control"><input name="hsts" type="checkbox"><span>Enable HSTS after HTTPS is verified</span></label>
<div id="custom-certificate-fields"><label>Certificate PEM<input name="certificateFile" type="file" accept=".pem,.crt,application/x-pem-file"></label><label>Private key PEM<input name="privateKeyFile" type="file" accept=".pem,.key,application/x-pem-file"></label><small>Both files are required when installing or replacing a custom certificate.</small></div>
<details><summary>Advanced options</summary><div class="details-body"><label>Access List<select name="accessListId"><option value="">Public — no Access List</option></select><small>Reusable network or login protection.</small></label><label>Health-check path<input name="healthPath" value="/"></label><label>Health-check method<select name="healthMethod"><option value="GET">GET — retrieve a response</option><option value="HEAD">HEAD — headers only</option></select></label><label>Expected status<input name="healthExpected" value="200-499"><small>Examples: 200, 200,204, or 200-399.</small></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this upstream</span></label><label>Compression<select name="compression"><option value="automatic">Automatic zstd + gzip</option><option value="gzip">gzip only</option><option value="off">Off</option></select></label><h3>Custom locations <span class="optional">Optional</span></h3><label>Locations<textarea name="customLocationsText" placeholder="/api/* | http://192.168.1.20:3001 | strip&#10;/media/* | http://192.168.1.21:8080 | preserve"></textarea><small>One per line: path | destination | strip or preserve.</small></label><h3>Headers and upstream TLS</h3><label>Request headers<textarea name="requestHeadersText" placeholder="X-Forwarded-Host: {host}"></textarea><small>One Name: value pair per line.</small></label><label>Response headers<textarea name="responseHeadersText" placeholder="X-Frame-Options: SAMEORIGIN"></textarea></label><label>Upstream TLS server name<input name="upstreamTlsServerName" placeholder="service.internal"><small>Optional SNI name expected by the upstream certificate.</small></label><label class="check-control"><input name="upstreamTlsInsecure" type="checkbox"><span>Ignore upstream TLS certificate errors</span><small>Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate.</small></label><label class="check-control"><input name="hstsSubdomains" type="checkbox"><span>Apply HSTS to subdomains</span></label><label>Custom Caddy configuration<textarea name="customConfig" class="code-input" placeholder="# Expert use only"></textarea><small>Validated before Caddy reload. NGINX syntax is not supported.</small></label></div></details>
<p id="proxy-error" class="error" role="alert"></p>
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Create & publish</button></div>
</form>
</dialog>
<dialog id="redirect-dialog"><form id="redirect-form" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">New route</p><h2>Create a redirect host</h2></div><button type="button" class="icon-button close-dialog">×</button></div><label>Name<input name="name" required placeholder="Old website"></label><label>Source domain<input name="domain" required placeholder="old.example.com"></label><label>Destination<input name="target" type="url" required placeholder="https://new.example.com"></label><label>Redirect type<select name="code"><option value="302">302 · Temporary</option><option value="301">301 · Permanent</option><option value="307">307 · Temporary, preserve method</option><option value="308">308 · Permanent, preserve method</option></select></label><label>TLS<select name="tls"><option value="automatic">Automatic HTTPS</option><option value="http">HTTP only</option><option value="internal">Internal HTTPS</option></select></label><label class="check-control"><input name="preservePath" type="checkbox" checked><span>Preserve path and query</span></label><p id="redirect-error" class="error"></p><div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Create redirect</button></div></form></dialog>
<dialog id="access-dialog"><form id="access-form" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Reusable protection</p><h2>Create an Access List</h2></div><button type="button" class="icon-button close-dialog">×</button></div><label>Name<input name="name" required placeholder="LAN and family"></label><label>Allowed networks<textarea name="networks" placeholder="private_ranges&#10;192.168.50.0/24"></textarea><small>When supplied, every other network is denied. Use one IP, CIDR range, or private_ranges per line.</small></label><label>Denied networks <span class="optional">Optional</span><textarea name="deniedNetworks" placeholder="203.0.113.0/24"></textarea><small>These rules are evaluated before allowed networks and logins.</small></label><div id="access-credential-editor" class="credential-editor"></div><div id="access-assignment-summary" class="callout hidden"></div><p id="access-error" class="error"></p><div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Save Access List</button></div></form></dialog>
<dialog id="settings-dialog">
<form id="settings-form" class="dialog-card">
<div class="dialog-heading"><div><p class="eyebrow">Gateway settings</p><h2 id="settings-title">Edit route</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
<label id="settings-name-wrap">Name<input name="name" maxlength="80"></label>
<label>Primary domain<input name="domain" placeholder="www.example.com"></label>
<label>Additional domains <span class="optional">Optional</span><textarea name="domainsText" placeholder="www.example.com&#10;example.net"></textarea><small>One alias per line. All domains use the same route and TLS settings.</small></label>
<label id="settings-target-wrap">Forward to<input name="target" type="url" placeholder="http://192.168.1.20:3000"></label>
<label>TLS<select name="tls"><option value="automatic">Automatic public HTTPS</option><option value="internal">Internal HTTPS for trusted local devices</option><option value="custom">Custom uploaded certificate</option><option value="http">HTTP only</option></select></label>
<label class="check-control"><input name="hsts" type="checkbox"><span>Enable HSTS after HTTPS is verified</span></label>
<div class="custom-certificate-fields"><label>Certificate PEM<input name="certificateFile" type="file" accept=".pem,.crt,application/x-pem-file"></label><label>Private key PEM<input name="privateKeyFile" type="file" accept=".pem,.key,application/x-pem-file"></label><small>Both files are required when installing or replacing a custom certificate.</small></div>
<details id="settings-hosted-advanced"><summary>Advanced options</summary><div class="details-body"><label>Access List<select name="accessListId"><option value="">Public — no Access List</option></select><small>Reusable network or login protection.</small></label><label>Compression<select name="compression"><option value="automatic">Automatic zstd + gzip</option><option value="gzip">gzip only</option><option value="off">Off</option></select></label><label>Request headers<textarea name="requestHeadersText" placeholder="Name: value"></textarea></label><label>Response headers<textarea name="responseHeadersText" placeholder="Name: value"></textarea></label><label class="check-control"><input name="hstsSubdomains" type="checkbox"><span>Apply HSTS to subdomains</span></label><label>Custom Caddy configuration<textarea name="customConfig" class="code-input"></textarea></label></div></details>
<details id="settings-advanced"><summary>Advanced options</summary><div class="details-body"><label>Access List<select name="accessListId"><option value="">Public — no Access List</option></select></label><label>Health-check path<input name="healthPath" value="/"></label><label>Health-check method<select name="healthMethod"><option value="GET">GET — retrieve a response</option><option value="HEAD">HEAD — headers only</option></select></label><label>Expected status<input name="healthExpected" value="200-499"></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this upstream</span></label><label>Compression<select name="compression"><option value="automatic">Automatic zstd + gzip</option><option value="gzip">gzip only</option><option value="off">Off</option></select></label><label>Custom locations<textarea name="customLocationsText" placeholder="/api/* | http://192.168.1.20:3001 | strip"></textarea><small>One per line: path | destination | strip or preserve.</small></label><label>Request headers<textarea name="requestHeadersText" placeholder="Name: value"></textarea></label><label>Response headers<textarea name="responseHeadersText" placeholder="Name: value"></textarea></label><label>Upstream TLS server name<input name="upstreamTlsServerName"><small>Optional SNI name expected by the upstream certificate.</small></label><label class="check-control"><input name="upstreamTlsInsecure" type="checkbox"><span>Ignore upstream TLS certificate errors</span><small>Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate.</small></label><label class="check-control"><input name="hstsSubdomains" type="checkbox"><span>Apply HSTS to subdomains</span></label><label>Custom Caddy configuration<textarea name="customConfig" class="code-input"></textarea></label></div></details>
<p id="settings-error" class="error" role="alert"></p>
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Save & apply</button></div>
</form>
</dialog>
<dialog id="confirm-dialog">
<form method="dialog" class="dialog-card compact"><h2 id="confirm-title">Delete this site?</h2><p id="confirm-copy" class="muted">Its uploaded files will be permanently removed.</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button danger">Delete</button></div></form>
</dialog>
<dialog id="readiness-dialog">
<form method="dialog" class="dialog-card readiness-dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Domain readiness</p><h2 id="readiness-title">Diagnostics</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><div id="readiness-detail-content"></div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>
</dialog>
<dialog id="icon-dialog" class="icon-dialog">
<form method="dialog" class="dialog-card icon-picker">
<div class="dialog-heading"><div><p class="eyebrow">Appearance</p><h2>Choose an icon</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div>
<p class="muted">Search Dashboard Icons. Selected icons are validated and stored locally in <code>/data/icons</code>.</p>
<label>Search icons<input id="icon-search" type="search" placeholder="Jellyfin" autocomplete="off"></label>
<label>Upload a custom icon<input id="icon-upload" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"><small>PNG, JPEG, WebP, GIF, or SVG · up to 2 MB. Stored locally in <code>/data/icons</code>.</small></label>
<label>Or use an image URL <span class="optional">Optional</span><input id="icon-url" type="url" placeholder="https://example.com/icon.png"><small>Use a trusted HTTPS URL. The two-letter fallback remains available.</small></label>
<div id="icon-results" class="icon-results" aria-live="polite"><p class="quiet-state">Enter at least two characters to search.</p></div>
<p id="icon-error" class="error" role="alert"></p>
<div class="dialog-actions"><button id="save-icon-url" type="button" class="button secondary">Save URL</button><button id="reset-icon" value="none" class="button secondary">Use two-letter fallback</button><button value="cancel" class="button secondary">Cancel</button></div>
</form>
</dialog>
<dialog id="user-dialog">
<form id="user-form" class="dialog-card">
<div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create a user</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
<label>Display name<input name="displayName" placeholder="Marvin Wade" maxlength="80" required></label>
<label>Username<input name="username" placeholder="marvin" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._-]{2,63}" autocomplete="off" required></label>
<label>Role<select name="role"><option value="standard">Standard User</option><option value="viewer">Viewer</option><option value="administrator">Administrator</option></select><small>Viewer accounts can inspect gateway data. Standard Users and Administrators retain their assigned management capabilities.</small></label>
<label>Temporary password<input name="password" type="password" minlength="8" autocomplete="new-password" required><small>At least 8 characters. Share it securely.</small></label>
<p id="user-error" class="error" role="alert"></p>
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Create user</button></div>
</form>
</dialog>
<dialog id="password-dialog">
<form id="password-form" class="dialog-card">
<div class="dialog-heading"><div><p class="eyebrow">Credentials</p><h2 id="password-title">Reset password</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
<label>New password<input name="password" type="password" minlength="8" autocomplete="new-password" required><small>At least 8 characters.</small></label>
<p id="password-error" class="error" role="alert"></p>
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Save password</button></div>
</form>
</dialog>
<input id="replace-files" type="file" accept=".zip,.html,text/html,application/zip" hidden>
<div id="toast" class="toast" role="status"></div>
<script src="/app.js?v=0.11.28" defer></script><script src="/features.js?v=0.11.28" defer></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Not found</title><style>body{font:16px system-ui;background:#08101d;color:#f4f7fb;min-height:100vh;display:grid;place-items:center;margin:0;text-align:center}h1{font-size:4rem;margin:0;color:#62e6a7}p{color:#91a0b6}</style></head><body><main><h1>404</h1><p>This file doesnt exist on this site.</p></main></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1800 320" role="img" aria-labelledby="title desc">
<title id="title">Site Gateway</title>
<desc id="desc">Site Gateway wordmark with a green and blue gateway icon and colored routing nodes.</desc>
<defs><filter id="glow" x="-30%" y="-50%" width="160%" height="200%"><feGaussianBlur stdDeviation="14" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
<g opacity=".8" stroke="#9bb2ca" stroke-width="2"><path d="M30 160h330"/><path d="M1470 160h300"/></g>
<g fill="#62e6a7" stroke="#0b1525" stroke-width="3"><circle cx="150" cy="160" r="14"/><circle cx="1650" cy="160" r="14"/></g>
<g fill="#1686ff" stroke="#0b1525" stroke-width="3"><circle cx="215" cy="160" r="14"/><circle cx="1585" cy="160" r="14"/></g>
<g fill="#ffbf69" stroke="#0b1525" stroke-width="3"><circle cx="280" cy="160" r="14"/><circle cx="1520" cy="160" r="14"/></g>
<g transform="translate(405 54)" filter="url(#glow)">
<path d="M92 0 172 47v83l-51-30V76L92 59 63 76v24l-51 30V47z" fill="#62e6a7"/>
<path d="m92 59 29 17v83l51-30v58l-80 47-80-47v-58l51 30V76z" fill="#1686ff"/>
<path d="m92 59 29 17v83l-29 17-29-17V76z" fill="#172a47"/>
</g>
<text x="650" y="202" fill="#f4f7fb" font-family="Arial,sans-serif" font-size="112" font-weight="800" letter-spacing="-5">Site</text>
<text x="985" y="202" fill="#62e6a7" font-family="Arial,sans-serif" font-size="112" font-weight="800" letter-spacing="-5">Gateway</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long