This commit is contained in:
Mateusz Gruszczyński committed 2026-11-07 13:56:11 +01:00
1 parent 672c84bc13
commit 6f52ac3d00
38 files changed
+1183 -221

No files matched your search

+10 -5
View File
@@ -171,6 +171,7 @@ async fn metrics_summary(State(state): State<Arc<App>>) -> Response {
#[serde(default, deny_unknown_fields)]
struct EventsQuery {
interval: Option<u64>,
logs: Option<bool>,
}
async fn events(
@@ -179,6 +180,7 @@ async fn events(
Query(query): Query<EventsQuery>,
) -> Result<Response, ApiError> {
let interval = query.interval.unwrap_or(3);
let include_logs = query.logs.unwrap_or(true);
if ![1, 2, 3, 5, 10].contains(&interval) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -198,8 +200,8 @@ async fn events(
)
})?;
let stream = futures_util::stream::unfold(
(state, secret, permit, false, interval),
|(state, secret, permit, delayed, interval)| async move {
(state, secret, permit, false, interval, include_logs),
|(state, secret, permit, delayed, interval, include_logs)| async move {
if delayed {
tokio::time::sleep(Duration::from_secs(interval)).await;
}
@@ -218,13 +220,16 @@ async fn events(
Ok(Some(_)) => {}
_ => return None,
}
let payload =
let payload = if include_logs {
json!({"status":live_status_payload(&state), "logs":logs_payload(&state)})
.to_string();
} else {
json!({"status":live_status_payload(&state)})
}
.to_string();
let event = Event::default().event("snapshot").data(payload);
Some((
Ok::<Event, Infallible>(event),
(state, secret, permit, true, interval),
(state, secret, permit, true, interval, include_logs),
))
},
);
+184 -34
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.36.0",
"version": "0.36.1",
"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": [
@@ -443,10 +443,31 @@
"type": "object",
"description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.",
"additionalProperties": false,
"required": ["provider", "method"],
"required": [
"provider",
"method"
],
"properties": {
"provider": {"type": "string", "enum": ["basic", "tinyauth", "authelia", "authentik", "pocket_id", "custom"]},
"method": {"type": "string", "enum": ["basic", "forward_auth", "oidc"], "description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."}
"provider": {
"type": "string",
"enum": [
"basic",
"tinyauth",
"authelia",
"authentik",
"pocket_id",
"custom"
]
},
"method": {
"type": "string",
"enum": [
"basic",
"forward_auth",
"oidc"
],
"description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."
}
}
},
"OidcConfig": {
@@ -454,20 +475,76 @@
"description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean", "default": false},
"issuer_url": {"type": "string", "format": "uri", "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"},
"backend_url": {"type": "string", "format": "uri", "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"},
"redirect_url": {"type": "string", "format": "uri", "description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"},
"client_id": {"type": "string", "maxLength": 256},
"client_secret": {"type": "string", "maxLength": 2048, "description": "Confidential OIDC client secret; stored in Vhost configuration"},
"session_secret": {"type": "string", "minLength": 32, "maxLength": 4096, "description": "High-entropy secret for encrypted session and temporary state cookies"},
"scopes": {"type": "string", "default": "openid profile email"},
"allow_private": {"type": "boolean", "default": false},
"address_allowlist": {"type": "array", "items": {"type": "string"}},
"tls_ca_pem": {"type": "string", "nullable": true},
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
"session_ttl_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "default": 3600}
"enabled": {
"type": "boolean",
"default": false
},
"issuer_url": {
"type": "string",
"format": "uri",
"description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"
},
"backend_url": {
"type": "string",
"format": "uri",
"description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"
},
"redirect_url": {
"type": "string",
"format": "uri",
"description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"
},
"client_id": {
"type": "string",
"maxLength": 256
},
"client_secret": {
"type": "string",
"maxLength": 2048,
"description": "Confidential OIDC client secret; stored in Vhost configuration"
},
"session_secret": {
"type": "string",
"minLength": 32,
"maxLength": 4096,
"description": "High-entropy secret for encrypted session and temporary state cookies"
},
"scopes": {
"type": "string",
"default": "openid profile email"
},
"allow_private": {
"type": "boolean",
"default": false
},
"address_allowlist": {
"type": "array",
"items": {
"type": "string"
}
},
"tls_ca_pem": {
"type": "string",
"nullable": true
},
"connect_timeout_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"default": 5
},
"timeout_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 60,
"default": 10
},
"session_ttl_seconds": {
"type": "integer",
"minimum": 60,
"maximum": 86400,
"default": 3600
}
}
},
"ForwardAuthConfig": {
@@ -475,17 +552,70 @@
"description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean", "default": false},
"url": {"type": "string", "maxLength": 2048, "description": "Internal service/outpost origin or the provider's verifier endpoint"},
"login_url": {"type": "string", "maxLength": 2048, "description": "Required public HTTPS portal URL for Authelia; ignored for authentik"},
"allow_private": {"type": "boolean", "default": false},
"address_allowlist": {"type": "array", "maxItems": 32, "items": {"type": "string"}},
"tls_ca_pem": {"type": "string", "nullable": true},
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
"request_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"},
"response_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"},
"redirect_forbidden": {"type": "boolean", "default": false, "description": "Authelia only: accept trusted portal Location on explicit 403 if provided"}
"enabled": {
"type": "boolean",
"default": false
},
"url": {
"type": "string",
"maxLength": 2048,
"description": "Internal service/outpost origin or the provider's verifier endpoint"
},
"login_url": {
"type": "string",
"maxLength": 2048,
"description": "Required public HTTPS portal URL for Authelia; ignored for authentik"
},
"allow_private": {
"type": "boolean",
"default": false
},
"address_allowlist": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string"
}
},
"tls_ca_pem": {
"type": "string",
"nullable": true
},
"connect_timeout_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"default": 5
},
"timeout_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 60,
"default": 10
},
"request_headers": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 128
},
"description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"
},
"response_headers": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 128
},
"description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"
},
"redirect_forbidden": {
"type": "boolean",
"default": false,
"description": "Authelia only: accept trusted portal Location on explicit 403 if provided"
}
}
},
"Vhost": {
@@ -591,8 +721,13 @@
"$ref": "#/components/schemas/ForwardAuthConfig",
"description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia."
},
"oidc": {"$ref": "#/components/schemas/OidcConfig"},
"authentication": {"$ref": "#/components/schemas/AuthenticationChoice", "description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."},
"oidc": {
"$ref": "#/components/schemas/OidcConfig"
},
"authentication": {
"$ref": "#/components/schemas/AuthenticationChoice",
"description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."
},
"cache": {
"type": "object",
"properties": {
@@ -3687,6 +3822,11 @@
"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."
},
"interface_icons": {
"type": "boolean",
"default": false,
"description": "Account-scoped administration UI preference. When enabled, semantic inline SVG icons are added to navigation, actions, tabs and dialogs while preserving text labels and accessibility."
},
"button_style": {
"type": "string",
"enum": [
@@ -10414,7 +10554,7 @@
},
"/api/events": {
"get": {
"summary": "Live SSE status and log snapshots",
"summary": "Live SSE status snapshots with optional logs",
"tags": [
"Monitoring"
],
@@ -10471,7 +10611,7 @@
}
}
},
"description": "SSE snapshot events contain compact volatile status and bounded logs. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.",
"description": "SSE snapshot events always contain compact volatile status. Bounded recent logs are included by default for backward compatibility and can be disabled per viewer with logs=false; the administration panel disables them until Live Logs is open/docked and polls /api/logs only while that view is active. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.",
"parameters": [
{
"name": "interval",
@@ -10489,6 +10629,16 @@
],
"default": 3
}
},
{
"name": "logs",
"in": "query",
"required": false,
"description": "Include the bounded live log snapshot in each SSE event. Defaults to true for API compatibility; the panel uses false and fetches logs only while Live Logs is active.",
"schema": {
"type": "boolean",
"default": true
}
}
]
}
@@ -14446,4 +14596,4 @@
}
},
"tags": []
}
}
+3
View File
@@ -118,6 +118,8 @@ struct AdminPreferences {
chart_style: String,
#[serde(default)]
chart_axes: bool,
#[serde(default)]
interface_icons: bool,
#[serde(default = "default_admin_button_style")]
button_style: String,
#[serde(default = "default_admin_corner_style")]
@@ -180,6 +182,7 @@ impl Default for AdminPreferences {
time_format: default_admin_time_format(),
chart_style: default_admin_chart_style(),
chart_axes: false,
interface_icons: false,
button_style: default_admin_button_style(),
corner_style: default_admin_corner_style(),
table_density: default_admin_table_density(),
+14
View File
@@ -41,6 +41,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);
assert!(!AdminPreferences::default().interface_icons);
}
#[test]
fn optional_interface_icons_preference_is_backward_compatible_and_off_by_default() {
let legacy: AdminPreferences = serde_json::from_str(r#"{"theme":"light"}"#).unwrap();
assert!(!legacy.interface_icons);
let mut enabled = legacy.clone();
enabled.interface_icons = true;
assert!(validate_admin_preferences(&enabled).is_ok());
let stored = serde_json::to_string(&enabled).unwrap();
assert!(stored.contains("\"interface_icons\":true"));
let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap();
assert!(recovered.interface_icons);
}
#[test]
+1
View File
@@ -57,6 +57,7 @@ fn admin_app_javascript() -> &'static str {
");",
include_str!("web/app/overview-samples.js"),
include_str!("web/app/core.js"),
include_str!("web/app/icons.js"),
include_str!("web/app/path-permissions.js"),
include_str!("web/app/session-resume.js"),
include_str!("web/app/refresh.js"),
+17 -1
View File
@@ -2146,6 +2146,7 @@ mod tests {
assert_eq!(initial["time_format"], "24h");
assert_eq!(initial["chart_style"], "default");
assert_eq!(initial["chart_axes"], false);
assert_eq!(initial["interface_icons"], false);
assert_eq!(initial["button_style"], "normal");
assert_eq!(initial["corner_style"], "default");
assert_eq!(
@@ -2183,6 +2184,7 @@ mod tests {
assert_eq!(legacy["time_format"], "24h");
assert_eq!(legacy["chart_style"], "default");
assert_eq!(legacy["chart_axes"], false);
assert_eq!(legacy["interface_icons"], false);
assert_eq!(legacy["button_style"], "normal");
assert_eq!(legacy["corner_style"], "default");
assert_eq!(
@@ -2205,6 +2207,7 @@ mod tests {
"time_format":"12h",
"chart_style":"neon",
"chart_axes":true,
"interface_icons":true,
"button_style":"compact",
"corner_style":"rounded",
"table_density":"ultra",
@@ -3524,6 +3527,16 @@ mod tests {
.await
.unwrap();
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
let bad_sse_logs = router
.oneshot(
request("/api/events?logs=maybe")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(bad_sse_logs.status(), StatusCode::BAD_REQUEST);
store.close().await;
}
@@ -3562,7 +3575,10 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
let value = json_body(response).await;
for &field in omitted {
assert!(value.get(field).is_none(), "{path} unexpectedly contains {field}");
assert!(
value.get(field).is_none(),
"{path} unexpectedly contains {field}"
);
}
for &field in retained {
assert!(value.get(field).is_some(), "{path} is missing {field}");
+8 -10
View File
@@ -59,6 +59,7 @@ function translate() {
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
syncSettingsDirtyIndicators();
window.rustProxyIcons?.refresh?.();
}
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
function raiseToast() {
@@ -379,13 +380,9 @@ function renderFooter(s) {
}
let lastLogs = null;
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
async function refresh(force = false, includeLogs = false) {
async function refresh(force = false) {
if (!authenticated) return;
if (force) {
const tasks = [rustProxyRefresh.manual("status-full")];
if (includeLogs) tasks.push(rustProxyRefresh.manual("logs-fallback"));
return Promise.all(tasks);
}
if (force) return rustProxyRefresh.manual("status-full");
return rustProxyRefresh.manual("status-fallback");
}
function renderLiveStatus(value) { renderStatus(value); }
@@ -399,15 +396,16 @@ function startLive() {
if (liveEvents && liveInterval === seconds) return;
stopLive();
liveInterval = seconds;
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}`);
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}&logs=false`);
liveEvents = stream;
stream.addEventListener("snapshot", event => {
if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return;
try {
const payload = JSON.parse(event.data);
if (!payload.status || !payload.logs) throw new Error("Invalid SSE payload");
if (!payload.status) throw new Error("Invalid SSE payload");
liveConnected = true; liveFallback = false;
renderLiveStatus(payload.status); renderLogs(payload.logs);
renderLiveStatus(payload.status);
if (payload.logs) renderLogs(payload.logs);
window.rustProxyRefresh?.sync();
} catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); }
});
@@ -529,7 +527,7 @@ async function activate(info) {
void window.rustProxyApiTokens?.refresh?.();
window.rustProxySettings?.scrollToApiTokens?.();
}
void refresh(true, true).catch(error => {
void refresh(true).catch(error => {
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
});
return true;
+251
View File
@@ -0,0 +1,251 @@
const UI_ICON_NS = "http://www.w3.org/2000/svg";
const UI_ICON_SHAPES = Object.freeze({
activity: [["path", { d: "M3 12h4l2-6 4 12 2-6h6" }]],
alert: [["path", { d: "M12 3 2.8 20h18.4L12 3Z" }], ["path", { d: "M12 9v4" }], ["circle", { cx: "12", cy: "17", r: ".7", fill: "currentColor", stroke: "none" }]],
archive: [["rect", { x: "3", y: "5", width: "18", height: "4", rx: "1" }], ["path", { d: "M5 9v10h14V9M9 13h6" }]],
book: [["path", { d: "M4 4.5h5.5A2.5 2.5 0 0 1 12 7v13a3 3 0 0 0-3-3H4V4.5ZM20 4.5h-5.5A2.5 2.5 0 0 0 12 7v13a3 3 0 0 1 3-3h5V4.5Z" }]],
box: [["path", { d: "m12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z" }], ["path", { d: "m4 7.5 8 4.5 8-4.5M12 12v9" }]],
certificate: [["rect", { x: "4", y: "3", width: "16", height: "14", rx: "2" }], ["path", { d: "M8 7h8M8 10h5M9 17l-1 4 4-2 4 2-1-4" }]],
check: [["path", { d: "m5 12 4 4L19 6" }]],
chevronDown: [["path", { d: "m6 9 6 6 6-6" }]],
chevronLeft: [["path", { d: "m15 6-6 6 6 6" }]],
chevronRight: [["path", { d: "m9 6 6 6-6 6" }]],
chevronUp: [["path", { d: "m6 15 6-6 6 6" }]],
code: [["path", { d: "m9 7-5 5 5 5M15 7l5 5-5 5M13 5l-2 14" }]],
command: [["rect", { x: "4", y: "4", width: "16", height: "16", rx: "4" }], ["path", { d: "M8 12h8M12 8v8" }]],
copy: [["rect", { x: "8", y: "8", width: "11", height: "11", rx: "2" }], ["path", { d: "M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" }]],
cpu: [["rect", { x: "7", y: "7", width: "10", height: "10", rx: "2" }], ["path", { d: "M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4M10 10h4v4h-4z" }]],
database: [["ellipse", { cx: "12", cy: "5", rx: "7", ry: "3" }], ["path", { d: "M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" }]],
download: [["path", { d: "M12 3v12m0 0-4-4m4 4 4-4M4 19h16" }]],
edit: [["path", { d: "M4 20h4l11-11-4-4L4 16v4ZM13.5 6.5l4 4" }]],
external: [["path", { d: "M14 4h6v6M20 4l-9 9" }], ["path", { d: "M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" }]],
eye: [["path", { d: "M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6S2.5 12 2.5 12Z" }], ["circle", { cx: "12", cy: "12", r: "2.5" }]],
eyeOff: [["path", { d: "M3 3l18 18M10.6 6.2A10 10 0 0 1 12 6c6 0 9.5 6 9.5 6a15 15 0 0 1-3 3.6M6.1 6.2C3.8 8 2.5 12 2.5 12s3.5 6 9.5 6a10 10 0 0 0 3.2-.5M9.9 9.9a3 3 0 0 0 4.2 4.2" }]],
file: [["path", { d: "M6 3h8l4 4v14H6V3Z" }], ["path", { d: "M14 3v5h5" }]],
fileX: [["path", { d: "M6 3h8l4 4v14H6V3ZM14 3v5h5M9 13l6 6M15 13l-6 6" }]],
filter: [["path", { d: "M3 5h18l-7 8v6l-4 2v-8L3 5Z" }]],
globe: [["circle", { cx: "12", cy: "12", r: "9" }], ["path", { d: "M3 12h18M12 3c2.5 2.7 3.5 5.7 3.5 9S14.5 18.3 12 21M12 3C9.5 5.7 8.5 8.7 8.5 12S9.5 18.3 12 21" }]],
grid: [["rect", { x: "4", y: "4", width: "6", height: "6", rx: "1" }], ["rect", { x: "14", y: "4", width: "6", height: "6", rx: "1" }], ["rect", { x: "4", y: "14", width: "6", height: "6", rx: "1" }], ["rect", { x: "14", y: "14", width: "6", height: "6", rx: "1" }]],
history: [["path", { d: "M4 8V4m0 0h4M4 4l3 3a8 8 0 1 1-2.2 7" }], ["path", { d: "M12 8v5l3 2" }]],
key: [["circle", { cx: "8", cy: "12", r: "4" }], ["path", { d: "M12 12h9M18 12v3M15 12v2" }]],
list: [["path", { d: "M9 6h11M9 12h11M9 18h11" }], ["circle", { cx: "4.5", cy: "6", r: ".8", fill: "currentColor", stroke: "none" }], ["circle", { cx: "4.5", cy: "12", r: ".8", fill: "currentColor", stroke: "none" }], ["circle", { cx: "4.5", cy: "18", r: ".8", fill: "currentColor", stroke: "none" }]],
loader: [["path", { d: "M20 12a8 8 0 1 1-2.3-5.7" }]],
lock: [["rect", { x: "5", y: "10", width: "14", height: "10", rx: "2" }], ["path", { d: "M8 10V7a4 4 0 0 1 8 0v3" }]],
login: [["path", { d: "M10 5H5v14h5M14 8l4 4-4 4M8 12h10" }]],
logout: [["path", { d: "M14 5h5v14h-5M10 8l-4 4 4 4M16 12H6" }]],
menu: [["path", { d: "M4 7h16M4 12h16M4 17h16" }]],
network: [["rect", { x: "9", y: "3", width: "6", height: "5", rx: "1" }], ["rect", { x: "3", y: "16", width: "6", height: "5", rx: "1" }], ["rect", { x: "15", y: "16", width: "6", height: "5", rx: "1" }], ["path", { d: "M12 8v4M6 16v-4h12v4" }]],
palette: [["path", { d: "M12 3a9 9 0 1 0 0 18h1.5a2 2 0 0 0 0-4H12a2 2 0 0 1 0-4h4a5 5 0 0 0 0-10h-4Z" }], ["circle", { cx: "7", cy: "10", r: "1", fill: "currentColor", stroke: "none" }], ["circle", { cx: "9", cy: "6.5", r: "1", fill: "currentColor", stroke: "none" }], ["circle", { cx: "14", cy: "6", r: "1", fill: "currentColor", stroke: "none" }]],
pause: [["path", { d: "M8 5v14M16 5v14" }]],
pin: [["path", { d: "m9 3 6 6-2 2 4 4-2 2-4-4-2 2-6-6 2-2 4 4 2-2-4-4 2-2ZM5 19l4-4" }]],
play: [["path", { d: "m8 5 11 7-11 7V5Z" }]],
plus: [["path", { d: "M12 5v14M5 12h14" }]],
refresh: [["path", { d: "M20 7v5h-5M4 17v-5h5" }], ["path", { d: "M18.5 9A7 7 0 0 0 6.2 6.2L4 8M5.5 15A7 7 0 0 0 17.8 17.8L20 16" }]],
route: [["circle", { cx: "6", cy: "6", r: "2" }], ["circle", { cx: "18", cy: "18", r: "2" }], ["path", { d: "M8 6h4a4 4 0 0 1 4 4v0a4 4 0 0 1-4 4H8a2 2 0 0 0-2 2v0" }]],
save: [["path", { d: "M5 4h12l2 2v14H5V4Z" }], ["path", { d: "M8 4v6h8V4M8 20v-6h8v6" }]],
search: [["circle", { cx: "10.5", cy: "10.5", r: "6.5" }], ["path", { d: "m15.5 15.5 5 5" }]],
settings: [["circle", { cx: "12", cy: "12", r: "3" }], ["path", { d: "M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4" }]],
shield: [["path", { d: "M12 3 20 6v6c0 5-3.3 8-8 9-4.7-1-8-4-8-9V6l8-3Z" }], ["path", { d: "m8.5 12 2.2 2.2 4.8-5" }]],
sliders: [["path", { d: "M4 7h6M14 7h6M4 17h10M18 17h2M10 4v6M14 14v6" }]],
sortDown: [["path", { d: "M8 5v14M5 16l3 3 3-3M14 8h5M14 12h4M14 16h3" }]],
sortUp: [["path", { d: "M8 19V5M5 8l3-3 3 3M14 8h3M14 12h4M14 16h5" }]],
trash: [["path", { d: "M4 7h16M9 3h6l1 4H8l1-4ZM7 7l1 14h8l1-14M10 11v6M14 11v6" }]],
undo: [["path", { d: "M9 7H4v-5M4 7l4-4M4.5 8A8 8 0 1 1 5 17" }]],
upload: [["path", { d: "M12 16V4m0 0-4 4m4-4 4 4M4 20h16" }]],
users: [["circle", { cx: "9", cy: "9", r: "3" }], ["path", { d: "M3 20v-2a5 5 0 0 1 10 0v2M16 8a3 3 0 0 1 0 6M16 15a5 5 0 0 1 5 5" }]],
wand: [["path", { d: "m4 20 12-12 3 3L7 23 4 20ZM14 4l1-3 1 3 3 1-3 1-1 3-1-3-3-1 3-1ZM19 14l.7-2 .8 2 2 .8-2 .7-.8 2-.7-2-2-.7 2-.8Z" }]],
x: [["path", { d: "M6 6l12 12M18 6 6 18" }]],
});
const UI_PANEL_ICONS = Object.freeze({
overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box",
redirects: "route", notfound: "fileX", streams: "network", "acl-profiles": "shield",
tls: "certificate", traffic: "activity", cache: "database", transform: "wand", settings: "settings",
});
const UI_SETTINGS_TAB_ICONS = Object.freeze({
appearance: "palette", listeners: "network", connections: "globe", "docker-agents": "box",
policies: "shield", cache: "database", transform: "wand", forwarding: "route", pages: "file",
logging: "list", geoip: "globe", prometheus: "activity", administration: "settings",
"system-info": "cpu", "certificate-api": "key", "tls-policy": "lock", "manual-config": "code", security: "shield",
});
const UI_VHOST_TAB_ICONS = Object.freeze({
details: "sliders", backends: "network", health: "activity", locations: "route", auth: "key", ssl: "lock",
cache: "database", compression: "archive", transform: "wand", security: "shield", pages: "file", advanced: "code",
});
const UI_TRANSLATED_ICON_KEYS = Object.freeze([
["trash", ["remove", "delete", "draftDiscard", "cacheRemoveBan", "certificateApiRevokeAll", "revokeAll"]],
["x", ["close", "cancelAction", "overviewLayoutInlineCancel"]],
["save", ["saveCertificate", "savePassword", "dashboardPresetsSave", "overviewLayoutInlineSave"]],
["check", ["apply", "applyFilters", "savePolicy", "continueAction", "webServicePresetApply", "dockerApplyService"]],
["plus", ["addCertificate", "vhNew", "webServiceNew", "redirectNew", "notfoundNew", "streamNew", "aclProfileNew", "phpProfileAdd", "redirectAddRule", "streamAddListener", "aclProfileAddIpException", "apiTokenCreate"]],
["refresh", ["refresh", "refreshNow", "reload", "reloadAudit", "reloadCertificates", "tunnelsReload", "dockerRefresh", "cacheRefresh", "sessionRetry"]],
["undo", ["configHistoryRestoreAction", "dashboardPresetsFactory", "draftRestore"]],
["edit", ["edit"]],
["download", ["export"]],
["upload", ["acmeImport", "aclProfileImportRules"]],
["eye", ["certificateReadNames", "dockerShowHidden", "dashboardWidgetsShowAll"]],
["settings", ["settings", "vhManage", "dockerAgentsSettings"]],
["book", ["webServiceHtaccessDocs", "webServicePhpDocsButton"]],
["pause", ["logPause"]],
["pin", ["logPin"]],
["history", ["configHistory"]],
["chevronLeft", ["previous"]],
["chevronRight", ["next"]],
]);
function uiIconSvg(name) {
const definition = UI_ICON_SHAPES[name] || UI_ICON_SHAPES.command;
const svg = document.createElementNS(UI_ICON_NS, "svg");
svg.classList.add("ui-icon"); svg.dataset.uiIcon = name; svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false");
svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round");
for (const [tag, attrs] of definition) {
const node = document.createElementNS(UI_ICON_NS, tag);
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
svg.append(node);
}
return svg;
}
function uiIconStrings(target) {
return [target.textContent, target.getAttribute("aria-label"), target.title].filter(Boolean).map(value => String(value).trim().toLocaleLowerCase());
}
function uiIconMatchesTranslation(target, keys) {
if (typeof t !== "function") return false;
const strings = uiIconStrings(target); if (!strings.length) return false;
for (const key of keys) {
const translated = String(t(key) || "").trim().toLocaleLowerCase();
if (!translated || translated === key.toLocaleLowerCase()) continue;
if (strings.some(value => value === translated || value.startsWith(`${translated} `) || value.startsWith(`${translated}:`))) return true;
}
return false;
}
function uiIconForTarget(target) {
const manual = target.dataset.uiIconName;
if (manual === "none") return null;
if (manual && UI_ICON_SHAPES[manual]) return manual;
if (target.matches(".chart-style-choice")) return null;
if (target.matches(".overview-inline-grip, .overview-inline-narrow, .overview-inline-widen, .overview-inline-up, .overview-inline-down, .overview-inline-hide, .overview-layout-narrow, .overview-layout-widen, .overview-layout-up, .overview-layout-down, .overview-layout-hide")) return null;
if (target.matches("#tunnels-open")) return null;
if (target.matches("#web-service-preset-apply")) return "wand";
if (target.matches(".listener-h2-settings-link, .listener-h3-settings-link")) return "settings";
if (target.dataset.panel && UI_PANEL_ICONS[target.dataset.panel]) return UI_PANEL_ICONS[target.dataset.panel];
if (target.dataset.settingsTab && UI_SETTINGS_TAB_ICONS[target.dataset.settingsTab]) return UI_SETTINGS_TAB_ICONS[target.dataset.settingsTab];
if (target.dataset.tab && UI_VHOST_TAB_ICONS[target.dataset.tab]) return UI_VHOST_TAB_ICONS[target.dataset.tab];
if (target.dataset.certificateTab) return ({ installed: "certificate", renewal: "refresh", generate: "plus" })[target.dataset.certificateTab] || "certificate";
if (target.dataset.trafficTab) return ({ overview: "grid", clients: "users", requests: "list", delivery: "activity" })[target.dataset.trafficTab] || "activity";
if (target.dataset.httpSettingsTab) return "network";
if (target.dataset.policySettingsTab) return target.dataset.policySettingsTab === "headers" || target.dataset.policySettingsTab === "response" ? "list" : "shield";
if (target.dataset.cacheSettingsTab) return ({ general: "database", resilience: "shield", cold: "archive", diagnostics: "activity" })[target.dataset.cacheSettingsTab] || "database";
if (target.dataset.loggingSettingsTab) return ({ outputs: "list", format: "code", errors: "alert" })[target.dataset.loggingSettingsTab] || "list";
if (target.dataset.dashboardWidgetAction === "toggle") return target.classList.contains("is-hidden") ? "eyeOff" : "eye";
if (target.dataset.dashboardWidgetAction === "up") return "chevronLeft";
if (target.dataset.dashboardWidgetAction === "down") return "chevronRight";
const signature = [target.id, target.dataset.i18n, target.dataset.i18nAria, target.dataset.action, target.className]
.filter(Boolean).join(" ").toLocaleLowerCase();
const aria = String(target.getAttribute("aria-label") || "").toLocaleLowerCase();
if (/mobile-menu|menu-toggle/.test(signature)) return "menu";
if (/is-saving|saving/.test(signature)) return "loader";
if (/sort-direction|sort-button/.test(signature)) return /descending|desc/.test(aria) ? "sortDown" : "sortUp";
if (/scroll-prev|move-earlier|layout-up/.test(signature)) return "chevronLeft";
if (/scroll-next|move-later|layout-down/.test(signature)) return "chevronRight";
if (/collapse|narrow/.test(signature)) return "chevronDown";
if (/expand|widen/.test(signature)) return "chevronUp";
if (/health/.test(signature) && !/certificate/.test(signature)) return "activity";
if (/copy/.test(signature)) return "copy";
if (/export|download/.test(signature)) return "download";
if (/import|upload/.test(signature)) return "upload";
if (/search|find/.test(signature)) return "search";
if (/filter/.test(signature)) return "filter";
if (/inspect|preview|read-names|readnames|show-hidden|visibility|view-toggle/.test(signature)) return "eye";
if (/history|revision/.test(signature)) return "history";
if (/restore|factory/.test(signature)) return "undo";
if (/refresh|reload|retry|renew|update-now|updatenow/.test(signature)) return "refresh";
if (/reset/.test(signature)) return "undo";
if (/revoke|remove|delete|discard|purge|kill/.test(signature)) return "trash";
if (/close|cancel/.test(signature)) return "x";
if (/new|add|create|generate|issue/.test(signature)) return "plus";
if (/save/.test(signature)) return "save";
if (/apply|submit|continue/.test(signature)) return "check";
if (/edit/.test(signature)) return "edit";
if (/pause/.test(signature)) return "pause";
if (/pin|dock/.test(signature)) return "pin";
if (/start|run-operation|cleanup-start/.test(signature)) return "play";
if (/logout|sign-out|lock/.test(signature)) return "logout";
if (/login|sign-in/.test(signature)) return "login";
if (/certificate-api|api-token|token|secret|key/.test(signature)) return "key";
if (/certificate|acme|tls/.test(signature)) return "certificate";
if (/security|acl|policy|audit/.test(signature)) return "shield";
if (/settings|manage|configuration/.test(signature)) return "settings";
if (/docker/.test(signature)) return "box";
if (/vhost|backend|stream|tunnel|listener/.test(signature)) return "network";
if (/redirect|forward/.test(signature)) return "route";
if (/notfound|404/.test(signature)) return "fileX";
if (/cache|cold/.test(signature)) return "database";
if (/transform|minif/.test(signature)) return "wand";
if (/traffic|metric|prometheus/.test(signature)) return "activity";
if (/log/.test(signature)) return "list";
if (/docs|help|documentation/.test(signature)) return "book";
if (/php|json|advanced|code/.test(signature)) return "code";
if (/layout|preset|dashboard|overview/.test(signature)) return "grid";
if (/about|info/.test(signature)) return "book";
if (target.matches("a[href]")) return target.href?.startsWith(location.origin) ? "file" : "external";
for (const [icon, keys] of UI_TRANSLATED_ICON_KEYS) if (uiIconMatchesTranslation(target, keys)) return icon;
if (aria && typeof t === "function") {
if (aria.startsWith(String(t("remove")).toLocaleLowerCase())) return "trash";
if (aria.startsWith(String(t("copyValue")).toLocaleLowerCase())) return "copy";
if (aria === String(t("dashboardWidgetsMoveEarlier")).toLocaleLowerCase()) return "chevronLeft";
if (aria === String(t("dashboardWidgetsMoveLater")).toLocaleLowerCase()) return "chevronRight";
}
return "command";
}
const UI_ICON_TARGET_SELECTOR = "button, a.primary, a.outline, a.quiet, #logs-menu > summary";
function uiIconTargetIsEligible(target) {
return target instanceof Element && target.matches(UI_ICON_TARGET_SELECTOR);
}
function uiDecorateIconTarget(target) {
if (!uiIconTargetIsEligible(target)) return;
const existing = target.querySelector(":scope > svg.ui-icon");
if (document.documentElement.dataset.interfaceIcons !== "true") { existing?.remove(); return; }
const name = uiIconForTarget(target);
if (!name) { existing?.remove(); return; }
if (existing?.dataset.uiIcon === name) return;
existing?.remove(); target.prepend(uiIconSvg(name));
}
function refreshUiIcons(root = document) {
if (root instanceof Element && uiIconTargetIsEligible(root)) uiDecorateIconTarget(root);
root.querySelectorAll?.(UI_ICON_TARGET_SELECTOR).forEach(uiDecorateIconTarget);
}
let uiIconObserverActive = false;
function setUiIconsEnabled(value) {
const enabled = value === true;
document.documentElement.dataset.interfaceIcons = String(enabled);
if (enabled) {
if (!uiIconObserverActive) {
uiIconObserver.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-label", "aria-expanded", "aria-pressed", "data-i18n", "data-action"] });
uiIconObserverActive = true;
}
refreshUiIcons();
} else {
if (uiIconObserverActive) { uiIconObserver.disconnect(); uiIconObserverActive = false; }
document.querySelectorAll("svg.ui-icon").forEach(node => node.remove());
}
const input = $("ui-interface-icons"); if (input) input.checked = enabled;
return enabled;
}
const uiIconObserver = new MutationObserver(records => {
if (document.documentElement.dataset.interfaceIcons !== "true") return;
const targets = new Set();
for (const record of records) {
const element = record.target instanceof Element ? record.target : record.target.parentElement;
const action = element?.closest?.(UI_ICON_TARGET_SELECTOR); if (action) targets.add(action);
for (const node of record.addedNodes) {
if (!(node instanceof Element)) continue;
if (uiIconTargetIsEligible(node)) targets.add(node);
node.querySelectorAll?.(UI_ICON_TARGET_SELECTOR).forEach(item => targets.add(item));
}
}
targets.forEach(uiDecorateIconTarget);
});
window.rustProxyIcons = { refresh: refreshUiIcons, setEnabled: setUiIconsEnabled, iconFor: uiIconForTarget };
+5 -1
View File
@@ -219,7 +219,11 @@ window.rustProxyExtraOverview = (() => {
donut("extra-cache-ratio", slices?.[0], slices?.[1]);
}
const detail = $(`overview-extra-${id}-detail`);
if (detail && id !== "extra_cache_hit_rate") detail.textContent = metric.detail || t("overviewExtraRecentSamples");
if (detail && id !== "extra_cache_hit_rate") {
const detailText = metric.detail || "";
detail.textContent = detailText;
detail.hidden = !detailText;
}
}
}
function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); lastSegments.clear(); window.rustProxyChartScale.reset("extra:"); }
+14 -5
View File
@@ -13,7 +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,
chart_axes: false, interface_icons: 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" });
@@ -123,6 +123,7 @@ function applyAdminChartAxes(value) {
window.rustProxyChartScale?.refreshAxes?.();
return enabled;
}
function applyAdminInterfaceIcons(value) { return window.rustProxyIcons?.setEnabled?.(value === true) ?? false; }
function applyAdminButtonStyle(value) {
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
const select = $("ui-button-style"); if (select) select.value = style; return style;
@@ -287,7 +288,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), 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),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), interface_icons: applyAdminInterfaceIcons(value.interface_icons), 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;
@@ -335,7 +336,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.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.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.interface_icons = next.interface_icons === 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;
@@ -343,7 +344,7 @@ function updateAdminPreferences(patch) {
});
adminPreferencesWrite = task.catch(() => undefined); return task;
}
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); applyAdminInterfaceIcons(false); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
function renderRefreshPreferences(value = REFRESH_DEFAULTS) {
const refresh = normalizeRefreshPreferences(value);
const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled;
@@ -419,6 +420,14 @@ $("ui-chart-axes")?.addEventListener("change", () => {
applyAdminChartAxes(previous);
});
});
$("ui-interface-icons")?.addEventListener("change", () => {
const control = $("ui-interface-icons"), previous = adminPreferences?.interface_icons === true, next = control.checked;
applyAdminInterfaceIcons(next); if (adminPreferences) adminPreferences.interface_icons = next;
void saveAppearancePreference({ interface_icons: next }, control, () => {
if (adminPreferences) adminPreferences.interface_icons = previous;
applyAdminInterfaceIcons(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); });
@@ -450,7 +459,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", chart_axes: false }, tables: { table_density: "normal" },
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", interface_icons: false, 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);
+2 -2
View File
@@ -170,7 +170,7 @@ const rustProxyRefresh = (() => {
}, onError: error => { if (error?.status !== 401) { connected = false; $("connection-label").textContent = t("disconnected"); $("signal").classList.add("status-error"); $("app-health-slot").hidden = false; renderAppHealth(null); } }
});
register("logs-fallback", {
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
interval: () => options.overview_seconds, active: () => !!window.rustProxyLogs?.active?.(), run: async ({ signal, isCurrent }) => {
const value = await api("/api/logs", { signal });
if (isCurrent()) renderLogs(value);
}
@@ -199,6 +199,6 @@ const rustProxyRefresh = (() => {
window.rustProxyRefresh = rustProxyRefresh;
$("overview-refresh")?.addEventListener("click", () => {
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback"), rustProxyRefresh.manual("system-info")]);
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("system-info")]);
});
$("system-info-refresh")?.addEventListener("click", () => void rustProxyRefresh.manual("system-info"));
+47
View File
@@ -87,6 +87,53 @@ button:disabled {
cursor: wait;
}
.ui-icon {
display: inline-block;
width: 14px;
height: 14px;
margin-inline-end: 6px;
vertical-align: -2px;
flex: 0 0 auto;
pointer-events: none;
}
html[data-interface-icons="true"] svg.ui-icon {
width: 14px;
height: 14px;
}
.nav>.ui-icon {
width: 16px;
height: 16px;
margin-inline-end: 0;
}
html[data-interface-icons="true"] .nav>svg.ui-icon {
width: 16px;
height: 16px;
}
.backend-sort-direction>.ui-icon,
.dashboard-widget-scroll-button>.ui-icon,
.host-chip button>.ui-icon,
.acl-picker-remove>.ui-icon,
.draft-dock-remove>.ui-icon,
.vhost-backend-popup-close>.ui-icon,
.dashboard-widget-toggle>.ui-icon {
margin-inline-end: 0;
}
button.is-saving>.ui-icon[data-ui-icon="loader"] {
animation: ui-icon-spin .8s linear infinite;
}
@keyframes ui-icon-spin {
to {
transform: rotate(360deg);
}
}
html[data-interface-icons="true"] .nav>span:first-of-type,
[hidden],
#file-log-view-counts[hidden],
#file-log-view-entries[hidden],
+84 -26
View File
@@ -15,8 +15,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
margin-bottom: var(--ui-gap);
}
/* Summary tiles outside the Overview dashboard (Cache, Traffic, Transform) fill
every row instead of leaving empty grid columns after the last tile. */
.stats-grid.cache-stats {
display: flex;
flex-wrap: wrap;
@@ -86,7 +84,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
stroke-dasharray: var(--chart-grid-dash, 2 5);
}
/* Axis labels are HTML overlays, not stretched SVG text. */
.rp-chart-axis-host {
position: relative;
display: flow-root;
@@ -94,7 +91,10 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
min-width: 0;
}
.rp-chart-axis-host>svg { display: block; vertical-align: top; }
.rp-chart-axis-host>svg {
display: block;
vertical-align: top;
}
.rp-chart-axis-y {
position: absolute;
@@ -115,9 +115,20 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
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-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;
@@ -133,12 +144,29 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
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-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[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;
@@ -150,8 +178,13 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
}
@media (max-width: 560px) {
.rp-chart-axis-y .rp-chart-axis-middle { display: none; }
.rp-chart-axis-x { font-size: 8px; }
.rp-chart-axis-y .rp-chart-axis-middle {
display: none;
}
.rp-chart-axis-x {
font-size: 8px;
}
}
.chart-footer {
@@ -291,13 +324,19 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
min-width: 640px;
}
.pool-table td:nth-child(n/**/+4) {
.pool-table td:nth-child(n
/**/
+4) {
font-variant-numeric: tabular-nums;
text-align: right;
}
[dir=rtl] table,
.pool-table th:nth-child(n/**/+4),
.pool-table th:nth-child(n
/**/
+4),
.logging-builder-action .small-label {
text-align: right;
}
@@ -561,7 +600,6 @@ body[data-density=compact] .pool-card .table-scroll {
margin: 0;
}
/* Keep the block title clear of the corner button whatever padding the grid applies. */
#panel-overview .stat-with-action>span:first-child {
display: block;
padding-right: 104px;
@@ -576,6 +614,20 @@ body[data-density=compact] .pool-card .table-scroll {
text-align: center;
}
#panel-overview .stat-with-action .stat-corner-badge {
min-height: 0;
border-radius: 999px;
background: transparent;
cursor: pointer;
text-transform: none;
}
#panel-overview .stat-with-action .stat-corner-badge:hover,
#panel-overview .stat-with-action .stat-corner-badge:focus-visible {
border-color: var(--accent);
background: var(--notice-bg);
}
#panel-overview .dashboard-overview-grid.is-unlocked .stat-with-action .stat-corner-action {
top: 44px;
}
@@ -1201,7 +1253,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
outline-style: solid;
}
/* While sorting, the grid keeps strict DOM order: the placeholder is exactly where the block lands. */
#panel-overview .dashboard-overview-grid.is-overview-sorting {
grid-auto-flow: row !important;
user-select: none;
@@ -1216,10 +1267,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] { grid-column: auto/span 3; }
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] { grid-column: auto/span 4; }
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] { grid-column: auto/span 6; }
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] { grid-column: 1/-1; }
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] {
grid-column: auto/span 3;
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] {
grid-column: auto/span 4;
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] {
grid-column: auto/span 6;
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] {
grid-column: 1/-1;
}
#panel-overview .dashboard-overview-grid>.is-overview-lifted,
#panel-overview .dashboard-overview-grid>.is-overview-lifted * {
@@ -1256,7 +1318,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
flex: 0 0 auto;
}
/* Narrow blocks keep only the essentials; arrow keys on the grip still move them. */
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down),
#panel-overview .dashboard-overview-grid>[data-dashboard-span="3"] .overview-inline-toolbar :is(.overview-inline-up, .overview-inline-down) {
display: none;
@@ -1886,8 +1947,6 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
margin: 4px 10px 8px;
}
/* Custom layouts follow the saved order exactly (no implicit back-filling of gaps);
"Fill gaps" in the layout editor compacts explicitly. */
#panel-overview .dashboard-overview-grid.is-aligned[data-overview-layout-mode=custom] {
grid-auto-flow: row;
}
@@ -2224,7 +2283,6 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
line-height: inherit;
}
/* Live values stay on one line so refreshes do not change card heights. */
.mini-live-chart-head> :last-child,
.network-interface-row-head .network-interface-head-actions {
flex: 0 0 auto;
@@ -2236,4 +2294,4 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
+80 -14
View File
@@ -81,11 +81,13 @@
max-width: none;
}
.appearance-interface-icons-setting,
.appearance-chart-axes-setting {
align-self: center;
min-width: 0;
}
.appearance-interface-icons-setting>span,
.appearance-chart-axes-setting>span {
display: grid;
gap: 4px;
@@ -1203,6 +1205,7 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
width: 100%;
min-width: 0;
}
/* Global security policy forms */
.global-policy-form .global-policy-section {
display: grid;
@@ -1212,18 +1215,81 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
padding-top: 17px;
min-width: 0;
}
.global-policy-form .global-policy-section h3 { margin: 0; font-size: 14px; }
.global-policy-heading { display: grid; gap: 5px; }
.global-policy-heading p { margin: 0; max-width: 85ch; }
.global-policy-fields { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr); gap: 14px; align-items: start; }
.global-rate-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: end; }
.global-policy-fields label, .global-rate-fields label, .global-policy-section > label:not(.check) { display: grid; gap: 6px; min-width: 0; }
.global-policy-section textarea { width: 100%; box-sizing: border-box; }
.global-policy-form .form-actions { margin-top: 20px; }
.cache-static-guidance { margin: 9px 0 0; max-width: 88ch; }
.cache-header-settings-heading { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 24px; margin-bottom: 13px; }
.transform-operations-title { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 22px; margin-bottom: 14px; }
.transform-operations-grid { margin-top: 3px; row-gap: 12px; }
@media (max-width: 760px) {
.global-policy-fields, .global-rate-fields { grid-template-columns: 1fr; }
.global-policy-form .global-policy-section h3 {
margin: 0;
font-size: 14px;
}
.global-policy-heading {
display: grid;
gap: 5px;
}
.global-policy-heading p {
margin: 0;
max-width: 85ch;
}
.global-policy-fields {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr);
gap: 14px;
align-items: start;
}
.global-rate-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
align-items: end;
}
.global-policy-fields label,
.global-rate-fields label,
.global-policy-section>label:not(.check) {
display: grid;
gap: 6px;
min-width: 0;
}
.global-policy-section textarea {
width: 100%;
box-sizing: border-box;
}
.global-policy-form .form-actions {
margin-top: 20px;
}
.cache-static-guidance {
margin: 9px 0 0;
max-width: 88ch;
}
.cache-header-settings-heading {
border-top: 1px solid var(--line);
padding-top: 18px;
margin-top: 24px;
margin-bottom: 13px;
}
.transform-operations-title {
border-top: 1px solid var(--line);
padding-top: 18px;
margin-top: 22px;
margin-bottom: 14px;
}
.transform-operations-grid {
margin-top: 3px;
row-gap: 12px;
}
@media (max-width: 760px) {
.global-policy-fields,
.global-rate-fields {
grid-template-columns: 1fr;
}
}
+165 -49
View File
@@ -61,7 +61,8 @@
<div class="sidebar-bottom" id="app-health-slot" hidden>
<button class="local-tag app-health-button is-checking" id="app-health-open" type="button"
aria-haspopup="dialog" aria-controls="app-health-dialog">
<i aria-hidden="true"></i><span id="app-health-label" data-i18n="appHealthSidebarChecking">CHECKING</span>
<i aria-hidden="true"></i><span id="app-health-label"
data-i18n="appHealthSidebarChecking">CHECKING</span>
</button>
</div>
</aside>
@@ -1792,6 +1793,12 @@
</select><small class="fine" data-i18n="buttonStyleHelp">Normal uses the
refreshed controls; Compact restores the smaller source-style buttons
across all actions and dialog tabs.</small></label>
<label class="check appearance-interface-icons-setting"><input
id="ui-interface-icons" type="checkbox" /><span><b
data-i18n="interfaceIcons">Show SVG interface icons</b><small
data-i18n="interfaceIconsHelp">Adds semantic SVG icons to
navigation,
actions, tabs and dialogs.</small></span></label>
<label><span data-i18n="cornerStyle">Corner rounding</span><select
id="ui-corner-style">
<option data-i18n="cornerStyleDefault" value="default">Original
@@ -2102,79 +2109,184 @@
<div data-policy-settings-page="block-allow">
<form class="card edit-form global-policy-form" id="global-security-form">
<div class="card-heading">
<div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP LISTENERS</span>
<div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP
LISTENERS</span>
<h2 data-i18n="globalBlockAllow">Global block / allow</h2>
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
</div>
<p class="fine" data-i18n="globalPolicyHelp">These rules apply before routing to the backend on all public HTTP vhosts, redirects and 404 hosts. Per-vhost restrictions remain active; a local allow cannot bypass a global block. Administration and TCP/UDP Streams are excluded.</p>
<p class="fine" data-i18n="globalPolicyHelp">These rules apply before routing to the
backend on all public HTTP vhosts, redirects and 404 hosts. Per-vhost
restrictions remain active; a local allow cannot bypass a global block.
Administration and TCP/UDP Streams are excluded.</p>
<section class="global-policy-section">
<div class="global-policy-heading"><h3 data-i18n="globalCountryTitle">Country access</h3><p class="fine" data-i18n="globalCountryHelp">Uses the verified client IP and GeoIP database. Unknown countries are denied in allow mode, but allowed in block mode.</p></div>
<div class="global-policy-heading">
<h3 data-i18n="globalCountryTitle">Country access</h3>
<p class="fine" data-i18n="globalCountryHelp">Uses the verified client IP
and GeoIP database. Unknown countries are denied in allow mode, but
allowed in block mode.</p>
</div>
<div class="global-policy-fields">
<label><span data-i18n="globalCountryMode">Country policy</span><select id="global-country-mode">
<option value="off" data-i18n="vhOff">Off</option>
<option value="block" data-i18n="vhCountryBlock">Block listed</option>
<option value="allow" data-i18n="vhCountryAllow">Allow listed only</option>
</select></label>
<label><span data-i18n="globalCountryList">ISO country codes (one per line)</span><textarea id="global-country-list" rows="3" maxlength="1024" spellcheck="false" placeholder="RU&#10;CN"></textarea></label>
<label><span data-i18n="globalCountryMode">Country policy</span><select
id="global-country-mode">
<option value="off" data-i18n="vhOff">Off</option>
<option value="block" data-i18n="vhCountryBlock">Block listed
</option>
<option value="allow" data-i18n="vhCountryAllow">Allow listed only
</option>
</select></label>
<label><span data-i18n="globalCountryList">ISO country codes (one per
line)</span><textarea id="global-country-list" rows="3"
maxlength="1024" spellcheck="false"
placeholder="RU&#10;CN"></textarea></label>
</div>
</section>
<section class="global-policy-section">
<div class="global-policy-heading"><h3 data-i18n="globalUaTitle">User-Agent blocklist</h3><p class="fine" data-i18n="uaBlockPatternHelp">Case-insensitive substring matching; * is a wildcard. A missing User-Agent is not blocked.</p></div>
<label class="check"><input id="global-ua-block-enabled" type="checkbox"/><span data-i18n="globalUaEnabled">Block matching User-Agents</span></label>
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="4" maxlength="32768" spellcheck="false" placeholder="BadBot&#10;curl/*&#10;*scanner*"></textarea></label>
<div class="global-policy-heading">
<h3 data-i18n="globalUaTitle">User-Agent blocklist</h3>
<p class="fine" data-i18n="uaBlockPatternHelp">Case-insensitive substring
matching; * is a wildcard. A missing User-Agent is not blocked.</p>
</div>
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span
data-i18n="globalUaEnabled">Block matching User-Agents</span></label>
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per
line</span><textarea id="global-ua-block-patterns" rows="4"
maxlength="32768" spellcheck="false"
placeholder="BadBot&#10;curl/*&#10;*scanner*"></textarea></label>
</section>
<section class="global-policy-section">
<div class="global-policy-heading"><h3 data-i18n="globalIpTitle">IP / CIDR access</h3><p class="fine" data-i18n="globalIpHelp">Each line: allow or deny followed by IPv4/IPv6 address, CIDR or allow-only DNS name. Default allow with no rules leaves access unrestricted.</p></div>
<label class="check"><input id="global-ip-enabled" type="checkbox"/><span data-i18n="globalIpEnabled">Enable global IP rules</span></label>
<div class="global-policy-heading">
<h3 data-i18n="globalIpTitle">IP / CIDR access</h3>
<p class="fine" data-i18n="globalIpHelp">Each line: allow or deny followed
by IPv4/IPv6 address, CIDR or allow-only DNS name. Default allow with no
rules leaves access unrestricted.</p>
</div>
<label class="check"><input id="global-ip-enabled" type="checkbox" /><span
data-i18n="globalIpEnabled">Enable global IP rules</span></label>
<div class="global-policy-fields">
<label><span data-i18n="aclDefault">Default action</span><select id="global-ip-default"><option value="allow" data-i18n="allow">Allow</option><option value="deny" data-i18n="deny">Deny</option></select></label>
<label><span data-i18n="globalIpRules">Rules (one per line)</span><textarea id="global-ip-rules" rows="3" maxlength="32768" placeholder="deny 192.0.2.0/24&#10;allow 10.0.0.0/8" spellcheck="false"></textarea></label>
<label><span data-i18n="aclDefault">Default action</span><select
id="global-ip-default">
<option value="allow" data-i18n="allow">Allow</option>
<option value="deny" data-i18n="deny">Deny</option>
</select></label>
<label><span data-i18n="globalIpRules">Rules (one per line)</span><textarea
id="global-ip-rules" rows="3" maxlength="32768"
placeholder="deny 192.0.2.0/24&#10;allow 10.0.0.0/8"
spellcheck="false"></textarea></label>
</div>
</section>
<section class="global-policy-section">
<div class="global-policy-heading"><h3 data-i18n="globalMethodTitle">HTTP methods</h3><p class="fine" data-i18n="globalMethodHelp">Block selected methods or allow only those listed. Applies before redirects and cache invalidation; denied requests receive 405.</p></div>
<div class="global-policy-heading">
<h3 data-i18n="globalMethodTitle">HTTP methods</h3>
<p class="fine" data-i18n="globalMethodHelp">Block selected methods or allow
only those listed. Applies before redirects and cache invalidation;
denied requests receive 405.</p>
</div>
<div class="global-policy-fields">
<label><span data-i18n="globalMethodMode">Method policy</span><select id="global-method-mode"><option value="off" data-i18n="vhOff">Off</option><option value="block" data-i18n="locationMethodBlock">Block listed</option><option value="allow" data-i18n="locationMethodAllow">Allow listed only</option></select></label>
<label><span data-i18n="globalMethodList">Methods (comma, space or newline separated)</span><textarea id="global-method-list" rows="2" maxlength="1024" spellcheck="false" placeholder="TRACE, TRACK"></textarea></label>
<label><span data-i18n="globalMethodMode">Method policy</span><select
id="global-method-mode">
<option value="off" data-i18n="vhOff">Off</option>
<option value="block" data-i18n="locationMethodBlock">Block listed
</option>
<option value="allow" data-i18n="locationMethodAllow">Allow listed
only</option>
</select></label>
<label><span data-i18n="globalMethodList">Methods (comma, space or newline
separated)</span><textarea id="global-method-list" rows="2"
maxlength="1024" spellcheck="false"
placeholder="TRACE, TRACK"></textarea></label>
</div>
</section>
<section class="global-policy-section">
<div class="global-policy-heading"><h3 data-i18n="globalContentTypeTitle">Request Content-Type</h3><p class="fine" data-i18n="globalContentTypeHelp">Reject declared MIME types (exact or type/*) with 403. This is a header rule, not body inspection.</p></div>
<label class="check"><input id="global-content-type-enabled" type="checkbox"/><span data-i18n="globalContentTypeEnabled">Enable Content-Type denylist</span></label>
<label><span data-i18n="globalContentTypeList">Blocked MIME types, one per line</span><textarea id="global-content-type-list" rows="3" maxlength="16384" spellcheck="false" placeholder="application/x-msdownload&#10;application/x-sh"></textarea></label>
<div class="global-policy-heading">
<h3 data-i18n="globalContentTypeTitle">Request Content-Type</h3>
<p class="fine" data-i18n="globalContentTypeHelp">Reject declared MIME types
(exact or type/*) with 403. This is a header rule, not body inspection.
</p>
</div>
<label class="check"><input id="global-content-type-enabled"
type="checkbox" /><span data-i18n="globalContentTypeEnabled">Enable
Content-Type denylist</span></label>
<label><span data-i18n="globalContentTypeList">Blocked MIME types, one per
line</span><textarea id="global-content-type-list" rows="3"
maxlength="16384" spellcheck="false"
placeholder="application/x-msdownload&#10;application/x-sh"></textarea></label>
</section>
<section class="global-policy-section">
<div class="global-policy-heading"><h3 data-i18n="globalRateTitle">Shared rate limit</h3><p class="fine" data-i18n="globalRateHelp">Optional per-client-IP limit shared by all public vhosts. Runs in addition to per-vhost limits; use conservative values to avoid blocking legitimate shared clients.</p></div>
<label class="check"><input id="global-rate-enabled" type="checkbox"/><span data-i18n="globalRateEnabled">Enable global request rate limit</span></label>
<div class="global-policy-heading">
<h3 data-i18n="globalRateTitle">Shared rate limit</h3>
<p class="fine" data-i18n="globalRateHelp">Optional per-client-IP limit
shared by all public vhosts. Runs in addition to per-vhost limits; use
conservative values to avoid blocking legitimate shared clients.</p>
</div>
<label class="check"><input id="global-rate-enabled" type="checkbox" /><span
data-i18n="globalRateEnabled">Enable global request rate
limit</span></label>
<div class="global-rate-fields">
<label><span data-i18n="globalRateRequests">Requests per window</span><input id="global-rate-requests" type="number" min="1" max="1000000" required value="120"/></label>
<label><span data-i18n="globalRateWindow">Window (seconds)</span><input id="global-rate-window" type="number" min="1" max="3600" required value="60"/></label>
<label><span data-i18n="globalRateBurst">Burst allowance</span><input id="global-rate-burst" type="number" min="0" max="1000000" required value="30"/></label>
<label><span data-i18n="globalRateRequests">Requests per window</span><input
id="global-rate-requests" type="number" min="1" max="1000000"
required value="120" /></label>
<label><span data-i18n="globalRateWindow">Window (seconds)</span><input
id="global-rate-window" type="number" min="1" max="3600" required
value="60" /></label>
<label><span data-i18n="globalRateBurst">Burst allowance</span><input
id="global-rate-burst" type="number" min="0" max="1000000" required
value="30" /></label>
</div>
</section>
<div class="form-actions"><button class="primary" data-i18n="globalPolicySave" type="submit">Apply global block / allow rules</button></div>
<div class="form-actions"><button class="primary" data-i18n="globalPolicySave"
type="submit">Apply global block / allow rules</button></div>
</form>
</div>
<div data-policy-settings-page="security-headers" hidden="">
<form class="card edit-form global-policy-form" id="global-security-headers-form">
<div class="card-heading"><div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP LISTENERS</span><h2 data-i18n="globalSecurityHeaders">Security headers</h2></div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span></div>
<p class="fine" data-i18n="globalHeadersSecurityHelp">Global response-header baseline for public HTTP vhosts, redirects and 404 hosts. Explicit per-vhost values override matching global headers. HSTS is sent only over HTTPS; test CSP carefully before rollout.</p>
<section class="global-policy-section"><h3 data-i18n="vhSecurityHstsSection">HTTP Strict Transport Security (HSTS)</h3>
<label class="check"><input id="global-hsts-enabled" type="checkbox"/><span data-i18n="vhHsts">Enable HSTS</span></label>
<div class="card-heading">
<div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP
LISTENERS</span>
<h2 data-i18n="globalSecurityHeaders">Security headers</h2>
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
</div>
<p class="fine" data-i18n="globalHeadersSecurityHelp">Global response-header
baseline for public HTTP vhosts, redirects and 404 hosts. Explicit per-vhost
values override matching global headers. HSTS is sent only over HTTPS; test CSP
carefully before rollout.</p>
<section class="global-policy-section">
<h3 data-i18n="vhSecurityHstsSection">HTTP Strict Transport Security (HSTS)</h3>
<label class="check"><input id="global-hsts-enabled" type="checkbox" /><span
data-i18n="vhHsts">Enable HSTS</span></label>
<div class="global-rate-fields">
<label><span data-i18n="vhHstsAge">Max age (seconds)</span><input id="global-hsts-age" type="number" min="0" max="63072000" required value="31536000"/></label>
<label class="check"><input id="global-hsts-sub" type="checkbox"/><span data-i18n="vhHstsSub">Include subdomains</span></label>
<label class="check"><input id="global-hsts-preload" type="checkbox"/><span data-i18n="vhHstsPreload">Preload</span></label>
<label><span data-i18n="vhHstsAge">Max age (seconds)</span><input
id="global-hsts-age" type="number" min="0" max="63072000" required
value="31536000" /></label>
<label class="check"><input id="global-hsts-sub" type="checkbox" /><span
data-i18n="vhHstsSub">Include subdomains</span></label>
<label class="check"><input id="global-hsts-preload" type="checkbox" /><span
data-i18n="vhHstsPreload">Preload</span></label>
</div>
</section>
<section class="global-policy-section"><h3 data-i18n="vhSecurityHeadersSection">Response security headers</h3>
<label class="check"><input id="global-nosniff" type="checkbox"/><span data-i18n="vhNosniff">X-Content-Type-Options: nosniff</span></label>
<label><span data-i18n="vhFrame">X-Frame-Options</span><select id="global-frame"><option value="preserve" data-i18n="vhPreserve">Preserve origin value</option><option value="deny" data-i18n="vhDeny">DENY</option><option value="sameorigin" data-i18n="vhSameorigin">SAMEORIGIN</option></select></label>
<label><span data-i18n="vhReferrer">Referrer-Policy</span><input id="global-referrer" maxlength="8192" spellcheck="false" placeholder="strict-origin-when-cross-origin"/></label>
<label><span data-i18n="vhCsp">Content-Security-Policy</span><textarea id="global-csp" rows="3" maxlength="8192" spellcheck="false" placeholder="default-src 'self'"></textarea></label>
<label><span data-i18n="vhPermissions">Permissions-Policy</span><input id="global-permissions" maxlength="8192" spellcheck="false" placeholder="camera=(), microphone=()"/></label>
<section class="global-policy-section">
<h3 data-i18n="vhSecurityHeadersSection">Response security headers</h3>
<label class="check"><input id="global-nosniff" type="checkbox" /><span
data-i18n="vhNosniff">X-Content-Type-Options: nosniff</span></label>
<label><span data-i18n="vhFrame">X-Frame-Options</span><select
id="global-frame">
<option value="preserve" data-i18n="vhPreserve">Preserve origin value
</option>
<option value="deny" data-i18n="vhDeny">DENY</option>
<option value="sameorigin" data-i18n="vhSameorigin">SAMEORIGIN</option>
</select></label>
<label><span data-i18n="vhReferrer">Referrer-Policy</span><input
id="global-referrer" maxlength="8192" spellcheck="false"
placeholder="strict-origin-when-cross-origin" /></label>
<label><span data-i18n="vhCsp">Content-Security-Policy</span><textarea
id="global-csp" rows="3" maxlength="8192" spellcheck="false"
placeholder="default-src 'self'"></textarea></label>
<label><span data-i18n="vhPermissions">Permissions-Policy</span><input
id="global-permissions" maxlength="8192" spellcheck="false"
placeholder="camera=(), microphone=()" /></label>
</section>
<div class="form-actions"><button class="primary" data-i18n="globalSecurityHeadersSave" type="submit">Apply security headers</button></div>
<div class="form-actions"><button class="primary"
data-i18n="globalSecurityHeadersSave" type="submit">Apply security
headers</button></div>
</form>
</div>
<div data-policy-settings-page="headers" hidden="">
@@ -2501,9 +2613,11 @@
<label data-i18n="cacheStaticExtensions" for="cache-static-extensions">Global static
extensions</label><textarea id="cache-static-extensions" rows="5"
spellcheck="false"></textarea>
<p class="fine cache-static-guidance" data-i18n="cacheStaticHelp">Cookie bypass is limited to these file
<p class="fine cache-static-guidance" data-i18n="cacheStaticHelp">Cookie bypass is
limited to these file
extensions. Response privacy rules still apply.</p>
<h3 class="subheading cache-header-settings-heading" data-i18n="cacheHeadersSection">Cache response headers</h3>
<h3 class="subheading cache-header-settings-heading"
data-i18n="cacheHeadersSection">Cache response headers</h3>
<div class="switch-grid"><label class="check"><input id="cache-status-header"
type="checkbox" /><span>X-Cache: HIT / MISS /
BYPASS</span></label><label class="check"><input id="cache-hits-header"
@@ -2609,7 +2723,8 @@
concurrent transformations</label><input id="transform-max-inflight" min="1"
max="1024" required="" type="number" /></div>
</div>
<h3 class="subheading transform-operations-title" data-i18n="transformOperationsTitle">Content transformations</h3>
<h3 class="subheading transform-operations-title" data-i18n="transformOperationsTitle">
Content transformations</h3>
<div class="switch-grid transform-operations-grid">
<label class="check"><input id="transform-javascript" type="checkbox" /><span
data-i18n="transformJavascript">JavaScript minification</span></label>
@@ -3144,8 +3259,8 @@
<p class="fine" data-i18n="tlsPqcHelp">TLS 1.3 only: prioritize hybrid
X25519MLKEM768 with classic key exchanges as fallback. This is a key exchange
group, not a cipher suite. Disabled by default.</p>
<a href="/docs#doc=docs%2FTRANSPORT.md" target="_blank"
rel="noopener noreferrer" data-i18n="tlsPqcDocs">Learn about PQC, compatibility
<a href="/docs#doc=docs%2FTRANSPORT.md" target="_blank" rel="noopener noreferrer"
data-i18n="tlsPqcDocs">Learn about PQC, compatibility
and testing (documentation)</a>
</div>
<p class="fine" id="tls-policy-key-exchange"></p>
@@ -4353,7 +4468,8 @@ log_errors = On</code></pre>
</section>
<div class="about-links">
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutPackagesRepository">Website and packages</span><span>rustproxy.linuxiarz.pl</span></a>
target="_blank"><span data-i18n="aboutPackagesRepository">Website and
packages</span><span>rustproxy.linuxiarz.pl</span></a>
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy"
rel="noopener noreferrer" target="_blank"><span data-i18n="aboutRepository">Source
code</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
+2 -2
View File
@@ -133,7 +133,7 @@
window.rustProxySettings?.selectHttpTab?.("http2");
document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" });
});
h2Settings.classList.add("listener-h2-settings-link");
h2Settings.classList.add("listener-h2-settings-link"); h2Settings.dataset.uiIconName = "settings";
const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null);
const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox");
const h3Settings = button(protocolActions, "h3Configure", () => {
@@ -141,7 +141,7 @@
window.rustProxySettings?.selectHttpTab?.("http3");
document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" });
});
h3Settings.classList.add("listener-h3-settings-link");
h3Settings.classList.add("listener-h3-settings-link"); h3Settings.dataset.uiIconName = "settings";
const sync = () => {
h2.disabled = !tls.checked; h2c.disabled = tls.checked; h3.disabled = !tls.checked;
h2Settings.hidden = !((tls.checked && h2.checked) || (!tls.checked && h2c.checked));
+11 -6
View File
@@ -85,6 +85,11 @@
];
const defaultColumns = () => new Set(columns.map(column => column.id));
let visibleColumns = defaultColumns();
function active() { return dialog.open && !paused && !expanded; }
function syncLivePolling(fetchNow = false) {
window.rustProxyRefresh?.sync?.();
if (fetchNow && active()) void window.rustProxyRefresh?.manual?.("logs-fallback");
}
function source() { return paused || expanded || lastLogs || { entries: [] }; }
function mergeExpanded(data) {
if (!expanded || paused || !data?.entries) return;
@@ -189,7 +194,7 @@
if (!preferencesLoaded) void loadPreferences();
if (vhost) { const opt = el("option", vhost); opt.value = vhost; $("log-vhost").append(opt); $("log-vhost").value = vhost; }
if (!dialog.open) { if (docked) dialog.show(); else dialog.showModal(); }
render();
render(); syncLivePolling(true);
}
syncColumnPicker(); syncHeaders();
$("logs-open").addEventListener("click", () => { $("logs-menu").open = false; open(); }); $("logs-close").addEventListener("click", () => dialog.close());
@@ -200,14 +205,14 @@
expanded = { ...snapshot, capacity: 4096, window: 4096 }; render();
toast(t("logBufferExpanded").replace("{count}", String(snapshot.entries?.length || 0)));
} catch (error) { toast(error.message, true); }
finally { button.disabled = Boolean(expanded); }
finally { button.disabled = Boolean(expanded); syncLivePolling(); }
});
$("logs-pause").addEventListener("click", () => { paused = paused ? null : structuredClone(source()); render(); });
$("logs-pin").addEventListener("click", () => { pinning = true; dialog.close(); docked = !docked; dialog.classList.toggle("docked", docked); if (docked) dialog.show(); else dialog.showModal(); pinning = false; render(); });
dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); });
$("logs-pause").addEventListener("click", () => { paused = paused ? null : structuredClone(source()); render(); syncLivePolling(!paused); });
$("logs-pin").addEventListener("click", () => { pinning = true; dialog.close(); docked = !docked; dialog.classList.toggle("docked", docked); if (docked) dialog.show(); else dialog.showModal(); pinning = false; render(); syncLivePolling(true); });
dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); syncLivePolling(); });
for (const id of filters) $(id).addEventListener(id === "log-filter" ? "input" : "change", render);
$("export-logs").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify({ entries: filtered(), capacity: source().capacity, filtered: true }, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = "RustProxy-access-log.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
window.rustProxyLogs = { render, open, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); } };
window.rustProxyLogs = { render, open, active, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); syncLivePolling(); } };
})();
(() => {
+31 -20
View File
@@ -34,30 +34,37 @@
<article class="stat stat-cache-outcomes" data-dashboard-widget="cache_outcomes">
<div class="cache-mix-header">
<span data-i18n="cacheMixTitle">Cache outcomes</span>
<small class="cache-mix-caption" id="cache-mix-caption" aria-label="HTTP requests since service start" title="HTTP requests since service start">-</small>
<small class="cache-mix-caption" id="cache-mix-caption" aria-label="HTTP requests since service start"
title="HTTP requests since service start">-</small>
</div>
<strong id="cache-mix-headline">-</strong>
<div class="cache-mix-bar" id="cache-mix-bar" role="img" aria-label="Cumulative HTTP cache outcomes since service start">
<div class="cache-mix-bar" id="cache-mix-bar" role="img"
aria-label="Cumulative HTTP cache outcomes since service start">
<span class="cache-mix-hit" id="cache-mix-hit" style="width:0%"></span>
<span class="cache-mix-miss" id="cache-mix-miss" style="width:0%"></span>
<span class="cache-mix-bypass" id="cache-mix-bypass" style="width:0%"></span>
<span class="cache-mix-other" id="cache-mix-other" style="width:0%"></span>
</div>
<small class="cache-mix-legend">
<span class="cache-mix-hit-key"><i></i>HIT <b><span id="cache-mix-hit-count">-</span><span class="cache-mix-percent" id="cache-mix-hit-percent">-</span></b></span>
<span class="cache-mix-miss-key"><i></i>MISS <b><span id="cache-mix-miss-count">-</span><span class="cache-mix-percent" id="cache-mix-miss-percent">-</span></b></span>
<span class="cache-mix-bypass-key"><i></i>BYPASS <b><span id="cache-mix-bypass-count">-</span><span class="cache-mix-percent" id="cache-mix-bypass-percent">-</span></b></span>
<span class="cache-mix-other-key"><i></i><span data-i18n="cacheMixOther">OTHER</span> <b><span id="cache-mix-other-count">-</span><span class="cache-mix-percent"
id="cache-mix-other-percent">-</span></b></span>
<span class="cache-mix-hit-key"><i></i>HIT <b><span id="cache-mix-hit-count">-</span><span
class="cache-mix-percent" id="cache-mix-hit-percent">-</span></b></span>
<span class="cache-mix-miss-key"><i></i>MISS <b><span id="cache-mix-miss-count">-</span><span
class="cache-mix-percent" id="cache-mix-miss-percent">-</span></b></span>
<span class="cache-mix-bypass-key"><i></i>BYPASS <b><span id="cache-mix-bypass-count">-</span><span
class="cache-mix-percent" id="cache-mix-bypass-percent">-</span></b></span>
<span class="cache-mix-other-key"><i></i><span data-i18n="cacheMixOther">OTHER</span> <b><span
id="cache-mix-other-count">-</span><span class="cache-mix-percent"
id="cache-mix-other-percent">-</span></b></span>
</small>
</article>
<article class="stat" data-dashboard-widget="memory"><span data-i18n="processMemory">Process
memory</span><strong id="metric-rss">-</strong><small id="process-summary">-</small>
</article>
<article class="stat stat-with-action" data-dashboard-widget="websocket"><span data-i18n="wsActive">WebSocket
tunnels</span><button class="quiet stat-corner-action" data-i18n="tunnelsOpen" id="tunnels-open"
type="button">Active tunnels</button><strong id="metric-ws">0</strong><small
aria-label="Active WebSocket tunnels by frontend protocol"><span data-i18n="wsH1">H1
tunnels</span><button class="pill stat-corner-action stat-corner-badge" data-i18n="tunnelsOpen"
data-ui-icon-name="none" id="tunnels-open" type="button">Active tunnels</button><strong
id="metric-ws">0</strong><small aria-label="Active WebSocket tunnels by frontend protocol"><span
data-i18n="wsH1">H1
Upgrade</span> <span id="metric-ws-h1">0</span> · <span data-i18n="wsH2Rfc8441">H2
RFC 8441</span> <span id="metric-ws-h2">0</span> · <span data-i18n="wsH3Rfc9220">H3
RFC 9220</span> <span id="metric-ws-h3">0</span><br><span data-i18n="wsEstablished">Established</span>:
@@ -366,14 +373,16 @@
</svg><small id="overview-extra-extra_backend_unhealthy-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_backends_healthy" hidden>
<span data-i18n="overviewExtraBackendsHealthy">overviewExtraBackendsHealthy</span><strong id="overview-extra-extra_backends_healthy">-</strong>
<span data-i18n="overviewExtraBackendsHealthy">overviewExtraBackendsHealthy</span><strong
id="overview-extra-extra_backends_healthy">-</strong>
<div class="overview-meter" id="overview-extra-extra_backends_healthy-meter" role="progressbar"
data-i18n-aria="overviewExtraBackendsHealthy" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_backends_healthy-meter-fill"></span>
</div><small id="overview-extra-extra_backends_healthy-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_error_share" hidden>
<span data-i18n="overviewExtraErrorShare">overviewExtraErrorShare</span><strong id="overview-extra-extra_error_share">-</strong>
<span data-i18n="overviewExtraErrorShare">overviewExtraErrorShare</span><strong
id="overview-extra-extra_error_share">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraErrorShare" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_error_share-line" points="0,55 300,55">
@@ -381,7 +390,8 @@
</svg><small id="overview-extra-extra_error_share-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_rejected_rate" hidden>
<span data-i18n="overviewExtraRejectedRate">overviewExtraRejectedRate</span><strong id="overview-extra-extra_rejected_rate">-</strong>
<span data-i18n="overviewExtraRejectedRate">overviewExtraRejectedRate</span><strong
id="overview-extra-extra_rejected_rate">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraRejectedRate" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_rejected_rate-line" points="0,55 300,55">
@@ -389,7 +399,8 @@
</svg><small id="overview-extra-extra_rejected_rate-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_cert_expiry" hidden>
<span data-i18n="overviewExtraCertExpiry">overviewExtraCertExpiry</span><strong id="overview-extra-extra_cert_expiry">-</strong>
<span data-i18n="overviewExtraCertExpiry">overviewExtraCertExpiry</span><strong
id="overview-extra-extra_cert_expiry">-</strong>
<div class="overview-meter" id="overview-extra-extra_cert_expiry-meter" role="progressbar"
data-i18n-aria="overviewExtraCertExpiry" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_cert_expiry-meter-fill"></span>
@@ -401,7 +412,7 @@
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="HTTP requests / s">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_total_rps-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_total_rps-detail" data-i18n="overviewExtraRecentSamples">-</small>
</svg><small id="overview-extra-extra_total_rps-detail" hidden></small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_active_exchanges" hidden>
<span data-i18n="overviewExtraActiveExchanges">Active HTTP exchanges</span><strong
@@ -410,21 +421,21 @@
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_active_exchanges-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_active_exchanges-detail" data-i18n="overviewExtraRecentSamples">-</small>
</svg><small id="overview-extra-extra_active_exchanges-detail" hidden></small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_h1_share" hidden>
<span data-i18n="overviewExtraH1Share">HTTP/1.1 share</span><strong id="overview-extra-extra_h1_share">-</strong>
<div class="overview-meter" id="overview-extra-extra_h1_share-meter" role="progressbar"
data-i18n-aria="overviewExtraH1Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_h1_share-meter-fill"></span>
</div><small id="overview-extra-extra_h1_share-detail" data-i18n="overviewExtraRecentSamples">-</small>
</div><small id="overview-extra-extra_h1_share-detail" hidden></small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_h3_share" hidden>
<span data-i18n="overviewExtraH3Share">HTTP/3 share</span><strong id="overview-extra-extra_h3_share">-</strong>
<div class="overview-meter" id="overview-extra-extra_h3_share-meter" role="progressbar"
data-i18n-aria="overviewExtraH3Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_h3_share-meter-fill"></span>
</div><small id="overview-extra-extra_h3_share-detail" data-i18n="overviewExtraRecentSamples">-</small>
</div><small id="overview-extra-extra_h3_share-detail" hidden></small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_cache_miss_rate" hidden>
<span data-i18n="overviewExtraCacheMissRate">Cache misses / s</span><strong
@@ -433,7 +444,7 @@
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_cache_miss_rate-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_cache_miss_rate-detail" data-i18n="overviewExtraRecentSamples">-</small>
</svg><small id="overview-extra-extra_cache_miss_rate-detail" hidden></small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_backend_active" hidden>
<span data-i18n="overviewExtraBackendActive">In-flight backend requests</span><strong
@@ -441,7 +452,7 @@
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="In-flight backend requests">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_backend_active-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_backend_active-detail" data-i18n="overviewExtraRecentSamples">-</small>
</svg><small id="overview-extra-extra_backend_active-detail" hidden></small>
</article>
<!-- RP_METRICS_SPLIT -->
<article class="stat stat-donut" data-dashboard-widget="www_status" hidden>