This commit is contained in:
Mateusz Gruszczyński committed 2026-10-25 09:58:39 +01:00
1 parent 37c197c095
commit 5ac3617947
48 files changed
+1049 -255

No files matched your search

+6 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.11",
"version": "0.34.12",
"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": [
@@ -3594,6 +3594,11 @@
"default": "default",
"description": "Account-scoped visual presentation preset applied consistently to administration charts."
},
"chart_axes": {
"type": "boolean",
"default": false,
"description": "Account-scoped optional X/Y scales on sampled time-series charts. Shows the same Y range used to draw each SVG and X sample indices rather than inferred elapsed time; does not change adaptive scaling."
},
"button_style": {
"type": "string",
"enum": [
+3
View File
@@ -116,6 +116,8 @@ struct AdminPreferences {
time_format: String,
#[serde(default = "default_admin_chart_style")]
chart_style: String,
#[serde(default)]
chart_axes: bool,
#[serde(default = "default_admin_button_style")]
button_style: String,
#[serde(default = "default_admin_corner_style")]
@@ -177,6 +179,7 @@ impl Default for AdminPreferences {
ui_scale: default_admin_ui_scale(),
time_format: default_admin_time_format(),
chart_style: default_admin_chart_style(),
chart_axes: false,
button_style: default_admin_button_style(),
corner_style: default_admin_corner_style(),
table_density: default_admin_table_density(),
+14
View File
@@ -40,6 +40,20 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
}
assert!(hidden.iter().all(|id| order.contains(id)));
assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok());
assert!(!AdminPreferences::default().chart_axes);
}
#[test]
fn optional_chart_axes_preference_is_backward_compatible() {
let legacy: AdminPreferences = serde_json::from_str(r#"{"chart_style":"neon"}"#).unwrap();
assert!(!legacy.chart_axes);
let mut enabled = legacy.clone();
enabled.chart_axes = true;
assert!(validate_admin_preferences(&enabled).is_ok());
let stored = serde_json::to_string(&enabled).unwrap();
assert!(stored.contains("\"chart_axes\":true"));
let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap();
assert!(recovered.chart_axes);
}
#[test]
+3
View File
@@ -1820,6 +1820,7 @@ mod tests {
assert_eq!(initial["ui_scale"], 100);
assert_eq!(initial["time_format"], "24h");
assert_eq!(initial["chart_style"], "default");
assert_eq!(initial["chart_axes"], false);
assert_eq!(initial["button_style"], "normal");
assert_eq!(initial["corner_style"], "default");
assert_eq!(
@@ -1856,6 +1857,7 @@ mod tests {
assert_eq!(legacy["ui_scale"], 100);
assert_eq!(legacy["time_format"], "24h");
assert_eq!(legacy["chart_style"], "default");
assert_eq!(legacy["chart_axes"], false);
assert_eq!(legacy["button_style"], "normal");
assert_eq!(legacy["corner_style"], "default");
assert_eq!(
@@ -1877,6 +1879,7 @@ mod tests {
"ui_scale":70,
"time_format":"12h",
"chart_style":"neon",
"chart_axes":true,
"button_style":"compact",
"corner_style":"rounded",
"table_density":"ultra",
+9 -5
View File
@@ -18,7 +18,6 @@ window.rustProxyBackendDashboard = (() => {
}
function syncVisibility(hiddenOverride = null) {
const prefs = window.rustProxyPreferences?.current?.();
// While unlocked, currentDraft is authoritative until Save or Cancel.
const hidden = new Set(hiddenOverride ?? window.rustProxyInlineLayout?.currentDraft?.()?.hidden
?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
@@ -30,7 +29,13 @@ window.rustProxyBackendDashboard = (() => {
}
function paintSeries(name, id, scale) {
const node = $(id);
if (node) node.setAttribute("points", line(samples[name], scale));
if (node) {
node.setAttribute("points", line(samples[name], scale));
const unit = name === "latency" ? "ms" : ["inbound", "outbound"].includes(name) ? "bytes/s"
: name === "active" ? "count" : "rate";
window.rustProxyChartScale.axes(node, scale, unit, samples[name].length,
{ top: 5 / 60, bottom: 55 / 60 });
}
}
function push(name, value) {
samples[name].push(Math.max(0, value));
@@ -50,8 +55,7 @@ window.rustProxyBackendDashboard = (() => {
window.rustProxyExtraOverview?.meter?.("backend-dashboard-capacity", null);
reset();
}
// Counter deltas are matched to stable (pool, member) IDs, not aggregate
// totals: removed/rebuilt pools must never become negative or spike rates.
function memberCounters(members) {
const values = new Map();
for (const { pool, member } of members) {
@@ -73,7 +77,7 @@ window.rustProxyBackendDashboard = (() => {
for (const [id, next] of current) {
const old = earlier.get(id);
if (!old) continue;
// A generation reset on a member invalidates its entire interval.
if (tracked.some(field => next[field] < old[field])) continue;
for (const field of tracked) total[field] += next[field] - old[field];
}
+105 -9
View File
@@ -1,5 +1,4 @@
// Overview charts retain stable, expanding ranges (and fixed 0-100% axes).
// Vhost request-rate sparklines use a separate, adaptive plotting policy.
window.rustProxyChartScale = (() => {
const floors = { rate: 100, count: 10, ms: 100, bytes: 64 * 1024 * 1024, "bytes/s": 64 * 1024 };
const ceilings = new Map();
@@ -17,17 +16,12 @@ window.rustProxyChartScale = (() => {
ceilings.set(key, result);
return result;
}
// Vhost live charts show the entire session history, but their Y range
// follows the recent typical rate. A one-off surge must not flatten the
// subsequent request activity; the real (unclipped) peak stays in the legend.
// The recent quantile also prevents a range that only grows over time.
function liveRate(values) {
const samples = (Array.isArray(values) ? values : []).map(value =>
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
const maximum = Math.max(0, ...samples);
const recent = samples.slice(-24).sort((a, b) => a - b);
// Interpolation would allow a single extreme point to dominate small
// histories. Nearest lower rank excludes that isolated maximum.
const typical = recent.length ? recent[Math.floor((recent.length - 1) * 0.85)] : 0;
const scale = Math.max(1, typical * 1.25);
const points = samples.map((value, index) => {
@@ -40,5 +34,107 @@ window.rustProxyChartScale = (() => {
function reset(prefix) {
for (const key of ceilings.keys()) if (key.startsWith(prefix)) ceilings.delete(key);
}
return { peak, liveRate, reset };
function axisValue(value, unit) {
if (value === 0) return "0";
const n = Number(value);
const format = v => v.toLocaleString(typeof locale === "function" ? locale() : undefined,
{ maximumFractionDigits: v < 1 ? 3 : v < 10 ? 2 : 1 });
if (unit === "bytes" || unit === "bytes/s") {
let magnitude = n, suffix = "B";
for (const next of ["KiB", "MiB", "GiB", "TiB"]) {
if (magnitude < 1024) break;
magnitude /= 1024; suffix = next;
}
return `${format(magnitude)} ${suffix}${unit === "bytes/s" ? "/s" : ""}`;
}
return `${format(n)}${unit === "percent" ? "%" : unit === "ms" ? " ms" : unit === "rate" ? "/s" : ""}`;
}
function setAxisText(node, value) {
if (node.textContent !== value) node.textContent = value;
}
function renderAxes(svg) {
if (!svg?.isConnected || !svg.rustProxyAxisData) return;
let host = svg.parentElement;
if (!host?.classList.contains("rp-chart-axis-host")) {
host = document.createElement("div");
host.className = "rp-chart-axis-host";
svg.before(host);
host.append(svg);
const overlay = document.createElement("div");
overlay.className = "rp-chart-axis-y";
overlay.setAttribute("aria-hidden", "true");
for (const name of ["top", "middle", "bottom"]) {
const tick = document.createElement("span");
tick.className = `rp-chart-axis-${name}`;
overlay.append(tick);
}
const perSeries = document.createElement("div");
perSeries.className = "rp-chart-axis-series";
perSeries.hidden = true;
const x = document.createElement("div");
x.className = "rp-chart-axis-x";
x.setAttribute("aria-label", "X");
for (const name of ["start", "caption", "end"]) {
const tick = document.createElement("span");
tick.className = `rp-chart-axis-${name}`;
x.append(tick);
}
host.append(overlay, perSeries, x);
}
const { peak: maximum, unit, count, top, bottom, clipped, seriesScales } = svg.rustProxyAxisData;
const compact = svg.closest(".stat-chart") !== null;
host.classList.toggle("rp-chart-axis-compact", compact);
const multi = Array.isArray(seriesScales) && seriesScales.length > 0;
host.classList.toggle("rp-chart-axis-multiple", multi);
const perSeries = host.querySelector(".rp-chart-axis-series");
perSeries.hidden = !multi;
if (multi) {
const values = seriesScales.map(([name, value]) => `${name}: 0–${axisValue(value, unit)}`);
if (perSeries.dataset.values !== values.join("|")) {
perSeries.replaceChildren(...values.map(value => {
const label = document.createElement("span");
label.textContent = value;
return label;
}));
perSeries.dataset.values = values.join("|");
}
}
host.style.setProperty("--rp-axis-top", `${Math.round(top * 1000) / 10}%`);
host.style.setProperty("--rp-axis-bottom", `${Math.round(bottom * 1000) / 10}%`);
setAxisText(host.querySelector(".rp-chart-axis-top"), `Y ${axisValue(maximum, unit)}${clipped ? " ↑" : ""}`);
setAxisText(host.querySelector(".rp-chart-axis-middle"), axisValue(maximum / 2, unit));
setAxisText(host.querySelector(".rp-chart-axis-bottom"), "0");
setAxisText(host.querySelector(".rp-chart-axis-start"), compact && !multi
? `Y 0–${axisValue(maximum, unit)}` : count ? "1" : "–");
setAxisText(host.querySelector(".rp-chart-axis-caption"), `X · ${typeof t === "function" ? t("chartAxisSamples") : "samples"}`);
setAxisText(host.querySelector(".rp-chart-axis-end"), count ? (compact ? `1–${count}` : String(count)) : "–");
host.querySelector(".rp-chart-axis-x").title = typeof t === "function" ? t("chartAxisSamplesHint") : "Sample indices, not elapsed time";
}
function axes(target, peakValue, unit, count,
{ top = 0.08, bottom = 0.92, clipped = false, seriesScales = null } = {}) {
const node = typeof target === "string" ? document.getElementById(target) : target;
const svg = node?.tagName?.toLowerCase() === "svg" ? node : node?.ownerSVGElement;
if (!svg) return;
svg.rustProxyAxisData = {
peak: Number.isFinite(peakValue) && peakValue > 0 ? peakValue : 1,
unit, count: Number.isInteger(count) && count > 0 ? count : 0,
top: Math.min(1, Math.max(0, top)), bottom: Math.min(1, Math.max(0, bottom)), clipped: clipped === true,
seriesScales: Array.isArray(seriesScales) ? seriesScales.filter(row =>
Array.isArray(row) && typeof row[0] === "string" && Number.isFinite(row[1]) && row[1] >= 0) : null,
};
if (document.documentElement.dataset.chartAxes === "true") renderAxes(svg);
}
function refreshAxes() {
const show = document.documentElement.dataset.chartAxes === "true";
for (const svg of document.querySelectorAll("svg")) {
if (show && svg.rustProxyAxisData) renderAxes(svg);
else if (!show && svg.parentElement?.classList.contains("rp-chart-axis-host")) {
const host = svg.parentElement;
host.before(svg);
host.remove();
}
}
}
return { peak, liveRate, reset, axes, refreshAxes, axisValue };
})();
+11
View File
@@ -201,6 +201,8 @@ function redrawOverviewHistoryCharts() {
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));
window.rustProxyChartScale.axes("connection-chart", connectionPeak, "count", connectionOpenSamples.length,
{ top: 10 / 100, bottom: 95 / 100 });
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));
@@ -208,10 +210,19 @@ function redrawOverviewHistoryCharts() {
$("sparkline-h2").setAttribute("points", points(protocolSamples.h2));
$("sparkline-h3").setAttribute("points", points(protocolSamples.h3));
$("sparkline-transform").setAttribute("points", points(transformSamples));
window.rustProxyChartScale.axes("traffic-chart", peak, "rate", samples.length,
{ top: 15 / 130, bottom: 120 / 130 });
for (const key of ["h1", "h2", "h3"]) {
$("http-protocol-" + key).setAttribute("points", sparklinePoints(protocolSamples[key], 300, 60, 5));
$("ws-protocol-" + key).setAttribute("points", sparklinePoints(websocketProtocolSamples[key], 300, 60, 5));
}
const httpValues = Object.values(protocolSamples), websocketValues = Object.values(websocketProtocolSamples);
window.rustProxyChartScale.axes("http-protocol-chart", Math.max(1, ...httpValues.flat()), "rate",
protocolSamples.h1.length, { seriesScales: Object.entries(protocolSamples).map(([name, values]) =>
[name.toUpperCase(), Math.max(1, ...values)]) });
window.rustProxyChartScale.axes("ws-protocol-chart", Math.max(1, ...websocketValues.flat()), "count",
websocketProtocolSamples.h1.length, { seriesScales: Object.entries(websocketProtocolSamples).map(([name, values]) =>
[name.toUpperCase(), Math.max(1, ...values)]) });
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
}
window.rustProxyOverviewSamples.onChange(() => {
+17 -10
View File
@@ -1,5 +1,4 @@
// Opt-in compact metrics and bounded visualizations. All readings reuse the
// existing Overview live-status payload (no additional requests or timers).
window.rustProxyExtraOverview = (() => {
const limit = () => window.rustProxyOverviewSamples?.getLimit?.() ?? 60;
const names = [
@@ -87,13 +86,17 @@ window.rustProxyExtraOverview = (() => {
}
if (id === "extra_backend_active") {
const members = Array.isArray(status.backend_pools) ? status.backend_pools.flatMap(pool => pool.members || []) : null;
return { value: members ? members.reduce((sum, member) => sum + (positive(member.active) ?? 0), 0) : null,
unit: "count", detail: "" };
return {
value: members ? members.reduce((sum, member) => sum + (positive(member.active) ?? 0), 0) : null,
unit: "count", detail: ""
};
}
if (id === "extra_backend_unhealthy") {
return { value: Array.isArray(status.backend_pools) ? status.backend_pools.reduce((sum, pool) =>
sum + (pool.members || []).filter(member => member.status === "down").length, 0) : null,
unit: "count", detail: "" };
return {
value: Array.isArray(status.backend_pools) ? status.backend_pools.reduce((sum, pool) =>
sum + (pool.members || []).filter(member => member.status === "down").length, 0) : null,
unit: "count", detail: ""
};
}
if (id === "extra_cache_memory") return ramBudget(c.resident_bytes, c.hot_limit_bytes);
if (id === "extra_cold_queue_memory") return ramBudget(c.cold_queue_bytes, c.cold_queue_limit_bytes);
@@ -123,8 +126,10 @@ window.rustProxyExtraOverview = (() => {
const { deltas, seconds } = result;
if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" };
const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1];
return { value: sum > 0 ? Math.min(100, deltas[0] / sum * 100) : null, unit, detail: "",
slices: id === "extra_cache_hit_rate" ? [deltas[0], deltas[1]] : null };
return {
value: sum > 0 ? Math.min(100, deltas[0] / sum * 100) : null, unit, detail: "",
slices: id === "extra_cache_hit_rate" ? [deltas[0], deltas[1]] : null
};
}
function format(value, unit) {
if (value == null) return "-";
@@ -145,7 +150,6 @@ window.rustProxyExtraOverview = (() => {
for (const id of names) {
const tile = node(id);
if (!tile) { lastCounter.delete(id); continue; }
// Visibility is only a presentation concern; retain live history.
const metric = read(id, status, now, record);
const value = metric.value;
if (lastUnits.get(id) !== metric.unit) {
@@ -161,6 +165,9 @@ window.rustProxyExtraOverview = (() => {
histories.set(id, history);
}
$(`overview-extra-${id}-line`)?.setAttribute("points", points(id, history, metric.unit));
window.rustProxyChartScale.axes(`overview-extra-${id}-line`,
window.rustProxyChartScale.peak(`extra:${id}`, metric.unit, [history]), metric.unit,
history.length, { top: 5 / 60, bottom: 55 / 60 });
}
if (record) {
lastValue.set(id, value);
+2 -1
View File
@@ -72,7 +72,6 @@ window.rustProxyWideOverview = (() => {
const value = values[i];
if (value === null) { drawing = false; continue; }
const x = Math.round(600 * i / Math.max(1, count - 1));
// Fixed-range charts stop at the upper bound; the legend keeps the real reading.
const y = Math.round(137 - 120 * Math.min(value, peak) / peak);
result += `${drawing ? " L" : " M"}${x} ${y}`;
drawing = true;
@@ -106,6 +105,8 @@ window.rustProxyWideOverview = (() => {
if (field) field.textContent = format(value, config.unit);
$(`overview-wide-${id}-${key}-line`)?.setAttribute("d", path(series[i], peak));
});
window.rustProxyChartScale.axes(`overview-wide-${id}-${config.names[0]}-line`,
peak, config.unit, series[0].length, { top: 17 / 150, bottom: 137 / 150 });
const summary = $(`overview-wide-${id}-summary`);
if (summary) summary.textContent = config.total
? format(values.every(value => value !== null) ? values.reduce((sum, value) => sum + value, 0) : null, config.unit)
+21 -9
View File
@@ -13,6 +13,7 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([
])]);
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
chart_axes: false,
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null,
});
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
@@ -35,14 +36,12 @@ function normalizeDashboardWidgets(value) {
function normalizeHiddenDashboardWidgets(value, savedOrder = ADMIN_DASHBOARD_WIDGETS) {
const seen = new Set();
const hidden = (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
// Also protect preferences written by 0.31.x with an explicit empty hidden list.
for (const id of ADMIN_DEFAULT_HIDDEN_WIDGETS) {
if (!savedOrder?.includes(id) && !seen.has(id)) { hidden.push(id); seen.add(id); }
}
return hidden;
}
// An absent map preserves the previous grouped visual order after upgrading.
// An explicit object (even empty) opts into free ordering and card resizing.
const DASHBOARD_WIDTHS = Object.freeze([2, 3, 4, 6, 12]);
function dashboardAllowedWidths(id) {
switch (dashboardWidgetType(id)) {
@@ -76,7 +75,6 @@ function dashboardWidgetWidth(id, sizes) {
function dashboardVisualOrder(order, sizes = null) {
const normalized = normalizeDashboardWidgets(order);
if (sizes !== null) return normalized;
// Preserve the old grouped appearance until the first intentional edit.
return normalized.map((id, index) => ({ id, index }))
.sort((a, b) => (window.rustProxyMasonry?.groupFor?.(a.id) ?? 0) -
(window.rustProxyMasonry?.groupFor?.(b.id) ?? 0) || a.index - b.index)
@@ -118,6 +116,13 @@ function applyAdminChartStyle(value) {
const style = normalizeAdminChartStyle(value); document.documentElement.dataset.chartStyle = style;
const select = $("ui-chart-style"); if (select) select.value = style; refreshChartStylePalette(style); return style;
}
function applyAdminChartAxes(value) {
const enabled = value === true;
document.documentElement.dataset.chartAxes = String(enabled);
const input = $("ui-chart-axes"); if (input) input.checked = enabled;
window.rustProxyChartScale?.refreshAxes?.();
return enabled;
}
function applyAdminButtonStyle(value) {
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
const select = $("ui-button-style"); if (select) select.value = style; return style;
@@ -271,8 +276,7 @@ function formatDateTime(value) { const date = value instanceof Date ? value : ne
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
function applyAdminPreferences(value, cache = true) {
if (!value) return value;
// A live inline edit is a draft, not a saved preference. Background updates
// (including an unrelated preference save) must not repaint the old layout.
const editing = window.rustProxyInlineLayout?.currentDraft?.();
const savedOrder = normalizeDashboardWidgets(value.dashboard_widgets);
const savedHidden = normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets);
@@ -283,7 +287,7 @@ function applyAdminPreferences(value, cache = true) {
...value,
refresh: normalizeRefreshPreferences(value.refresh),
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes,
};
adminPreferences = normalized;
@@ -331,7 +335,7 @@ function updateAdminPreferences(patch) {
const next = { ...current, ...patch };
next.refresh = normalizeRefreshPreferences(next.refresh);
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes);
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
if (!authenticated || epoch !== authEpoch) return adminPreferences;
@@ -407,6 +411,14 @@ $("ui-chart-style")?.addEventListener("change", () => {
const control = $("ui-chart-style"), previous = adminPreferences?.chart_style || "default", next = normalizeAdminChartStyle(control.value);
applyAdminChartStyle(next); if (adminPreferences) adminPreferences.chart_style = next; void saveAppearancePreference({ chart_style: next }, control, () => { if (adminPreferences) adminPreferences.chart_style = previous; applyAdminChartStyle(previous); });
});
$("ui-chart-axes")?.addEventListener("change", () => {
const control = $("ui-chart-axes"), previous = adminPreferences?.chart_axes === true, next = control.checked;
applyAdminChartAxes(next); if (adminPreferences) adminPreferences.chart_axes = next;
void saveAppearancePreference({ chart_axes: next }, control, () => {
if (adminPreferences) adminPreferences.chart_axes = previous;
applyAdminChartAxes(previous);
});
});
$("ui-button-style")?.addEventListener("change", () => {
const control = $("ui-button-style"), previous = adminPreferences?.button_style || "normal", next = normalizeAdminButtonStyle(control.value);
applyAdminButtonStyle(next); if (adminPreferences) adminPreferences.button_style = next; void saveAppearancePreference({ button_style: next }, control, () => { if (adminPreferences) adminPreferences.button_style = previous; applyAdminButtonStyle(previous); });
@@ -438,7 +450,7 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
}
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
const patches = {
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null },
};
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
+22
View File
@@ -346,6 +346,8 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
setChartText(row.querySelector(".network-interface-hide"), t("hideMetric"));
row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55");
row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55");
window.rustProxyChartScale.axes(row.querySelector("svg"), peak, "bytes/s", history.rx.length,
{ top: 5 / 60, bottom: 55 / 60 });
row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
children.push(row);
}
@@ -379,6 +381,8 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
if (children.length !== target.children.length || children.some((node, index) => node !== target.children[index])) {
target.replaceChildren(...children);
if (document.documentElement.dataset.chartAxes === "true") window.rustProxyChartScale.refreshAxes();
}
}
@@ -457,6 +461,8 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
}
$("system-chart").hidden = live.cpu_percent == null; if (appendSample && live.cpu_percent != null) { systemSamples.push(Number(live.cpu_percent)); systemSamples = systemSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
window.rustProxyChartScale.axes("system-chart", 100, "percent", systemSamples.length,
{ top: 5 / 100, bottom: 95 / 100 });
$("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)}`;
@@ -496,10 +502,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("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));
window.rustProxyChartScale.axes("cache-hit-sparkline", cacheOutcomePeak, "count", cacheHitSamples.length,
{ top: 5 / 60, bottom: 55 / 60 });
const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)];
setChartText($("cache-outcome-chart-value"), latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-");
const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null;
$("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak));
window.rustProxyChartScale.axes("cache-memory-sparkline", Math.max(1, hotPeak ?? Math.max(0, ...cacheMemorySamples)),
"bytes", cacheMemorySamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("cache-chart-value"), occupancyText(c.resident_bytes, c.hot_limit_bytes));
const transientLimit = Number(c.transient_limit_bytes);
const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0
@@ -509,10 +519,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak));
$("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak));
$("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak));
window.rustProxyChartScale.axes("cache-transient-sparkline", transientPeak, "bytes", cacheTransientSamples.length,
{ top: 5 / 60, bottom: 55 / 60 });
setChartText($("cache-transient-chart-value"), occupancyText(c.transient_bytes, c.transient_limit_bytes));
const coldEnabled = envelope?.config.cache?.cold_enabled === true;
const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null;
$("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak));
window.rustProxyChartScale.axes("cache-cold-sparkline", Math.max(1, coldPeak ?? Math.max(0, ...cacheColdStorageSamples)),
"bytes", cacheColdStorageSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("cache-cold-chart-value"), coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"));
if (systemSample) {
const processPlatform = [d.platform, d.environment].filter(Boolean).join(" / ");
@@ -545,8 +559,12 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory);
}
$("process-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
window.rustProxyChartScale.axes("process-cpu-sparkline", Math.max(1, ...processCpuSamples), "percent",
processCpuSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("process-cpu-chart-value"), live.process_cpu_percent == null ? "" : `${fmt(live.process_cpu_percent, 1)}%`);
$("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
window.rustProxyChartScale.axes("process-rss-sparkline", Math.max(1, ...processRssSamples), "bytes",
processRssSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
$("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
@@ -564,10 +582,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
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));
window.rustProxyChartScale.axes("network-rx-sparkline", networkPeak, "bytes/s", networkRxSamples.length,
{ top: 5 / 60, bottom: 55 / 60 });
setChartText($("network-chart-value"), networkRateSupported ? `${fmtBytes(live.network_rx_bytes_per_second)} / ${fmtBytes(live.network_tx_bytes_per_second)}` : "");
$("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported;
renderNetworkInterfaceCharts(live.network_interfaces || [], appendSample);
$("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples));
window.rustProxyChartScale.axes("latency-sparkline", Math.max(1, ...responseLatencySamples), "ms",
responseLatencySamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("latency-chart-value"), m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`);
$("latency-sparkline").closest(".mini-live-chart").hidden = m.mean_duration_ms == null;
$("compression-counters").textContent = [m.compression_gzip, m.compression_br, m.compression_zstd, m.compression_deflate].map(n => fmt(n)).join(" / ");
+2 -1
View File
@@ -16,7 +16,6 @@ window.rustProxyStreamDashboard = (() => {
}
function syncVisibility(hiddenOverride = null) {
const prefs = window.rustProxyPreferences?.current?.();
// While unlocked, currentDraft is authoritative until Save or Cancel.
const hidden = new Set(hiddenOverride ?? window.rustProxyInlineLayout?.currentDraft?.()?.hidden
?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
@@ -103,6 +102,8 @@ window.rustProxyStreamDashboard = (() => {
trafficRates(pausedRate ? "-" : `${bytes(Math.round(rx))}/s`, pausedRate ? "-" : `${bytes(Math.round(tx))}/s`);
$("stream-dashboard-rx")?.setAttribute("points", line(history.rx));
$("stream-dashboard-tx")?.setAttribute("points", line(history.tx));
window.rustProxyChartScale.axes("stream-dashboard-rx", Math.max(1, ...history.rx, ...history.tx),
"bytes/s", history.rx.length, { top: 5 / 60, bottom: 55 / 60 });
}
function reset() { clearTraffic(); wasVisible = false; }
window.rustProxyOverviewSamples.onChange(() => {
+7 -3
View File
@@ -160,15 +160,17 @@ window.rustProxyWebOverview = (() => {
histories.set("www_php_requests", values);
}
$(`www_php_requests-line`)?.setAttribute("points", smallPath(histories.get("www_php_requests") || []));
const phpSamples = histories.get("www_php_requests") || [];
window.rustProxyChartScale.axes("www_php_requests-line",
window.rustProxyChartScale.peak("web:php-small", "rate", [phpSamples]), "rate",
phpSamples.filter(value => value !== null).length, { top: 5 / 60, bottom: 55 / 60 });
const set = {
www_requests: r?.rps, www_latency: r?.latency, www_failures: r?.failures,
www_traffic: r?.traffic, www_status_trend: r?.status,
};
for (const [id, names] of Object.entries(seriesNames)) {
const series = histories.get(id) || names.map(() => []);
// A resumed/overdue interval has no rate; skipping that entire
// snapshot keeps existing paths connected without inventing 0/s.
// A genuinely unavailable series inside a valid interval stays null.
if (record && r) {
names.forEach((_, i) => { series[i].push(set[id]?.[i] ?? null); window.rustProxyOverviewSamples.trim(series[i]); });
histories.set(id, series);
@@ -182,6 +184,8 @@ window.rustProxyWebOverview = (() => {
show(`${id}-${name}-value`, label);
$(`${id}-${name}-line`)?.setAttribute("d", chartPath(series[i], peak));
});
window.rustProxyChartScale.axes(`${id}-${names[0]}-line`, peak, unit,
series[0].length, { top: 17 / 150, bottom: 137 / 150 });
const summary = id === "www_latency" ? (set[id]?.[0] == null ? "-" : `${fmt(set[id][0], 1)} ms`)
: id === "www_traffic" ? (set[id] ? `${fmtBytes(set[id][0] + set[id][1])}/s` : "-")
: fmtRate(set[id]?.[0]);
+2
View File
@@ -80,6 +80,8 @@
$("cache-live-hit-line")?.setAttribute("points", sparklinePoints(liveChartSamples.hits, 900, 180, 10, peak));
$("cache-live-miss-line")?.setAttribute("points", sparklinePoints(liveChartSamples.misses, 900, 180, 10, peak));
$("cache-live-bypass-line")?.setAttribute("points", sparklinePoints(liveChartSamples.bypasses, 900, 180, 10, peak));
window.rustProxyChartScale.axes("cache-live-activity-chart", peak, "rate", liveChartSamples.hits.length,
{ top: 10 / 180, bottom: 170 / 180 });
}
function setLiveChartLimit(value, persist = true) {
const parsed = Number(value);
+69
View File
@@ -76,6 +76,75 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
stroke-dasharray: var(--chart-grid-dash, 2 5);
}
/* Optional X/Y scale readouts do not change SVG coordinates or chart ranges.
* Labels are HTML, not distorted SVG text under preserveAspectRatio="none". */
.rp-chart-axis-host {
position: relative;
display: flow-root;
width: 100%;
min-width: 0;
}
.rp-chart-axis-host>svg { display: block; vertical-align: top; }
.rp-chart-axis-y {
position: absolute;
inset: 0 0 15px;
pointer-events: none;
font: 9px/1.15 var(--mono);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.rp-chart-axis-y span {
position: absolute;
left: 3px;
max-width: calc(100% - 6px);
padding: 1px 3px;
border-radius: 2px;
white-space: nowrap;
background: color-mix(in srgb, var(--panel) 82%, transparent);
}
.rp-chart-axis-top { top: var(--rp-axis-top, 8%); transform: translateY(-30%); }
.rp-chart-axis-middle { top: 50%; transform: translateY(-50%); }
.rp-chart-axis-bottom { top: var(--rp-axis-bottom, 92%); transform: translateY(-70%); }
.rp-chart-axis-x {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 5px;
min-height: 15px;
padding: 2px 3px 0;
border-top: 1px solid var(--line);
color: var(--muted);
font: 9px/1.2 var(--mono);
font-variant-numeric: tabular-nums;
white-space: nowrap;
align-items: center;
}
.rp-chart-axis-x .rp-chart-axis-start { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.rp-chart-axis-x .rp-chart-axis-end { min-width: 0; text-align: right; }
.rp-chart-axis-compact .rp-chart-axis-y,
.rp-chart-axis-multiple .rp-chart-axis-y,
.rp-chart-axis-series[hidden] { display: none; visibility: hidden; }
.rp-chart-axis-compact .rp-chart-axis-x { font-size: 8px; gap: 2px; }
.rp-chart-axis-series {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
padding: 2px 3px;
font: 8px/1.2 var(--mono);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
@media (max-width: 560px) {
.rp-chart-axis-y .rp-chart-axis-middle { display: none; }
.rp-chart-axis-x { font-size: 8px; }
}
.chart-footer {
display: flex;
justify-content: space-between;
+21
View File
@@ -83,6 +83,27 @@
max-width: none;
}
.appearance-chart-axes-setting {
align-self: center;
min-width: 0;
}
.appearance-chart-axes-setting>span {
display: grid;
gap: 4px;
min-width: 0;
}
.appearance-chart-axes-setting>span b {
font-weight: 600;
}
.appearance-chart-axes-setting>span small {
color: var(--muted);
font-size: 10px;
line-height: 1.45;
}
.settings-page>.edit-form+.edit-form {
margin-top: var(--ui-gap);
}
+2
View File
@@ -286,6 +286,8 @@
const data = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points;
const plot = window.rustProxyChartScale.liveRate(data);
view.line.setAttribute("points", plot.points);
window.rustProxyChartScale.axes(view.line, plot.scale, "rate", data.length,
{ top: 6 / 54, bottom: 50 / 54, clipped: plot.clipped });
const scaleHint = `${t("vhChartAdaptiveScale")}: 0–${plot.scale.toFixed(1)} ${t("vhReqPerSecond")}${plot.clipped ? ` · ${t("vhChartClippedPeak")}` : ""}`;
view.line.parentNode?.setAttribute("aria-label", `${t("vhLifetimeChart")} · ${scaleHint}`);
view.chartValue.textContent = `${history.paused ? "-" : history.rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${plot.peak.toFixed(1)}`;
+12 -5
View File
@@ -1101,8 +1101,10 @@
placeholder="app.key" required="" />
<div hidden id="certificate-external-acl-example"></div>
<div class="form-actions"><button type="button" class="quiet"
id="certificate-inspect" data-i18n="certificateReadNames"></button></div>
<p class="fine" id="certificate-inspect-status" role="status" aria-live="polite"></p>
id="certificate-inspect" data-i18n="certificateReadNames"></button>
</div>
<p class="fine" id="certificate-inspect-status" role="status"
aria-live="polite"></p>
<label class="check"><input id="certificate-default" type="checkbox" /><span
data-i18n="defaultCertificate"></span></label>
<div class="form-actions"><button class="primary"
@@ -1826,6 +1828,11 @@
<option data-i18n="chartStyleWarm" value="warm">Warm</option>
<option data-i18n="chartStyleCool" value="cool">Cool</option>
</select></label>
<label class="check appearance-chart-axes-setting"><input id="ui-chart-axes"
type="checkbox" /><span><b data-i18n="chartAxes">Show X/Y scales on
charts</b><small data-i18n="chartAxesHelp">Keep automatic scaling;
show Y values and sample indices on X (not elapsed
time).</small></span></label>
<div class="appearance-chart-preview" aria-hidden="true">
<svg viewBox="0 0 300 74" preserveAspectRatio="none">
<path class="chart-grid" d="M0 18H300 M0 37H300 M0 56H300"></path>
@@ -2451,8 +2458,8 @@
class="check"><input id="cache-resident-age-header"
type="checkbox" /><span>X-Cache-Resident-Age</span></label></div>
<label class="check"><input id="cache-only-hits" type="checkbox" /><span
data-i18n="cacheOnlyHits">Expose cache diagnostics only on HIT (hide
MISS/BYPASS)</span></label>
data-i18n="cacheOnlyHits">Show RustProxy cache diagnostics only on HIT
(retain origin headers)</span></label>
<div class="cache-via-setting">
<label class="check"><input id="cache-via-enabled" type="checkbox" /><span
data-i18n="cacheViaEnable">Add Via response header</span></label>
@@ -4297,4 +4304,4 @@ log_errors = On</code></pre>
</dialog>
</body>
</html>
</html>
+2
View File
@@ -277,6 +277,8 @@
const points = history.pendingCount ? [...history.points, [history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]] : history.points;
const peak = Math.max(1, ...points.flat()); const toPoints = index => points.map((pair, pointIndex) => `${pointIndex * 320 / Math.max(1, points.length - 1)},${50 - pair[index] / peak * 44}`).join(" ");
const svg = document.querySelector(`[data-stream-chart="${CSS.escape(id)}"]`); svg?.querySelector(".stream-chart-rx")?.setAttribute("points", toPoints(0)); svg?.querySelector(".stream-chart-tx")?.setAttribute("points", toPoints(1));
window.rustProxyChartScale.axes(svg, peak, "bytes/s", points.length,
{ top: 6 / 54, bottom: 50 / 54 });
const label = document.querySelector(`[data-stream-chart-value="${CSS.escape(id)}"]`); if (label) label.textContent = pausedRate ? "RX - · TX -" : `RX ${bytes(Math.round(rx))}/s · TX ${bytes(Math.round(tx))}/s`;
}
if (editing) { const data = metrics[editing]; $("stream-live-summary").textContent = data ? `${t("streamActiveSessions")}: ${fmt(data.active)} · RX ${bytes(data.bytes_in)} · TX ${bytes(data.bytes_out)} · ${t("streamErrors")}: ${fmt(data.errors)}` : t("streamNoLiveMetrics"); }
+2
View File
@@ -33,6 +33,8 @@
$("transform-live-cache-line")?.setAttribute("points", sparklinePoints(liveChartSamples.cache_hits, 900, 180, 10, peak));
$("transform-live-skipped-line")?.setAttribute("points", sparklinePoints(liveChartSamples.skipped, 900, 180, 10, peak));
$("transform-live-error-line")?.setAttribute("points", sparklinePoints(liveChartSamples.errors, 900, 180, 10, peak));
window.rustProxyChartScale.axes("transform-live-activity-chart", peak, "rate", liveChartSamples.applied.length,
{ top: 10 / 180, bottom: 170 / 180 });
}
function setLiveChartLimit(value, persist = true) {
const parsed = Number(value);
+2
View File
@@ -540,6 +540,8 @@ function renderStatus(status) {
const plot = window.rustProxyChartScale.liveRate(displayPoints);
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`);
svg?.querySelector("polyline")?.setAttribute("points", plot.points);
window.rustProxyChartScale.axes(svg, plot.scale, "rate", displayPoints.length,
{ top: 6 / 54, bottom: 50 / 54, clipped: plot.clipped });
const scaleHint = `${t("vhChartAdaptiveScale")}: 0–${plot.scale.toFixed(1)} ${t("vhReqPerSecond")}${plot.clipped ? ` · ${t("vhChartClippedPeak")}` : ""}`;
svg?.setAttribute("aria-label", `${t("vhLifetimeChart")} · ${scaleHint}`);
const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`);
+2
View File
@@ -449,6 +449,8 @@
const points = previous.length === 1 ? `0,${y(previous[0])} 320,${y(previous[0])}` :
previous.map((value, i) => `${320 * i / Math.max(1, previous.length - 1)},${y(value)}`).join(" ");
panel.querySelector("polyline")?.setAttribute("points", points || "0,50 320,50");
window.rustProxyChartScale.axes(panel.querySelector(".vhost-chart svg"), peak, "rate", previous.length,
{ top: 4 / 54, bottom: 50 / 54 });
set("requests", fmt(total)); set("rate", rate == null ? "-" : `${fmt(rate, 1)}/s`);
set("errors", fmt(n(web.status_5xx)));
set("latency", deltas?.[0] > 0 ? `${fmt(deltas[1] / deltas[0] / 1000, 1)} ms` : "-");
+84
View File
@@ -62,6 +62,90 @@ mod cold_tier_tests {
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn upstream_cache_headers_survive_cold_demotion_and_hit_annotation() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/origin-cache",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("cached-origin-body"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response.headers_mut().append(
"cache-status",
HeaderValue::from_static("OriginCache; hit; ttl=300"),
);
response
.headers_mut()
.insert("x-cache", HeaderValue::from_static("Origin-HIT"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
fill(&cache, &settings, "/second", "other-cached-body").await;
wait_for_cold(&cache, 1).await;
let mut hit = cache
.get_tiered(&ticket, &settings)
.await
.expect("COLD hit");
assert_eq!(hit.response.headers()["x-cache"], "Origin-HIT");
assert_eq!(
hit.response.headers()["cache-status"],
"OriginCache; hit; ttl=300"
);
let display = Settings {
expose_status: true,
expose_cache_status: true,
..settings.clone()
};
display.annotate_reason_transformed(
hit.response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
"cache_hit",
false,
);
assert_eq!(hit.response.headers()["x-cache"], "HIT");
assert_eq!(hit.response.headers()["x-upstream-cache"], "Origin-HIT");
assert_eq!(
hit.response
.headers()
.get_all("cache-status")
.iter()
.count(),
2
);
shutdown.cancel();
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
let dir = tempfile::tempdir().unwrap();
+200 -31
View File
@@ -103,7 +103,10 @@ mod dashboard_tests {
}
#[test]
fn bypass_reasons_are_fixed_and_private_inputs_are_not_echoed() {
let settings = Settings { varnish_compatible: false, ..Settings::default() };
let settings = Settings {
varnish_compatible: false,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.insert("cookie", HeaderValue::from_static("session=very-private"));
assert_eq!(
@@ -125,13 +128,15 @@ mod dashboard_tests {
expose_cache_status: true,
..settings
};
diagnostics.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert_eq!(headers["x-cache-reason"], "request_cookie");
assert_eq!(
headers["cache-status"],
"RustProxy; fwd=bypass; detail=\"request_cookie\""
diagnostics.annotate_reason_transformed(
&mut headers,
"BYPASS",
0,
0,
"request_cookie",
true,
);
diagnostics.annotate_transformed(&mut headers, true);
assert_eq!(headers["x-cache-reason"], "request_cookie");
assert_eq!(
headers["cache-status"],
"RustProxy; fwd=bypass; detail=\"request_cookie\"; transformed"
@@ -149,29 +154,39 @@ mod dashboard_tests {
};
let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream"));
headers.insert(
"cache-status",
HeaderValue::from_static("untrusted-upstream"),
headers.insert("cache-status", HeaderValue::from_static("Origin; hit"));
global.annotate_reason_transformed(
&mut headers,
"MISS",
0,
0,
"response_headers_policy",
true,
);
global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy");
global.annotate_transformed(&mut headers, true);
for name in [
"x-cache",
"x-cache-hits",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
assert!(!headers.contains_key(name), "{name} leaked on MISS");
}
assert_eq!(headers["x-cache"], "untrusted-upstream");
assert_eq!(headers["cache-status"], "Origin; hit");
assert!(!headers.contains_key("x-cache-hits"));
assert!(!headers.contains_key("x-cache-reason"));
assert_eq!(headers["via"], "RustProxy");
global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert!(!headers.contains_key("cache-status"));
assert_eq!(headers.get_all("cache-status").iter().count(), 1);
global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit");
global.annotate_resident_age(&mut headers, 10);
assert_eq!(headers["x-cache"], "HIT");
assert_eq!(headers["x-cache-hits"], "4");
assert!(headers["cache-status"].to_str().unwrap().contains("; hit;"));
let chain: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(
chain,
vec![
"Origin; hit",
"RustProxy; hit; ttl=60; detail=\"cache_hit\""
]
);
assert_eq!(headers["x-upstream-cache"], "untrusted-upstream");
assert_eq!(headers["x-cache-resident-age"], "10");
let mut override_on = Overrides {
expose_only_hits: Some(false),
@@ -210,7 +225,7 @@ mod dashboard_tests {
}
#[test]
fn disabled_cache_strips_diagnostics_without_exposing_bypass_headers() {
fn disabled_cache_forwards_origin_diagnostics_unchanged() {
let settings = Settings {
enabled: false,
expose_status: true,
@@ -228,16 +243,170 @@ mod dashboard_tests {
headers.insert("x-cache-resident-age", HeaderValue::from_static("5"));
settings.annotate_reason(&mut headers, "BYPASS", 0, 0, "cache_disabled");
settings.annotate_resident_age(&mut headers, 9);
for name in [
"x-cache",
"x-cache-hits",
"x-cache-status",
assert_eq!(headers["x-cache"], "origin");
assert_eq!(headers["x-cache-hits"], "99");
assert_eq!(headers["x-cache-status"], "origin");
assert_eq!(headers["cache-status"], "Origin; hit");
assert_eq!(headers["x-cache-reason"], "origin");
assert_eq!(headers["x-cache-resident-age"], "5");
assert!(!headers.contains_key("x-upstream-cache"));
assert!(!headers.contains_key("via"));
}
#[test]
fn chain_and_legacy_header_collisions_keep_origin_values() {
let settings = Settings {
expose_status: true,
expose_hits: true,
expose_cache_status: true,
expose_resident_age: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.append(
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
HeaderValue::from_static("Inner; hit; ttl=100"),
);
headers.append("cache-status", HeaderValue::from_static("Shield; fwd=miss"));
for (name, value) in [
("x-cache", "BACKEND-MISS"),
("x-cache-hits", "21"),
("x-cache-reason", "origin_reason"),
("x-cache-resident-age", "17"),
("x-cache-status", "backend-status"),
] {
assert!(!headers.contains_key(name), "{name}");
headers.insert(name, value.parse().unwrap());
}
settings.annotate_reason_transformed(&mut headers, "HIT", 3, 482, "cache_hit", true);
settings.annotate_resident_age(&mut headers, 9);
let cache_chain: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(
cache_chain,
vec![
"Inner; hit; ttl=100",
"Shield; fwd=miss",
"RustProxy; hit; ttl=482; detail=\"cache_hit\"; transformed",
]
);
assert_eq!(headers["x-cache"], "HIT");
assert_eq!(headers["x-upstream-cache"], "BACKEND-MISS");
assert_eq!(headers["x-cache-hits"], "3");
assert_eq!(headers["x-upstream-cache-hits"], "21");
assert_eq!(headers["x-cache-reason"], "cache_hit");
assert_eq!(headers["x-upstream-cache-reason"], "origin_reason");
assert_eq!(headers["x-cache-resident-age"], "9");
assert_eq!(headers["x-upstream-cache-resident-age"], "17");
assert_eq!(headers["x-cache-status"], "backend-status");
}
#[test]
fn diagnostic_options_do_not_hide_backend_cache_status() {
for (enabled, status, only_hits) in [
(true, false, false),
(true, true, true),
(false, true, false),
] {
let settings = Settings {
enabled,
expose_status: status,
expose_cache_status: status,
expose_only_hits: only_hits,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("Backend; MISS"));
headers.insert("cache-status", HeaderValue::from_static("Origin; fwd=miss"));
settings.annotate_reason_transformed(&mut headers, "MISS", 0, 0, "cache_miss", true);
assert_eq!(headers["x-cache"], "Backend; MISS");
assert_eq!(headers["cache-status"], "Origin; fwd=miss");
assert_eq!(headers.get_all("cache-status").iter().count(), 1);
assert!(!headers.contains_key("x-upstream-cache"));
}
}
#[test]
fn repeated_upstream_legacy_values_are_not_collapsed_or_discarded() {
let settings = Settings {
expose_status: true,
expose_cache_status: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.append("x-cache", HeaderValue::from_static("inner-HIT"));
headers.append("x-cache", HeaderValue::from_static("outer-MISS"));
headers.append("cache-status", HeaderValue::from_static("Inner; hit"));
headers.append("cache-status", HeaderValue::from_static("Outer; fwd=miss"));
settings.annotate_reason(&mut headers, "MISS", 0, 0, "cache_miss");
let upstream_values: Vec<_> = headers
.get_all("x-upstream-cache")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(upstream_values, vec!["inner-HIT", "outer-MISS"]);
assert_eq!(headers["x-cache"], "MISS");
let status_chain: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(
status_chain,
vec![
"Inner; hit",
"Outer; fwd=miss",
"RustProxy; fwd=miss; detail=\"cache_miss\"",
]
);
}
#[test]
fn client_no_cache_miss_does_not_claim_a_fresh_candidate() {
let settings = Settings {
expose_cache_status: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
settings.annotate_reason(&mut headers, "MISS", 0, 0, "request_no_cache");
assert_eq!(
headers["cache-status"],
"RustProxy; fwd=miss; detail=\"request_no_cache\"",
);
}
#[test]
fn revalidated_304_is_forwarded_under_rfc_9211_even_if_legacy_x_cache_is_hit() {
let settings = Settings {
expose_status: true,
expose_cache_status: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.insert("cache-status", HeaderValue::from_static("Origin; hit"));
settings.annotate_reason_transformed(
&mut headers,
"HIT",
2,
50,
"cache_revalidated",
false,
);
assert_eq!(headers["x-cache"], "HIT");
let chain: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert_eq!(
chain,
vec![
"Origin; hit",
"RustProxy; fwd=stale; fwd-status=304; detail=\"cache_revalidated\"",
]
);
}
#[test]
+1 -8
View File
@@ -97,14 +97,7 @@ impl Candidate {
self.headers.insert("date", value);
}
}
for name in [
"x-request-id",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-reason",
"cache-status",
] {
for name in ["x-request-id"] {
self.headers.remove(name);
}
let vary = std::mem::take(&mut self.plan.vary);
+1 -8
View File
@@ -27,14 +27,7 @@ impl Cache {
headers.insert("date", value);
}
}
for name in [
"x-request-id",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-reason",
"cache-status",
] {
for name in ["x-request-id"] {
headers.remove(name);
}
let hits = cached.hits.saturating_add(1);
+87 -74
View File
@@ -171,16 +171,41 @@ impl Settings {
.any(|item| item.eq_ignore_ascii_case(extension))
}
pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) {
for name in [
"x-cache",
"x-cache-hits",
"x-cache-status",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
headers.remove(name);
}
self.annotate_internal(headers, outcome, hits, ttl, None, false);
}
pub fn annotate_reason(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: &'static str,
) {
self.annotate_internal(headers, outcome, hits, ttl, Some(reason), false);
}
pub fn annotate_reason_transformed(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: &'static str,
transformed: bool,
) {
self.annotate_internal(headers, outcome, hits, ttl, Some(reason), transformed);
}
fn annotate_internal(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: Option<&'static str>,
transformed: bool,
) {
if !self.enabled {
return;
}
@@ -196,89 +221,77 @@ impl Settings {
return;
}
if self.expose_status {
headers.insert("x-cache", HeaderValue::from_static(outcome));
insert_cache_diagnostic(
headers,
"x-cache",
"x-upstream-cache",
HeaderValue::from_static(outcome),
);
if let Some(reason) = reason {
insert_cache_diagnostic(
headers,
"x-cache-reason",
"x-upstream-cache-reason",
HeaderValue::from_static(reason),
);
}
}
if self.expose_hits {
headers.insert(
insert_cache_diagnostic(
headers,
"x-cache-hits",
"x-upstream-cache-hits",
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
);
}
if self.expose_cache_status {
let value = match outcome {
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
"MISS" => "RustProxy; fwd=uri-miss".into(),
_ => "RustProxy; fwd=bypass".into(),
let mut value = if matches!(
reason,
Some("cache_revalidated" | "cache_revalidated_uncached")
) {
"RustProxy; fwd=stale; fwd-status=304".to_owned()
} else {
match outcome {
"MISS" => "RustProxy; fwd=miss".to_owned(),
_ => "RustProxy; fwd=bypass".to_owned(),
}
};
headers.insert(
if let Some(reason) = reason {
value.push_str(&format!("; detail=\"{reason}\""));
}
if transformed {
value.push_str("; transformed");
}
headers.append(
"cache-status",
HeaderValue::from_str(&value).expect("generated cache status"),
HeaderValue::from_str(&value).expect("generated Cache-Status member"),
);
}
}
pub fn annotate_resident_age(&self, headers: &mut HeaderMap, resident_age: u64) {
headers.remove("x-cache-resident-age");
if !self.enabled {
return;
}
if self.expose_resident_age {
headers.insert(
if self.enabled && self.expose_resident_age {
insert_cache_diagnostic(
headers,
"x-cache-resident-age",
"x-upstream-cache-resident-age",
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
);
}
}
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
if !self.enabled
|| !self.expose_cache_status
|| !transformed
|| (self.expose_only_hits
&& !headers
.get("cache-status")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("RustProxy; hit")))
{
return;
}
let base = headers
.get("cache-status")
.and_then(|value| value.to_str().ok())
.unwrap_or("RustProxy");
if base
.split(';')
.any(|part| part.trim().eq_ignore_ascii_case("transformed"))
{
return;
}
if let Ok(value) = format!("{base}; transformed").parse() {
headers.insert("cache-status", value);
}
}
pub fn annotate_reason(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: &'static str,
) {
self.annotate(headers, outcome, hits, ttl);
if !self.enabled || (self.expose_only_hits && outcome != "HIT") {
return;
}
if self.expose_status {
headers.insert("x-cache-reason", HeaderValue::from_static(reason));
}
if self.expose_cache_status {
let base = headers
.get("cache-status")
.and_then(|v| v.to_str().ok())
.unwrap_or("RustProxy");
if let Ok(value) = format!("{base}; detail=\"{reason}\"").parse() {
headers.insert("cache-status", value);
}
}
}
fn insert_cache_diagnostic(
headers: &mut HeaderMap,
name: &'static str,
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);
}
headers.insert(name, value);
}
fn valid_relative_cache_path(value: &str) -> bool {
if value.is_empty() || value.len() > 256 {
+4
View File
@@ -87,6 +87,10 @@ fn enforce_local_page_no_cache(response: &mut Response) {
"x-cache-status",
"x-cache-resident-age",
"x-cache-reason",
"x-upstream-cache",
"x-upstream-cache-hits",
"x-upstream-cache-reason",
"x-upstream-cache-resident-age",
"cache-status",
] {
response.headers_mut().remove(name);
+144 -1
View File
@@ -472,7 +472,9 @@ mod cache_fast_path_tests {
"cache-control",
HeaderValue::from_static(match parts.uri.path() {
"/private" => "private, max-age=600",
"/backend-no-cache" => "public, no-cache, max-age=600",
"/backend-no-cache" | "/cache-headers-revalidate" => {
"public, no-cache, max-age=600"
}
"/backend-zero-stale" => "public, max-age=0, stale-if-error=120",
"/grace" => "public, max-age=1",
_ => "public, max-age=600",
@@ -484,6 +486,28 @@ mod cache_fast_path_tests {
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/plain"));
if matches!(
parts.uri.path(),
"/cache-headers" | "/cache-headers-revalidate"
) {
for (name, value) in [
("x-cache", "backend-MISS"),
("x-cache-hits", "14"),
("x-cache-status", "origin-status"),
("x-cache-reason", "origin_reason"),
("x-cache-resident-age", "16"),
] {
response.headers_mut().insert(name, value.parse().unwrap());
}
response.headers_mut().append(
"cache-status",
HeaderValue::from_static("InnerCache; hit; ttl=120"),
);
response.headers_mut().append(
"cache-status",
HeaderValue::from_static("OriginCache; fwd=miss"),
);
}
for name in [
"range",
"if-range",
@@ -577,6 +601,124 @@ mod cache_fast_path_tests {
fixture.stop().await;
}
fn cache_chain(headers: &HeaderMap) -> Vec<&str> {
headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect()
}
#[tokio::test]
async fn origin_cache_headers_survive_bypass_and_hot_storage_when_rustproxy_debug_is_off() {
for cache_enabled in [false, true] {
let origin = origin().await;
let mut config = config_for(origin.addr, cache_enabled);
config.cache.expose_status = false;
config.cache.expose_hits = false;
config.cache.expose_cache_status = false;
config.cache.expose_resident_age = false;
let fixture = Fixture::new(config).await;
for _ in 0..2 {
let response = fixture.get("/cache-headers", &[]).await;
assert_eq!(response.status, StatusCode::OK);
assert_eq!(response.headers["x-cache"], "backend-MISS");
assert_eq!(response.headers["x-cache-hits"], "14");
assert_eq!(response.headers["x-cache-status"], "origin-status");
assert_eq!(response.headers["x-cache-reason"], "origin_reason");
assert_eq!(response.headers["x-cache-resident-age"], "16");
assert_eq!(
cache_chain(&response.headers),
vec!["InnerCache; hit; ttl=120", "OriginCache; fwd=miss",]
);
}
assert_eq!(
origin.requests.load(Ordering::Relaxed),
if cache_enabled { 1 } else { 2 }
);
fixture.stop().await;
}
}
#[tokio::test]
async fn origin_and_rustproxy_diagnostics_coexist_on_miss_hit_and_hit_only() {
for hit_only in [false, true] {
let origin = origin().await;
let mut config = config_for(origin.addr, true);
config.cache.expose_only_hits = hit_only;
config.cache.expose_resident_age = true;
let fixture = Fixture::new(config).await;
let miss = fixture.get("/cache-headers", &[]).await;
assert_eq!(miss.status, StatusCode::OK);
assert_eq!(
miss.headers["x-cache"],
if hit_only { "backend-MISS" } else { "MISS" }
);
assert_eq!(miss.headers["x-cache-status"], "origin-status");
let miss_chain = cache_chain(&miss.headers);
assert_eq!(
&miss_chain[..2],
&["InnerCache; hit; ttl=120", "OriginCache; fwd=miss"][..]
);
if hit_only {
assert_eq!(miss_chain.len(), 2);
assert!(!miss.headers.contains_key("x-upstream-cache"));
} else {
assert_eq!(miss.headers["x-upstream-cache"], "backend-MISS");
assert_eq!(miss.headers["x-upstream-cache-hits"], "14");
assert!(miss_chain[2].starts_with("RustProxy; fwd=miss; detail="));
}
let hit = fixture.get("/cache-headers", &[]).await;
assert_eq!(hit.status, StatusCode::OK);
assert_eq!(hit.headers["x-cache"], "HIT");
assert_eq!(hit.headers["x-upstream-cache"], "backend-MISS");
assert_eq!(hit.headers["x-upstream-cache-hits"], "14");
assert_eq!(hit.headers["x-upstream-cache-reason"], "origin_reason");
assert_eq!(hit.headers["x-upstream-cache-resident-age"], "16");
assert_eq!(hit.headers["x-cache-status"], "origin-status");
let hit_chain = cache_chain(&hit.headers);
assert_eq!(
&hit_chain[..2],
&["InnerCache; hit; ttl=120", "OriginCache; fwd=miss"][..]
);
assert_eq!(hit_chain.len(), 3);
assert!(hit_chain[2].starts_with("RustProxy; hit; ttl="));
assert!(hit_chain[2].ends_with("; detail=\"cache_hit\""));
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
fixture.stop().await;
}
}
#[tokio::test]
async fn origin_cache_status_survives_conditional_304_and_hot_refresh() {
let origin = origin().await;
let mut config = config_for(origin.addr, true);
config.cache.default_ttl_seconds = 0;
let fixture = Fixture::new(config).await;
let first = fixture.get("/cache-headers-revalidate", &[]).await;
assert_eq!(first.headers["x-cache"], "MISS");
assert_eq!(first.headers["x-upstream-cache"], "backend-MISS");
assert_eq!(cache_chain(&first.headers).len(), 3);
let validated = fixture.get("/cache-headers-revalidate", &[]).await;
assert_eq!(validated.status, StatusCode::OK);
assert_eq!(validated.headers["x-cache"], "HIT");
assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated");
assert_eq!(validated.headers["x-upstream-cache"], "backend-MISS");
assert_eq!(validated.headers["x-cache-status"], "origin-status");
let chain = cache_chain(&validated.headers);
assert_eq!(
&chain[..2],
&["InnerCache; hit; ttl=120", "OriginCache; fwd=miss"][..]
);
assert_eq!(chain.len(), 3);
assert_eq!(
chain[2],
"RustProxy; fwd=stale; fwd-status=304; detail=\"cache_revalidated\""
);
assert_eq!(origin.requests.load(Ordering::Relaxed), 2);
fixture.stop().await;
}
#[tokio::test]
async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() {
let origin = origin().await;
@@ -648,6 +790,7 @@ mod cache_fast_path_tests {
.get("/missing", &[("cache-control", "no-cache, only-if-cached")])
.await;
assert_eq!(miss.status, StatusCode::GATEWAY_TIMEOUT);
assert!(!miss.headers.contains_key("cache-status"));
assert_eq!(
origin.requests.load(Ordering::Relaxed),
0,
+8 -9
View File
@@ -26,8 +26,7 @@ if let Some(ticket) = &cache_ticket {
} else {
"cache_hit"
};
// only-if-cached forbids any origin communication, including
// asynchronous grace validation and proactive refresh.
if hit.background_revalidate && !only_cached {
if let (Some(cached), Some(guard)) = (
cached_revalidation.clone(),
@@ -108,14 +107,14 @@ if let Some(ticket) = &cache_ticket {
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
hit.response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
reason,
hit.transformed,
);
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
@@ -165,14 +164,14 @@ let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str
if let crate::cache::RangeSelection::Single(range) = range_selection {
hit = hit.apply_range(range, if_range.as_ref());
}
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
hit.response.headers_mut(),
"HIT",
hit.hits,
0,
reason,
hit.transformed,
);
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
@@ -198,6 +197,7 @@ if only_cached {
"RustProxy: no reusable cached response\n",
);
cache_settings.annotate(response.headers_mut(), "MISS", 0, 0);
response.headers_mut().remove("cache-status");
return response;
}
@@ -254,15 +254,14 @@ if handshake.is_none()
if conditional {
hit.response = crate::cache::make_not_modified(hit.response);
}
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
hit.response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
reason,
hit.transformed,
);
cache_settings
.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
+7 -8
View File
@@ -21,15 +21,14 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
}
let resident_age = hit.resident_age_seconds;
let mut response = hit.response;
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
"cache_revalidated",
hit.transformed,
);
cache_settings
.annotate_transformed(response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
@@ -46,15 +45,14 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
} else {
cached.response(merged)
};
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
response.headers_mut(),
"HIT",
0,
0,
"cache_revalidated_uncached",
cached.transformed(),
);
cache_settings
.annotate_transformed(response.headers_mut(), cached.transformed());
if cached.transformed() {
state.transform_metrics.record_cache_hit(scope);
}
@@ -184,7 +182,8 @@ let transformed = response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some();
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
cache_settings.annotate_transformed(response.headers_mut(), transformed);
cache_settings.annotate_reason_transformed(
response.headers_mut(), outcome, 0, 0, reason, transformed,
);
response
}
+5 -5
View File
@@ -96,9 +96,8 @@
if request_method == Method::HEAD {
*response.body_mut() = Body::empty();
}
cache_settings.annotate_reason(response.headers_mut(), "HIT", hit.hits,
hit.remaining_ttl, "cache_hit");
cache_settings.annotate_transformed(response.headers_mut(), hit.transformed);
cache_settings.annotate_reason_transformed(response.headers_mut(), "HIT", hit.hits,
hit.remaining_ttl, "cache_hit", hit.transformed);
cache_settings.annotate_resident_age(response.headers_mut(), hit.resident_age_seconds);
state.cache.record(scope, &host, "HIT", "cache_hit");
if hit.transformed { state.transform_metrics.record_cache_hit(scope); }
@@ -257,9 +256,10 @@
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason;
}
state.cache.record(scope, &host, outcome, reason);
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
cache_settings.annotate_transformed(response.headers_mut(), transformed);
cache_settings.annotate_reason_transformed(
response.headers_mut(), outcome, 0, 0, reason, transformed,
);
#[cfg(target_os = "linux")]
if !enabled && identity_request && request_method == Method::GET
&& !transform_settings.is_some_and(|settings| settings.enabled)
+10 -1
View File
@@ -322,13 +322,22 @@ mod authority_tests {
("etag", "\"old\""),
("x-cache", "HIT"),
("x-cache-reason", "origin"),
("x-upstream-cache", "origin-MISS"),
("x-upstream-cache-hits", "3"),
("cache-status", "Origin; hit"),
] {
response.headers_mut().insert(name, value.parse().unwrap());
}
enforce_local_page_no_cache(&mut response);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
for name in ["etag", "x-cache", "x-cache-reason", "cache-status"] {
for name in [
"etag",
"x-cache",
"x-cache-reason",
"x-upstream-cache",
"x-upstream-cache-hits",
"cache-status",
] {
assert!(!response.headers().contains_key(name), "{name}");
}
assert!(response
+18 -2
View File
@@ -258,6 +258,12 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
"x-cache-hits",
"x-cache-status",
"x-cache-resident-age",
"x-cache-reason",
"x-upstream-cache",
"x-upstream-cache-hits",
"x-upstream-cache-reason",
"x-upstream-cache-resident-age",
"cache-status",
"content-encoding",
"vary",
"strict-transport-security",
@@ -395,7 +401,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner());
cache.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
let transformed = response
.extensions()
.get::<crate::transform::Applied>()
@@ -404,7 +409,14 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some();
cache.annotate_transformed(response.headers_mut(), transformed);
cache.annotate_reason_transformed(
response.headers_mut(),
outcome,
0,
0,
reason,
transformed,
);
}
}
if !minimal_welcome {
@@ -441,6 +453,10 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
"x-cache-status",
"x-cache-resident-age",
"x-cache-reason",
"x-upstream-cache",
"x-upstream-cache-hits",
"x-upstream-cache-reason",
"x-upstream-cache-resident-age",
"cache-status",
] {
response.headers_mut().remove(name);
+8 -2
View File
@@ -129,8 +129,14 @@ pub(super) fn finish_web_revalidation(
if *method == Method::HEAD {
*response.body_mut() = Body::empty();
}
settings.annotate_reason(response.headers_mut(), "HIT", hits, ttl, reason);
settings.annotate_transformed(response.headers_mut(), transformed);
settings.annotate_reason_transformed(
response.headers_mut(),
"HIT",
hits,
ttl,
reason,
transformed,
);
settings.annotate_resident_age(response.headers_mut(), resident_age);
Some(WebRevalidated {
response,