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` : "-");