// ============================================================================ // app.js -- core client application: state, API helper, 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: [], usersLoaded: false, user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] }; // One-time DOM patches: move the Access List field into the create/settings // forms (features.js owns the Access List data, this file owns these forms). 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(); // --- 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.usersLoaded = false; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); } function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); } function toast(message, type = "success") { const el = $("#toast"); el.textContent = message; el.classList.toggle("toast-error", type === "error"); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); } function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; } 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 formatRelativeTime(value) { if (!value) return "Just now"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently"; const seconds = Math.round((Date.now() - date.getTime()) / 1000); if (seconds < 45) return "Just now"; if (seconds < 90) return "1 minute ago"; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes} minutes ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.round(hours / 24); 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 // trusting the unscoped FormData value (which can silently pick up the other section's field). const read = (name, fallback = "") => scoped ? scopedValue(scoped.formEl, scoped.scope, name, fallback) : (form.get(name) || fallback); const checked = (name) => scoped ? Boolean(scoped.formEl.querySelector(`${scoped.scope} [name="${name}"]`)?.checked) : form.has(name); body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure"); body.accessListId = read("accessListId", body.accessListId || ""); body.requestHeaders = parseHeaderLines(read("requestHeadersText")); body.responseHeaders = parseHeaderLines(read("responseHeadersText")); body.compression = read("compression", "automatic"); body.customConfig = read("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.lbPolicy = read("lbPolicy", "random"); body.healthPath = read("healthPath", "/"); body.healthMethod = read("healthMethod", "GET"); body.healthExpected = read("healthExpected", "200-499"); body.healthTimeoutSeconds = Number(read("healthTimeoutSeconds", "4")); body.healthRetries = Number(read("healthRetries", "0")); 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; event.preventDefault(); event.stopImmediatePropagation(); const form = new FormData(event.target), button = resolveSubmitter(event); const certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"); let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile; if (state.editing.kind === "proxy") body = advancedFormBody(form, body, { scope: "#settings-advanced", formEl: event.target }); else { const scope = "#settings-hosted-advanced"; body = { name: body.name, domain: body.domain, domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), 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") }; } 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."; return; } } 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) }); 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 refreshCurrentView(); toast("Gateway settings applied."); } catch (error) { $("#settings-error").textContent = error.message; } 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`; 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 = `

Operations

Scheduled jobs

${(system.jobs || []).map(job => `
${escapeHtml(job.name)}${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; } // Dashboard tiles share one baseline accent (green) and switch to the existing // --warning / --danger tokens when the thing they count is actually in trouble -- // the same mechanism the "Needs attention" chip already used. const TILE_ACCENT_CLASSES = ["accent-green", "accent-blue", "accent-amber", "accent-purple", "accent-warning", "accent-danger"]; function setTileAccent(valueId, level) { const tile = $(valueId)?.closest(".metric-card, .metric-chip"); if (!tile) return; tile.classList.remove(...TILE_ACCENT_CLASSES); tile.classList.add(level === "danger" ? "accent-danger" : level === "warning" ? "accent-warning" : "accent-green"); } function applyTileAccents(data) { const group = value => !value?.total || !value.errors ? "green" : value.errors >= value.total ? "danger" : "warning"; setTileAccent("#dash-hosted-total", group(data.hosted)); const upstreams = data.upstreams || { total: 0, unhealthy: 0 }; const proxyLevel = group(data.proxies); setTileAccent("#dash-proxy-total", proxyLevel !== "green" ? proxyLevel : upstreams.unhealthy > 0 ? (upstreams.unhealthy >= upstreams.total ? "danger" : "warning") : "green"); const certificates = data.certificates || {}; const certificateLevel = (certificates.expired || 0) + (certificates.mismatch || 0) > 0 ? "danger" : (certificates.warning || 0) + (certificates.critical || 0) > 0 ? "warning" : "green"; setTileAccent("#dash-tls-total", certificateLevel); // Redirect hosts have no runtime failure state of their own, so they stay on the baseline. setTileAccent("#dash-redirect-total", "green"); const streaming = data.streamingPorts || { total: 0, listening: 0 }; setTileAccent("#dash-stream-total", !streaming.total || streaming.listening === streaming.total ? "green" : streaming.listening === 0 ? "danger" : "warning"); // Throughput is a rate, not a health signal: there is no "bad" value to react to. setTileAccent("#dash-throughput-total", "green"); } 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.certificates.total; $("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains"; $("#dash-redirect-total").textContent = state.redirects?.length || 0; $("#dash-stream-total").textContent = state.streams?.length || 0; $("#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"; applyTileAccents(data); $("#dash-attention-chip").classList.toggle("accent-danger", data.attention.length > 0); $("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0); $("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓"; $("#dash-throughput-total").textContent = data.throughput?.liveRequests ?? 0; const panelStreaming = data.streamingPorts || { total: 0, listening: 0 }, panelUpstreams = data.upstreams || { total: 0, healthy: 0, unhealthy: 0 }; const hasErrors = data.gateway.status === "error" || data.services.http.status === "error" || data.services.https.status === "error" || !data.services.storage.healthy || (panelStreaming.total > 0 && panelStreaming.listening !== panelStreaming.total) || (panelUpstreams.total > 0 && panelUpstreams.unhealthy > 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"; $("#health-panel").className = `dashboard-panel health-panel ${hasErrors ? "status-error" : isChecking || hasNothingRunning ? "status-warning" : "status-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"; const streaming = data.streamingPorts || { total: 0, listening: 0 }; $("#streaming-health-dot").className = `status-dot ${!streaming.total ? "inactive" : streaming.listening === streaming.total ? "running" : "error"}`; $("#streaming-health-copy").textContent = !streaming.total ? "No streaming hosts configured" : `${streaming.listening} of ${streaming.total} port${streaming.total === 1 ? "" : "s"} listening`; const upstreams = data.upstreams || { total: 0, healthy: 0, unhealthy: 0 }; $("#upstream-health-dot").className = `status-dot ${!upstreams.total ? "inactive" : upstreams.unhealthy > 0 ? "error" : "running"}`; $("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`; $("#health-checked").innerHTML = `Last checked ${formatTime(data.checkedAt)}`; // Memory/Data/Storage/Version/Database/Public IP moved to the Administration > System tab's // Version panel -- the Dashboard's own Runtime/System panel is now the shared hero component // (see renderHeroPanel/refreshDashboardHero), which reads real container-scoped CPU/memory/ // swap/disk/network from /api/system/health instead of this endpoint's coarser numbers. $("#attention-panel").classList.toggle("is-clear", data.attention.length === 0); $("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0); $("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => item.kind === "drift" ? `
${escapeHtml(item.name)}${escapeHtml(item.message)}${canAdmin() ? '' : ""}
` : `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}>${escapeHtml(item.name)}${escapeHtml(item.message)}` ).join("") : '
Everything looks good — no issues to review.
'; $("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `
${item.status === "error" || item.status === "warning" ? "!" : "✓"}${escapeHtml(item.message)}${escapeHtml(formatRelativeTime(item.at))}
`).join("") : '

No recent activity.

'; } // --- 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 "??"; 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 ? `` : 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"; } // --- Hosted Sites & Proxy Hosts: card templates ------------------------------------ function upstreamStateClass(enabled, upstream) { if (!enabled || upstream?.status === "unmonitored" || !upstream || upstream.status === "pending") return "idle"; return upstream.status === "healthy" ? "" : "bad"; } 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() ? `` : ""; const toggle = canManage() ? `` : ""; return `
${iconMarkup(site)}
${menu}

${escapeHtml(site.name)}

${escapeHtml(site.domain || `Port ${site.port}`)}

${site.domain ? `

${escapeHtml(publicUrl(site))}

` : ""}

${upstream}

`; } 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() ? `` : ""; const toggle = canManage() ? `` : ""; const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List"; return `
${iconMarkup(proxy)}
${menu}

${escapeHtml(proxy.name)}

${escapeHtml(proxy.target)}

${escapeHtml(publicUrl(proxy))}

${upstream}

${escapeHtml(access)}

`; } // --- Certificates view -------------------------------------------------------------- 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"}`; const routes = state.readiness?.routes || []; state.certRows = data.certificates.map(cert => ({ cert, readiness: routes.find(item => item.domain === cert.domain) || null })); $("#certificate-list").innerHTML = state.certRows.length ? state.certRows.map((row, index) => { const cert = row.cert, item = row.readiness; const dnsOk = item ? item.dns.healthy : null; const tlsOk = item ? ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status) : null; const dnsCell = item ? `${dnsOk ? "Resolved" : "Failed"}` : `—`; const tlsCell = item ? `${escapeHtml(item.tls.status.replaceAll("-", " "))}` : `—`; const upstreamCell = !item ? `—` : !item.upstream || item.upstream.status === "unmonitored" ? `Monitoring paused` : item.upstream.status === "pending" ? `Check pending` : item.upstream.status === "healthy" ? `${item.upstream.httpStatus}` : `${escapeHtml(item.upstream.error || "Unavailable")}`; const statusLabel = cert.status === "mismatch" ? "Domain mismatch" : cert.status.charAt(0).toUpperCase() + cert.status.slice(1); return `${escapeHtml(cert.domain)}
${escapeHtml(cert.kind)} · ${escapeHtml(cert.source)}${escapeHtml(statusLabel)}${cert.expiresAt ? `${cert.daysRemaining} days` : "—"}${escapeHtml(cert.issuer || "—")}${dnsCell}${tlsCell}${upstreamCell}`; }).join("") : 'No HTTPS domains are configured.'; } // --- Certificate detail popup (deep fields for a single certificate row) ------------ function openCertificateDetail(row) { const cert = row.cert, item = row.readiness; $("#cert-detail-title").textContent = cert.domain; $("#cert-detail-eyebrow").textContent = `${cert.kind} · ${cert.source}`; const certRows = `
Status
${escapeHtml(cert.status)}
Valid from
${cert.validFrom ? escapeHtml(formatTime(cert.validFrom)) : "—"}
Expires
${cert.expiresAt ? escapeHtml(formatTime(cert.expiresAt)) : "—"}
Issuer
${escapeHtml(cert.issuer || "—")}
Covered domains
${escapeHtml((cert.coveredNames || []).join(", ") || "—")}
Serial number
${escapeHtml(cert.serialNumber || "—")}
SHA-256 fingerprint
${escapeHtml(cert.fingerprint || "—")}
Last detected update
${cert.updatedAt ? escapeHtml(formatTime(cert.updatedAt)) : "—"}
`; const readinessRows = item ? `
DNS
${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}
Gateway ports
HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}
TLS
${escapeHtml(item.tls.status.replaceAll("-", " "))}
${item.upstream ? `
Upstream
Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${item.upstream.httpStatus ?? "no response"}${item.upstream.responseMs != null ? ` · ${item.upstream.responseMs} ms` : ""} · ${item.upstream.attempts || 1} attempt${(item.upstream.attempts || 1) === 1 ? "" : "s"}
Last checked
${escapeHtml(formatTime(item.upstream.checkedAt))}
${item.upstream.error ? `
Failure detail
${escapeHtml(item.upstream.error)}
` : ""}` : "
Upstream
No upstream health check configured.
"}` : "
Domain readiness
No readiness data available for this domain.
"; $("#cert-detail-body").innerHTML = certRows + readinessRows; $("#certificate-detail-dialog").showModal(); } $("#certificate-list").addEventListener("click", event => { const row = event.target.closest(".cert-table-row"); if (!row) return; const data = state.certRows?.[Number(row.dataset.index)]; if (data) openCertificateDetail(data); }); $("#certificate-list").addEventListener("keydown", event => { if (event.key !== "Enter" && event.key !== " ") return; const row = event.target.closest(".cert-table-row"); if (!row) return; event.preventDefault(); const data = state.certRows?.[Number(row.dataset.index)]; if (data) openCertificateDetail(data); }); $("#cert-threshold-trigger").addEventListener("click", () => { renderHealthSettings(); $("#health-settings-dialog").showModal(); }); // --- 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; 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`} · Checked ${escapeHtml(formatTime(new Date().toISOString()))}`; $("#log-rows").innerHTML = entries.length ? entries.map(entry => `${escapeHtml(formatTime(entry.at))}${escapeHtml(entry.host || "—")}${escapeHtml(entry.method || "")} ${escapeHtml(entry.uri || "")}${entry.status ?? "—"}${entry.durationMs == null ? "—" : `${entry.durationMs} ms`}`).join("") : 'No matching requests have been logged yet.'; 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"; const severityLabel = item.status === "error" ? "Error" : item.status === "warning" ? "Warning" : "Normal"; return `${escapeHtml(formatTime(item.at))}${severityLabel}${escapeHtml(eventCategory)}${escapeHtml(item.message)}`; }).join("") : 'No matching gateway events. Try a different severity or category filter.'; } // --- Performance view: summary, request trend chart (hand-drawn SVG sparkline), // and the per-domain throughput table ----------------------------------------------- // Clock-boundary label spacing per selected range (hours -> minutes between labels). const PERFORMANCE_LABEL_MINUTES = { 1: 15, 3: 30, 6: 60, 12: 120, 24: 180, 72: 720, 168: 1440 }; function formatChartTime(value, intervalMinutes) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; if (intervalMinutes >= 1440) return date.toLocaleDateString([], { month: "short", day: "numeric" }); if (intervalMinutes >= 720) return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric" }); return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } function formatLatency(ms) { return ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; } // Geometry shared by the chart renderer and the hover tooltip. const PERFORMANCE_CHART = { left: 34, right: 8, top: 10, bottom: 20, width: 600, height: 140 }; function renderPerformance() { const data = state.performance; if (!data) return; const selected = $("#performance-host").value; $("#performance-host").innerHTML = '' + data.hosts.map(host => ``).join(""); $("#performance-host").value = selected; const label = selected ? escapeHtml(selected) : "all domains"; $("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · Checked ${escapeHtml(formatTime(data.checkedAt))}`; const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours"; $("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`; const points = data.trend || []; state.performancePoints = points; const max = Math.max(1, ...points.map(point => point.count)); const { left, right, top, bottom, width, height } = PERFORMANCE_CHART; const plotWidth = width - left - right, plotHeight = height - top - bottom; const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth); const yAt = count => top + plotHeight - (count / max) * plotHeight; const gridFractions = [0, 0.5, 1]; const gridLines = gridFractions.map(fraction => { const y = (top + plotHeight * (1 - fraction)).toFixed(1); return ``; }).join(""); const leftPct = (left / width) * 100, plotWidthPct = (plotWidth / width) * 100, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100; const axisLabels = gridFractions.map(fraction => { const value = Math.round(max * fraction); const yPct = topInsetPct + plotHeightPct * (1 - fraction); return `${value}`; }).join(""); // Time axis: labels land on real clock boundaries scaled to the selected range, and the // true first and last sample are always labelled so the window's edges stay readable. const hours = Number($("#performance-range").value) || 6; const intervalMinutes = PERFORMANCE_LABEL_MINUTES[hours] || 60; const intervalMs = intervalMinutes * 60000; let timeLabels = ""; if (points.length) { const candidates = new Set([0, points.length - 1]); const aligned = []; points.forEach((point, index) => { const time = new Date(point.at).getTime(); if (!Number.isNaN(time) && time % intervalMs === 0) aligned.push(index); }); const stride = Math.max(1, Math.ceil(aligned.length / 8)); aligned.forEach((index, position) => { if (position % stride === 0) candidates.add(index); }); const ordered = [...candidates].sort((a, b) => a - b); timeLabels = ordered.map(index => { const leftEdge = leftPct + (points.length > 1 ? (index / (points.length - 1)) * plotWidthPct : plotWidthPct); const alignment = index === 0 ? "" : index === points.length - 1 ? " time-label-end" : " time-label-mid"; return `${escapeHtml(formatChartTime(points[index].at, intervalMinutes))}`; }).join(""); } $("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : ""; const coords = points.map((point, index) => [xAt(index), yAt(point.count)]); state.performanceCoords = coords; const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => { if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`; const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1]; const p1 = coords[index - 1]; const p2 = point; const p3 = coords[index + 1] || point; const cp1x = p1[0] + (p2[0] - p0[0]) / 6, cp1y = p1[1] + (p2[1] - p0[1]) / 6; const cp2x = p2[0] - (p3[0] - p1[0]) / 6, cp2y = p2[1] - (p3[1] - p1[1]) / 6; return `${d} C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`; }, ""); const baseline = (top + plotHeight).toFixed(1); const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : ""; $("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`); $("#performance-sparkline").innerHTML = points.length ? `${gridLines}