Files
RustProxy/src/admin/web/app/runtime.js
T
2026-10-20 12:36:58 +02:00

592 lines
44 KiB
JavaScript

function verifyAdminModules() {
const required = [
["rustProxyIntegrations", "bindings"],
["rustProxyVhosts", "render"],
["rustProxySettings", "render"],
["rustProxyCachePanel", "refresh"],
["rustProxyTrafficPanel", "refresh"],
["rustProxyPreferences", "load"],
["rustProxyRefresh", "start"],
];
const missing = required.find(([module, method]) => typeof window[module]?.[method] !== "function");
if (missing) throw new Error(`Panel resources incomplete (${missing[0]}). Reload to retry.`);
}
async function startAdminPanel() {
try {
verifyAdminModules();
const info = await api("/api/auth/session");
await loadLanguages();
setupRequired = false;
translate();
syncAuthForm();
if (await activate(info)) clearInvalidOriginRecovery();
} catch (e) {
if (recoverInvalidOrigin(e)) return;
if (e.status === 401) {
location.replace(`${location.pathname}${location.search}`);
return;
}
showStartupFailure(e);
}
}
if (document.readyState === "complete") startAdminPanel();
else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true });
function certificateUsesExternalFiles(cert) {
return String(cert?.cert_file || "").startsWith("/") && String(cert?.key_file || "").startsWith("/");
}
function syncCertificateFileSource() {
const external = $("certificate-file-source").value === "external";
const cert = $("certificate-file"), key = $("certificate-key");
for (const input of [cert, key]) {
input.maxLength = external ? 4096 : 96;
if (external) input.removeAttribute("pattern");
else input.pattern = "[A-Za-z0-9_.-]+";
}
cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem";
key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key";
const helpKey = external ? "certificateSourceExternalHelp" : "certificateSourceManagedHelp";
$("certificate-file-source-help").dataset.i18n = helpKey;
$("certificate-file-source-help").textContent = t(helpKey);
$("certificate-external-acl-example").hidden = !external;
}
$("certificate-file-source").addEventListener("change", syncCertificateFileSource);
function renderCertificates() {
if (!envelope) return;
const settings = envelope.config.tls || {};
const entries = (settings.certificates || []).map(cert => {
const inventory = certificateInventory.get(cert.id);
const coverage = (inventory?.covered_hosts?.length ? inventory.covered_hosts : (cert.covered_hosts || []));
const bindings = cert.hosts || [];
const used = [];
for (const vhost of envelope.config.vhosts || []) if (vhost.certificate_id === cert.id) used.push(`vhost:${vhost.id}`);
for (const redirect of envelope.config.redirects || []) if (redirect.certificate_id === cert.id) used.push(`redirect:${redirect.id}`);
for (const host of envelope.config.not_found_hosts || []) if (host.certificate_id === cert.id) used.push(`404:${host.id}`);
if (envelope.config.administration?.certificate_id === cert.id) used.push(t("administration"));
if (settings.default_certificate === cert.id) used.push(t("defaultLabel"));
const managed = !!cert.issuer, inUse = used.length > 0;
const box = el("div", undefined, "entity certificate-card");
const head = el("div", undefined, "certificate-card-head");
const identity = el("div", undefined, "certificate-card-identity");
identity.append(el("span", cert.id, "entity-title"));
const statePills = el("div", undefined, "vhost-policy-pills certificate-state-pills");
if (managed) statePills.append(el("small", `${t("certificateManaged")} · ${cert.issuer.provider} / ${cert.issuer.environment}`, "pill"));
else if (certificateUsesExternalFiles(cert)) statePills.append(el("small", t("certificateExternalFiles"), "pill"));
if (settings.default_certificate === cert.id) statePills.append(el("small", t("defaultLabel"), "pill"));
if (inventory?.expiry_status === "expired") statePills.append(el("small", t("certificateExpired"), "pill status-error"));
else if (inventory?.expiry_status === "warning") statePills.append(el("small", t("certificateExpiresSoon"), "pill status-error"));
if (statePills.children.length) identity.append(statePills);
head.append(identity);
box.append(head);
const coverageBlock = el("div", undefined, "certificate-coverage");
coverageBlock.append(el("span", t("certificateCovers"), "small-label"));
const domainPills = el("div", undefined, "vhost-policy-pills certificate-domain-pills");
if (coverage.length) {
for (const host of coverage) domainPills.append(el("small", host, "pill"));
} else {
domainPills.append(el("small", t("certificateCoverageUnknown"), "certificate-coverage-unknown"));
}
coverageBlock.append(domainPills);
box.append(coverageBlock);
const details = el("details", undefined, "certificate-details");
details.append(el("summary", t("certificateDetails")));
const detailBody = el("div", undefined, "certificate-details-body");
const stamp = value => Number(value) ? formatDateTime(Number(value) * 1000) : "-";
detailBody.append(
el("p", `${t("certificateBindings")}: ${bindings.join(", ") || "-"}`, "entity-details"),
el("p", `${t("certificateFiles")}: ${cert.cert_file} / ${cert.key_file}`, "entity-details"),
el("p", `${t("certAssigned")}: ${used.join(", ") || t("certUnassigned")}`, "entity-details"),
el("p", `${t("certificateIssuerName")}: ${inventory?.certificate_issuer || "-"}`, "entity-details"),
el("p", `${t("certificateValidFrom")}: ${stamp(inventory?.not_before_unix)}`, "entity-details"),
el("p", `${t("renewalExpires")}: ${stamp(inventory?.not_after_unix)}`, "entity-details"),
el("p", `${t("certificateDaysRemaining")}: ${inventory?.days_remaining ?? "-"}`, "entity-details"),
el("p", `${t("certificateRenewalStatus")}: ${inventory?.renewal_status || "-"}`, "entity-details"),
el("p", `${t("renewalLast")}: ${stamp(inventory?.last_renewal_attempt)}`, "entity-details"),
el("p", `${t("certificateRenewalError")}: ${inventory?.last_renewal_error ? t(inventory.last_renewal_error) : "-"}`, "entity-details"),
);
details.append(detailBody);
box.append(details);
const buttons = el("div", undefined, "entity-actions");
const assignPane = el("div", undefined, "certificate-bulk-assign");
assignPane.hidden = true;
const assignmentList = el("div", undefined, "certificate-bulk-list");
for (const host of envelope.config.vhosts || []) {
const label = el("label", undefined, "check");
const check = document.createElement("input");
check.type = "checkbox"; check.value = host.id;
check.checked = host.certificate_id === cert.id;
label.append(check, el("span", `${host.id} · ${(host.frontend?.hosts || []).join(", ")}`));
assignmentList.append(label);
}
const applyBindings = action(t("certAssignApply"), async () => {
if (busy) return;
const selected = new Set([...assignmentList.querySelectorAll('input:checked')].map(input => input.value));
const next = structuredClone(envelope.config);
for (const host of next.vhosts) {
if (selected.has(host.id)) host.certificate_id = cert.id;
else if (host.certificate_id === cert.id) host.certificate_id = null;
}
await saveSection("vhosts", next.vhosts);
});
assignPane.append(el("p", t("certAssignHelp"), "fine"), assignmentList, applyBindings);
const assignButton = action(t("certAssignVhosts"), () => {
assignPane.hidden = !assignPane.hidden;
});
assignButton.disabled = !(envelope.config.vhosts || []).length;
buttons.append(assignButton);
if (!managed) {
buttons.append(action(t("reloadCertificate"), async () => {
if (busy) return;
try {
busy = true;
const result = await api(`/api/certificates/${encodeURIComponent(cert.id)}/reload`, { method: "POST", body: "{}" });
await refreshCertificateInventory();
renderCertificates();
toast(`${t("reloadCertificate")}: ${result.reloaded ? "OK" : "-"}`);
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
} catch (error) { toast(error.message, true); } finally { busy = false; }
}));
buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file-source").value = certificateUsesExternalFiles(cert) ? "external" : "managed"; syncCertificateFileSource(); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; }));
}
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
box.append(buttons, assignPane);
return box;
});
$("certificate-list").replaceChildren(...entries);
if (!entries.length) $("certificate-list").append(el("p", t("emptyCertificates"), "empty"));
}
$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); syncCertificateFileSource(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } });
$("reload-certificates").addEventListener("click", async () => {
if (!envelope || busy) return;
try {
busy = true;
const result = await api("/api/certificates/reload", { method: "POST", body: "{}" });
await refreshCertificateInventory();
renderCertificates();
toast(`${t("reloadCertificates")}: ${result.reloaded ? "OK" : "-"}`);
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
} catch (error) { toast(error.message, true); }
finally { busy = false; }
});
$("reset-certificate").addEventListener("click", () => { $("certificate-form").reset(); $("certificate-file-source").value = "managed"; syncCertificateFileSource(); });
syncCertificateFileSource();
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, lastCacheOutcomeRates = null,
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
const hiddenNetworkInterfaces = (() => {
try {
const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]");
return new Set(Array.isArray(parsed) ? parsed.filter(value => typeof value === "string") : []);
} catch { return new Set(); }
})();
function saveHiddenNetworkInterfaces() {
try { localStorage.setItem("rp-hidden-network-interfaces", JSON.stringify([...hiddenNetworkInterfaces].sort())); } catch { }
}
function syncSettingsDirtyIndicators() {
const dirtyTabs = new Set();
for (const id of settingsDirty) {
const page = $(id)?.closest?.("[data-settings-page]"); if (page?.dataset.settingsPage) dirtyTabs.add(page.dataset.settingsPage);
}
document.querySelectorAll("[data-settings-tab]").forEach(button => {
const dirty = dirtyTabs.has(button.dataset.settingsTab); button.classList.toggle("has-unsaved", dirty);
if (dirty) button.title = t("unsavedChanges"); else button.removeAttribute("title");
});
window.rustProxyIntegrations?.syncHttp2Availability?.();
window.rustProxyIntegrations?.syncHttp3Availability?.();
}
function setSettingsDirty(id, dirty = true) { if (dirty) settingsDirty.add(id); else settingsDirty.delete(id); syncSettingsDirtyIndicators(); }
function clearSettingsDirty() { settingsDirty.clear(); syncSettingsDirtyIndicators(); }
const fmt = (value, digits = 0) => value == null ? t("unavailable") : Number(value).toLocaleString(locale(), { maximumFractionDigits: digits });
const fmtBytes = value => value == null ? t("unavailable") : bytes(value);
function setOptionalMetric(id, supported, text) {
const node = $(id); if (!node) return;
const row = node.closest("div"); if (row) row.hidden = !supported;
if (supported) node.textContent = text;
}
function knownPair(left, right, format, zeroRight = null) {
if (left == null && right == null) return null;
if (left == null) return format(right);
if (right == null) return format(left);
const rightText = right === 0 && zeroRight ? zeroRight : format(right);
return `${format(left)} / ${rightText}`;
}
function occupancyText(used, limit) {
if (used == null) return "-";
const usedNumber = Number(used), limitNumber = Number(limit);
if (!Number.isFinite(limitNumber) || limitNumber <= 0) return fmtBytes(usedNumber);
return `${fmtBytes(usedNumber)} / ${fmtBytes(limitNumber)} · ${fmt(usedNumber / limitNumber * 100, 1)}%`;
}
function sparklinePoints(values, width = 300, height = 60, padding = 5, peakOverride = null) {
const peak = Math.max(1, peakOverride ?? Math.max(0, ...values));
return values.map((value, index) => `${index * width / Math.max(1, values.length - 1)},${height - padding - Math.max(0, value) / peak * (height - padding * 2)}`).join(" ");
}
function svgNode(tag, attrs = {}) {
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
return node;
}
function setChartText(node, value) {
const text = String(value);
if (node.firstChild?.nodeType === 3 && node.firstChild === node.lastChild) {
if (node.firstChild.data !== text) node.firstChild.data = text;
} else if (node.textContent !== text) {
node.textContent = text;
}
}
let latestNetworkInterfaceSnapshot = [];
function renderNetworkInterfaceCharts(interfaces, appendSample) {
const current = Array.isArray(interfaces) ? interfaces.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))) : [];
latestNetworkInterfaceSnapshot = current;
const names = new Set(current.map(item => item.name));
for (const name of [...networkInterfaceSamples.keys()]) if (!names.has(name)) networkInterfaceSamples.delete(name);
if (appendSample) {
for (const item of current) {
if (!item?.name) continue;
const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] };
const rx = Number(item.rx_bytes_per_second), tx = Number(item.tx_bytes_per_second);
if (Number.isFinite(rx)) { history.rx.push(rx); history.rx = history.rx.slice(-window.rustProxyOverviewSamples.getLimit()); }
if (Number.isFinite(tx)) { history.tx.push(tx); history.tx = history.tx.slice(-window.rustProxyOverviewSamples.getLimit()); }
networkInterfaceSamples.set(item.name, history);
}
}
const target = $("network-interface-charts");
if (!target) return;
const visible = current.filter(item => item?.name && !hiddenNetworkInterfaces.has(item.name));
setChartText($("network-interface-chart-value"), t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length));
const existing = new Map([...target.children].filter(node => node.classList.contains("network-interface-row"))
.map(node => [node.dataset.interfaceName, node]));
const children = [];
if (!current.length) {
const empty = target.querySelector(":scope > .network-interface-empty") || el("p", undefined, "empty network-interface-empty");
empty.textContent = t("networkNoInterfaces");
children.push(empty);
} else {
for (const item of visible) {
const name = item.name;
let row = existing.get(name);
if (!row) {
row = el("div", undefined, "network-interface-row");
row.dataset.interfaceName = name;
const head = el("div", undefined, "mini-live-chart-head network-interface-row-head");
const rate = el("span", undefined, "fine network-interface-rate");
const hide = action(t("hideMetric"), () => {
hiddenNetworkInterfaces.add(name);
saveHiddenNetworkInterfaces();
renderNetworkInterfaceCharts(latestNetworkInterfaceSnapshot, false);
});
hide.classList.add("network-interface-hide");
const actions = el("span", undefined, "network-interface-head-actions");
actions.append(rate, hide);
head.append(el("b", name, "mono"), actions);
const svg = svgNode("svg", { viewBox: "0 0 300 60", preserveAspectRatio: "none", role: "img", "aria-label": `${name} ${t("networkInterfacesHistory")}` });
svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" }));
svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: "0,55 300,55" }));
svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: "0,55 300,55" }));
row.append(head, svg);
}
const history = networkInterfaceSamples.get(name) || { rx: [], tx: [] };
const peak = Math.max(1, ...history.rx, ...history.tx);
setChartText(row.querySelector(".network-interface-rate"), `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`);
setChartText(row.querySelector(".network-interface-hide"), t("hideMetric"));
row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55");
row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55");
row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
children.push(row);
}
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
if (hidden.length) {
const shelf = target.querySelector(":scope > .network-interface-hidden") || el("div", undefined, "network-interface-hidden");
const previous = [...shelf.querySelectorAll("button[data-interface-name]")].map(node => node.dataset.interfaceName);
const changed = previous.length !== hidden.length || previous.some((name, i) => name !== hidden[i].name);
if (changed) {
shelf.replaceChildren(el("span", t("hiddenInterfaces"), "fine"));
for (const item of hidden) {
const show = action(`${t("showMetric")} ${item.name}`, () => {
hiddenNetworkInterfaces.delete(item.name);
saveHiddenNetworkInterfaces();
renderNetworkInterfaceCharts(latestNetworkInterfaceSnapshot, false);
});
show.dataset.interfaceName = item.name;
shelf.append(show);
}
} else {
setChartText(shelf.querySelector(".fine"), t("hiddenInterfaces"));
for (const button of shelf.querySelectorAll("button[data-interface-name]")) setChartText(button, `${t("showMetric")} ${button.dataset.interfaceName}`);
}
children.push(shelf);
} else if (!visible.length) {
const empty = target.querySelector(":scope > .network-interface-empty") || el("p", undefined, "empty network-interface-empty");
empty.textContent = t("networkNoVisibleInterfaces");
children.push(empty);
}
}
if (children.length !== target.children.length || children.some((node, index) => node !== target.children[index])) {
target.replaceChildren(...children);
}
}
// Cumulative process counters, not rate-sampler deltas. Never invent OTHER
// or force a 100% mix if independently read totals are inconsistent.
function cacheOutcomeMix(totals) {
if (!Array.isArray(totals) || totals.length !== 4 ||
totals.some(value => !Number.isSafeInteger(value) || value < 0)) return null;
const [requests, hits, misses, bypasses] = totals;
const cacheTotal = hits + misses + bypasses;
if (requests === 0 || !Number.isSafeInteger(cacheTotal) || cacheTotal > requests) return null;
const counts = [hits, misses, bypasses, requests - cacheTotal];
return { counts, percentages: counts.map(value => value / requests * 100), requests };
}
function renderCacheMix(mix, totalRequests) {
const names = ["hit", "miss", "bypass", "other"];
const unavailable = t(totalRequests === 0 ? "cacheMixWaiting" : "cacheMixInvalid");
names.forEach((name, i) => {
const pct = mix ? mix.percentages[i] : 0;
$(`cache-mix-${name}`).style.width = `${pct}%`;
$(`cache-mix-${name}-count`).textContent = mix ? fmt(mix.counts[i]) : "-";
$(`cache-mix-${name}-percent`).textContent = mix ? `${fmt(pct, 1)}%` : "-";
});
$("cache-mix-headline").textContent = mix ? `${fmt(mix.percentages[0], 1)}% HIT` : "-";
const caption = $("cache-mix-caption");
const validTotal = Number.isSafeInteger(totalRequests) && totalRequests >= 0;
const total = validTotal ? fmt(totalRequests) : "-";
const totalLabel = validTotal
? t("cacheMixSample").replace("{count}", total)
: t("cacheMixInvalid");
caption.textContent = total;
caption.setAttribute("title", totalLabel);
caption.setAttribute("aria-label", totalLabel);
$("cache-mix-bar").setAttribute("aria-label", mix
? t("cacheMixAria").replace("{hit}", fmt(mix.counts[0]))
.replace("{miss}", fmt(mix.counts[1]))
.replace("{bypass}", fmt(mix.counts[2]))
.replace("{other}", fmt(mix.counts[3]))
: unavailable);
}
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
const d = status.system || {}, live = status.live_system || d, c = status.cache || {}, m = status.metrics || {};
$("metric-h3").textContent = fmt(m.http3_requests);
const cacheHits = c.hits == null ? null : Number(c.hits), cacheMisses = c.misses == null ? null : Number(c.misses);
const cacheEvents = cacheHits !== null && cacheMisses !== null ? cacheHits + cacheMisses : 0;
$("metric-cache-ratio").textContent = cacheEvents > 0 ? `${fmt(cacheHits / cacheEvents * 100, 1)}%` : "-";
window.rustProxyExtraOverview?.donut?.("cache-ratio", cacheHits, cacheMisses);
const counters = [m.requests, c.hits, c.misses, c.bypasses].map(value => value == null ? NaN : Number(value));
renderCacheMix(cacheOutcomeMix(counters), counters[0]);
if (systemSample) {
if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes);
$("process-summary").hidden = d.process_threads == null;
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
setOptionalMetric("system-cpu", d.cpu_percent != null, `${fmt(d.cpu_percent, 1)}%`);
const sourceSupported = !!d.source && d.source !== "unsupported";
$("system-source").hidden = !sourceSupported; if (sourceSupported) $("system-source").textContent = t("realSample");
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
const ramMeter = $("memory-meter"), ramFill = $("memory-meter-fill");
ramMeter.hidden = systemMemory == null;
const ramUsed = Number(d.memory_used_bytes), ramTotal = Number(d.memory_total_bytes);
const ramPercent = d.memory_used_bytes != null && d.memory_total_bytes != null &&
Number.isFinite(ramUsed) && Number.isFinite(ramTotal) && ramTotal > 0
? Math.max(0, Math.min(100, ramUsed / ramTotal * 100)) : null;
ramFill.style.width = `${ramPercent ?? 0}%`;
if (ramPercent == null) {
ramMeter.removeAttribute("aria-valuenow");
ramMeter.setAttribute("aria-valuetext", "-");
} else {
ramMeter.setAttribute("aria-valuenow", String(ramPercent));
ramMeter.setAttribute("aria-valuetext", `${fmt(ramPercent, 1)}%`);
}
const systemSwap = knownPair(d.system_swap_used_bytes, d.system_swap_total_bytes, value => fmtBytes(value));
setOptionalMetric("system-swap", systemSwap != null, systemSwap);
const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0;
setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : "");
setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : uptime(d.system_uptime_seconds));
}
$("system-chart").hidden = live.cpu_percent == null; if (appendSample && live.cpu_percent != null) { systemSamples.push(Number(live.cpu_percent)); systemSamples = systemSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
$("cache-state").textContent = t(envelope?.config.cache?.enabled ? "enabled" : "notEnabled");
$("cache-counters").textContent = [c.hits, c.misses, c.bypasses].map(n => fmt(n)).join(" / ");
$("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`;
$("cache-memory-breakdown").textContent = `${fmtBytes(c.resident_bytes)} / ${fmtBytes(c.transient_bytes)}`;
$("cache-transient-breakdown").textContent = `${fmtBytes(c.fill_bytes_active)} / ${fmtBytes(c.cold_queue_bytes)} / ${fmtBytes((Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0))}`;
const coldStorage = knownPair(c.cold_bytes, c.cold_limit_bytes, value => fmtBytes(value));
const coldObjects = knownPair(c.cold_entries, c.cold_limit_entries, value => fmt(value));
$("cache-cold-storage").textContent = envelope?.config.cache?.cold_enabled === true
? `${coldStorage ?? "-"}${coldObjects == null ? "" : ` · ${coldObjects} ${t("cacheColdStorageObjects")}`}`
: t("notEnabled");
$("cache-evictions").textContent = `${fmt(c.evictions)} / ${fmt(c.expired)}`;
if (appendSample) {
if (c.resident_bytes != null) { cacheMemorySamples.push(Number(c.resident_bytes)); cacheMemorySamples = cacheMemorySamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
if (c.transient_bytes != null) { cacheTransientSamples.push(Number(c.transient_bytes)); cacheTransientSamples = cacheTransientSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
if (c.fill_bytes_active != null) { cacheFillSamples.push(Number(c.fill_bytes_active)); cacheFillSamples = cacheFillSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
if (c.cold_queue_bytes != null) { cacheColdQueueSamples.push(Number(c.cold_queue_bytes)); cacheColdQueueSamples = cacheColdQueueSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
const coldIoBytes = (Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0);
if (c.cold_write_bytes_active != null || c.cold_read_bytes_active != null) { cacheColdIoSamples.push(coldIoBytes); cacheColdIoSamples = cacheColdIoSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
if (c.cold_bytes != null) { cacheColdStorageSamples.push(Number(c.cold_bytes)); cacheColdStorageSamples = cacheColdStorageSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
const currentCounters = [c.hits, c.misses, c.bypasses].map(Number);
if (currentCounters.every(Number.isFinite)) {
const delta = rustProxyRateSampling.difference(lastCacheCounters, currentCounters, performance.now(), rustProxyRateSampling.cadence("overview_seconds"));
const values = delta.deltas || lastCacheOutcomeRates || (!lastCacheCounters ? [0, 0, 0] : null);
if (values) {
cacheHitSamples.push(values[0]); cacheMissSamples.push(values[1]); cacheBypassSamples.push(values[2]);
cacheHitSamples = cacheHitSamples.slice(-window.rustProxyOverviewSamples.getLimit()); cacheMissSamples = cacheMissSamples.slice(-window.rustProxyOverviewSamples.getLimit()); cacheBypassSamples = cacheBypassSamples.slice(-window.rustProxyOverviewSamples.getLimit());
lastCacheOutcomeRates = [...values];
}
lastCacheCounters = delta.next;
}
}
if (appendSample) {
if (live.process_cpu_percent != null) { processCpuSamples.push(Number(live.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
if (live.process_rss_bytes != null) { processRssSamples.push(Number(live.process_rss_bytes)); processRssSamples = processRssSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
}
const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples);
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
$("cache-miss-sparkline").setAttribute("points", sparklinePoints(cacheMissSamples, 300, 60, 5, cacheOutcomePeak));
$("cache-bypass-sparkline").setAttribute("points", sparklinePoints(cacheBypassSamples, 300, 60, 5, cacheOutcomePeak));
const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)];
setChartText($("cache-outcome-chart-value"), latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-");
const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null;
$("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak));
setChartText($("cache-chart-value"), occupancyText(c.resident_bytes, c.hot_limit_bytes));
const transientLimit = Number(c.transient_limit_bytes);
const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0
? Math.max(transientLimit, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples)
: Math.max(1, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples);
$("cache-transient-sparkline").setAttribute("points", sparklinePoints(cacheTransientSamples, 300, 60, 5, transientPeak));
$("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak));
$("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak));
$("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak));
setChartText($("cache-transient-chart-value"), occupancyText(c.transient_bytes, c.transient_limit_bytes));
const coldEnabled = envelope?.config.cache?.cold_enabled === true;
const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null;
$("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak));
setChartText($("cache-cold-chart-value"), coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"));
if (systemSample) {
const processPlatform = [d.platform, d.environment].filter(Boolean).join(" / ");
setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", processPlatform);
setOptionalMetric("process-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`);
const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited"));
setOptionalMetric("process-fds", fds != null, fds);
setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads));
const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
setOptionalMetric("process-virtual-memory", virtualMemory != null, virtualMemory);
setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : formatDateTime(Number(d.process_start_time_seconds) * 1000));
setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
const memory = status.memory_accounting || {};
const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value));
const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value));
const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value));
const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value));
setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory);
setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory);
setOptionalMetric("memory-transform", transformMemory != null, transformMemory);
setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight);
setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
setOptionalMetric("process-rss-file", rssFile != null, rssFile);
const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value));
setOptionalMetric("process-data-swap", dataSwap != null, dataSwap);
const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory);
}
$("process-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
setChartText($("process-cpu-chart-value"), live.process_cpu_percent == null ? "" : `${fmt(live.process_cpu_percent, 1)}%`);
$("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
$("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
updateCacheMemorySizing();
const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null;
if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`);
setOptionalMetric("mean-latency", m.mean_duration_ms != null, m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`);
if (appendSample) {
if (networkRateSupported) {
networkRxSamples.push(Number(live.network_rx_bytes_per_second)); networkTxSamples.push(Number(live.network_tx_bytes_per_second));
networkRxSamples = networkRxSamples.slice(-window.rustProxyOverviewSamples.getLimit()); networkTxSamples = networkTxSamples.slice(-window.rustProxyOverviewSamples.getLimit());
}
if (m.mean_duration_ms != null) { responseLatencySamples.push(Number(m.mean_duration_ms)); responseLatencySamples = responseLatencySamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
}
const networkPeak = Math.max(1, ...networkRxSamples, ...networkTxSamples);
$("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak));
$("network-tx-sparkline").setAttribute("points", sparklinePoints(networkTxSamples, 300, 60, 5, networkPeak));
setChartText($("network-chart-value"), networkRateSupported ? `${fmtBytes(live.network_rx_bytes_per_second)} / ${fmtBytes(live.network_tx_bytes_per_second)}` : "");
$("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported;
renderNetworkInterfaceCharts(live.network_interfaces || [], appendSample);
$("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples));
setChartText($("latency-chart-value"), m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`);
$("latency-sparkline").closest(".mini-live-chart").hidden = m.mean_duration_ms == null;
$("compression-counters").textContent = [m.compression_gzip, m.compression_br, m.compression_zstd, m.compression_deflate].map(n => fmt(n)).join(" / ");
const age = d.sampled_at_ms ? Math.max(0, Math.floor((Date.now() - d.sampled_at_ms) / 1000)) : null;
$("system-sample-age").hidden = age == null;
if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`;
$("system-sample-age").classList.toggle("status-error", age != null && age > 10);
const l = status.listeners || {};
$("listener-pending").hidden = !l.restart_required;
$("listener-pending").textContent = t("restartPending");
const activeListeners = $("listener-active"), listenerItems = el("div", undefined, "listener-active-items");
let endpoints = Array.isArray(l.active_endpoints) ? l.active_endpoints : [];
if (!endpoints.length) {
endpoints = [];
if (status.proxy_listen) endpoints.push({ tls: false, address: status.proxy_listen, http3: false });
if (status.tls_listen) endpoints.push({ tls: true, address: status.tls_listen, http3: false });
}
for (const endpoint of endpoints) {
const item = el("div", undefined, "listener-active-item");
item.append(el("strong", endpoint.tls ? "HTTPS" : "HTTP", "listener-active-protocol"), el("code", endpoint.address || "-", "listener-active-address"));
const protocols = ["H1"];
if (endpoint.tls) {
if (endpoint.http2) protocols.push("H2");
if (endpoint.http3) protocols.push("H3/UDP");
} else if (endpoint.h2c) {
protocols.push("H2C");
}
for (const protocol of protocols) item.append(el("span", protocol, "listener-active-feature"));
listenerItems.append(item);
}
if (!endpoints.length) listenerItems.append(el("span", t("notEnabled"), "fine"));
activeListeners.replaceChildren(el("strong", t("activeListeners"), "listener-active-title"), listenerItems);
}
function renderHeaderRules(direction, rules) {
const target = $(`${direction}-header-rules`); target.replaceChildren();
for (const rule of rules) appendHeaderRule(direction, rule, false);
}
function appendHeaderRule(direction, rule = { op: "set", name: "", value: "" }, mark = true) {
const target = $(`${direction}-header-rules`); if (target.children.length >= 32) { toast(t("headerLimit"), true); return; }
const row = el("div", undefined, "header-rule");
const op = el("select"); op.className = "header-op"; op.setAttribute("aria-label", t("headerOperation"));
for (const value of ["set", "append", "remove"]) { const option = el("option", t(`header_${value}`)); option.value = value; op.append(option); } op.value = rule.op;
const name = el("input"); name.className = "header-name"; name.value = rule.name; name.required = true; name.maxLength = 128; name.placeholder = t("headerName"); name.setAttribute("aria-label", t("headerName")); name.autocomplete = "off"; name.spellcheck = false;
const value = el("input"); value.className = "header-value"; value.value = rule.value ?? ""; value.maxLength = 8192; value.placeholder = t("headerValue"); value.setAttribute("aria-label", t("headerValue")); value.autocomplete = "off"; value.spellcheck = false;
function sync() { value.disabled = op.value === "remove"; } sync(); op.addEventListener("change", sync);
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("headers-form"); });
row.append(op, name, value, remove); target.append(row); if (mark) setSettingsDirty("headers-form");
}
function collectHeaderRules(direction) {
return [...$(`${direction}-header-rules`).children].map(row => {
const op = row.querySelector(".header-op").value, name = row.querySelector(".header-name").value.trim(), value = row.querySelector(".header-value").value;
return op === "remove" ? { op, name } : { op, name, value };
});
}
window.rustProxyOverviewSamples.onChange(() => {
for (const history of [systemSamples, cacheMemorySamples, cacheTransientSamples,
cacheFillSamples, cacheColdQueueSamples, cacheColdIoSamples, cacheColdStorageSamples,
cacheHitSamples, cacheMissSamples, cacheBypassSamples, processCpuSamples,
processRssSamples, networkRxSamples, networkTxSamples, responseLatencySamples]) {
window.rustProxyOverviewSamples.trim(history);
}
for (const history of networkInterfaceSamples.values()) {
window.rustProxyOverviewSamples.trim(history.rx);
window.rustProxyOverviewSamples.trim(history.tx);
}
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview") {
renderDiagnostics(latestStatus, false, false);
}
});