1118 lines
105 KiB
JavaScript
1118 lines
105 KiB
JavaScript
"use strict";
|
|
const $ = (id) => document.getElementById(id);
|
|
let language = null;
|
|
try { language = localStorage.getItem("rp-language"); } catch { }
|
|
let csrf = "", authenticated = false, setupRequired = false, sessionInfo = null;
|
|
const ADMIN_THEMES = new Set(["dark", "light"]);
|
|
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve();
|
|
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
|
function applyAdminTheme(value, cache = true) {
|
|
const theme = normalizeAdminTheme(value);
|
|
document.documentElement.dataset.theme = theme;
|
|
const select = $("ui-theme"); if (select) select.value = theme;
|
|
if (cache) { try { localStorage.setItem("rp-theme", theme); } catch { } }
|
|
return theme;
|
|
}
|
|
try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); }
|
|
async function loadAdminPreferences(force = false) {
|
|
if (!authenticated) return adminPreferences;
|
|
if (adminPreferences && !force) return adminPreferences;
|
|
if (adminPreferencesLoading) return adminPreferencesLoading;
|
|
const epoch = authEpoch;
|
|
adminPreferencesLoading = (async () => {
|
|
const value = await api("/api/preferences");
|
|
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
|
adminPreferences = { ...value, theme: applyAdminTheme(value?.theme) };
|
|
return adminPreferences;
|
|
})();
|
|
try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; }
|
|
}
|
|
function updateAdminPreferences(patch) {
|
|
const epoch = authEpoch;
|
|
const task = adminPreferencesWrite.then(async () => {
|
|
if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
|
const current = await loadAdminPreferences();
|
|
if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
|
const next = { ...current, ...patch }; next.theme = normalizeAdminTheme(next.theme);
|
|
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
|
|
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
|
adminPreferences = { ...saved, theme: applyAdminTheme(saved?.theme) };
|
|
return adminPreferences;
|
|
});
|
|
adminPreferencesWrite = task.catch(() => undefined);
|
|
return task;
|
|
}
|
|
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
|
|
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme };
|
|
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", cache: "/cache", transform: "/transform", settings: "/settings" };
|
|
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
|
|
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
|
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
|
|
let liveEvents = null, liveConnected = false, authEpoch = 0, pendingAuthDestination = null;
|
|
let lastApiLatencyMs = null;
|
|
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
|
|
// Language content is discovered by build.rs from langs/*.json, never compiled
|
|
// into a two-language switch here. Locale metadata also drives Intl and RTL.
|
|
let languagePacks = new Map(), defaultLanguage = null;
|
|
function activePack() { return languagePacks.get(language) || languagePacks.get(defaultLanguage); }
|
|
function locale() { try { return Intl.getCanonicalLocales(activePack()?.meta.locale || "und")[0]; } catch { return "und"; } }
|
|
function t(key) { const pack = activePack()?.translations; const fallback = languagePacks.get(defaultLanguage)?.translations; return (pack && Object.hasOwn(pack, key) ? pack[key] : fallback?.[key]) ?? key; }
|
|
async function loadLanguages() {
|
|
const bundle = await api("/assets/langs.json");
|
|
if (!Array.isArray(bundle.languages) || !bundle.languages.length) throw new Error("i18n.invalid_bundle");
|
|
languagePacks = new Map(bundle.languages.map(pack => [pack.id, pack])); defaultLanguage = bundle.default;
|
|
if (!languagePacks.has(defaultLanguage)) throw new Error("i18n.missing_fallback");
|
|
const candidates = [language, ...(navigator.languages || [navigator.language])].filter(Boolean).map(v => v.replaceAll("_", "-").toLowerCase());
|
|
const packs = [...languagePacks.values()];
|
|
const selected = candidates.map(value => packs.find(p => p.id.replaceAll("_", "-").toLowerCase() === value || p.meta.locale.toLowerCase() === value) || packs.find(p => p.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean);
|
|
language = selected?.id || defaultLanguage;
|
|
$("language").replaceChildren(...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
|
$("language").value = language;
|
|
}
|
|
let latestStatus = null, certificateInventory = new Map();
|
|
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
|
function translate() {
|
|
clearTimeout(toastTimer); hideToast();
|
|
document.documentElement.lang = locale(); document.documentElement.dir = activePack()?.meta.direction || "ltr";
|
|
document.querySelectorAll("[data-i18n]").forEach(e => e.textContent = t(e.dataset.i18n));
|
|
document.querySelectorAll("[data-i18n-placeholder]").forEach(e => e.placeholder = t(e.dataset.i18nPlaceholder));
|
|
document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria)));
|
|
$("language").value = language; setPanel(currentPanel, false); syncAuthForm(); renderSession();
|
|
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
|
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); } if (lastLogs) renderLogs(lastLogs);
|
|
syncSettingsDirtyIndicators();
|
|
}
|
|
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
|
function raiseToast() {
|
|
const node = $("toast"); if (node.hidden) return;
|
|
const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body;
|
|
// A popover outside a modal is still inert. Keep it inside the active dialog,
|
|
// then lift it to the top layer so its backdrop cannot blur or cover it.
|
|
if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover();
|
|
if (node.parentElement !== parent) parent.append(node);
|
|
if (typeof node.showPopover === "function") node.showPopover();
|
|
}
|
|
function toast(message, error = false) { clearTimeout(toastTimer); const node = $("toast"); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); }
|
|
function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); }
|
|
function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; }
|
|
function clearFieldError(target) {
|
|
if (!target) return; target.classList?.remove("field-invalid"); target.removeAttribute?.("aria-invalid");
|
|
}
|
|
function clearFormFieldErrors(form) { form?.querySelectorAll(".field-invalid,[aria-invalid=true]").forEach(clearFieldError); }
|
|
function showFieldError(error, form = null) {
|
|
let target = error?.fieldTarget; if (typeof target === "string") target = $(target);
|
|
if (!target || (form && !form.contains(target))) return false;
|
|
const focusTarget = target.matches?.("input,select,textarea,button") ? target : target.querySelector?.("input,select,textarea,button");
|
|
target.classList?.add("field-invalid"); target.setAttribute?.("aria-invalid", "true");
|
|
const clear = () => clearFieldError(target); target.addEventListener?.("input", clear, { once: true }); target.addEventListener?.("change", clear, { once: true });
|
|
target.scrollIntoView?.({ block: "center", behavior: "smooth" }); focusTarget?.focus?.({ preventScroll: true });
|
|
return true;
|
|
}
|
|
async function withFormSubmitState(form, submitter, task) {
|
|
const button = submitter || form?.querySelector('button[type="submit"]');
|
|
const originalText = button?.textContent ?? "", i18nKey = button?.dataset?.i18n || "";
|
|
clearFormFieldErrors(form);
|
|
if (button) { button.disabled = true; button.classList.add("is-saving"); button.textContent = t("saving"); }
|
|
try { return await task(); }
|
|
catch (error) { showFieldError(error, form); throw error; }
|
|
finally { if (button) { button.disabled = false; button.classList.remove("is-saving"); button.textContent = i18nKey ? t(i18nKey) : originalText; } }
|
|
}
|
|
function appConfirm(message, options = {}) {
|
|
const title = options.title || t("confirmTitle"), confirmLabel = options.confirmLabel || t("continueAction"), cancelLabel = options.cancelLabel || t("cancelAction");
|
|
return new Promise(resolve => {
|
|
const dialog = el("dialog", undefined, "app-confirm-dialog"); dialog.setAttribute("aria-modal", "true");
|
|
const card = el("div", undefined, "card app-confirm-card"), heading = el("div", undefined, "card-heading"), headingText = el("div");
|
|
headingText.append(el("span", options.danger ? t("warningLabel") : t("confirmationLabel"), `small-label${options.danger ? " status-error" : ""}`), el("h2", title));
|
|
heading.append(headingText); card.append(heading, el("p", String(message ?? ""), "app-confirm-message"));
|
|
const actions = el("div", undefined, "form-actions app-confirm-actions"), cancel = el("button", cancelLabel, "quiet"), accept = el("button", confirmLabel, options.danger ? "outline app-confirm-danger" : "primary");
|
|
cancel.type = "button"; accept.type = "button"; actions.append(cancel, accept); card.append(actions); dialog.append(card); document.body.append(dialog);
|
|
let settled = false;
|
|
const finish = value => { if (settled) return; settled = true; if (dialog.open) dialog.close(); dialog.remove(); resolve(value); };
|
|
cancel.addEventListener("click", () => finish(false)); accept.addEventListener("click", () => finish(true));
|
|
dialog.addEventListener("cancel", event => { event.preventDefault(); finish(false); });
|
|
dialog.addEventListener("close", () => finish(false));
|
|
dialog.showModal(); cancel.focus();
|
|
});
|
|
}
|
|
function apiArea(path) {
|
|
const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1];
|
|
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration";
|
|
if (path === "/api/config") return "configuration";
|
|
if (path.startsWith("/api/cache")) return "cache";
|
|
if (path.startsWith("/api/tunnels")) return "overview";
|
|
if (path.startsWith("/api/transform")) return "transform";
|
|
if (path.startsWith("/api/certificates") || path.startsWith("/api/acme")) return "tls";
|
|
if (path.startsWith("/api/certificate-api")) return "settings";
|
|
if (path.startsWith("/api/docker")) return "dockerVhosts";
|
|
if (path.startsWith("/api/logs")) return "logging";
|
|
if (path.startsWith("/api/audit") || path.startsWith("/api/preferences") || path.startsWith("/api/auth/password") || path.startsWith("/api/auth/logout")) return "security";
|
|
return null;
|
|
}
|
|
function apiErrorMessage(path, response, value) {
|
|
const code = typeof value?.error === "string" ? value.error : "";
|
|
const translated = code ? t(code) : "";
|
|
const detail = response.status === 409 ? t("stale") : (translated && translated !== code ? translated : (code || `HTTP ${response.status}`));
|
|
const area = apiArea(path);
|
|
return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail;
|
|
}
|
|
new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = $("toast"); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] });
|
|
async function api(path, options = {}) {
|
|
const started = performance.now(), trackLatency = path.startsWith("/api/");
|
|
try {
|
|
const response = await fetch(path, { ...options, cache: "no-store", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-RustProxy-Request": "1", ...(csrf ? { "X-CSRF-Token": csrf } : {}), ...options.headers } });
|
|
const text = await response.text(); let value; try { value = JSON.parse(text); } catch { value = { error: text }; }
|
|
if (!response.ok) { if (response.status === 401 && authenticated) lock(); const error = new Error(apiErrorMessage(path, response, value)); error.status = response.status; error.code = value.error; throw error; } return value;
|
|
} finally {
|
|
if (trackLatency) { lastApiLatencyMs = performance.now() - started; if (latestStatus) renderFooter(latestStatus); }
|
|
}
|
|
}
|
|
function setPanel(name, navigate = true) {
|
|
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
|
|
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
|
|
currentPanel = name;
|
|
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
|
|
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
|
|
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
|
|
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
|
|
if (name === "settings") window.rustProxySettings?.syncLocation?.();
|
|
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
|
|
if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
|
|
}
|
|
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
|
|
function uptime(value) {
|
|
let seconds = Math.max(0, Math.floor(Number(value) || 0));
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const units = [[365 * 86400, "y"], [86400, "d"], [3600, "h"], [60, "m"]], parts = [];
|
|
for (const [size, suffix] of units) {
|
|
const count = Math.floor(seconds / size);
|
|
if (count) { parts.push(`${count}${suffix}`); seconds %= size; }
|
|
if (parts.length === 2) break;
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
|
|
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
|
|
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
|
|
function renderStatus(s) {
|
|
const firstStatus = latestStatus === null; latestStatus = s; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
|
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
|
|
$("metric-ws").textContent = m.websocket_active || 0;
|
|
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
|
|
$("metric-ws-h2").textContent = m.websocket_h2_active || 0;
|
|
$("metric-ws-h3").textContent = m.websocket_h3_active || 0;
|
|
$("metric-ws-total").textContent = (m.websocket_total || 0).toLocaleString(locale());
|
|
$("metric-h2").textContent = m.http2_requests || 0; $("metric-h2-active").textContent = m.http2_active_connections || 0; $("metric-tls-errors").textContent = m.tls_handshake_errors || 0;
|
|
$("metric-requests").textContent = m.requests.toLocaleString(locale()); $("metric-active").textContent = m.active; $("metric-bytes").textContent = bytes(m.bytes_in + m.bytes_out); $("metric-bytes-down").textContent = bytes(m.bytes_out); $("metric-bytes-up").textContent = bytes(m.bytes_in); $("metric-errors").textContent = m.errors;
|
|
const openConnections = Math.max(0, Number(m.active_connections) || 0), activeExchanges = Math.max(0, Number(m.active) || 0);
|
|
connectionOpenSamples.push(openConnections); connectionOpenSamples = connectionOpenSamples.slice(-30);
|
|
connectionExchangeSamples.push(activeExchanges); connectionExchangeSamples = connectionExchangeSamples.slice(-30);
|
|
const connectionPeak = Math.max(1, ...connectionOpenSamples, ...connectionExchangeSamples);
|
|
const connectionPoints = values => values.map((value, index) => `${index * 300 / Math.max(1, values.length - 1)},${95 - value / connectionPeak * 85}`).join(" ");
|
|
$("connection-open-sparkline").setAttribute("points", connectionPoints(connectionOpenSamples));
|
|
$("connection-exchange-sparkline").setAttribute("points", connectionPoints(connectionExchangeSamples));
|
|
$("connection-current").textContent = fmt(openConnections); $("connection-open-value").textContent = fmt(openConnections); $("connection-exchange-value").textContent = fmt(activeExchanges); $("connection-backends").textContent = fmt(s.backends);
|
|
const now = performance.now();
|
|
const transformed = s.transform_metrics || {};
|
|
const counters = { total: Number(m.requests) || 0, h1: Number(m.http1_requests) || 0, h2: Number(m.http2_requests) || 0, h3: Number(m.http3_requests) || 0, transform: (Number(transformed.applied) || 0) + (Number(transformed.cache_hits) || 0) };
|
|
const rates = { total: 0, h1: 0, h2: 0, h3: 0, transform: 0 };
|
|
if (previous) {
|
|
const elapsed = Math.max(.001, (now - previous.time) / 1000);
|
|
for (const key of ["total", "h1", "h2", "h3", "transform"]) if (counters[key] >= previous[key]) rates[key] = (counters[key] - previous[key]) / elapsed;
|
|
}
|
|
previous = { time: now, ...counters };
|
|
samples.push(rates.total); samples = samples.slice(-30);
|
|
for (const key of ["h1", "h2", "h3"]) { protocolSamples[key].push(rates[key]); protocolSamples[key] = protocolSamples[key].slice(-30); }
|
|
websocketProtocolSamples.h1.push(Number(m.websocket_h1_active) || 0); websocketProtocolSamples.h1 = websocketProtocolSamples.h1.slice(-30);
|
|
websocketProtocolSamples.h2.push(Number(m.websocket_h2_active) || 0); websocketProtocolSamples.h2 = websocketProtocolSamples.h2.slice(-30);
|
|
websocketProtocolSamples.h3.push(Number(m.websocket_h3_active) || 0); websocketProtocolSamples.h3 = websocketProtocolSamples.h3.slice(-30);
|
|
transformSamples.push(rates.transform); transformSamples = transformSamples.slice(-30);
|
|
const peak = Math.max(1, ...samples, ...transformSamples);
|
|
const points = values => values.map((n, i) => `${i * 600 / Math.max(1, values.length - 1)},${120 - n / peak * 105}`).join(" ");
|
|
$("sparkline").setAttribute("points", points(samples));
|
|
$("sparkline-h1").setAttribute("points", points(protocolSamples.h1));
|
|
$("sparkline-h2").setAttribute("points", points(protocolSamples.h2));
|
|
$("sparkline-h3").setAttribute("points", points(protocolSamples.h3));
|
|
$("sparkline-transform").setAttribute("points", points(transformSamples));
|
|
$("request-rate").textContent = `${rates.total.toFixed(1)} req/s`;
|
|
$("rate-total").textContent = `${rates.total.toFixed(1)}/s`; $("rate-h1").textContent = `${rates.h1.toFixed(1)}/s`; $("rate-h2").textContent = `${rates.h2.toFixed(1)}/s`; $("rate-h3").textContent = `${rates.h3.toFixed(1)}/s`; $("rate-transform").textContent = `${rates.transform.toFixed(1)}/s`;
|
|
$("metric-http-proto-total").textContent = `${rates.total.toFixed(1)}/s`;
|
|
$("metric-http-proto-h1").textContent = `${rates.h1.toFixed(1)}/s`;
|
|
$("metric-http-proto-h2").textContent = `${rates.h2.toFixed(1)}/s`;
|
|
$("metric-http-proto-h3").textContent = `${rates.h3.toFixed(1)}/s`;
|
|
$("http-protocol-h1").setAttribute("points", sparklinePoints(protocolSamples.h1, 300, 60, 5));
|
|
$("http-protocol-h2").setAttribute("points", sparklinePoints(protocolSamples.h2, 300, 60, 5));
|
|
$("http-protocol-h3").setAttribute("points", sparklinePoints(protocolSamples.h3, 300, 60, 5));
|
|
const wsTotalActive = (Number(m.websocket_h1_active) || 0) + (Number(m.websocket_h2_active) || 0) + (Number(m.websocket_h3_active) || 0);
|
|
$("metric-ws-proto-total").textContent = wsTotalActive.toLocaleString(locale());
|
|
$("metric-ws-proto-h1").textContent = (Number(m.websocket_h1_active) || 0).toLocaleString(locale());
|
|
$("metric-ws-proto-h2").textContent = (Number(m.websocket_h2_active) || 0).toLocaleString(locale());
|
|
$("metric-ws-proto-h3").textContent = (Number(m.websocket_h3_active) || 0).toLocaleString(locale());
|
|
$("ws-protocol-h1").setAttribute("points", sparklinePoints(websocketProtocolSamples.h1, 300, 60, 5));
|
|
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
|
|
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
|
|
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
|
|
renderFooter(s); renderDiagnostics(s); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
|
|
}
|
|
function renderFooter(s) {
|
|
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
|
const footer = $("footer-status");
|
|
footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`));
|
|
const update = s.software_update;
|
|
if (update?.update_available && update.latest_version) {
|
|
footer.append(document.createTextNode(" / "));
|
|
const label = `${t("updateAvailable")}: ${update.latest_version}`;
|
|
if (update.repository_url) {
|
|
const link = el("a", label, "footer-update");
|
|
link.href = update.repository_url;
|
|
link.target = "_blank";
|
|
link.rel = "noopener noreferrer";
|
|
footer.append(link);
|
|
} else {
|
|
footer.append(document.createTextNode(label));
|
|
}
|
|
}
|
|
}
|
|
let lastLogs = null;
|
|
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
|
|
async function refresh(force = false) { if (!authenticated || polling || (!force && liveConnected)) return; polling = true; const epoch = authEpoch; try { const [status, logs] = await Promise.all([api("/api/status"), api("/api/logs")]); if (!authenticated || epoch !== authEpoch) return; renderStatus(status); renderLogs(logs); } catch (e) { connected = false; $("connection-label").textContent = t("disconnected"); if (e.status === 401) lock(); } finally { polling = false; } }
|
|
function stopLive() { if (liveEvents) liveEvents.close(); liveEvents = null; liveConnected = false; }
|
|
function startLive() { stopLive(); if (!authenticated || document.hidden || typeof EventSource === "undefined") return; const epoch = authEpoch; const stream = new EventSource("/api/events"); liveEvents = stream; stream.addEventListener("snapshot", event => { if (!authenticated || epoch !== authEpoch) return; try { const payload = JSON.parse(event.data); liveConnected = true; renderStatus(payload.status); renderLogs(payload.logs); } catch { liveConnected = false; } }); stream.onerror = () => { if (epoch !== authEpoch) return; liveConnected = false; void refresh(); }; }
|
|
async function refreshCertificateInventory() {
|
|
if (!authenticated) return;
|
|
const epoch = authEpoch;
|
|
try {
|
|
const value = await api("/api/certificates");
|
|
if (!authenticated || epoch !== authEpoch) return;
|
|
certificateInventory = new Map((value.certificates || []).map(cert => [cert.id, cert]));
|
|
} catch (e) {
|
|
if (e.status !== 401 && authenticated && epoch === authEpoch) certificateInventory = new Map();
|
|
}
|
|
}
|
|
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
|
|
function rulesText(acl) { return (acl?.rules || []).map(r => `${r.action} ${r.network}`).join("\n"); }
|
|
function normalizeAclRuleText(value) {
|
|
return String(value ?? "").normalize("NFKC")
|
|
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
|
|
.replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
|
}
|
|
function parseRules(text, target = null) {
|
|
return normalizeAclRuleText(text).split(/\r?\n/).map((value, index) => ({ line: value.trim(), number: index + 1 })).filter(item => item.line).map(item => {
|
|
const match = item.line.match(/^(allow|deny)\s+(\S+)$/i);
|
|
if (!match) throw fieldError(target, `${t("ruleSyntaxLine")} ${item.number}: ${item.line}`);
|
|
return { action: match[1].toLowerCase(), network: match[2] };
|
|
});
|
|
}
|
|
function renderConfig() {
|
|
if (!envelope) return; window.rustProxyCachePanel?.configure(); renderCertificates(); renderSettings(); window.rustProxyPages?.render(); window.rustProxyAclProfiles?.render(); window.rustProxyVhosts?.render(); window.rustProxyDocker?.render(); window.rustProxyRedirects?.render(); window.rustProxyNotFound?.render(); window.rustProxyStreams?.render(); const config = envelope.config; if (!settingsDirty.has("config-editor")) $("config-editor").value = JSON.stringify(config, null, 2);
|
|
$("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; }));
|
|
if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty"));
|
|
if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length);
|
|
}
|
|
async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true); return false; } if (!authenticated || !envelope) return false; busy = true; const epoch = authEpoch; try { const value = await api(path, { method: "PUT", body: JSON.stringify(payload) }); if (!authenticated || epoch !== authEpoch) return false; envelope = value; if (path === "/api/config" || path === "/api/config/tls") await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch || !envelope) return false; renderConfig(); toast(t("saved")); await refresh(true); return true; } catch (e) { toast(e.message, true); if (e.status === 401) lock(); return false; } finally { busy = false; } }
|
|
async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); }
|
|
async function saveSection(section, value) { return saveRequest(`/api/config/${section}`, { expected_revision: envelope?.revision, value }); }
|
|
function syncAuthForm() { $("setup-fields").hidden = !setupRequired; $("confirm-field").hidden = !setupRequired; $("setup-token").required = setupRequired; $("password-confirm").required = setupRequired; $("password").minLength = setupRequired ? 12 : 1; $("password").autocomplete = setupRequired ? "new-password" : "current-password"; $("auth-title").textContent = t(setupRequired ? "setupTitle" : "signIn"); $("login-submit").textContent = t(setupRequired ? "createAndSignIn" : "signIn"); $("auth-badge").textContent = setupRequired ? "SETUP" : "LOCAL"; }
|
|
function renderSession() { $("session-user").textContent = sessionInfo?.username || ""; $("session-expiry").textContent = sessionInfo ? t("expires") + new Date(sessionInfo.expires_at * 1000).toLocaleString(locale()) : ""; }
|
|
function authReturnDestination() {
|
|
if (typeof location === "undefined") return "/";
|
|
if (location.pathname === "/logging") return "/settings?tab=logging";
|
|
const allowed = new Set([...Object.values(PANEL_PATHS), "/configuration"]);
|
|
const path = allowed.has(location.pathname) ? location.pathname : "/";
|
|
if (path !== "/settings") return path;
|
|
const tab = window.rustProxySettings?.current?.() || new URLSearchParams(location.search).get("tab");
|
|
return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings";
|
|
}
|
|
function lock(notify = true) {
|
|
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
|
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
|
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
|
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
|
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
|
document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); });
|
|
renderSession(); syncAuthForm();
|
|
// Replace the authenticated document with the minimal public login document.
|
|
// Keep the exact panel/settings tab so re-authentication resumes in place.
|
|
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
|
}
|
|
|
|
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
|
|
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [new Date(item.time * 1000).toLocaleString(locale()), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
|
|
let tunnelRefreshTimer = null;
|
|
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
|
|
async function loadTunnels() {
|
|
if (!authenticated) return;
|
|
const epoch = authEpoch;
|
|
try {
|
|
const data = await api("/api/tunnels");
|
|
if (!authenticated || epoch !== authEpoch) return;
|
|
const rows = (data.tunnels || []).map(item => {
|
|
const tr = el("tr");
|
|
for (const value of [item.id, item.client, item.host, item.upstream, item.protocol, `${fmt((Number(item.duration_ms) || 0) / 1000, 1)} s`, fmtBytes(item.rx_bytes), fmtBytes(item.tx_bytes)]) {
|
|
const td = el("td", String(value)); td.title = String(value); tr.append(td);
|
|
}
|
|
const actions = el("td");
|
|
const kill = el("button", t("tunnelKill"), "quiet"); kill.type = "button";
|
|
kill.addEventListener("click", async () => {
|
|
if (!(await appConfirm(t("tunnelKillConfirm"), { danger: true }))) return;
|
|
kill.disabled = true; tunnelMessage();
|
|
try {
|
|
const reason = $("tunnel-kill-reason")?.value.trim() || t("tunnelKillReasonDefault");
|
|
await api(`/api/tunnels/${encodeURIComponent(item.id)}/kill`, { method: "POST", body: JSON.stringify({ reason }) });
|
|
tunnelMessage(t("tunnelKilled"));
|
|
await loadTunnels();
|
|
} catch (error) { tunnelMessage(error.message, true); }
|
|
finally { kill.disabled = false; }
|
|
});
|
|
actions.append(kill); tr.append(actions); return tr;
|
|
});
|
|
$("tunnels-rows").replaceChildren(...rows); $("tunnels-empty").hidden = rows.length > 0;
|
|
} catch (error) { if (authenticated && epoch === authEpoch) tunnelMessage(error.message, true); }
|
|
}
|
|
function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefreshTimer); tunnelRefreshTimer = null; }
|
|
function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); }
|
|
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
|
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
|
$("language").addEventListener("change", () => { language = $("language").value; try { localStorage.setItem("rp-language", language); } catch { } translate(); });
|
|
$("ui-theme").addEventListener("change", async () => {
|
|
const select = $("ui-theme"), previousTheme = adminPreferences?.theme || document.documentElement.dataset.theme || "dark", nextTheme = normalizeAdminTheme(select.value);
|
|
applyAdminTheme(nextTheme); select.disabled = true;
|
|
try { await updateAdminPreferences({ theme: nextTheme }); }
|
|
catch (error) { applyAdminTheme(previousTheme); if (error.status !== 401) toast(error.message, true); }
|
|
finally { select.disabled = false; }
|
|
});
|
|
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => setPanel(button.dataset.panel)));
|
|
window.addEventListener("popstate", () => setPanel(panelFromLocation(), false));
|
|
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
|
|
$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } });
|
|
$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } });
|
|
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
|
|
$("audit-dialog-close").addEventListener("click", () => $("audit-dialog").close());
|
|
$("reload-audit").addEventListener("click", () => void loadAudit());
|
|
$("tunnels-open").addEventListener("click", () => { const dialog = $("tunnels-dialog"); tunnelMessage(); if (!dialog.open) dialog.showModal(); void loadTunnels(); startTunnelRefresh(); });
|
|
$("tunnels-close").addEventListener("click", () => { stopTunnelRefresh(); $("tunnels-dialog").close(); });
|
|
$("tunnels-reload").addEventListener("click", () => void loadTunnels());
|
|
$("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
|
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
|
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
|
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
|
|
async function revalidateSession() {
|
|
const epoch = authEpoch;
|
|
try {
|
|
const info = await api("/api/auth/session");
|
|
if (epoch !== authEpoch) return;
|
|
if (!authenticated) { await activate(info); return; }
|
|
sessionInfo = info; csrf = info.csrf_token; renderSession(); await refresh(true); startLive();
|
|
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
|
}
|
|
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
|
|
document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } });
|
|
setInterval(() => { if (!document.hidden) void refresh(); }, 5000);
|
|
(async () => { try { await loadLanguages(); translate(); const status = await api("/api/auth/status"); setupRequired = status.setup_required; syncAuthForm(); if (!setupRequired) { try { await activate(await api("/api/auth/session")); } catch (e) { if (e.status === 401) { location.replace(`${location.pathname}${location.search}`); return; } throw e; } } } catch (e) { $("login").hidden = false; document.documentElement.classList.remove("app-loading"); toast(e.message, true); } })();
|
|
|
|
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"));
|
|
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) ? new Date(Number(value) * 1000).toLocaleString(locale()) : "-";
|
|
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");
|
|
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").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);
|
|
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(); 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());
|
|
|
|
// Dashboard metrics are actual server samples. Null means unavailable, not zero.
|
|
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = 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 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 renderNetworkInterfaceCharts(interfaces, appendSample) {
|
|
const current = Array.isArray(interfaces) ? interfaces.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))) : [];
|
|
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(-60); }
|
|
if (Number.isFinite(tx)) { history.tx.push(tx); history.tx = history.tx.slice(-60); }
|
|
networkInterfaceSamples.set(item.name, history);
|
|
}
|
|
}
|
|
const target = $("network-interface-charts");
|
|
if (!target) return;
|
|
target.replaceChildren();
|
|
const visible = current.filter(item => item?.name && !hiddenNetworkInterfaces.has(item.name));
|
|
$("network-interface-chart-value").textContent = t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length);
|
|
if (!current.length) { target.append(el("p", t("networkNoInterfaces"), "empty network-interface-empty")); return; }
|
|
for (const item of visible) {
|
|
const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] };
|
|
const peak = Math.max(1, ...history.rx, ...history.tx);
|
|
const row = el("div", undefined, "network-interface-row");
|
|
const head = el("div", undefined, "mini-live-chart-head network-interface-row-head");
|
|
const rate = el("span", `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`, "fine");
|
|
const hide = action(t("hideMetric"), () => { hiddenNetworkInterfaces.add(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); });
|
|
hide.classList.add("network-interface-hide");
|
|
const actions = el("span", undefined, "network-interface-head-actions"); actions.append(rate, hide);
|
|
head.append(el("b", item.name || "-", "mono"), actions);
|
|
const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${item.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: sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55" }));
|
|
svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55" }));
|
|
row.append(head, svg); target.append(row);
|
|
}
|
|
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
|
|
if (hidden.length) {
|
|
const hiddenRow = el("div", undefined, "network-interface-hidden");
|
|
hiddenRow.append(el("span", t("hiddenInterfaces"), "fine"));
|
|
for (const item of hidden) {
|
|
const show = action(`${t("showMetric")} ${item.name}`, () => { hiddenNetworkInterfaces.delete(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); });
|
|
hiddenRow.append(show);
|
|
}
|
|
target.append(hiddenRow);
|
|
} else if (!visible.length) {
|
|
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty"));
|
|
}
|
|
}
|
|
function renderDiagnostics(status, appendSample = true) {
|
|
const d = status.system || {}, c = status.cache || {}, m = status.metrics || {};
|
|
$("metric-h3").textContent = fmt(m.http3_requests);
|
|
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`;
|
|
$("cache-summary").textContent = `${fmt(c.hits)} HIT / ${fmt(c.misses)} MISS`;
|
|
$("metric-rss").closest("article.stat").hidden = d.process_rss_bytes == null;
|
|
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")}`;
|
|
const systemCpuSupported = d.cpu_percent != null;
|
|
setOptionalMetric("system-cpu", systemCpuSupported, systemCpuSupported ? `${fmt(d.cpu_percent, 1)}%` : "");
|
|
$("system-chart").hidden = !systemCpuSupported;
|
|
const sourceSupported = !!d.source && d.source !== "unsupported";
|
|
$("system-source").hidden = !sourceSupported;
|
|
if (sourceSupported) $("system-source").textContent = t("realSample");
|
|
if (appendSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); }
|
|
$("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(" "));
|
|
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
|
|
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
|
|
$("memory-meter").hidden = systemMemory == null;
|
|
if (d.memory_used_bytes != null && d.memory_total_bytes > 0) $("memory-meter").value = d.memory_used_bytes / d.memory_total_bytes * 100; else $("memory-meter").removeAttribute("value");
|
|
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));
|
|
$("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(-60); }
|
|
const currentCounters = [c.hits, c.misses, c.bypasses].map(Number);
|
|
if (currentCounters.every(Number.isFinite)) {
|
|
const delta = lastCacheCounters ? currentCounters.map((value, index) => value >= lastCacheCounters[index] ? value - lastCacheCounters[index] : 0) : [0, 0, 0];
|
|
cacheHitSamples.push(delta[0]); cacheMissSamples.push(delta[1]); cacheBypassSamples.push(delta[2]);
|
|
cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60);
|
|
lastCacheCounters = currentCounters;
|
|
}
|
|
if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); }
|
|
if (d.process_rss_bytes != null) { processRssSamples.push(Number(d.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); }
|
|
}
|
|
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)];
|
|
$("cache-outcome-chart-value").textContent = latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-";
|
|
$("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples));
|
|
$("cache-chart-value").textContent = fmtBytes(c.resident_bytes);
|
|
setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", d.platform || "");
|
|
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 ? "" : new Date(Number(d.process_start_time_seconds) * 1000).toLocaleString(locale()));
|
|
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));
|
|
$("process-cpu-chart-value").textContent = d.process_cpu_percent == null ? "" : `${fmt(d.process_cpu_percent, 1)}%`;
|
|
$("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
|
|
$("process-rss-chart-value").textContent = d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes);
|
|
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = d.process_cpu_percent == null;
|
|
$("process-rss-sparkline").closest(".mini-live-chart").hidden = d.process_rss_bytes == null;
|
|
updateCacheMemorySizing();
|
|
const networkRateSupported = d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null;
|
|
setOptionalMetric("system-network", networkRateSupported, networkRateSupported ? `${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 (d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null) {
|
|
networkRxSamples.push(Number(d.network_rx_bytes_per_second)); networkTxSamples.push(Number(d.network_tx_bytes_per_second));
|
|
networkRxSamples = networkRxSamples.slice(-60); networkTxSamples = networkTxSamples.slice(-60);
|
|
}
|
|
if (m.mean_duration_ms != null) { responseLatencySamples.push(Number(m.mean_duration_ms)); responseLatencySamples = responseLatencySamples.slice(-60); }
|
|
}
|
|
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));
|
|
$("network-chart-value").textContent = networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : "";
|
|
$("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported;
|
|
renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample);
|
|
$("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples));
|
|
$("latency-chart-value").textContent = 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");
|
|
$("listener-active").textContent = `${t("activeListeners")}: ${l.active_endpoints?.map(e => `${e.tls ? "HTTPS" : "HTTP"} ${e.address}${e.http3 ? " + H3/UDP" : ""}`).join(" | ") || `HTTP ${status.proxy_listen} / HTTPS ${status.tls_listen || t("notEnabled")}`}`;
|
|
}
|
|
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 };
|
|
});
|
|
}
|
|
function renderSettings() {
|
|
if (!envelope) return; const config = envelope.config; window.rustProxySettings?.render();
|
|
if (!settingsDirty.has("listeners-form")) window.rustProxyIntegrations?.renderListeners();
|
|
if (!settingsDirty.has("http2-form")) window.rustProxyIntegrations?.renderHttp2Settings();
|
|
if (!settingsDirty.has("http3-form")) window.rustProxyIntegrations?.renderHttp3Settings();
|
|
if (!settingsDirty.has("response-form")) {
|
|
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
|
$("expose-request-id").checked = !!diagnostics.request_id;
|
|
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.7";
|
|
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
|
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
|
}
|
|
if (!settingsDirty.has("cache-form")) {
|
|
const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576;
|
|
$("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576;
|
|
$("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096;
|
|
$("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64;
|
|
$("cache-cold-enabled").checked = !!c.cold_enabled; $("cache-cold-path").value = c.cold_path || "cache/cold";
|
|
$("cache-cold-bytes").value = (c.cold_max_bytes ?? 4294967296) / 1048576; $("cache-cold-entries").value = c.cold_max_entries ?? 50000; $("cache-cold-after").value = c.cold_after_seconds ?? 300;
|
|
$("cache-cold-queue-bytes").value = (c.cold_queue_max_bytes ?? 33554432) / 1048576;
|
|
$("cache-cold-gc-interval").value = c.cold_gc_interval_seconds ?? 60; $("cache-cold-gc-max-delete").value = c.cold_gc_max_delete ?? 256;
|
|
$("cache-cold-on-idle").checked = c.cold_on_idle !== false; $("cache-cold-on-age").checked = c.cold_on_age === true; $("cache-cold-on-lru").checked = c.cold_on_lru !== false; $("cache-cold-on-shutdown").checked = c.cold_on_shutdown !== false; $("cache-cold-max-hot").value = c.cold_max_hot_seconds ?? 3600;
|
|
$("cache-ttl").value = c.max_ttl_seconds ?? 86400; $("cache-default-ttl").value = c.default_ttl_seconds ?? 0;
|
|
$("cache-collapse-enabled").checked = c.collapse_enabled !== false; $("cache-collapse-timeout").value = c.collapse_wait_timeout_ms ?? 1500; $("cache-collapse-fallback").value = c.collapse_timeout_fallback || "origin";
|
|
$("cache-sie-timeout").checked = c.stale_if_error_timeout !== false; $("cache-sie-connection").checked = c.stale_if_error_connection !== false; $("cache-sie-dns").checked = c.stale_if_error_dns !== false; $("cache-sie-tls").checked = c.stale_if_error_tls !== false; $("cache-sie-statuses").value = (c.stale_if_error_statuses || [500, 502, 503, 504]).join(", ");
|
|
$("cache-proactive-enabled").checked = c.proactive_refresh_enabled === true; $("cache-proactive-before").value = c.proactive_refresh_before_expiry_seconds ?? 15; $("cache-proactive-percent").value = c.proactive_refresh_percent ?? 10; $("cache-proactive-inflight").value = c.proactive_refresh_max_inflight ?? 4;
|
|
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
|
|
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
|
|
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age;
|
|
setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null);
|
|
setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null);
|
|
syncColdCachePolicy();
|
|
syncAdvancedCachePolicy();
|
|
updateCacheMemorySizing();
|
|
}
|
|
if (!settingsDirty.has("transform-form")) {
|
|
const tr = config.transform || {};
|
|
setTransformPresetSelection(["conservative", "recommended", "performance", "uber"].includes(tr.preset) ? tr.preset : null);
|
|
$("transform-enabled").checked = !!tr.enabled;
|
|
$("transform-javascript").checked = tr.javascript !== false;
|
|
$("transform-css").checked = tr.css !== false;
|
|
$("transform-html").checked = !!tr.html;
|
|
$("transform-svg").checked = !!tr.svg;
|
|
$("transform-json").checked = !!tr.json;
|
|
$("transform-extensions").value = (tr.extensions || ["js", "mjs", "css", "html", "htm", "svg", "json"]).join("\n");
|
|
$("transform-max-body").value = (tr.max_body_bytes ?? 2097152) / 1024;
|
|
$("transform-memory-budget").value = (tr.memory_budget_bytes ?? 134217728) / 1048576;
|
|
$("transform-max-inflight").value = tr.max_inflight ?? 32;
|
|
updateTransformResourceSizing();
|
|
}
|
|
if (!settingsDirty.has("logging-form")) {
|
|
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
|
|
$("log-file-enabled").checked = !!file.enabled; $("log-file-per-vhost").checked = !!file.per_vhost; $("log-file-path").value = file.path || "/var/log/rustproxy/access.jsonl"; $("log-file-directory").value = file.directory || "/var/log/rustproxy/vhosts"; syncLoggingFileMode();
|
|
$("log-logstash-enabled").checked = !!logstash.enabled; $("log-logstash-address").value = logstash.address || "127.0.0.1:5044";
|
|
$("log-loki-enabled").checked = !!loki.enabled; $("log-loki-url").value = loki.url || "http://127.0.0.1:3100/loki/api/v1/push"; $("log-loki-token").value = loki.bearer_token_env || "";
|
|
$("log-loki-labels").value = JSON.stringify(loki.labels || { job: "rustproxy" }, null, 2); $("log-format-fields").value = JSON.stringify(format.fields || {}, null, 2); $("log-format-static").value = JSON.stringify(format.static_fields || {}, null, 2);
|
|
$("log-format-preset").value = detectLoggingPreset(format.fields || {});
|
|
}
|
|
const sink = latestStatus?.logging?.sinks; if (sink) $("logging-status").textContent = `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}`;
|
|
if (!settingsDirty.has("forwarding-form")) { $("trusted-proxies").value = (config.forwarding?.trusted_proxies || []).join("\n"); $("trusted-proto").checked = !!config.forwarding?.trust_forwarded_proto; }
|
|
if (!settingsDirty.has("headers-form")) { for (const direction of ["request", "response"]) renderHeaderRules(direction, config.headers?.[direction] || []); }
|
|
}
|
|
function effectiveCacheMemory() {
|
|
const d = latestStatus?.system || {}, host = Number(d.memory_total_bytes), cgroup = Number(d.cgroup_memory_max_bytes);
|
|
const candidates = [host, cgroup].filter(value => Number.isFinite(value) && value > 0);
|
|
if (!candidates.length) return null;
|
|
const bytes = Math.min(...candidates);
|
|
return { bytes, cgroupLimited: Number.isFinite(cgroup) && cgroup > 0 && (!Number.isFinite(host) || cgroup < host) };
|
|
}
|
|
function cacheMemoryHeadroom() {
|
|
const d = latestStatus?.system || {};
|
|
const hostTotal = Number(d.memory_total_bytes), hostUsed = Number(d.memory_used_bytes);
|
|
const cgroupMax = Number(d.cgroup_memory_max_bytes), cgroupCurrent = Number(d.cgroup_memory_current_bytes);
|
|
const candidates = [];
|
|
if (Number.isFinite(hostTotal) && hostTotal > 0 && Number.isFinite(hostUsed) && hostUsed >= 0) candidates.push(Math.max(0, hostTotal - hostUsed));
|
|
if (Number.isFinite(cgroupMax) && cgroupMax > 0 && Number.isFinite(cgroupCurrent) && cgroupCurrent >= 0) candidates.push(Math.max(0, cgroupMax - cgroupCurrent));
|
|
return candidates.length ? Math.min(...candidates) : null;
|
|
}
|
|
function cachePresetValues(kind, bytes) {
|
|
const totalMiB = bytes / 1048576, profile = {
|
|
conservative: { ratio: .125, objectMiB: 8, eviction: 32 },
|
|
recommended: { ratio: .20, objectMiB: 16, eviction: 64 },
|
|
performance: { ratio: .30, objectMiB: 32, eviction: 128 }
|
|
}[kind] || { ratio: .20, objectMiB: 16, eviction: 64 };
|
|
const round16 = value => Math.max(16, Math.floor(value / 16) * 16);
|
|
const hot = Math.max(64, Math.min(round16(totalMiB * profile.ratio), round16(totalMiB * .5)));
|
|
const transient = Math.max(16, round16(hot * .25));
|
|
const queue = Math.max(8, Math.floor((transient * .5) / 8) * 8);
|
|
const objectMiB = Math.max(1, Math.min(profile.objectMiB, Math.floor(hot / 16)));
|
|
const entries = Math.max(1024, Math.min(16384, Math.round(hot * 16)));
|
|
return { hot, transient, queue, objectKiB: objectMiB * 1024, entries, eviction: profile.eviction };
|
|
}
|
|
function cacheMemoryPresetName(kind) {
|
|
return kind ? t({ conservative: "cachePresetConservative", recommended: "cachePresetRecommended", performance: "cachePresetPerformance" }[kind]) : t("cachePresetCustom");
|
|
}
|
|
function setCacheMemoryPresetSelection(kind) {
|
|
selectedCacheMemoryPreset = ["conservative", "recommended", "performance"].includes(kind) ? kind : null;
|
|
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => {
|
|
const active = button.dataset.cacheMemoryPreset === selectedCacheMemoryPreset;
|
|
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
|
button.setAttribute("aria-pressed", String(active));
|
|
});
|
|
const badge = $("cache-memory-preset-selected");
|
|
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${cacheMemoryPresetName(selectedCacheMemoryPreset)}`;
|
|
}
|
|
function setCacheColdPresetSelection(kind) {
|
|
selectedCacheColdPreset = kind === "recommended" ? "recommended" : null;
|
|
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => {
|
|
const active = button.dataset.cacheColdPreset === selectedCacheColdPreset;
|
|
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
|
button.setAttribute("aria-pressed", String(active));
|
|
});
|
|
const badge = $("cache-cold-preset-selected");
|
|
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${selectedCacheColdPreset ? t("cachePresetRecommended") : t("cachePresetCustom")}`;
|
|
}
|
|
function updateCacheMemorySizing() {
|
|
const detected = effectiveCacheMemory(), badge = $("cache-memory-detected"), warning = $("cache-memory-warning");
|
|
if (!badge || !warning) return;
|
|
const buttons = document.querySelectorAll("[data-cache-memory-preset]");
|
|
if (!detected) {
|
|
badge.textContent = t("cacheMemoryNotDetected"); buttons.forEach(button => { button.disabled = true; });
|
|
$("cache-memory-preset-summary").textContent = t("cacheMemoryPresetUnavailable"); warning.hidden = true; return;
|
|
}
|
|
if (detected.bytes < 256 * 1048576) {
|
|
badge.textContent = `${detected.cgroupLimited ? t("cacheMemoryCgroup") : t("cacheMemoryDetected")}: ${fmtBytes(detected.bytes)}`;
|
|
buttons.forEach(button => { button.disabled = true; });
|
|
$("cache-memory-preset-summary").textContent = t("cacheMemoryPresetTooSmall");
|
|
warning.hidden = false; warning.textContent = t("cacheMemoryPresetTooSmall"); return;
|
|
}
|
|
buttons.forEach(button => { button.disabled = false; });
|
|
badge.textContent = `${detected.cgroupLimited ? t("cacheMemoryCgroup") : t("cacheMemoryDetected")}: ${fmtBytes(detected.bytes)}`;
|
|
const recommended = cachePresetValues("recommended", detected.bytes);
|
|
$("cache-memory-preset-summary").textContent = `${t("cacheMemoryRecommendedFor")} HOT ${recommended.hot} MiB · ${t("cachePresetTransientShort")} ${recommended.transient} MiB · ${t("cachePresetObjectShort")} ${recommended.objectKiB / 1024} MiB · ${t("cachePresetQueueShort")} ${recommended.queue} MiB`;
|
|
const hot = Number($("cache-memory")?.value) * 1048576, transient = Number($("cache-transient-memory")?.value) * 1048576;
|
|
const queue = Number($("cache-cold-queue-bytes")?.value) * 1048576;
|
|
const object = Number($("cache-object")?.value) * 1024;
|
|
const high = Number.isFinite(hot + transient) && hot + transient > detected.bytes * .35;
|
|
const invalidQueue = Number.isFinite(queue) && Number.isFinite(transient) && queue > transient;
|
|
const objectOverTransient = Number.isFinite(object) && Number.isFinite(transient) && object > transient;
|
|
const headroom = cacheMemoryHeadroom();
|
|
const currentCache = (Number(latestStatus?.cache?.hot_allocated_bytes ?? latestStatus?.cache?.resident_bytes) || 0) + (Number(latestStatus?.cache?.transient_bytes) || 0);
|
|
const additionalBudget = Math.max(0, hot + transient - currentCache);
|
|
const lowHeadroom = headroom != null && Number.isFinite(additionalBudget) && additionalBudget > headroom * .75;
|
|
warning.hidden = !(high || invalidQueue || objectOverTransient || lowHeadroom);
|
|
if (invalidQueue) warning.textContent = t("cacheQueueOverTransientWarning");
|
|
else if (objectOverTransient) warning.textContent = t("cacheObjectOverTransientWarning");
|
|
else if (lowHeadroom) warning.textContent = `${t("cacheMemoryHeadroomWarning")} ${fmtBytes(additionalBudget)} / ${fmtBytes(headroom)}.`;
|
|
else if (high) warning.textContent = `${t("cacheMemoryHighWarning")} ${fmtBytes(hot + transient)} / ${fmtBytes(detected.bytes)}.`;
|
|
}
|
|
function applyCacheMemoryPreset(kind) {
|
|
const detected = effectiveCacheMemory(); if (!detected || detected.bytes < 256 * 1048576) return;
|
|
const preset = cachePresetValues(kind, detected.bytes);
|
|
$("cache-memory").value = preset.hot; $("cache-transient-memory").value = preset.transient;
|
|
$("cache-object").value = preset.objectKiB; $("cache-entries").value = preset.entries;
|
|
$("cache-eviction-limit").value = preset.eviction; $("cache-cold-queue-bytes").value = preset.queue;
|
|
setCacheMemoryPresetSelection(kind);
|
|
setSettingsDirty("cache-form"); updateCacheMemorySizing();
|
|
}
|
|
function applyRecommendedColdCachePreset() {
|
|
const transient = Number($("cache-transient-memory").value);
|
|
const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32;
|
|
$("cache-cold-enabled").checked = true;
|
|
if (!$("cache-cold-path").value.trim()) $("cache-cold-path").value = "cache/cold";
|
|
$("cache-cold-on-idle").checked = true; $("cache-cold-on-age").checked = false;
|
|
$("cache-cold-on-lru").checked = true; $("cache-cold-on-shutdown").checked = true;
|
|
$("cache-cold-after").value = 300; $("cache-cold-max-hot").value = 3600;
|
|
$("cache-cold-gc-interval").value = 60; $("cache-cold-gc-max-delete").value = 256;
|
|
$("cache-cold-queue-bytes").value = Math.min(queue, transient || queue);
|
|
setCacheColdPresetSelection("recommended");
|
|
syncColdCachePolicy(); setSettingsDirty("cache-form"); updateCacheMemorySizing();
|
|
}
|
|
function transformPresetValues(kind) {
|
|
return {
|
|
conservative: { memoryMiB: 32, bodyKiB: 1024, inflight: 8 },
|
|
recommended: { memoryMiB: 128, bodyKiB: 2048, inflight: 32 },
|
|
performance: { memoryMiB: 512, bodyKiB: 8192, inflight: 64 },
|
|
uber: { memoryMiB: 1024, bodyKiB: 32768, inflight: 128 }
|
|
}[kind] || { memoryMiB: 128, bodyKiB: 2048, inflight: 32 };
|
|
}
|
|
function transformPresetName(kind) {
|
|
return kind ? t({ conservative: "transformPresetConservative", recommended: "transformPresetRecommended", performance: "transformPresetPerformance", uber: "transformPresetUber" }[kind]) : t("cachePresetCustom");
|
|
}
|
|
function setTransformPresetSelection(kind) {
|
|
selectedTransformPreset = ["conservative", "recommended", "performance", "uber"].includes(kind) ? kind : null;
|
|
document.querySelectorAll("[data-transform-preset]").forEach(button => {
|
|
const active = button.dataset.transformPreset === selectedTransformPreset;
|
|
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
|
button.setAttribute("aria-pressed", String(active));
|
|
});
|
|
const badge = $("transform-preset-selected");
|
|
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${transformPresetName(selectedTransformPreset)}`;
|
|
}
|
|
function updateTransformResourceSizing() {
|
|
const warning = $("transform-memory-warning"); if (!warning) return;
|
|
const memoryMiB = Number($("transform-memory-budget")?.value), bodyKiB = Number($("transform-max-body")?.value), inflight = Number($("transform-max-inflight")?.value);
|
|
const detected = effectiveCacheMemory();
|
|
const budgetBytes = memoryMiB * 1048576, bodyBytes = bodyKiB * 1024;
|
|
const worstEncodedEstimate = bodyBytes * 3;
|
|
const budgetCannotFitMax = Number.isFinite(budgetBytes) && Number.isFinite(worstEncodedEstimate) && budgetBytes < worstEncodedEstimate;
|
|
const highHostShare = detected && Number.isFinite(budgetBytes) && budgetBytes > detected.bytes * .5;
|
|
warning.hidden = !(budgetCannotFitMax || highHostShare);
|
|
if (budgetCannotFitMax) warning.textContent = t("transformBudgetTooSmallWarning");
|
|
else if (highHostShare) warning.textContent = `${t("transformBudgetHighWarning")} ${fmtBytes(budgetBytes)} / ${fmtBytes(detected.bytes)}.`;
|
|
const summary = $("transform-preset-summary");
|
|
if (summary) summary.textContent = `${t("transformResourceCurrent")}: ${Number.isFinite(memoryMiB) ? memoryMiB : "-"} MiB · ${Number.isFinite(bodyKiB) ? bodyKiB : "-"} KiB · ${t("transformMaxInflightShort")} ${Number.isFinite(inflight) ? inflight : "-"}`;
|
|
}
|
|
function applyTransformPreset(kind) {
|
|
const preset = transformPresetValues(kind);
|
|
$("transform-memory-budget").value = preset.memoryMiB;
|
|
$("transform-max-body").value = preset.bodyKiB;
|
|
$("transform-max-inflight").value = preset.inflight;
|
|
setTransformPresetSelection(kind);
|
|
setSettingsDirty("transform-form"); updateTransformResourceSizing();
|
|
}
|
|
function syncServerMode() { $("server-value").disabled = $("server-mode").value !== "custom"; $("server-value").required = !$("server-value").disabled; }
|
|
function syncLoggingFileMode() {
|
|
const enabled = $("log-file-enabled").checked, perVhost = $("log-file-per-vhost").checked;
|
|
$("log-file-per-vhost").disabled = !enabled;
|
|
$("log-file-path").disabled = !enabled || perVhost;
|
|
$("log-file-directory").disabled = !enabled || !perVhost;
|
|
}
|
|
function syncColdCachePolicy() {
|
|
const enabled = $("cache-cold-enabled").checked;
|
|
for (const id of ["cache-cold-path", "cache-cold-bytes", "cache-cold-entries", "cache-cold-queue-bytes", "cache-cold-gc-interval", "cache-cold-gc-max-delete", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown"]) $(id).disabled = !enabled;
|
|
$("cache-cold-after").disabled = !enabled || !$("cache-cold-on-idle").checked;
|
|
$("cache-cold-max-hot").disabled = !enabled || !$("cache-cold-on-age").checked;
|
|
}
|
|
function syncAdvancedCachePolicy() {
|
|
const collapse = $("cache-collapse-enabled").checked;
|
|
$("cache-collapse-timeout").disabled = !collapse;
|
|
$("cache-collapse-fallback").disabled = !collapse;
|
|
const proactive = $("cache-proactive-enabled").checked;
|
|
for (const id of ["cache-proactive-before", "cache-proactive-percent", "cache-proactive-inflight"]) $(id).disabled = !proactive;
|
|
}
|
|
function bindSettingsForm(id, apply, section, selectValue = next => next[section]) {
|
|
const form = $(id); for (const event of ["input", "change"]) form.addEventListener(event, () => setSettingsDirty(id));
|
|
form.addEventListener("submit", async event => {
|
|
event.preventDefault(); if (!envelope || busy) return;
|
|
try {
|
|
await withFormSubmitState(form, event.submitter, async () => {
|
|
const next = structuredClone(envelope.config); apply(next);
|
|
if (await saveSection(section, selectValue(next))) { setSettingsDirty(id, false); renderSettings(); }
|
|
});
|
|
} catch (error) { toast(error.message, true); }
|
|
});
|
|
}
|
|
bindSettingsForm("listeners-form", next => { next.listeners = window.rustProxyIntegrations.collectListeners(); }, "listeners");
|
|
bindSettingsForm("http2-form", next => {
|
|
if (settingsDirty.has("listeners-form")) throw new Error(t("http2ListenersDirty"));
|
|
next.listeners = window.rustProxyIntegrations.collectHttp2Listeners();
|
|
}, "listeners", next => next.listeners);
|
|
bindSettingsForm("http3-form", next => {
|
|
if (settingsDirty.has("listeners-form")) throw new Error(t("http3ListenersDirty"));
|
|
next.listeners = window.rustProxyIntegrations.collectHttp3Listeners();
|
|
}, "listeners", next => next.listeners);
|
|
bindSettingsForm("response-form", next => {
|
|
next.server_header = { mode: $("server-mode").value, value: $("server-value").value };
|
|
next.diagnostic_headers = { request_id: $("expose-request-id").checked };
|
|
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
|
|
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
|
|
bindSettingsForm("cache-form", next => {
|
|
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
|
|
}, "cache");
|
|
bindSettingsForm("transform-form", next => {
|
|
next.transform = {
|
|
enabled: $("transform-enabled").checked,
|
|
preset: selectedTransformPreset,
|
|
javascript: $("transform-javascript").checked,
|
|
css: $("transform-css").checked,
|
|
html: $("transform-html").checked,
|
|
svg: $("transform-svg").checked,
|
|
json: $("transform-json").checked,
|
|
extensions: $("transform-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean),
|
|
max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024),
|
|
memory_budget_bytes: Math.round(Number($("transform-memory-budget").value) * 1048576),
|
|
max_inflight: Number($("transform-max-inflight").value)
|
|
};
|
|
}, "transform");
|
|
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
|
|
const LOGGING_PRESETS = {
|
|
standard: {},
|
|
minimal: { time_ms: "time_ms", method: "method", host: "host", path: "path", status: "status", duration_ms: "duration_ms" },
|
|
nginx: { time_ms: "time_ms", remote_addr: "client_ip", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
|
|
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
|
|
};
|
|
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
|
|
function parseJsonObjectValue(value, target = null) { let parsed; try { parsed = JSON.parse(value || "{}"); } catch { throw fieldError(target, t("loggingJsonInvalid")); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw fieldError(target, t("loggingJsonInvalid")); return parsed; }
|
|
function loggingMappingKey(mapping) { return JSON.stringify(Object.entries(mapping || {}).sort(([a], [b]) => a.localeCompare(b))); }
|
|
function detectLoggingPreset(mapping) { const key = loggingMappingKey(mapping); for (const [name, preset] of Object.entries(LOGGING_PRESETS)) if (loggingMappingKey(preset) === key) return name; return "custom"; }
|
|
function selectedLoggingMapping() {
|
|
const rows = [...$("logging-field-rows").querySelectorAll("tr")], mapping = {};
|
|
for (const row of rows) {
|
|
const enabled = row.querySelector("input[type=checkbox]").checked;
|
|
const source = row.dataset.source;
|
|
const output = row.querySelector("input[type=text]").value.trim();
|
|
if (enabled) {
|
|
if (!output) throw new Error(t("loggingOutputRequired"));
|
|
if (Object.hasOwn(mapping, output)) throw new Error(t("loggingOutputDuplicate"));
|
|
mapping[output] = source;
|
|
}
|
|
}
|
|
if (Object.keys(mapping).length === LOGGING_SOURCE_FIELDS.length && LOGGING_SOURCE_FIELDS.every(name => mapping[name] === name)) return {};
|
|
return mapping;
|
|
}
|
|
function renderLoggingPreview() {
|
|
let mapping;
|
|
try { mapping = selectedLoggingMapping(); } catch (error) { $("logging-format-preview").textContent = error.message; return; }
|
|
let staticFields = {}; try { staticFields = parseJsonObjectValue($("log-format-static").value, $("log-format-static")); } catch { }
|
|
const source = Object.keys(mapping).length ? mapping : Object.fromEntries(LOGGING_SOURCE_FIELDS.map(name => [name, name]));
|
|
const preview = { ...staticFields };
|
|
for (const [output, name] of Object.entries(source)) preview[output] = LOGGING_PREVIEW_VALUES[name];
|
|
$("logging-format-preview").textContent = JSON.stringify(preview, null, 2);
|
|
}
|
|
function openLoggingBuilder() {
|
|
let mapping; try { mapping = parseJsonObjectValue($("log-format-fields").value, $("log-format-fields")); } catch (error) { toast(error.message, true); return; }
|
|
const identity = !Object.keys(mapping).length;
|
|
const bySource = new Map();
|
|
for (const [output, source] of Object.entries(mapping)) if (!bySource.has(source)) bySource.set(source, output);
|
|
const rows = LOGGING_SOURCE_FIELDS.map(source => {
|
|
const row = el("tr"); row.dataset.source = source;
|
|
const checkCell = el("td"), sourceCell = el("td", source), outputCell = el("td");
|
|
const check = document.createElement("input"); check.type = "checkbox"; check.checked = identity || bySource.has(source);
|
|
const output = document.createElement("input"); output.type = "text"; output.maxLength = 128; output.value = bySource.get(source) || source; output.disabled = !check.checked;
|
|
check.addEventListener("change", () => { output.disabled = !check.checked; renderLoggingPreview(); });
|
|
output.addEventListener("input", renderLoggingPreview);
|
|
checkCell.append(check); outputCell.append(output); row.append(checkCell, sourceCell, outputCell); return row;
|
|
});
|
|
$("logging-field-rows").replaceChildren(...rows); renderLoggingPreview();
|
|
const dialog = $("logging-format-dialog"); if (!dialog.open) dialog.showModal();
|
|
}
|
|
function applyLoggingPreset(name) {
|
|
if (name === "custom") return;
|
|
$("log-format-fields").value = JSON.stringify(LOGGING_PRESETS[name], null, 2);
|
|
setSettingsDirty("logging-form");
|
|
}
|
|
bindSettingsForm("logging-form", next => {
|
|
const parseObject = id => parseJsonObjectValue($(id).value, $(id));
|
|
next.logging = {
|
|
file: { enabled: $("log-file-enabled").checked, path: $("log-file-path").value.trim(), per_vhost: $("log-file-per-vhost").checked, directory: $("log-file-directory").value.trim() },
|
|
logstash: { enabled: $("log-logstash-enabled").checked, address: $("log-logstash-address").value.trim() },
|
|
loki: { enabled: $("log-loki-enabled").checked, url: $("log-loki-url").value.trim(), bearer_token_env: $("log-loki-token").value.trim() || null, labels: parseObject("log-loki-labels") },
|
|
format: { fields: parseObject("log-format-fields"), static_fields: parseObject("log-format-static") }
|
|
};
|
|
}, "logging");
|
|
bindSettingsForm("forwarding-form", next => { next.forwarding = { trusted_proxies: $("trusted-proxies").value.split(/[\n,]/).map(s => s.trim()).filter(Boolean), trust_forwarded_proto: $("trusted-proto").checked }; }, "forwarding");
|
|
bindSettingsForm("headers-form", next => { next.headers = { request: collectHeaderRules("request"), response: collectHeaderRules("response") }; }, "headers");
|
|
$("log-format-preset").addEventListener("change", event => applyLoggingPreset(event.target.value));
|
|
$("log-format-open").addEventListener("click", openLoggingBuilder);
|
|
$("logging-format-close").addEventListener("click", () => $("logging-format-dialog").close());
|
|
$("logging-format-cancel").addEventListener("click", () => $("logging-format-dialog").close());
|
|
$("logging-format-apply").addEventListener("click", () => { try { const mapping = selectedLoggingMapping(); $("log-format-fields").value = JSON.stringify(mapping, null, 2); $("log-format-preset").value = detectLoggingPreset(mapping); setSettingsDirty("logging-form"); $("logging-format-dialog").close(); } catch (error) { toast(error.message, true); } });
|
|
$("server-mode").addEventListener("change", syncServerMode);
|
|
$("log-file-enabled").addEventListener("change", syncLoggingFileMode);
|
|
$("log-file-per-vhost").addEventListener("change", syncLoggingFileMode);
|
|
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age"]) $(id).addEventListener("change", syncColdCachePolicy);
|
|
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => button.addEventListener("click", () => applyCacheMemoryPreset(button.dataset.cacheMemoryPreset)));
|
|
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset));
|
|
document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset)));
|
|
for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-entries", "cache-eviction-limit"]) $(id).addEventListener("input", () => { setCacheMemoryPresetSelection(null); updateCacheMemorySizing(); });
|
|
$("cache-cold-queue-bytes").addEventListener("input", () => { setCacheMemoryPresetSelection(null); setCacheColdPresetSelection(null); updateCacheMemorySizing(); });
|
|
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown", "cache-cold-after", "cache-cold-max-hot", "cache-cold-gc-interval", "cache-cold-gc-max-delete"]) {
|
|
for (const event of ["input", "change"]) $(id).addEventListener(event, () => setCacheColdPresetSelection(null));
|
|
}
|
|
for (const id of ["transform-memory-budget", "transform-max-body", "transform-max-inflight"]) $(id).addEventListener("input", () => { setTransformPresetSelection(null); updateTransformResourceSizing(); });
|
|
for (const id of ["cache-collapse-enabled", "cache-proactive-enabled"]) $(id).addEventListener("change", syncAdvancedCachePolicy);
|
|
syncColdCachePolicy();
|
|
syncAdvancedCachePolicy();
|
|
$("purge-cache").addEventListener("click", async () => { if (!(await appConfirm(t("confirmPurge"), { danger: true }))) return; const button = $("purge-cache"); button.disabled = true; try { const result = await api("/api/cache/purge", { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } });
|
|
$("add-response-header").addEventListener("click", () => appendHeaderRule("response"));
|
|
$("add-request-header").addEventListener("click", () => appendHeaderRule("request"));
|
|
window.addEventListener("beforeunload", event => { if (settingsDirty.size) { event.preventDefault(); event.returnValue = ""; } });
|