v0.34.12
This commit is contained in:
1 parent
37c197c095
commit
5ac3617947
48 files changed
+1049
-255
No files matched your search
@@ -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": [
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(" / ");
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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>
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}"]`);
|
||||
|
||||
@@ -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` : "-");
|
||||
|
||||
Vendored
+84
@@ -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();
|
||||
|
||||
Vendored
+200
-31
@@ -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]
|
||||
|
||||
Vendored
+1
-8
@@ -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);
|
||||
|
||||
Vendored
+1
-8
@@ -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);
|
||||
|
||||
Vendored
+87
-74
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user