This commit is contained in:
Mateusz Gruszczyński committed 2026-10-16 13:02:53 +02:00
1 parent 162e986d37
commit 6b937aaf85
54 files changed
+3527 -1325

No files matched your search

+177 -9
View File
@@ -1,3 +1,158 @@
// Shared, anchored Vhost popover for backend quick-view and upstream HTTP
// protocol observations. Both consume existing live status without extra probes.
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] });
@@ -107,7 +262,8 @@ function upstreamProtocolBadgeText(backend, member, now = Date.now()) {
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;
return { label: `${backend.id} · ${mode} → ${recent ? member.upstream_protocol_observed.toUpperCase() : "?"}`, recent };
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 =>
@@ -119,13 +275,12 @@ function renderUpstreamProtocolBadges(status) {
if (!backend) return;
const member = members.get(`${vhost}\u0000${id}`);
const observed = upstreamProtocolBadgeText(backend, member, now);
node.textContent = observed.label;
if (node.textContent !== observed.label) node.textContent = observed.label;
node.dataset.observed = String(!!observed.recent);
const counts = member && observed.recent ? ` H1: ${member.upstream_protocol_http1_total || 0}; H2: ${member.upstream_protocol_http2_total || 0}.` : "";
node.title = `${t(observed.recent ? "vhBackendProtocolRecent" : "vhBackendProtocolUnobserved")}${counts}`;
});
}
function render() {
closeVhostBackendPopup();
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
const config = envelope.config;
@@ -209,10 +364,21 @@ function render() {
: 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); }
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 item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
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);
@@ -243,11 +409,12 @@ function render() {
// Retain their own badge class so observed H1/H2 keeps its green styling.
if (hasProxySource) {
for (const backend of v.backends || []) {
const badge = el("span", undefined, "vhost-backend-protocol-badge");
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;
badge.title = t("vhBackendProtocolUnobserved");
enableVhostBackendPopup(badge, v, backend.id);
policy.append(badge);
}
}
@@ -282,6 +449,7 @@ function vhostTrafficRate(id, metrics, snapshot, now, seen) {
}
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();
@@ -415,5 +583,5 @@ $("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() { 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(); } };
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);