592 lines
44 KiB
JavaScript
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);
|
|
}
|
|
});
|