0.35.4
This commit is contained in:
1 parent
c038ad44b3
commit
cd3c28d390
33 files changed
+807
-221
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.35.3",
|
||||
"version": "0.35.4",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -44,7 +44,7 @@ async function loadLanguages() {
|
||||
$("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
||||
$("language").value = languagePreference === "auto" ? "auto" : language;
|
||||
}
|
||||
let latestStatus = null, certificateInventory = new Map();
|
||||
let latestStatus = null, certificateInventory = new Map(), appHealthUpdatedAt = null;
|
||||
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
||||
function translate() {
|
||||
clearTimeout(toastTimer); hideToast();
|
||||
@@ -179,6 +179,68 @@ function uptime(value) {
|
||||
}
|
||||
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 appHealthChecks(status) {
|
||||
if (!connected || !status) return [{ level: "error", label: t("appHealthApi"), detail: t("appHealthApiError") }];
|
||||
const checks = [{ level: "ok", label: t("appHealthApi"), detail: t("appHealthApiOk") }];
|
||||
if (status.listeners?.restart_required) {
|
||||
checks.push({ level: "warning", label: t("appHealthListeners"), detail: t("appHealthListenersRestart") });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthListeners"), detail: t("appHealthListenersOk") });
|
||||
}
|
||||
|
||||
const pools = Array.isArray(status.backend_pools) ? status.backend_pools : [];
|
||||
const members = pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member })));
|
||||
const down = members.filter(member => member.status === "down");
|
||||
const checking = members.filter(member => member.status === "checking");
|
||||
const unavailablePools = pools.filter(pool => pool.ready === false && (pool.members || []).some(member => ["up", "down", "checking"].includes(member.status))).map(pool => pool.id).filter(Boolean);
|
||||
const affected = [...down, ...checking].slice(0, 5).map(member => `${member.pool}/${member.id}`).join(", ");
|
||||
if (unavailablePools.length) {
|
||||
let detail = t("appHealthBackendsError").replace("{pools}", unavailablePools.slice(0, 5).join(", ") + (unavailablePools.length > 5 ? " …" : ""));
|
||||
if (affected) detail += ` ${t("appHealthBackendsAffected").replace("{members}", affected + (down.length + checking.length > 5 ? " …" : ""))}`;
|
||||
checks.push({ level: "error", label: t("appHealthBackends"), detail });
|
||||
} else if (down.length || checking.length) {
|
||||
checks.push({ level: "warning", label: t("appHealthBackends"), detail: t("appHealthBackendsWarning").replace("{down}", String(down.length)).replace("{checking}", String(checking.length)).replace("{members}", affected + (down.length + checking.length > 5 ? " …" : "")) });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthBackends"), detail: pools.length ? t("appHealthBackendsOk") : t("appHealthBackendsNone") });
|
||||
}
|
||||
|
||||
const sink = status.logging?.sinks || {}, errorFile = status.logging?.error_file || {};
|
||||
const loggingDropped = (Number(sink.queue_dropped) || 0) + (Number(errorFile.queue_dropped) || 0);
|
||||
const loggingErrors = (Number(sink.errors) || 0) + (Number(errorFile.errors) || 0);
|
||||
if (loggingDropped || loggingErrors) {
|
||||
checks.push({ level: "warning", label: t("appHealthLogging"), detail: t("appHealthLoggingWarning").replace("{dropped}", String(loggingDropped)).replace("{errors}", String(loggingErrors)) });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthLogging"), detail: t("appHealthLoggingOk") });
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
function appHealthLevel(checks) {
|
||||
if (!checks.length) return "checking";
|
||||
if (checks.some(check => check.level === "error")) return "error";
|
||||
if (checks.some(check => check.level === "warning")) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
function setAppHealthClass(node, level) {
|
||||
if (!node) return;
|
||||
node.classList.remove("is-checking", "is-ok", "is-warning", "is-error");
|
||||
node.classList.add(`is-${level}`);
|
||||
}
|
||||
function renderAppHealth(status = latestStatus) {
|
||||
const checks = appHealthChecks(status), level = appHealthLevel(checks);
|
||||
const labelKeys = { checking: "appHealthSidebarChecking", ok: "appHealthSidebarOk", warning: "appHealthSidebarWarning", error: "appHealthSidebarError" };
|
||||
const summaryKeys = { checking: "appHealthChecking", ok: "appHealthOk", warning: "appHealthWarning", error: "appHealthError" };
|
||||
const button = $("app-health-open"), label = $("app-health-label"), summaryRow = $("app-health-summary-row"), summary = $("app-health-summary");
|
||||
setAppHealthClass(button, level); setAppHealthClass(summaryRow, level);
|
||||
if (label) { label.dataset.i18n = labelKeys[level]; label.textContent = t(labelKeys[level]); }
|
||||
if (summary) { summary.dataset.i18n = summaryKeys[level]; summary.textContent = t(summaryKeys[level]); }
|
||||
const updated = $("app-health-updated");
|
||||
if (updated) updated.textContent = appHealthUpdatedAt ? t("appHealthUpdated").replace("{time}", appHealthUpdatedAt.toLocaleTimeString(locale())) : t("appHealthNoStatus");
|
||||
const list = $("app-health-checks");
|
||||
if (list) list.replaceChildren(...checks.map(check => {
|
||||
const row = el("div", undefined, `app-health-check is-${check.level}`), dot = el("i"), copy = el("div");
|
||||
dot.setAttribute("aria-hidden", "true"); copy.append(el("strong", check.label), el("p", check.detail, "fine")); row.append(dot, copy); return row;
|
||||
}));
|
||||
}
|
||||
function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
|
||||
const label = input?.closest("label"); if (!label) return () => { };
|
||||
label.classList.add("vh-enable-control", "vh-wide");
|
||||
@@ -257,7 +319,7 @@ function renderStatusNow(s, systemSample = false) {
|
||||
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
|
||||
};
|
||||
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
|
||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); $("app-health-slot").hidden = false; appHealthUpdatedAt = new Date(); renderAppHealth(s); 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;
|
||||
@@ -299,7 +361,7 @@ function renderStatusNow(s, systemSample = false) {
|
||||
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}`));
|
||||
footer.replaceChildren(document.createTextNode(apiLatency));
|
||||
const update = s.software_update;
|
||||
if (update?.update_available && update.latest_version) {
|
||||
footer.append(document.createTextNode(" / "));
|
||||
@@ -421,12 +483,12 @@ function lock(notify = true) {
|
||||
window.rustProxyInlineLayout?.cancel?.();
|
||||
window.rustProxyRefresh?.stop();
|
||||
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; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { 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("");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; $("app-health-slot").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { 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();
|
||||
appHealthUpdatedAt = null; renderAppHealth(null); renderSession(); syncAuthForm();
|
||||
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
||||
}
|
||||
|
||||
@@ -506,6 +568,8 @@ function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||
$("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); } });
|
||||
$("app-health-open")?.addEventListener("click", () => { renderAppHealth(connected ? latestStatus : null); const dialog = $("app-health-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||
$("app-health-close")?.addEventListener("click", () => $("app-health-dialog").close());
|
||||
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||
$("about-close").addEventListener("click", () => $("about-dialog").close());
|
||||
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
|
||||
|
||||
@@ -9,9 +9,9 @@ window.rustProxyInlineLayout = (() => {
|
||||
const hiddenList = $("overview-inline-hidden-list");
|
||||
const hiddenCount = $("overview-inline-hidden-count");
|
||||
const modalButton = $("overview-layout-open");
|
||||
const compactButton = $("overview-inline-compact");
|
||||
let draft = null;
|
||||
let original = null;
|
||||
let dragging = null;
|
||||
let opening = false;
|
||||
let saving = false;
|
||||
|
||||
@@ -72,10 +72,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
if (widen) widen.disabled = at === widths.length - 1;
|
||||
}
|
||||
}
|
||||
function clearDrag() {
|
||||
dragging = null;
|
||||
for (const node of nodes()) node.classList.remove("is-overview-dragging", "is-overview-drop-before", "is-overview-drop-after");
|
||||
}
|
||||
function clearDrag() { if (pointerDrag) settle(false); }
|
||||
function move(id, target, after = false) {
|
||||
if (!draft || id === target) return;
|
||||
if (!visibleIds().includes(id) || !visibleIds().includes(target)) return;
|
||||
@@ -118,7 +115,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
const title = label(id);
|
||||
const bar = el("div", undefined, "overview-inline-toolbar");
|
||||
const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { });
|
||||
grip.draggable = true;
|
||||
grip.setAttribute("aria-describedby", "overview-inline-hint");
|
||||
grip.dataset.overviewDragHandle = id;
|
||||
const sizeControls = el("span", undefined, "overview-inline-width");
|
||||
const widthValue = el("span", "", "overview-inline-width-value");
|
||||
@@ -142,6 +139,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
actions.hidden = false;
|
||||
hint.hidden = false;
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint");
|
||||
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
|
||||
if (modalButton) modalButton.disabled = true;
|
||||
for (const node of nodes()) toolbar(node);
|
||||
@@ -195,40 +193,185 @@ window.rustProxyInlineLayout = (() => {
|
||||
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
|
||||
}
|
||||
function cancel() { if (draft && !saving) finish(false); }
|
||||
function dropTarget(event) {
|
||||
const widget = event.target?.closest?.("[data-dashboard-widget]");
|
||||
return widget && grid.contains(widget) && !widget.hidden && visibleIds().includes(widget.dataset.dashboardWidget) ? widget : null;
|
||||
// Pointer-driven sorting with a live placeholder. The grid never back-fills
|
||||
// holes while a block is lifted, so the placeholder shows exactly where the
|
||||
// block lands; neighbours glide (FLIP) instead of jumping.
|
||||
const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90;
|
||||
const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
let pointerDrag = null;
|
||||
function flip(mutate) {
|
||||
const before = new Map(nodes().filter(node => !node.hidden && node !== pointerDrag?.node)
|
||||
.map(node => [node, node.getBoundingClientRect()]));
|
||||
mutate();
|
||||
if (reducedMotion()) return;
|
||||
for (const [node, rect] of before) {
|
||||
const after = node.getBoundingClientRect();
|
||||
const dx = rect.left - after.left, dy = rect.top - after.top;
|
||||
if (!dx && !dy) continue;
|
||||
node.animate?.([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
|
||||
{ duration: 160, easing: "cubic-bezier(.2, .7, .3, 1)" });
|
||||
}
|
||||
}
|
||||
function positionAfter(widget, event) {
|
||||
const rect = widget.getBoundingClientRect();
|
||||
return event.clientX >= rect.left + rect.width / 2;
|
||||
function placeholderTarget(x, y) {
|
||||
const drag = pointerDrag;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const widget = hit?.closest?.("[data-dashboard-widget]");
|
||||
if (widget && grid.contains(widget) && widget !== drag.node && !widget.hidden) return { node: widget };
|
||||
if (hit === drag.placeholder || drag.placeholder.contains(hit)) return null;
|
||||
if (hit !== grid) return null;
|
||||
// Empty grid area (gap or hole at a row end): attach to the nearest block in that row.
|
||||
const candidates = nodes().filter(node => node !== drag.node && !node.hidden);
|
||||
let best = null, bestDistance = Infinity;
|
||||
for (const node of candidates) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (y < rect.top - 8 || y > rect.bottom + 8) continue;
|
||||
const distance = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
|
||||
if (distance < bestDistance) { bestDistance = distance; best = { node, after: x > rect.left + rect.width / 2 }; }
|
||||
}
|
||||
if (best) return best;
|
||||
const last = candidates.at(-1);
|
||||
if (last && y > last.getBoundingClientRect().bottom) return { node: last, after: true };
|
||||
return null;
|
||||
}
|
||||
grid?.addEventListener("dragstart", event => {
|
||||
function updatePlaceholder(x, y) {
|
||||
const drag = pointerDrag;
|
||||
if (performance.now() < drag.cooldownUntil) return;
|
||||
const target = placeholderTarget(x, y);
|
||||
if (!target) return;
|
||||
const children = [...grid.children];
|
||||
const placeholderIndex = children.indexOf(drag.placeholder), targetIndex = children.indexOf(target.node);
|
||||
// Entering a block swaps the placeholder past it; this is stable and never oscillates.
|
||||
const after = target.after ?? placeholderIndex < targetIndex;
|
||||
const reference = after ? target.node.nextSibling : target.node;
|
||||
if (reference === drag.placeholder || (after ? target.node.nextSibling === drag.placeholder : target.node.previousSibling === drag.placeholder)) return;
|
||||
flip(() => grid.insertBefore(drag.placeholder, reference));
|
||||
drag.cooldownUntil = performance.now() + MOVE_COOLDOWN_MS;
|
||||
}
|
||||
function autoScroll() {
|
||||
const drag = pointerDrag;
|
||||
if (!drag?.active) return;
|
||||
const height = window.innerHeight;
|
||||
let speed = 0;
|
||||
if (drag.y < EDGE) speed = -Math.ceil((EDGE - drag.y) / 4);
|
||||
else if (drag.y > height - EDGE) speed = Math.ceil((drag.y - (height - EDGE)) / 4);
|
||||
if (speed) {
|
||||
const scroller = document.scrollingElement || document.documentElement;
|
||||
const previous = scroller.scrollTop;
|
||||
scroller.scrollTop += speed;
|
||||
if (scroller.scrollTop !== previous) updatePlaceholder(drag.x, drag.y);
|
||||
}
|
||||
drag.frame = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
function liftBlock() {
|
||||
const drag = pointerDrag;
|
||||
enterFreeLayout();
|
||||
paint();
|
||||
const rect = drag.node.getBoundingClientRect();
|
||||
const placeholder = el("div", undefined, "overview-drop-placeholder");
|
||||
placeholder.dataset.dashboardSpan = drag.node.dataset.dashboardSpan || "2";
|
||||
placeholder.style.minHeight = `${Math.round(rect.height)}px`;
|
||||
placeholder.setAttribute("aria-hidden", "true");
|
||||
drag.origin = drag.node.nextSibling;
|
||||
drag.placeholder = placeholder;
|
||||
drag.offsetX = drag.startX - rect.left;
|
||||
drag.offsetY = drag.startY - rect.top;
|
||||
drag.width = rect.width;
|
||||
drag.height = rect.height;
|
||||
drag.active = true;
|
||||
drag.cooldownUntil = 0;
|
||||
grid.insertBefore(placeholder, drag.node);
|
||||
grid.classList.add("is-overview-sorting");
|
||||
drag.node.classList.add("is-overview-lifted");
|
||||
Object.assign(drag.node.style, {
|
||||
position: "fixed", left: "0px", top: "0px", width: `${rect.width}px`, height: `${rect.height}px`,
|
||||
zIndex: "60", pointerEvents: "none", margin: "0",
|
||||
});
|
||||
moveLifted();
|
||||
hint.textContent = t("overviewLayoutInlineDragging");
|
||||
drag.frame = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
function moveLifted() {
|
||||
const drag = pointerDrag;
|
||||
drag.node.style.transform = `translate(${drag.x - drag.offsetX}px, ${drag.y - drag.offsetY}px) rotate(.6deg) scale(1.015)`;
|
||||
}
|
||||
function settle(commit) {
|
||||
const drag = pointerDrag;
|
||||
pointerDrag = null;
|
||||
if (!drag) return;
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
window.removeEventListener("pointercancel", onPointerCancel);
|
||||
window.removeEventListener("keydown", onDragKey, true);
|
||||
if (!drag.active) return;
|
||||
cancelAnimationFrame(drag.frame);
|
||||
for (const key of ["position", "left", "top", "width", "height", "zIndex", "pointerEvents", "margin", "transform"]) drag.node.style[key] = "";
|
||||
drag.node.classList.remove("is-overview-lifted");
|
||||
grid.classList.remove("is-overview-sorting");
|
||||
if (commit) drag.placeholder.replaceWith(drag.node);
|
||||
else { drag.placeholder.remove(); grid.insertBefore(drag.node, drag.origin?.parentNode === grid ? drag.origin : null); }
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
if (commit) {
|
||||
const domOrder = nodes().map(node => node.dataset.dashboardWidget).filter(id => !draft.hidden.has(id));
|
||||
const iter = domOrder.values();
|
||||
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||
}
|
||||
paint();
|
||||
drag.node.querySelector(".overview-inline-grip")?.focus({ preventScroll: true });
|
||||
drag.node.animate?.([{ boxShadow: "0 0 0 3px var(--accent)" }, { boxShadow: "0 0 0 0 transparent" }], { duration: 450 });
|
||||
}
|
||||
function onPointerMove(event) {
|
||||
const drag = pointerDrag;
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
drag.x = event.clientX; drag.y = event.clientY;
|
||||
if (!drag.active) {
|
||||
if (Math.hypot(drag.x - drag.startX, drag.y - drag.startY) < DRAG_THRESHOLD) return;
|
||||
liftBlock();
|
||||
}
|
||||
event.preventDefault();
|
||||
moveLifted();
|
||||
updatePlaceholder(drag.x, drag.y);
|
||||
}
|
||||
function onPointerUp(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(true); }
|
||||
function onPointerCancel(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(false); }
|
||||
function onDragKey(event) { if (event.key === "Escape" && pointerDrag) { event.preventDefault(); event.stopPropagation(); settle(false); } }
|
||||
grid?.addEventListener("pointerdown", event => {
|
||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||
if (!draft || !handle || !grid.contains(handle)) { event.preventDefault(); return; }
|
||||
dragging = handle.dataset.overviewDragHandle;
|
||||
event.dataTransfer?.setData("text/plain", dragging);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
nodeFor(dragging)?.classList.add("is-overview-dragging");
|
||||
});
|
||||
grid?.addEventListener("dragover", event => {
|
||||
if (!draft || !dragging) return;
|
||||
const target = dropTarget(event);
|
||||
if (!target || target.dataset.dashboardWidget === dragging) return;
|
||||
if (!draft || saving || pointerDrag || !handle || !grid.contains(handle) || event.button !== 0) return;
|
||||
const node = nodeFor(handle.dataset.overviewDragHandle);
|
||||
if (!node) return;
|
||||
event.preventDefault();
|
||||
for (const node of nodes()) node.classList.remove("is-overview-drop-before", "is-overview-drop-after");
|
||||
target.classList.add(positionAfter(target, event) ? "is-overview-drop-after" : "is-overview-drop-before");
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
pointerDrag = { node, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, active: false };
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointercancel", onPointerCancel);
|
||||
window.addEventListener("keydown", onDragKey, true);
|
||||
});
|
||||
grid?.addEventListener("drop", event => {
|
||||
if (!draft || !dragging) return;
|
||||
grid?.addEventListener("keydown", event => {
|
||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||
if (!draft || !handle) return;
|
||||
const delta = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key];
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
const target = dropTarget(event), from = dragging;
|
||||
clearDrag();
|
||||
if (target && target.dataset.dashboardWidget !== from)
|
||||
move(from, target.dataset.dashboardWidget, positionAfter(target, event));
|
||||
nudge(handle.dataset.overviewDragHandle, delta);
|
||||
nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus();
|
||||
});
|
||||
grid?.addEventListener("dragend", clearDrag);
|
||||
function compact() {
|
||||
// Explicit, one-off gap filling: reads the dense packing the browser would
|
||||
// produce and adopts it as the new order, so nothing moves unexpectedly later.
|
||||
if (!draft) return;
|
||||
enterFreeLayout();
|
||||
paint();
|
||||
flip(() => {
|
||||
grid.style.gridAutoFlow = "row dense";
|
||||
const placed = nodes().filter(node => !node.hidden).map(node => ({ id: node.dataset.dashboardWidget, rect: node.getBoundingClientRect() }));
|
||||
grid.style.gridAutoFlow = "";
|
||||
placed.sort((a, b) => Math.round(a.rect.top) - Math.round(b.rect.top) || a.rect.left - b.rect.left);
|
||||
const iter = placed.map(item => item.id).values();
|
||||
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||
paint();
|
||||
});
|
||||
}
|
||||
compactButton?.addEventListener("click", compact);
|
||||
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
|
||||
saveButton?.addEventListener("click", () => { void save(); });
|
||||
cancelButton?.addEventListener("click", cancel);
|
||||
|
||||
@@ -2,64 +2,40 @@ window.rustProxyOverviewSamples = (() => {
|
||||
const allowed = new Set([60, 120, 300, 600]);
|
||||
const storageKey = "rustproxy.overview.liveChartSamples";
|
||||
const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60;
|
||||
let saved = allowed.has(factory) ? factory : 60;
|
||||
let limit = allowed.has(factory) ? factory : 60;
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(storageKey));
|
||||
if (allowed.has(stored)) saved = stored;
|
||||
} catch { /* Private browsing or storage policy: temporary selection still works. */ }
|
||||
let limit = saved;
|
||||
if (allowed.has(stored)) limit = stored;
|
||||
} catch { /* Private browsing or storage policy: selection still works for this visit. */ }
|
||||
const callbacks = new Set();
|
||||
let confirmationTimer = null;
|
||||
const getLimit = () => limit;
|
||||
const trim = array => {
|
||||
if (Array.isArray(array) && array.length > limit) array.splice(0, array.length - limit);
|
||||
return array;
|
||||
};
|
||||
// The status is shown only for a temporary choice, a storage problem or briefly after saving.
|
||||
function sync(messageKey = limit === saved ? null : "overviewSamplesTemporary", confirmation = false) {
|
||||
function sync() {
|
||||
const select = document.getElementById("overview-live-samples");
|
||||
if (select) select.value = String(limit);
|
||||
const state = document.getElementById("overview-samples-state");
|
||||
if (state) {
|
||||
clearTimeout(confirmationTimer);
|
||||
if (messageKey) {
|
||||
state.dataset.i18n = messageKey;
|
||||
state.textContent = t(messageKey);
|
||||
state.style.visibility = "visible";
|
||||
} else {
|
||||
state.style.visibility = "hidden";
|
||||
}
|
||||
if (confirmation) confirmationTimer = setTimeout(() => { state.style.visibility = "hidden"; }, 2500);
|
||||
}
|
||||
const save = document.getElementById("overview-samples-save");
|
||||
if (save) save.disabled = limit === saved;
|
||||
}
|
||||
function setLimit(value, persist = false) {
|
||||
const count = Number(value);
|
||||
if (!allowed.has(count)) return false;
|
||||
const changed = count !== limit;
|
||||
limit = count;
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(count));
|
||||
saved = count;
|
||||
} catch {
|
||||
const changed = count !== limit;
|
||||
limit = count;
|
||||
if (changed) for (const callback of callbacks) callback(limit);
|
||||
sync("overviewSamplesStorageUnavailable");
|
||||
return false;
|
||||
if (typeof toast === "function") toast(t("overviewSamplesStorageUnavailable"), true);
|
||||
}
|
||||
}
|
||||
const changed = count !== limit;
|
||||
limit = count;
|
||||
if (changed) for (const callback of callbacks) callback(limit);
|
||||
if (persist) sync("overviewSamplesSaved", true); else sync();
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
function init() {
|
||||
const select = document.getElementById("overview-live-samples");
|
||||
const save = document.getElementById("overview-samples-save");
|
||||
select?.addEventListener("change", () => setLimit(select.value));
|
||||
save?.addEventListener("click", () => setLimit(select.value, true));
|
||||
select?.addEventListener("change", () => setLimit(select.value, true));
|
||||
sync();
|
||||
}
|
||||
if (document.readyState !== "complete") document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
|
||||
@@ -167,7 +167,7 @@ const rustProxyRefresh = (() => {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status?view=live", { signal });
|
||||
if (isCurrent()) renderLiveStatus(value);
|
||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }
|
||||
}, onError: error => { if (error?.status !== 401) { connected = false; $("connection-label").textContent = t("disconnected"); $("signal").classList.add("status-error"); $("app-health-slot").hidden = false; renderAppHealth(null); } }
|
||||
});
|
||||
register("logs-fallback", {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ function syncCertificateFileSource() {
|
||||
for (const input of [cert, key]) {
|
||||
input.maxLength = external ? 4096 : 96;
|
||||
if (external) input.removeAttribute("pattern");
|
||||
else input.pattern = "[A-Za-z0-9_.-]+";
|
||||
else input.pattern = "[A-Za-z0-9_.\\-]+";
|
||||
}
|
||||
cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem";
|
||||
key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key";
|
||||
|
||||
@@ -175,6 +175,37 @@ nav {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-health-button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-health-button i {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.app-health-button.is-checking,
|
||||
.app-health-summary.is-checking { color: var(--muted); }
|
||||
.app-health-button.is-ok,
|
||||
.app-health-summary.is-ok,
|
||||
.app-health-check.is-ok { color: var(--green); }
|
||||
.app-health-button.is-warning,
|
||||
.app-health-summary.is-warning,
|
||||
.app-health-check.is-warning { color: var(--notice-text); }
|
||||
.app-health-button.is-error,
|
||||
.app-health-summary.is-error,
|
||||
.app-health-check.is-error { color: var(--red); }
|
||||
|
||||
.app-health-button:hover { opacity: .82; }
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -216,11 +247,71 @@ nav {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.sidebar-bottom p {
|
||||
font-size: 11px;
|
||||
line-height: 1.9;
|
||||
color: var(--text-subtle);
|
||||
margin: 13px 0 26px;
|
||||
.app-health-dialog {
|
||||
width: min(620px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.app-health-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.app-health-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-health-summary>i {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.app-health-summary strong {
|
||||
color: currentColor;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.app-health-summary .fine {
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.app-health-checks {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.app-health-check {
|
||||
display: grid;
|
||||
grid-template-columns: 9px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--neutral-soft);
|
||||
}
|
||||
|
||||
.app-health-check>i {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.app-health-check strong {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-health-check .fine {
|
||||
margin: 2px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
main {
|
||||
|
||||
@@ -15,6 +15,18 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
margin-bottom: var(--ui-gap);
|
||||
}
|
||||
|
||||
/* Summary tiles outside the Overview dashboard (Cache, Traffic, Transform) fill
|
||||
every row instead of leaving empty grid columns after the last tile. */
|
||||
.stats-grid.cache-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stats-grid.cache-stats>.stat {
|
||||
flex: 1 1 190px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
@@ -549,6 +561,25 @@ body[data-density=compact] .pool-card .table-scroll {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Keep the block title clear of the corner button whatever padding the grid applies. */
|
||||
#panel-overview .stat-with-action>span:first-child {
|
||||
display: block;
|
||||
padding-right: 104px;
|
||||
}
|
||||
|
||||
#panel-overview .stat-with-action .stat-corner-action {
|
||||
max-width: 96px;
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked .stat-with-action .stat-corner-action {
|
||||
top: 44px;
|
||||
}
|
||||
|
||||
.stat-chart strong {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
@@ -1154,44 +1185,83 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
position: relative;
|
||||
outline: 1px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
padding-top: 101px;
|
||||
padding-top: 40px !important;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-dragging {
|
||||
opacity: .45;
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:hover,
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:focus-within {
|
||||
outline-style: solid;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-before {
|
||||
box-shadow: inset 4px 0 var(--accent);
|
||||
/* While sorting, the grid keeps strict DOM order: the placeholder is exactly where the block lands. */
|
||||
#panel-overview .dashboard-overview-grid.is-overview-sorting {
|
||||
grid-auto-flow: row !important;
|
||||
user-select: none;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-after {
|
||||
box-shadow: inset -4px 0 var(--accent);
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder {
|
||||
grid-column: auto/span 2;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: var(--rp-radius-surface, 9px);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] { grid-column: auto/span 3; }
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] { grid-column: auto/span 4; }
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] { grid-column: auto/span 6; }
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] { grid-column: 1/-1; }
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.is-overview-lifted,
|
||||
#panel-overview .dashboard-overview-grid>.is-overview-lifted * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.is-overview-lifted {
|
||||
box-shadow: 0 18px 40px rgb(0 0 0 / .28), 0 0 0 2px var(--accent);
|
||||
opacity: .94;
|
||||
transition: none;
|
||||
will-change: transform;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overview-inline-toolbar {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
gap: 3px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#panel-overview .overview-inline-toolbar button {
|
||||
min-width: 28px;
|
||||
min-height: 27px;
|
||||
padding: 2px 7px;
|
||||
min-width: 26px;
|
||||
min-height: 26px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Narrow blocks keep only the essentials; arrow keys on the grip still move them. */
|
||||
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down),
|
||||
#panel-overview .dashboard-overview-grid>[data-dashboard-span="3"] .overview-inline-toolbar :is(.overview-inline-up, .overview-inline-down) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar .overview-inline-hide {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-grip {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-grip:active {
|
||||
@@ -1690,26 +1760,13 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
margin: 0 0 0 auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.page-intro .overview-samples-toolbar .function-live-chart-samples select {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.page-intro .overview-samples-toolbar button {
|
||||
width: auto;
|
||||
flex: none;
|
||||
min-height: 28px;
|
||||
padding: 5px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-intro .overview-samples-toolbar .fine {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#panel-overview .stat-donut .overview-donut-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1821,8 +1878,10 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
margin: 4px 10px 8px;
|
||||
}
|
||||
|
||||
/* Custom layouts follow the saved order exactly (no implicit back-filling of gaps);
|
||||
"Fill gaps" in the layout editor compacts explicitly. */
|
||||
#panel-overview .dashboard-overview-grid.is-aligned[data-overview-layout-mode=custom] {
|
||||
grid-auto-flow: row dense;
|
||||
grid-auto-flow: row;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="2"] {
|
||||
|
||||
@@ -286,6 +286,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile-nav-open .sidebar-bottom {
|
||||
grid-column: 1/-1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 46px;
|
||||
}
|
||||
@@ -702,8 +710,8 @@
|
||||
}
|
||||
|
||||
.page-intro .overview-samples-toolbar {
|
||||
margin-left: 0;
|
||||
justify-content: flex-start;
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1457,6 +1465,10 @@
|
||||
}
|
||||
|
||||
@media (max-width:550px) {
|
||||
.stats-grid.cache-stats>.stat {
|
||||
flex-basis: 140px;
|
||||
}
|
||||
|
||||
.overview-layout-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -1474,7 +1486,19 @@
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] {
|
||||
padding-top: 135px;
|
||||
padding-top: 40px !important;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder:is([data-dashboard-span="2"], [data-dashboard-span="3"]) {
|
||||
grid-column: auto/span 1;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder:is([data-dashboard-span="4"], [data-dashboard-span="6"], [data-dashboard-span="12"]) {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1623,6 +1647,10 @@
|
||||
padding-right: 11px;
|
||||
}
|
||||
|
||||
#panel-overview .stat-with-action>span:first-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-aligned .stat-with-action .stat-corner-action {
|
||||
position: static;
|
||||
display: block;
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled")));
|
||||
const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim());
|
||||
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions);
|
||||
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_-]{1,64}"; if (agent) id.readOnly = true;
|
||||
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_\\-]{1,64}"; if (agent) id.readOnly = true;
|
||||
const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true;
|
||||
const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876";
|
||||
const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password";
|
||||
|
||||
+37
-20
@@ -58,9 +58,11 @@
|
||||
data-i18n="transform">Transform</b></button>
|
||||
<button class="nav" data-panel="settings"><span>13</span><b data-i18n="settings">Settings</b></button>
|
||||
</nav>
|
||||
<div class="sidebar-bottom"><span class="small-label" data-i18n="runtime">RUNTIME</span>
|
||||
<p>Rust / Tokio / Hyper<br />Axum / Tower / SQLite</p>
|
||||
<div class="local-tag"><i></i><span data-i18n="controlPlane">CONTROL PLANE</span></div>
|
||||
<div class="sidebar-bottom" id="app-health-slot" hidden>
|
||||
<button class="local-tag app-health-button is-checking" id="app-health-open" type="button"
|
||||
aria-haspopup="dialog" aria-controls="app-health-dialog">
|
||||
<i aria-hidden="true"></i><span id="app-health-label" data-i18n="appHealthSidebarChecking">CHECKING</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main>
|
||||
@@ -97,7 +99,7 @@
|
||||
</div>
|
||||
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
|
||||
autocomplete="username" id="username" maxlength="64" minlength="3"
|
||||
pattern="[A-Za-z0-9_.-]{3,64}" required="" spellcheck="false" />
|
||||
pattern="[A-Za-z0-9_.\-]{3,64}" required="" spellcheck="false" />
|
||||
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
|
||||
id="password" maxlength="128" required="" type="password" />
|
||||
<div hidden="" id="confirm-field"><label data-i18n="confirmPassword" for="password-confirm">Confirm
|
||||
@@ -121,17 +123,13 @@
|
||||
aria-label="Overview chart samples" data-i18n-aria="overviewSamplesTitle">
|
||||
<label class="function-live-chart-samples" for="overview-live-samples"><span
|
||||
data-i18n="liveChartSamples">Samples</span>
|
||||
<select id="overview-live-samples" aria-describedby="overview-samples-state">
|
||||
<select id="overview-live-samples">
|
||||
<option value="60">60</option>
|
||||
<option value="120">120</option>
|
||||
<option value="300">300</option>
|
||||
<option value="600">600</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="quiet" id="overview-samples-save" type="button"
|
||||
data-i18n="overviewSamplesRemember">Remember choice</button>
|
||||
<small class="fine" id="overview-samples-state" role="status" aria-live="polite"
|
||||
data-i18n="overviewSamplesSaved">Saved default</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-intro-title-row">
|
||||
@@ -145,6 +143,9 @@
|
||||
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
|
||||
<button class="primary" id="overview-inline-save" type="button"
|
||||
data-i18n="overviewLayoutInlineSave">Save and lock</button>
|
||||
<button class="quiet" id="overview-inline-compact" type="button"
|
||||
data-i18n="overviewLayoutInlineCompact"
|
||||
title="Move blocks into empty grid space once">Fill gaps</button>
|
||||
<button class="quiet" id="overview-inline-cancel" type="button"
|
||||
data-i18n="overviewLayoutInlineCancel">Cancel changes</button>
|
||||
</div>
|
||||
@@ -771,7 +772,7 @@
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label><span data-i18n="aclProfileId">Profile ID</span><input id="acl-profile-id"
|
||||
maxlength="64" pattern="[A-Za-z0-9_-]{1,64}" required /></label>
|
||||
maxlength="64" pattern="[A-Za-z0-9_\-]{1,64}" required /></label>
|
||||
<label><span data-i18n="aclProfileName">Display name</span><input id="acl-profile-name"
|
||||
maxlength="96" required /></label>
|
||||
</div>
|
||||
@@ -982,7 +983,7 @@
|
||||
<div class="vh-grid">
|
||||
<label><span data-i18n="certificateId">Certificate ID</span><input
|
||||
autocomplete="off" id="acme-name" maxlength="64"
|
||||
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
|
||||
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||
<label><span data-i18n="acmeEmail">ACME contact email</span><input
|
||||
autocomplete="off" id="acme-email" maxlength="254" required=""
|
||||
type="email" /></label>
|
||||
@@ -1063,7 +1064,7 @@
|
||||
<h3 data-i18n="certImport"></h3>
|
||||
<p class="fine" data-i18n="certLibraryHint"></p>
|
||||
<label data-i18n="certificateId" for="cert-import-name"></label><input
|
||||
id="cert-import-name" maxlength="64" pattern="[A-Za-z0-9_-]{1,64}"
|
||||
id="cert-import-name" maxlength="64" pattern="[A-Za-z0-9_\-]{1,64}"
|
||||
required="" />
|
||||
<label data-i18n="certCheckDomains" for="cert-import-domains"></label><input
|
||||
id="cert-import-domains" placeholder="app.example.com" required="" />
|
||||
@@ -1081,7 +1082,7 @@
|
||||
<form class="edit-form" id="certificate-form">
|
||||
<h3 data-i18n="addCertificate"></h3>
|
||||
<label data-i18n="certificateId" for="certificate-id"></label><input
|
||||
id="certificate-id" maxlength="64" pattern="[A-Za-z0-9_.-]+"
|
||||
id="certificate-id" maxlength="64" pattern="[A-Za-z0-9_.\-]+"
|
||||
placeholder="app" required="" />
|
||||
<label data-i18n="certLegacyNames" for="certificate-hosts"></label><input
|
||||
id="certificate-hosts" placeholder="app.localhost" />
|
||||
@@ -1094,10 +1095,10 @@
|
||||
<p class="fine" id="certificate-file-source-help"
|
||||
data-i18n="certificateSourceManagedHelp"></p>
|
||||
<label data-i18n="certificateFile" for="certificate-file"></label><input
|
||||
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
|
||||
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.\-]+"
|
||||
placeholder="app.pem" required="" />
|
||||
<label data-i18n="privateKeyFile" for="certificate-key"></label><input
|
||||
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.-]+"
|
||||
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.\-]+"
|
||||
placeholder="app.key" required="" />
|
||||
<div hidden id="certificate-external-acl-example"></div>
|
||||
<div class="form-actions"><button type="button" class="quiet"
|
||||
@@ -3298,8 +3299,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>__RUSTPROXY_VERSION__</b></span><span
|
||||
id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
id="footer-status">API -</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -3668,7 +3668,7 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
<label><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64"
|
||||
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
|
||||
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96"
|
||||
required="" /></label>
|
||||
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
|
||||
@@ -4158,7 +4158,7 @@ log_errors = On</code></pre>
|
||||
<legend data-i18n="hostIdentitySection">Host and domains</legend>
|
||||
<div class="vh-grid">
|
||||
<label><span data-i18n="redirectId">Redirect ID</span><input id="redirect-id" maxlength="64"
|
||||
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
|
||||
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||
<label><span data-i18n="redirectName">Display name</span><input id="redirect-name"
|
||||
maxlength="96" /></label>
|
||||
<label class="check"><input id="redirect-enabled" type="checkbox" /><span
|
||||
@@ -4216,7 +4216,7 @@ log_errors = On</code></pre>
|
||||
<legend data-i18n="hostIdentitySection">Host and domains</legend>
|
||||
<div class="vh-grid">
|
||||
<label><span data-i18n="notfoundId">Host ID</span><input id="notfound-id" maxlength="64"
|
||||
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
|
||||
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||
<label><span data-i18n="notfoundName">Display name</span><input id="notfound-name"
|
||||
maxlength="96" /></label>
|
||||
<label class="check"><input id="notfound-enabled" type="checkbox" /><span
|
||||
@@ -4290,6 +4290,23 @@ log_errors = On</code></pre>
|
||||
<p class="fine" data-i18n="streamRestartHelp">Changing a stream listener requires a RustProxy restart.</p>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="app-health-title" class="vhost-dialog app-health-dialog" id="app-health-dialog">
|
||||
<div class="window-heading">
|
||||
<div><span class="small-label" data-i18n="appHealthSelfCheck">SELF-CHECK</span>
|
||||
<h2 data-i18n="appHealthTitle" id="app-health-title">Application status</h2>
|
||||
</div>
|
||||
<button class="outline" data-i18n="close" id="app-health-close" type="button">Close</button>
|
||||
</div>
|
||||
<article class="card app-health-card">
|
||||
<div class="app-health-summary" id="app-health-summary-row">
|
||||
<i aria-hidden="true"></i>
|
||||
<div><strong id="app-health-summary" data-i18n="appHealthChecking">Checking application status</strong>
|
||||
<p class="fine" id="app-health-updated"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-health-checks" id="app-health-checks"></div>
|
||||
</article>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="about-title" class="vhost-dialog about-dialog" id="about-dialog">
|
||||
<div class="window-heading">
|
||||
<h2 data-i18n="aboutTitle" id="about-title">About RustProxy</h2>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
type="password" />
|
||||
</div>
|
||||
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
|
||||
autocomplete="username" id="username" maxlength="64" minlength="3" pattern="[A-Za-z0-9_.-]{3,64}"
|
||||
autocomplete="username" id="username" maxlength="64" minlength="3" pattern="[A-Za-z0-9_.\-]{3,64}"
|
||||
required="" spellcheck="false" />
|
||||
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
|
||||
id="password" maxlength="128" required="" type="password" />
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
const input = (labelKey, name, type = "text") => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("input"); node.type = type; node.dataset.field = name; label.append(node); grid.append(label); return node; };
|
||||
const select = (labelKey, name, values) => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("select"); node.dataset.field = name; for (const [v, k] of values) node.append(option(v, k)); label.append(node); grid.append(label); return node; };
|
||||
const check = (labelKey, name) => { const label = el("label", undefined, "check"); const node = el("input"); node.type = "checkbox"; node.dataset.field = name; label.append(node, el("span", t(labelKey))); grid.append(label); return node; };
|
||||
const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_-]{1,64}"; id.value = data.id;
|
||||
const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_\\-]{1,64}"; id.value = data.id;
|
||||
const enabled = check("enabled", "enabled"); enabled.checked = data.enabled !== false;
|
||||
const sourceScheme = select("redirectSourceScheme", "source_scheme", [["any", "redirectSchemeAny"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); sourceScheme.value = data.source_scheme;
|
||||
const matchType = select("redirectMatchType", "match_type", [["prefix", "redirectMatchPrefix"], ["exact", "redirectMatchExact"]]); matchType.value = data.match_type;
|
||||
|
||||
Vendored
+11
-2
@@ -126,10 +126,19 @@ impl Cache {
|
||||
.uncacheable_until
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
|
||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY && !memo.contains_key(&ticket.key) {
|
||||
memo.retain(|_, until| *until > now);
|
||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
|
||||
return;
|
||||
// Keep hit-for-miss working for new keys under sustained churn:
|
||||
// drop the marker closest to expiry instead of silently
|
||||
// re-enabling request collapsing for this (uncacheable) key.
|
||||
if let Some(oldest) = memo
|
||||
.iter()
|
||||
.min_by_key(|(_, until)| **until)
|
||||
.map(|(key, _)| *key)
|
||||
{
|
||||
memo.remove(&oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
memo.insert(ticket.key, now + UNCACHEABLE_MEMO_TTL);
|
||||
|
||||
Vendored
+1
@@ -106,6 +106,7 @@ impl Cache {
|
||||
cache.demote_idle();
|
||||
cache.sweep_cold().await;
|
||||
cache.gc_banned(256);
|
||||
flush_pending_allocator_release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+21
-6
@@ -43,7 +43,18 @@ impl Cache {
|
||||
ticket.freshness_headers(),
|
||||
);
|
||||
if state.servable() {
|
||||
let sequence = store.next_sequence();
|
||||
// Approximate LRU: an entry already among the most recently used
|
||||
// eighth of the store is not re-linked on every hit. This keeps the
|
||||
// global HOT lock short for popular objects without changing which
|
||||
// cold entries are evicted first.
|
||||
let promote = store
|
||||
.entries
|
||||
.get(&storage_key)
|
||||
.is_some_and(|entry| {
|
||||
store.sequence.saturating_sub(entry.sequence)
|
||||
> (store.entries.len() as u64) / 8
|
||||
});
|
||||
let sequence = if promote { store.next_sequence() } else { 0 };
|
||||
let (
|
||||
old_sequence,
|
||||
headers,
|
||||
@@ -59,7 +70,9 @@ impl Cache {
|
||||
let old_sequence = entry.sequence;
|
||||
entry.hits = entry.hits.saturating_add(1);
|
||||
entry.last_access = Instant::now();
|
||||
entry.sequence = sequence;
|
||||
if promote {
|
||||
entry.sequence = sequence;
|
||||
}
|
||||
(
|
||||
old_sequence,
|
||||
Arc::clone(&entry.headers),
|
||||
@@ -72,8 +85,10 @@ impl Cache {
|
||||
entry.body.len(),
|
||||
)
|
||||
};
|
||||
store.lru.remove(&old_sequence);
|
||||
store.lru.insert(sequence, storage_key);
|
||||
if promote {
|
||||
store.lru.remove(&old_sequence);
|
||||
store.lru.insert(sequence, storage_key);
|
||||
}
|
||||
self.counters.hits.fetch_add(1, Ordering::Relaxed);
|
||||
drop(store);
|
||||
|
||||
@@ -88,7 +103,7 @@ impl Cache {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
}
|
||||
response.headers_mut().insert(::http::header::AGE,
|
||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
||||
HeaderValue::from(age),
|
||||
);
|
||||
self.domain_stats
|
||||
.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
|
||||
@@ -285,7 +300,7 @@ impl Cache {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
}
|
||||
response.headers_mut().insert(::http::header::AGE,
|
||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
||||
HeaderValue::from(age),
|
||||
);
|
||||
self.domain_stats
|
||||
.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
|
||||
|
||||
Vendored
+1
-1
@@ -99,7 +99,7 @@ impl Cache {
|
||||
self.counters.hits.fetch_add(1, Ordering::Relaxed);
|
||||
let mut response = cached.response(headers);
|
||||
response.headers_mut().insert(::http::header::AGE,
|
||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
||||
HeaderValue::from(age),
|
||||
);
|
||||
Some(Hit {
|
||||
response,
|
||||
|
||||
Vendored
+5
-5
@@ -277,8 +277,8 @@ struct ColdEntry {
|
||||
|
||||
#[derive(Default)]
|
||||
struct ColdStore {
|
||||
entries: HashMap<Key, ColdEntry>,
|
||||
variants: HashMap<Key, Vec<Key>>,
|
||||
entries: KeyMap<ColdEntry>,
|
||||
variants: KeyMap<Vec<Key>>,
|
||||
lru: BTreeMap<u64, Key>,
|
||||
bytes: u64,
|
||||
sequence: u64,
|
||||
@@ -304,8 +304,8 @@ enum ColdCommand {
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Store {
|
||||
entries: HashMap<Key, Entry>,
|
||||
variants: HashMap<Key, Vec<Key>>,
|
||||
entries: KeyMap<Entry>,
|
||||
variants: KeyMap<Vec<Key>>,
|
||||
lru: BTreeMap<u64, Key>,
|
||||
bytes: usize,
|
||||
logical_bytes: usize,
|
||||
@@ -314,7 +314,7 @@ struct Store {
|
||||
revision: u64,
|
||||
control_revision: u64,
|
||||
scope_generations: HashMap<String, u64>,
|
||||
key_generations: HashMap<Key, u64>,
|
||||
key_generations: KeyMap<u64>,
|
||||
}
|
||||
impl Store {
|
||||
fn index_variant(&mut self, base_key: Key, key: Key) {
|
||||
|
||||
Vendored
+32
-24
@@ -268,13 +268,12 @@ impl Settings {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if let Some(value) = &self.via_header {
|
||||
headers.append(
|
||||
http::header::VIA,
|
||||
value
|
||||
.parse::<HeaderValue>()
|
||||
.expect("validated cache Via header"),
|
||||
);
|
||||
if let Some(value) = self
|
||||
.via_header
|
||||
.as_deref()
|
||||
.and_then(crate::settings::cached_header_value)
|
||||
{
|
||||
headers.append(http::header::VIA, value);
|
||||
}
|
||||
if self.expose_only_hits && outcome != "HIT" {
|
||||
return;
|
||||
@@ -300,7 +299,7 @@ impl Settings {
|
||||
headers,
|
||||
"x-cache-hits",
|
||||
"x-upstream-cache-hits",
|
||||
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
|
||||
HeaderValue::from(hits),
|
||||
);
|
||||
}
|
||||
if self.expose_cache_status {
|
||||
@@ -311,30 +310,33 @@ impl Settings {
|
||||
)
|
||||
.then_some(("stale", Some(StatusCode::NOT_MODIFIED)))
|
||||
});
|
||||
let mut value = if let Some((why, status)) = forwarded {
|
||||
let mut value = format!("RustProxy; fwd={why}");
|
||||
use std::fmt::Write as _;
|
||||
let mut value = String::with_capacity(96);
|
||||
value.push_str("RustProxy; ");
|
||||
if let Some((why, status)) = forwarded {
|
||||
let _ = write!(value, "fwd={why}");
|
||||
if let Some(status) = status {
|
||||
value.push_str(&format!("; fwd-status={}", status.as_u16()));
|
||||
let _ = write!(value, "; fwd-status={}", status.as_u16());
|
||||
}
|
||||
value
|
||||
} else {
|
||||
match outcome {
|
||||
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
|
||||
"MISS" => "RustProxy; fwd=miss".to_owned(),
|
||||
"BYPASS" if reason == Some("request_method") => {
|
||||
"RustProxy; fwd=method".to_owned()
|
||||
"HIT" => {
|
||||
let _ = write!(value, "hit; ttl={ttl}");
|
||||
}
|
||||
_ => "RustProxy; fwd=bypass".to_owned(),
|
||||
"MISS" => value.push_str("fwd=miss"),
|
||||
"BYPASS" if reason == Some("request_method") => value.push_str("fwd=method"),
|
||||
_ => value.push_str("fwd=bypass"),
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(reason) = reason {
|
||||
value.push_str(&format!("; detail=\"{reason}\""));
|
||||
let _ = write!(value, "; detail=\"{reason}\"");
|
||||
}
|
||||
if transformed {
|
||||
value.push_str("; transformed");
|
||||
}
|
||||
headers.append(::http::header::CACHE_STATUS,
|
||||
HeaderValue::from_str(&value).expect("generated Cache-Status member"),
|
||||
HeaderValue::from_maybe_shared(bytes::Bytes::from(value))
|
||||
.expect("generated Cache-Status member"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -345,7 +347,7 @@ impl Settings {
|
||||
headers,
|
||||
"x-cache-resident-age",
|
||||
"x-upstream-cache-resident-age",
|
||||
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
|
||||
HeaderValue::from(resident_age),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -357,9 +359,15 @@ fn insert_cache_diagnostic(
|
||||
upstream_name: &'static str,
|
||||
value: HeaderValue,
|
||||
) {
|
||||
let upstream_values: Vec<HeaderValue> = headers.get_all(name).iter().cloned().collect();
|
||||
for upstream_value in upstream_values {
|
||||
headers.append(upstream_name, upstream_value);
|
||||
let name = http::HeaderName::from_static(name);
|
||||
// Fast path: the origin did not send this diagnostic, so there is nothing to
|
||||
// preserve under the X-Upstream-* alias and no temporary vector is needed.
|
||||
if headers.contains_key(&name) {
|
||||
let upstream_values: Vec<HeaderValue> = headers.get_all(&name).iter().cloned().collect();
|
||||
let upstream_name = http::HeaderName::from_static(upstream_name);
|
||||
for upstream_value in upstream_values {
|
||||
headers.append(&upstream_name, upstream_value);
|
||||
}
|
||||
}
|
||||
headers.insert(name, value);
|
||||
}
|
||||
|
||||
Vendored
+69
@@ -28,6 +28,36 @@ const COLD_QUEUE: usize = 16;
|
||||
const COLD_FORMAT_VERSION: u8 = 3;
|
||||
type Key = [u8; 32];
|
||||
|
||||
/// Cache keys are SHA-256 digests, so their bytes are already uniformly
|
||||
/// distributed and not attacker-steerable. Hashing them again with SipHash
|
||||
/// costs measurable time on every HOT lookup; this hasher folds the digest
|
||||
/// cheaply instead (any other input is still mixed, Fx-style).
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct DigestKeyHasher(u64);
|
||||
impl std::hash::Hasher for DigestKeyHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
if bytes.len() == 32 {
|
||||
let mut word = [0u8; 8];
|
||||
word.copy_from_slice(&bytes[..8]);
|
||||
self.0 ^= u64::from_le_bytes(word);
|
||||
return;
|
||||
}
|
||||
for chunk in bytes.chunks(8) {
|
||||
let mut word = [0u8; 8];
|
||||
word[..chunk.len()].copy_from_slice(chunk);
|
||||
self.0 = (self.0.rotate_left(5) ^ u64::from_le_bytes(word))
|
||||
.wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
|
||||
}
|
||||
}
|
||||
fn write_usize(&mut self, _length: usize) {
|
||||
// The only usize written for `[u8; 32]` is its constant length prefix.
|
||||
}
|
||||
}
|
||||
type KeyMap<V> = HashMap<Key, V, std::hash::BuildHasherDefault<DigestKeyHasher>>;
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
enum CleanupPriority {
|
||||
Automatic(u64, SystemTime, SystemTime),
|
||||
@@ -41,7 +71,46 @@ struct CleanupCandidate {
|
||||
key: Key,
|
||||
}
|
||||
|
||||
/// Minimum spacing between two `malloc_trim` calls. Trimming walks every glibc
|
||||
/// arena under its lock and can take milliseconds with a large heap; doing it
|
||||
/// after each eviction made a full HOT cache with constant turnover several
|
||||
/// times slower. Skipped requests are remembered and flushed by the cache
|
||||
/// maintenance tick, so freed memory still returns to the OS within seconds.
|
||||
const ALLOCATOR_RELEASE_INTERVAL_MS: u64 = 250;
|
||||
static ALLOCATOR_RELEASE_LAST_MS: AtomicU64 = AtomicU64::new(0);
|
||||
static ALLOCATOR_RELEASE_PENDING: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn allocator_clock_ms() -> u64 {
|
||||
static EPOCH: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||
// +1 so the very first request is never mistaken for "just trimmed".
|
||||
(EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64)
|
||||
.saturating_add(ALLOCATOR_RELEASE_INTERVAL_MS + 1)
|
||||
}
|
||||
|
||||
fn release_allocator_memory() {
|
||||
let now = allocator_clock_ms();
|
||||
let last = ALLOCATOR_RELEASE_LAST_MS.load(Ordering::Relaxed);
|
||||
if now.saturating_sub(last) < ALLOCATOR_RELEASE_INTERVAL_MS
|
||||
|| ALLOCATOR_RELEASE_LAST_MS
|
||||
.compare_exchange(last, now, Ordering::AcqRel, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
ALLOCATOR_RELEASE_PENDING.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
ALLOCATOR_RELEASE_PENDING.store(false, Ordering::Relaxed);
|
||||
cache_trim_allocator();
|
||||
}
|
||||
|
||||
/// Called from the periodic cache maintenance task.
|
||||
fn flush_pending_allocator_release() {
|
||||
if ALLOCATOR_RELEASE_PENDING.load(Ordering::Relaxed) {
|
||||
release_allocator_memory();
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_trim_allocator() {
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
unsafe {
|
||||
libc::malloc_trim(0);
|
||||
|
||||
+23
-1
@@ -454,13 +454,35 @@ impl ServerHeader {
|
||||
headers.remove(::http::header::SERVER);
|
||||
}
|
||||
ServerMode::Custom => {
|
||||
if let Ok(value) = self.value.parse() {
|
||||
if let Some(value) = cached_header_value(&self.value) {
|
||||
headers.insert(::http::header::SERVER, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Returns a reusable `HeaderValue` for a configured, already validated header
|
||||
/// string. Values are parsed once per worker thread and then cloned (a reference
|
||||
/// count bump) instead of being re-validated and re-allocated on every response.
|
||||
pub(crate) fn cached_header_value(value: &str) -> Option<HeaderValue> {
|
||||
const CAPACITY: usize = 16;
|
||||
thread_local! {
|
||||
static CACHE: std::cell::RefCell<Vec<(Box<str>, HeaderValue)>> =
|
||||
const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
CACHE.with(|cache| {
|
||||
let mut cache = cache.borrow_mut();
|
||||
if let Some((_, header)) = cache.iter().find(|(key, _)| key.as_ref() == value) {
|
||||
return Some(header.clone());
|
||||
}
|
||||
let header = HeaderValue::from_maybe_shared(bytes::Bytes::copy_from_slice(value.as_bytes())).ok()?;
|
||||
if cache.len() >= CAPACITY {
|
||||
cache.remove(0);
|
||||
}
|
||||
cache.push((value.into(), header.clone()));
|
||||
Some(header)
|
||||
})
|
||||
}
|
||||
include!("settings/public_origin.rs");
|
||||
#[cfg(test)]
|
||||
include!("settings/tests.rs");
|
||||
@@ -132,7 +132,7 @@ pub async fn apply(
|
||||
parts.headers.remove(::http::header::ACCEPT_RANGES);
|
||||
parts.headers.insert(::http::header::ETAG, transformed_etag(&transformed));
|
||||
parts.headers.insert(::http::header::CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&transformed.len().to_string()).expect("integer header"),
|
||||
HeaderValue::from(transformed.len()),
|
||||
);
|
||||
drop_vary_accept_encoding(&mut parts.headers);
|
||||
parts.extensions.insert(validators);
|
||||
|
||||
Reference in new issue
Block a user