Files
RustProxy/src/admin/web/vhosts/render.js
T
2026-11-08 11:16:41 +01:00

627 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);