627 lines
45 KiB
JavaScript
627 lines
45 KiB
JavaScript
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);
|