const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"]; const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] }); let poolTablePrefs = defaultPoolTablePrefs(), 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 applyPoolPreferences(preferences) { poolTablePrefs = normalizePoolTablePrefs(preferences); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; syncPoolColumns(); syncPoolSortDirection(); applyPoolTableView(); } 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(); } function render() { 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 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", async () => { if (await allowDiscard()) load(structuredClone(v)); }), 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 item = el("div", undefined, "vhost-detail"); 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 item = el("div", undefined, "vhost-live-metric"); 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.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill")); if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "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.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")); } 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); } // SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it. // Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero. 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 elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; const rate = reported > 0 ? reported : observed; backendTrafficHistory.set(key, { total, at: now, 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 elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; vhostTrafficHistory.set(id, { total, at: now, snapshot, rate }); return rate; } function renderStatus(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") : (pool.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 || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-"; const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-"; 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, }); } 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) { const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 }; const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests; 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 actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" "); const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`; } 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) { 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; 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", async () => { if (await allowDiscard()) load(); }); $("vhost-search").addEventListener("input", 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.rustProxyVhosts = { render, renderStatus, applyPreferences: applyPoolPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); 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; applyPoolTableView(); } }; if (envelope) render(); if (latestStatus) renderStatus(latestStatus);