${escapeHtml(proxy.name)}
${escapeHtml(proxy.target)}
${escapeHtml(publicUrl(proxy))}
${upstream}
${escapeHtml(access)}
diff --git a/src/public/app.js b/src/public/app.js index 7a90ab7..4e70ec1 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -1,11 +1,25 @@ +// ============================================================================ +// app.js -- core client application: state, API helper, theme, dashboard, +// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/ +// edit/icon/user/account/MFA), and every event listener for those areas. The +// remaining views (Streaming, Redirects, Access Lists, Administration panels) +// live in features.js and are invoked from here via window.renderExtendedViews. +// ============================================================================ + +// --- Shared DOM shortcut and app state ---------------------------------------- const $ = selector => document.querySelector(selector); const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false }; + +// One-time DOM patches: move the Access List field into the create/settings +// forms (features.js owns the Access List data, this file owns these forms). 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 = 'Access List OptionalProtect this hosted site and all of its domains.'; 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 = 'Access List OptionalProtect this route and all of its domains.'; 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(); + +// --- Theme (light/dark/system) ------------------------------------------------- const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); function applyTheme(preference) { @@ -18,17 +32,22 @@ $("#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"); }); +// --- API helper ------------------------------------------------------------------ + 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(); } + +// --- Login/dashboard shell, toast, and small formatting helpers ------------------ function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); } function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); } function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); } function 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`; @@ -36,15 +55,18 @@ function formatBytes(value) { 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 formatRelativeTime(value) { if (!value) return "Just now"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently"; @@ -59,19 +81,25 @@ function formatRelativeTime(value) { if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`; return formatTime(value); } + 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); } + // event.submitter is null on implicit form submission (e.g. pressing Enter in a field instead of // clicking the button), which previously crashed every save handler below on `button.disabled = true` // and silently dropped the whole save. Fall back to the form's actual submit button. function resolveSubmitter(event) { return event.submitter || event.target.querySelector('button:not([type="button"])'); } function scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; } + // #settings-form reuses field names (healthEnabled, healthPath, accessListId, compression, etc.) between the // hidden site-scoped (#settings-hosted-advanced) and proxy-scoped (#settings-advanced) sections. form.elements.NAME // resolves to a RadioNodeList when a name is duplicated, and assigning .value/.checked to a RadioNodeList of // non-radio inputs silently does nothing — so every one of these fields must be read/written through its scope. function setScoped(form, scope, name, value) { const el = form.querySelector(`${scope} [name="${name}"]`); if (!el) return; if (el.type === "checkbox") el.checked = Boolean(value); else el.value = value; } + +// advancedFormBody -- reads the scoped or unscoped "advanced options" fields off +// a create/edit form and merges them into the outgoing request body. function advancedFormBody(form, body, scoped) { // scoped = { scope, formEl } — pass this when `form` came from a shared form (like #settings-form) where // field names collide with another section, so every ambiguous field is read from its own scope instead of @@ -88,6 +116,8 @@ function advancedFormBody(form, body, scoped) { delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText; return body; } + +// --- Proxy/Hosted settings dialog: submit handler --------------------------------- // 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; @@ -112,6 +142,8 @@ document.addEventListener("submit", async event => { finally { button.disabled = false; } }, true); + +// --- Dashboard rendering ---------------------------------------------------------- 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`; @@ -126,6 +158,7 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") { 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 = `
Operations
No recent activity.
'; } + setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000); + +// --- Card rendering helpers (icons, permissions) ----------------------------------- function initials(name) { const words = String(name || "").trim().split(/\s+/).map(word => word.replace(/[^a-z0-9]/gi, "")).filter(Boolean); if (!words.length) return "??"; @@ -193,6 +229,8 @@ document.addEventListener('error', event => { const image = event.target; if (!( function canManage() { return ["administrator", "standard"].includes(state.user?.role); } function canAdmin() { return state.user?.role === "administrator"; } + +// --- Hosted Sites & Proxy Hosts: card templates ------------------------------------ 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")}`; @@ -209,6 +247,8 @@ function proxyCard(proxy) { return `${escapeHtml(proxy.target)}
${escapeHtml(publicUrl(proxy))}
${upstream}
${escapeHtml(access)}
No configured domains to check.
'; } + +// --- Logs view ------------------------------------------------------------------------- function renderLogs() { const data = state.logs; if (!data) return; const selected = $("#log-host").value; $("#log-host").innerHTML = '' + data.hosts.map(host => ``).join(""); $("#log-host").value = selected; @@ -245,6 +289,9 @@ function renderLogs() { $("#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 `Enter at least two characters to search.
'; $("#icon-dialog").showModal(); setTimeout(() => $("#icon-search").focus(), 0); } @@ -537,6 +628,8 @@ $("#save-icon-url").addEventListener("click", async () => { 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 management: create, edit (role/status), password reset, delete -------------------- $("#user-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#user-error").textContent = ""; try { @@ -545,6 +638,7 @@ $("#user-form").addEventListener("submit", async event => { } 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 = ``; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true })); } $("#user-list").addEventListener("click", async event => { const menuCard = event.target.closest(".user-card"); @@ -571,6 +665,7 @@ $("#user-list").addEventListener("click", async event => { } catch (error) { toast(error.message); } finally { button.disabled = false; } }); + $("#password-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#password-error").textContent = ""; try { @@ -579,6 +674,8 @@ $("#password-form").addEventListener("submit", async event => { } catch (error) { $("#password-error").textContent = error.message; } finally { button.disabled = false; } }); + +// --- Hash-based routing: back/forward and deep links (#view or #administration/tab) --------- window.addEventListener("hashchange", () => { if (!state.user) return; // Not logged in yet; boot() handles initial routing. const requestedHash = location.hash.slice(1); @@ -588,8 +685,11 @@ window.addEventListener("hashchange", () => { render(); loadFeatureView().catch(error => toast(error.message)); }); + boot().catch(error => toast(error.message)); + +// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------ 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); @@ -603,14 +703,22 @@ function syncUpstreamTlsControls(form) { const lbPolicy = form.elements.lbPolicy; if (lbPolicy) { const poolTargets = String(form.elements.upstreamsText?.value || "").split("\n").map(value => value.trim()).filter(Boolean); const multi = poolTargets.length > 1; lbPolicy.disabled = !multi; lbPolicy.closest("label")?.classList.toggle("control-disabled", !multi); if (!multi) lbPolicy.value = "random"; } } + 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)); + +// --- Misc global click delegation (create triggers, generic [data-action] handlers) --------- 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); }); + +// --- Access Lists: periodic live refresh while that view is open ---------------------------- 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 slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `Operations
${extendedEscape(item.domain)}
→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}