let activeVhostBackendPopup = null; function closeVhostBackendPopup(returnFocus = false) { const current = activeVhostBackendPopup; if (!current) return; activeVhostBackendPopup = null; current.trigger.setAttribute("aria-expanded", "false"); current.node.remove(); if (returnFocus && current.trigger.isConnected) current.trigger.focus(); } function positionVhostBackendPopup() { const current = activeVhostBackendPopup; if (!current) return; const rect = current.trigger.getBoundingClientRect(), edge = 12, gap = 6; const width = Math.min(430, Math.max(0, window.innerWidth - edge * 2)); const height = Math.min(current.node.scrollHeight, 360, Math.max(100, window.innerHeight - edge * 2)); const below = window.innerHeight - rect.bottom - edge - gap; const above = rect.top - edge - gap; const placeAbove = below < height && above > below; const desiredTop = placeAbove ? rect.top - height - gap : rect.bottom + gap; current.node.style.width = `${width}px`; current.node.style.left = `${Math.max(edge, Math.min(rect.left, window.innerWidth - width - edge))}px`; current.node.style.top = `${Math.max(edge, Math.min(desiredTop, window.innerHeight - height - edge))}px`; } function vhostPopupHeading(label) { const heading = el("h4", label); heading.id = "vhost-backend-popup-heading"; const headingRow = el("div", undefined, "vhost-backend-popup-heading"); const closeButton = el("button", "×", "vhost-backend-popup-close"); closeButton.type = "button"; closeButton.setAttribute("aria-label", t("close")); closeButton.addEventListener("click", () => closeVhostBackendPopup(true)); headingRow.append(heading, closeButton); return { heading, headingRow }; } function refreshVhostProtocolPopup(current, vhost, members) { const backend = (vhost.backends || []).find(item => item.id === current.backendId); if (!backend) { closeVhostBackendPopup(); return; } const member = members.get(backend.id); const observed = upstreamProtocolBadgeText(backend, member); if (!current.protocolView) { const { heading, headingRow } = vhostPopupHeading(observed.label); const info = el("p", "", "fine vhost-backend-popup-hint"); const list = el("ul", undefined, "vhost-backend-popup-list"); const row = el("li", undefined, "vhost-backend-popup-row"); const columns = el("div", undefined, "vhost-backend-popup-columns"); const counters = []; for (const protocol of ["H1", "H2"]) { const field = el("div", undefined, "vhost-backend-popup-field"); const value = el("span", "0"); field.append(el("span", protocol), value); columns.append(field); counters.push(value); } row.append(columns); list.append(row); current.node.append(headingRow, info, list); current.node.setAttribute("aria-labelledby", heading.id); current.protocolView = { heading, info, list, counters }; } const view = current.protocolView; const explanation = t(observed.recent ? "vhBackendProtocolRecent" : "vhBackendProtocolUnobserved"); if (view.heading.textContent !== observed.label) view.heading.textContent = observed.label; if (view.info.textContent !== explanation) view.info.textContent = explanation; view.list.hidden = !observed.recent; if (observed.recent) { const totals = [member.upstream_protocol_http1_total || 0, member.upstream_protocol_http2_total || 0]; totals.forEach((total, index) => { const label = String(total); if (view.counters[index].textContent !== label) view.counters[index].textContent = label; }); } positionVhostBackendPopup(); } function refreshVhostBackendPopup(status = latestStatus) { const current = activeVhostBackendPopup; if (!current) return; const vhost = (envelope?.config.vhosts || []).find(item => item.id === current.id); if (!vhost || !current.trigger.isConnected) { closeVhostBackendPopup(); return; } const pool = (status?.backend_pools || []).find(item => item.id === vhost.id); const members = new Map((pool?.members || []).map(member => [member.id, member])); if (current.backendId !== null) { refreshVhostProtocolPopup(current, vhost, members); return; } const { heading, headingRow } = vhostPopupHeading(`${t("vhBackendPopupTitle")} · ${vhost.id}`); const list = el("ul", undefined, "vhost-backend-popup-list"); for (const backend of vhost.backends || []) { const member = members.get(backend.id); const row = el("li", undefined, "vhost-backend-popup-row"); row.append(el("strong", backend.id, "vhost-backend-popup-name")); let address = "-", scheme = "-"; try { const url = new URL(backend.url); scheme = url.protocol.replace(":", "").toUpperCase(); const port = url.port || (scheme === "HTTPS" ? "443" : scheme === "HTTP" ? "80" : ""); address = `${url.hostname}${port ? `:${port}` : ""}`; } catch { /* Configuration may have changed while status was refreshing. */ } const upstream = upstreamProtocolBadgeText(backend, member); const protocol = `${scheme} · ${upstream.mode}${upstream.observed ? ` → ${upstream.observed}` : ""}`; const statusCode = Number(member?.last_status); const hasStatus = member && Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599; const httpResponse = hasStatus ? `HTTP ${statusCode}` : t("vhBackendPopupNoResponse"); const statusLabel = !member ? t("vhHealthUnknown") : t(({ up: "vhUp", down: "vhDown", disabled: "vhDisabled", checking: "vhChecking", draining: "vhDraining" })[member.status] || "vhHealthUnknown"); const columns = el("div", undefined, "vhost-backend-popup-columns"); for (const [key, value] of [ ["vhBackendPopupAddress", address], ["vhBackendPopupProtocol", protocol], ["vhBackendPopupLastHttp", httpResponse], ["status", statusLabel] ]) { const field = el("div", undefined, "vhost-backend-popup-field"); field.append(text("span", key), el("span", value)); if (key === "vhBackendPopupLastHttp" && hasStatus) field.classList.add(statusCode >= 500 ? "has-error" : statusCode < 400 ? "has-success" : ""); columns.append(field); } row.append(columns); list.append(row); } if (!list.children.length) list.append(el("li", t("vhBackendPopupEmpty"), "fine")); const foot = el("p", t("vhBackendPopupHint"), "fine vhost-backend-popup-hint"); current.node.replaceChildren(headingRow, list, foot); current.node.setAttribute("aria-labelledby", heading.id); positionVhostBackendPopup(); } function toggleVhostBackendPopup(id, trigger, backendId = null) { if (activeVhostBackendPopup?.trigger === trigger) { closeVhostBackendPopup(); return; } closeVhostBackendPopup(); const node = el("section", undefined, "vhost-backend-popup"); node.id = "vhost-backend-popup"; node.setAttribute("role", "dialog"); node.setAttribute("aria-modal", "false"); trigger.setAttribute("aria-expanded", "true"); activeVhostBackendPopup = { id, trigger, node, backendId, protocolView: null }; document.body.append(node); refreshVhostBackendPopup(); } function enableVhostBackendPopup(trigger, vhost, backendId = null) { trigger.dataset.vhostBackendTrigger = vhost.id; trigger.setAttribute("aria-haspopup", "dialog"); trigger.setAttribute("aria-expanded", "false"); trigger.setAttribute("aria-controls", "vhost-backend-popup"); if (backendId === null) trigger.setAttribute("aria-label", `${t("vhBackendPopupOpen")}: ${vhost.id}`); trigger.addEventListener("click", () => toggleVhostBackendPopup(vhost.id, trigger, backendId)); } document.addEventListener("pointerdown", event => { const current = activeVhostBackendPopup; if (current && !current.node.contains(event.target) && !current.trigger.contains(event.target)) closeVhostBackendPopup(); }); document.addEventListener("keydown", event => { if (event.key === "Escape" && activeVhostBackendPopup) { event.preventDefault(); event.stopPropagation(); closeVhostBackendPopup(true); } }); window.addEventListener("resize", positionVhostBackendPopup); document.addEventListener("scroll", event => { if (activeVhostBackendPopup && !activeVhostBackendPopup.node.contains(event.target)) positionVhostBackendPopup(); }, true); const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"]; const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"]; const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] }); const defaultVhostListPrefs = () => ({ sort: "id", descending: false }); let poolTablePrefs = defaultPoolTablePrefs(), vhostListPrefs = defaultVhostListPrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false; function normalizePoolTablePrefs(preferences) { const selected = Array.isArray(preferences?.backend_table_columns) ? preferences.backend_table_columns.filter(key => poolColumns.includes(key)) : []; return { sort: poolColumns.includes(preferences?.backend_table_sort) ? preferences.backend_table_sort : "vhost", descending: preferences?.backend_table_descending === true, columns: selected.length ? selected : [...poolColumns], }; } function normalizeVhostListPrefs(preferences) { return { sort: vhostSortColumns.includes(preferences?.vhost_list_sort) ? preferences.vhost_list_sort : "id", descending: preferences?.vhost_list_descending === true }; } function syncVhostSortDirection() { const control = $("vhost-sort-direction"); if (!control) return; control.textContent = vhostListPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(vhostListPrefs.descending ? "backendSortDescending" : "backendSortAscending")); } function applyPreferences(preferences) { poolTablePrefs = normalizePoolTablePrefs(preferences); vhostListPrefs = normalizeVhostListPrefs(preferences); const poolSort = $("pool-sort"); if (poolSort) poolSort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncPoolColumns(); syncPoolSortDirection(); syncVhostSortDirection(); applyPoolTableView(); render(); } async function saveVhostListPrefs() { if (!authenticated) return; try { const saved = await window.rustProxyPreferences.update({ vhost_list_sort: vhostListPrefs.sort, vhost_list_descending: vhostListPrefs.descending }); vhostListPrefs = normalizeVhostListPrefs(saved); const sort = $("vhost-sort"); if (sort) sort.value = vhostListPrefs.sort; syncVhostSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } } async function savePoolTablePrefs() { if (!authenticated) return; poolPreferenceSaveQueued = true; if (poolPreferenceSaveRunning) return; poolPreferenceSaveRunning = true; try { while (poolPreferenceSaveQueued && authenticated) { poolPreferenceSaveQueued = false; const saved = await window.rustProxyPreferences.update({ backend_table_columns: poolColumns.filter(key => poolTablePrefs.columns.includes(key)), backend_table_sort: poolTablePrefs.sort, backend_table_descending: poolTablePrefs.descending, }); if (!authenticated) return; if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved); } syncPoolColumns(); syncPoolSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } finally { poolPreferenceSaveRunning = false; } } function syncPoolColumns() { const visible = new Set(poolTablePrefs.columns); document.querySelectorAll("[data-pool-column]").forEach(node => { node.hidden = !visible.has(node.dataset.poolColumn); }); document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); }); } function syncPoolSortDirection() { const control = $("pool-sort-direction"); if (!control) return; control.textContent = poolTablePrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending")); } function applyPoolTableView() { const body = $("pool-rows"); if (!body) return; const query = ($("pool-search")?.value || "").trim().toLowerCase(); const key = $("pool-sort")?.value || poolTablePrefs.sort; const rows = [...body.querySelectorAll("tr")]; rows.sort((left, right) => { const a = left.poolSort?.[key] ?? "", b = right.poolSort?.[key] ?? ""; const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return poolTablePrefs.descending ? -result : result; }); body.replaceChildren(...rows); let visible = 0; for (const row of rows) { const match = !query || (row.poolSearchValue || "").includes(query); row.hidden = !match; if (match) visible++; } syncPoolColumns(); syncPoolSortDirection(); const empty = $("pool-empty"); if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); } } function initPoolTableTools() { const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search"); if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); } direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); }); search?.addEventListener("input", applyPoolTableView); document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => { const visible = new Set(poolTablePrefs.columns); if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle); if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; } poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns(); })); syncPoolColumns(); syncPoolSortDirection(); } async function toggleVhostEnabled(vhost) { if (!envelope || busy) return; const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id); if (!target) return; target.enabled = vhost.enabled === false; if (await saveSection("vhosts", next.vhosts || [])) render(); } const VHOST_PROTOCOL_FRESH_MS = 5 * 60 * 1000; function upstreamProtocolBadgeText(backend, member, now = Date.now()) { const mode = { http1: "H1", auto: "AUTO", http2: "H2 compatible", h2: "H2 only" }[backend.protocol || "http1"] || "H1"; const seen = Number(member?.upstream_protocol_last_seen_ms); const recent = member && (member.upstream_protocol_observed === "h1" || member.upstream_protocol_observed === "h2") && Number.isFinite(seen) && seen > 0 && now >= seen && now - seen <= VHOST_PROTOCOL_FRESH_MS; const observed = recent ? member.upstream_protocol_observed.toUpperCase() : null; return { label: `${backend.id} · ${mode} → ${observed || "?"}`, recent, mode, observed }; } function renderUpstreamProtocolBadges(status) { const members = new Map((status.backend_pools || []).flatMap(pool => (pool.members || []).map(member => [`${pool.id}\u0000${member.id}`, member]))); const now = Date.now(); document.querySelectorAll("[data-upstream-protocol-vhost]").forEach(node => { const vhost = node.dataset.upstreamProtocolVhost, id = node.dataset.upstreamProtocolBackend; const backend = (envelope?.config.vhosts || []).find(host => host.id === vhost)?.backends?.find(item => item.id === id); if (!backend) return; const member = members.get(`${vhost}\u0000${id}`); const observed = upstreamProtocolBadgeText(backend, member, now); if (node.textContent !== observed.label) node.textContent = observed.label; node.dataset.observed = String(!!observed.recent); }); } function render() { closeVhostBackendPopup(); if (!envelope) return; if (!initialized) load(defaults(), [], false); const config = envelope.config; const configuredHasProxySource = (config.vhosts || []).some(vhost => vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") ); const diagnosticsCard = $("backend-diagnostics-card"); if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource; const query = ($("vhost-search")?.value || "").trim().toLowerCase(); const visibleVhosts = (config.vhosts || []).filter(v => { if (!query) return true; const haystack = [ v.id, ...(v.frontend?.hosts || []), ...(v.backends || []).flatMap(backend => [backend.id, backend.url]), v.source?.web_service_id, ...(v.routes || []).map(route => route.source?.web_service_id), ].filter(Boolean).join(" ").toLowerCase(); return haystack.includes(query); }); const sortValue = v => ({ id: String(v.id || "").toLowerCase(), hostname: String(v.frontend?.hosts?.[0] || "").toLowerCase(), status: v.enabled === false ? 1 : 0, listeners: (v.frontend?.listener_ids || []).join(" ").toLowerCase(), certificate: String(v.certificate_id || "").toLowerCase(), backends: Number(v.backends?.length || 0), }[vhostListPrefs.sort]); visibleVhosts.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return vhostListPrefs.descending ? -result : result; }); const cards = visibleVhosts.map(v => { const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id; const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"); const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title")); const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state); if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill")); identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity); const actions = el("div", undefined, "entity-actions"); const configuredHosts = (v.frontend?.hosts || []).filter(Boolean); const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null; const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean); const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true); const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); }); openButton.disabled = !browseHost || v.enabled === false; const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`); quickToggle.setAttribute("aria-pressed", String(v.enabled !== false)); actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("copyEntry", () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(v); copy.id = ""; copy.frontend = { ...(copy.frontend || {}), hosts: [] }; copy.certificate_id = null; copy.allow_certificate_mismatch = false; copy.www_redirect_target = null; load(copy); }), button("remove", async () => { if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id); if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); } }), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head); const details = el("div", undefined, "vhost-detail-strip"); const selectedEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(e => e.id === id) || { id } ); const listeners = selectedEndpoints.map(endpoint => { const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", "); return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`; }); const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2); const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3); const protocols = [ "HTTP/1.1", v.http2_enabled !== false && supportsH2 ? "H2" : null, v.http3_enabled !== false && supportsH3 ? "H3" : null, ].filter(Boolean).join(" + "); const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit"); const detailItems = [ ["vhListeners", listeners.join(" | ") || "-"], ["vhProtocols", protocols], ["tlsPolicy", tlsPolicy], ["vhCertificate", v.certificate_id || t("vhCertificateDefault")], ["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")], ["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")], ["vhBackendPool", v.source?.type === "web_server" ? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length ? `${t("webServer")} + ${(v.backends || []).length}` : t("webServer")) : `${(v.backends || []).length}`], ]; for (const [key, value] of detailItems) { const isBackend = key === "vhBackendPool"; const item = el(isBackend ? "button" : "div", undefined, `vhost-detail${isBackend ? " vhost-backend-trigger" : ""}`); if (isBackend) { item.type = "button"; enableVhostBackendPopup(item, v); } item.append(text("span", key, "small-label"), el("b", value)); details.append(item); } box.append(details); const live = el("div", undefined, "vhost-live-grid"); const metric = (key, suffix) => { const isHealth = key === "status"; const item = el(isHealth ? "button" : "div", undefined, `vhost-live-metric${isHealth ? " vhost-backend-trigger" : ""}`); if (isHealth) { item.type = "button"; enableVhostBackendPopup(item, v); } item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); }; metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic"); box.append(live); const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart); const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); if (hasProxySource) { const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health); } const policy = el("div", undefined, "vhost-policy-pills"); if (v.authentication?.provider) { const providers = { basic: "authBasic", tinyauth: "authTinyauth", authelia: "authAuthelia", authentik: "authAuthentik", pocket_id: "authPocketId", custom: "authCustomOidc" }; const provider = providers[v.authentication.provider]; const method = v.authentication.method; if (provider) policy.append(el("small", method === "basic" ? t(provider) : `${t(provider)} · ${method === "oidc" ? "OIDC" : t("authForwardAuth")}`, "pill")); } else { if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill")); if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill")); if (v.authelia?.enabled) policy.append(el("small", "Authelia", "pill")); if (v.authentik?.enabled) policy.append(el("small", "authentik", "pill")); if (v.oidc?.enabled) policy.append(el("small", "OIDC", "pill")); } if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill")); if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill")); if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill")); if (v.www_redirect) { const destination = v.www_redirect_target || (v.frontend?.hosts || []).find(host => !host.startsWith("*.")) || ""; policy.append(el("small", `WWW 308 \u2192 ${destination}`, "pill")); } if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill")); if (v.websockets) { const transports = ["H1"]; if (v.websocket_rfc8441) transports.push("H2/RFC8441"); if (v.websocket_rfc9220) transports.push("H3/RFC9220"); policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill")); if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill")); } // Keep the upstream protocol indicators with the other Vhost policy pills. // Retain their own badge class so observed H1/H2 keeps its green styling. if (hasProxySource) { for (const backend of v.backends || []) { const badge = el("button", undefined, "vhost-backend-protocol-badge"); badge.type = "button"; badge.dataset.upstreamProtocolVhost = v.id; badge.dataset.upstreamProtocolBackend = backend.id; badge.textContent = upstreamProtocolBadgeText(backend, null).label; enableVhostBackendPopup(badge, v, backend.id); policy.append(badge); } } if (policy.children.length) box.append(policy); return box; }); $("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")])); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel"); if (latestStatus) renderStatus(latestStatus); } function backendTrafficRate(poolId, member, snapshot, now, seen) { const key = `${poolId}\u0000${member.id}`; seen.add(key); const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0); const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0); const previous = backendTrafficHistory.get(key); if (previous?.snapshot === snapshot) return previous.rate; const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds")); const observed = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0; const rate = reported > 0 ? reported : observed; backendTrafficHistory.set(key, { ...rustProxyRateSampling.stamp(now), total, snapshot, rate }); return rate; } function vhostTrafficRate(id, metrics, snapshot, now, seen) { seen.add(id); const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0); const previous = vhostTrafficHistory.get(id); if (previous?.snapshot === snapshot) return previous.rate; const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds")); const rate = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0; vhostTrafficHistory.set(id, { ...rustProxyRateSampling.stamp(now), total, snapshot, rate }); return rate; } function renderStatus(status) { renderUpstreamProtocolBadges(status); refreshVhostBackendPopup(status); form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.()); const rows = [], summaries = new Map(), aggregate = new Map(); const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set(); const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" }; const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost])); for (const pool of status.backend_pools || []) { const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false; let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0; for (const member of members) { const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic); const label = vhostDisabled ? t("vhDisabled") : (member.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")); const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`; const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-"; const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-"; const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`; const generationAgeRaw = member.upstream_client_generation_age_seconds; const generationAge = Math.max(0, Number(generationAgeRaw)); const generationAgeText = generationAgeRaw == null || !Number.isFinite(generationAge) ? "-" : generationAge >= 3600 ? `${Math.floor(generationAge / 3600)}h ${Math.floor((generationAge % 3600) / 60)}m` : generationAge >= 60 ? `${Math.floor(generationAge / 60)}m ${Math.floor(generationAge % 60)}s` : `${Math.floor(generationAge)}s`; const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors, member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery]; const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 }; row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase(); cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row); if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic; const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; } } const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | "); summaries.set(pool.id, summary || t("vhHealthEmpty")); aggregate.set(pool.id, { disabled: vhostDisabled, healthMode: pool.health_mode, up, total: members.length, active, requests, errors, traffic, latency: latencyWeight ? weightedLatency / latencyWeight : 0, }); } for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key); for (const [id, vhost] of configuredVhosts) { const existing = aggregate.get(id) || {}; const metrics = status.vhost_metrics?.[id]; const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); const disabled = vhost.enabled === false; aggregate.set(id, { ...existing, disabled, staticOnly: !hasProxySource, healthMode: existing.healthMode, up: existing.up ?? 0, total: existing.total ?? 0, active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0, requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0, errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0, traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0, latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0, measured: Boolean(metrics) || Boolean(existing.measured), }); } for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key); const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost => vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") ); $("pool-rows").replaceChildren(...rows); const backendDiagnosticsCard = $("backend-diagnostics-card"); if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource; applyPoolTableView(); document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty")); const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; }); fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`); fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors)); fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`); const now = performance.now(); for (const [id, data] of aggregate) { if (!data.measured) continue; const history = chartHistory.get(id) || { last: null, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 }; const sample = rustProxyRateSampling.difference(history.last, [data.requests], now, rustProxyRateSampling.cadence("overview_seconds")); if (sample.rapid) continue; const pausedRate = history.last !== null && sample.deltas === null; history.last = sample.next; const rate = sample.deltas ? sample.deltas[0] / sample.seconds : 0; if (!pausedRate) { history.pendingSum += rate; history.pendingCount += 1; if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; } } if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; } chartHistory.set(id, history); const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const plot = window.rustProxyChartScale.liveRate(displayPoints); const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", plot.points); window.rustProxyChartScale.axes(svg, plot.scale, "rate", displayPoints.length, { top: 6 / 54, bottom: 50 / 54, clipped: plot.clipped }); const scaleHint = `${t("vhChartAdaptiveScale")}: 0–${plot.scale.toFixed(1)} ${t("vhReqPerSecond")}${plot.clipped ? ` · ${t("vhChartClippedPeak")}` : ""}`; svg?.setAttribute("aria-label", `${t("vhLifetimeChart")} · ${scaleHint}`); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) { label.textContent = `${pausedRate ? "-" : rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${plot.peak.toFixed(1)}`; label.title = scaleHint; } } const editingVhost = editing ? configuredVhosts.get(editing) : null; const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")); $("vhost-health-live").hidden = !!editingVhost && !editingHasProxy; $("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint"); } form.addEventListener("input", changed); form.addEventListener("change", changed); form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy || uploading) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { const healthPanel = invalid.closest(".vh-backend-health-panel"); if (healthPanel) backendHealthEditorControl?.reveal([...form.querySelector(".vh-members").children].find(row => row.memberHealthPanel === healthPanel)); activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const epoch = draftEpoch; uploading = true; try { await basicEditor.prepare(); } finally { uploading = false; } if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale")); const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= []; const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return; const oldVhost = (envelope.config.vhosts || []).find(v => v.id === editing); const certificateScope = item => JSON.stringify({ certificate: item?.certificate_id || null, hosts: [...(item?.frontend?.hosts || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort(), wwwRedirect: item?.www_redirect === true, wwwTarget: item?.www_redirect_target || null }); if (value.certificate_id && !value.allow_certificate_mismatch) { const certificate = [...(envelope?.config.tls?.certificates || []), ...stagedCertificates].find(item => item.id === value.certificate_id); const inventory = certificateInventory.get(value.certificate_id)?.covered_hosts || [], coverage = inventory.length ? inventory : (certificate?.covered_hosts || []); const uncovered = coverage.length ? (value.frontend?.hosts || []).filter(host => !coverage.some(pattern => window.rustProxyAutoCertificate.covers(pattern, host))) : []; if (uncovered.length) { if (!(await appConfirm(t("vhCertificateUncoveredPrompt").replace("{certificate}", value.certificate_id).replace("{hosts}", uncovered.join(", ")), { danger: true }))) { activateTab("ssl"); return; } value.allow_certificate_mismatch = true; } } if (value.allow_certificate_mismatch && (!oldVhost?.allow_certificate_mismatch || certificateScope(value) !== certificateScope(oldVhost)) && !(await appConfirm(t("vhCertificateMismatchConfirm"), { danger: true }))) return; if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict")); if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return; const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id); if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); } const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value; const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []); if (saved) { load(value, [], false); dialog.close(); render(); } } catch (error) { toast(error.message, true); } }); $("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("vhost-search").addEventListener("input", render); $("vhost-sort")?.addEventListener("change", event => { vhostListPrefs.sort = event.target.value; void saveVhostListPrefs(); render(); }); $("vhost-sort-direction")?.addEventListener("click", () => { vhostListPrefs.descending = !vhostListPrefs.descending; syncVhostSortDirection(); void saveVhostListPrefs(); render(); }); $("vhost-reset").addEventListener("click", async () => { if (!(await allowDiscard())) return; const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null; load(saved ? structuredClone(saved) : defaults()); }); $("pool-open").addEventListener("click", () => setPanel("vhosts")); initPoolTableTools(); $("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close()); window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } }); window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { closeVhostBackendPopup(); dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } }; if (envelope) render(); if (latestStatus) renderStatus(latestStatus);