This commit is contained in:
Mateusz Gruszczyński committed 2026-10-16 13:02:53 +02:00
1 parent 162e986d37
commit 6b937aaf85
54 files changed
+3527 -1325

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
/// Account-scoped snapshots of Overview order, visibility, widths and display.
use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct DashboardPresetLayout {
dashboard_widgets: Vec<String>,
dashboard_hidden_widgets: Vec<String>,
dashboard_widget_sizes: Option<BTreeMap<String, u8>>,
dashboard_density: String,
overview_samples: u16,
}
#[derive(serde::Serialize)]
struct NamedDashboardPreset {
name: String,
#[serde(flatten)]
layout: DashboardPresetLayout,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct DashboardPresetRename {
name: String,
}
fn valid_dashboard_preset_name(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value.trim() == value
&& !value.chars().any(|ch| ch.is_control() || ch == '/')
}
fn validate_dashboard_preset_layout(layout: &DashboardPresetLayout) -> Result<(), ApiError> {
if !matches!(layout.overview_samples, 60 | 120 | 300 | 600) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid Overview history samples".into(),
));
}
let mut pref = AdminPreferences::default();
pref.dashboard_widgets.clone_from(&layout.dashboard_widgets);
pref.dashboard_hidden_widgets
.clone_from(&layout.dashboard_hidden_widgets);
pref.dashboard_widget_sizes
.clone_from(&layout.dashboard_widget_sizes);
pref.dashboard_density.clone_from(&layout.dashboard_density);
validate_admin_preferences(&pref)
}
async fn dashboard_presets(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<Vec<NamedDashboardPreset>>, ApiError> {
let entries = state
.store
.list_admin_dashboard_presets(&session.username)
.await
.map_err(auth::internal)?;
let mut presets = Vec::with_capacity(entries.len());
for (name, document) in entries {
let Ok(mut layout) = serde_json::from_str::<DashboardPresetLayout>(&document) else {
tracing::warn!("skipped invalid saved Overview preset");
continue;
};
// Compatibility with future/retired widgets: use the same registry
// reconciliation logic as ordinary saved administrator preferences.
let mut prefs = AdminPreferences::default();
prefs.dashboard_widgets = layout.dashboard_widgets;
prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets;
prefs.dashboard_widget_sizes = layout.dashboard_widget_sizes;
reconcile_dashboard_widgets(&mut prefs);
layout.dashboard_widgets = prefs.dashboard_widgets;
layout.dashboard_hidden_widgets = prefs.dashboard_hidden_widgets;
layout.dashboard_widget_sizes = prefs.dashboard_widget_sizes;
if validate_dashboard_preset_layout(&layout).is_ok() {
presets.push(NamedDashboardPreset { name, layout });
}
}
Ok(Json(presets))
}
async fn save_dashboard_preset(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(name): Path<String>,
Json(layout): Json<DashboardPresetLayout>,
) -> Result<StatusCode, ApiError> {
if !valid_dashboard_preset_name(&name) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid dashboard preset name".into(),
));
}
validate_dashboard_preset_layout(&layout)?;
let document = serde_json::to_string(&layout).map_err(auth::internal)?;
if !state
.store
.save_admin_dashboard_preset(&session.username, &name, &document)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::CONFLICT,
"dashboard preset limit (16) reached".into(),
));
}
Ok(StatusCode::NO_CONTENT)
}
async fn delete_dashboard_preset(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(name): Path<String>,
) -> Result<StatusCode, ApiError> {
if !state
.store
.delete_admin_dashboard_preset(&session.username, &name)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::NOT_FOUND,
"dashboard preset not found".into(),
));
}
Ok(StatusCode::NO_CONTENT)
}
async fn rename_dashboard_preset(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(name): Path<String>,
Json(rename): Json<DashboardPresetRename>,
) -> Result<StatusCode, ApiError> {
if !valid_dashboard_preset_name(&rename.name) || !valid_dashboard_preset_name(&name) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid dashboard preset name".into(),
));
}
if !state
.store
.rename_admin_dashboard_preset(&session.username, &name, &rename.name)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::CONFLICT,
"dashboard preset missing or name in use".into(),
));
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod dashboard_preset_validation_tests {
use super::*;
#[test]
fn names_are_bounded_and_paths_are_not_accepted() {
assert!(valid_dashboard_preset_name("Work layout"));
assert!(valid_dashboard_preset_name("Układ na telefon"));
for invalid in ["", " name", "name ", "bad/name", "bad\nname"] {
assert!(!valid_dashboard_preset_name(invalid));
}
assert!(!valid_dashboard_preset_name(&"X".repeat(65)));
}
#[test]
fn layouts_reject_invalid_history_and_widgets() {
let mut layout = DashboardPresetLayout {
dashboard_widgets: default_admin_dashboard_widgets_for_tests(),
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None,
dashboard_density: default_admin_dashboard_density(),
overview_samples: 600,
};
assert!(validate_dashboard_preset_layout(&layout).is_ok());
layout.overview_samples = 601;
assert!(validate_dashboard_preset_layout(&layout).is_err());
layout.overview_samples = 600;
layout.dashboard_widgets.push("undefined-widget".into());
assert!(validate_dashboard_preset_layout(&layout).is_err());
}
fn default_admin_dashboard_widgets_for_tests() -> Vec<String> {
AdminPreferences::default().dashboard_widgets
}
}
+1
View File
@@ -4,6 +4,7 @@ include!("router.rs");
include!("access.rs");
include!("session.rs");
include!("preferences.rs");
include!("dashboard_presets.rs");
include!("status.rs");
include!("logging_api.rs");
include!("file_logs.rs");
+389 -9
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.33.4",
"version": "0.33.7",
"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."
},
"servers": [
@@ -1241,15 +1241,37 @@
"type": "object",
"description": "Cached OS/process metrics for fast SSE/live charts only; full System details remain on /api/system-info",
"properties": {
"sampled_at_ms": { "type": "integer", "format": "int64" },
"cpu_percent": { "type": "number", "nullable": true },
"process_cpu_percent": { "type": "number", "nullable": true },
"process_rss_bytes": { "type": "integer", "format": "int64", "nullable": true },
"network_rx_bytes_per_second": { "type": "number", "nullable": true },
"network_tx_bytes_per_second": { "type": "number", "nullable": true },
"sampled_at_ms": {
"type": "integer",
"format": "int64"
},
"cpu_percent": {
"type": "number",
"nullable": true
},
"process_cpu_percent": {
"type": "number",
"nullable": true
},
"process_rss_bytes": {
"type": "integer",
"format": "int64",
"nullable": true
},
"network_rx_bytes_per_second": {
"type": "number",
"nullable": true
},
"network_tx_bytes_per_second": {
"type": "number",
"nullable": true
},
"network_interfaces": {
"type": "array",
"items": { "type": "object", "additionalProperties": true }
"items": {
"type": "object",
"additionalProperties": true
}
}
},
"additionalProperties": false
@@ -3570,7 +3592,13 @@
"maxProperties": 78,
"additionalProperties": {
"type": "integer",
"enum": [2, 3, 4, 6, 12]
"enum": [
2,
3,
4,
6,
12
]
},
"description": "Nullable account-scoped per-card widths (12-column grid). Null preserves the legacy grouped visual arrangement; an object, including an empty one, enables freely ordered cards. Key must be a registered Overview widget ID. Panels require width >= 4, charts >= 3 and backend table >= 6."
},
@@ -6631,6 +6659,82 @@
}
}
}
},
"DashboardPresetLayout": {
"type": "object",
"additionalProperties": false,
"description": "Validated Overview snapshot. Factory layout is separate from stored user presets.",
"properties": {
"dashboard_widgets": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"description": "Ordered registry widget identifiers"
},
"dashboard_hidden_widgets": {
"type": "array",
"items": {
"type": "string"
},
"description": "Invisible registry widget IDs"
},
"dashboard_widget_sizes": {
"nullable": true,
"type": "object",
"additionalProperties": {
"type": "integer",
"minimum": 2,
"maximum": 12
},
"description": "Width overrides in a 12-column grid; null selects legacy groups"
},
"dashboard_density": {
"type": "string",
"enum": [
"normal",
"compact",
"ultra"
]
},
"overview_samples": {
"type": "integer",
"enum": [
60,
120,
300,
600
]
}
},
"required": [
"dashboard_widgets",
"dashboard_hidden_widgets",
"dashboard_widget_sizes",
"dashboard_density",
"overview_samples"
]
},
"NamedDashboardPreset": {
"allOf": [
{
"$ref": "#/components/schemas/DashboardPresetLayout"
},
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
"required": [
"name"
]
}
]
}
}
},
@@ -11931,6 +12035,282 @@
}
}
}
},
"/api/dashboard-presets": {
"get": {
"tags": [
"Administration"
],
"summary": "List administrator dashboard presets",
"operationId": "get_dashboard_presets",
"responses": {
"200": {
"description": "Named layouts owned by the authenticated administrator",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NamedDashboardPreset"
}
}
}
}
},
"401": {
"description": "Administrator session required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Invalid Host, Origin, request marker or CSRF token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Storage unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/dashboard-presets/{name}": {
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "URL-encoded user-defined preset name (1-64 UTF-8 bytes)",
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
],
"put": {
"tags": [
"Administration"
],
"summary": "Save or overwrite an Overview dashboard preset",
"operationId": "put_dashboard_preset",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardPresetLayout"
}
}
}
},
"responses": {
"204": {
"description": "Preset saved"
},
"409": {
"description": "Account already has 16 named presets",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"422": {
"description": "Invalid preset name or layout",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Administrator session required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Invalid Host, Origin, request marker or CSRF token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Storage unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"patch": {
"tags": [
"Administration"
],
"summary": "Rename an Overview dashboard preset",
"operationId": "patch_dashboard_preset",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
}
}
}
},
"responses": {
"204": {
"description": "Preset renamed"
},
"409": {
"description": "Source name not found or target name already exists",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"422": {
"description": "Invalid preset name",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Administrator session required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Invalid Host, Origin, request marker or CSRF token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Storage unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"delete": {
"tags": [
"Administration"
],
"summary": "Delete an Overview dashboard preset",
"operationId": "delete_dashboard_preset",
"responses": {
"204": {
"description": "Preset deleted"
},
"404": {
"description": "Preset not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Administrator session required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Invalid Host, Origin, request marker or CSRF token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Storage unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}
+26 -3
View File
@@ -1,6 +1,14 @@
//! Deliberately small anonymous surface. Operational templates/assets are private.
use crate::{auth::Session, runtime::{ApiError, App}, settings::PanelCompression};
use axum::{extract::{rejection::JsonRejection, State}, response::{Html, IntoResponse, Response}, Extension};
use crate::{
auth::Session,
runtime::{ApiError, App},
settings::PanelCompression,
};
use axum::{
extract::{rejection::JsonRejection, State},
response::{Html, IntoResponse, Response},
Extension,
};
use http::{HeaderMap, StatusCode};
use std::sync::{Arc, OnceLock};
@@ -10,7 +18,19 @@ static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
let content = ADMIN_INDEX.get_or_init(|| {
// Compile-time embedded HTML fragments are assembled once at startup.
// No additional browser requests, changed DOM nodes or script ordering.
let (metrics, web_metrics) = include_str!("web/partials/overview-metrics.html")
.split_once("<!-- RP_METRICS_SPLIT -->")
.expect("invalid Overview metrics partial");
let (charts, web_charts) = include_str!("web/partials/overview-charts.html")
.split_once("<!-- RP_CHARTS_SPLIT -->")
.expect("invalid Overview charts partial");
let html = include_str!("web/index.html")
.replace("<!-- RP_METRICS_MAIN -->", metrics)
.replace("<!-- RP_CHARTS_MAIN -->", charts)
.replace("<!-- RP_METRICS_WEB -->", web_metrics)
.replace("<!-- RP_CHARTS_WEB -->", web_charts)
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"));
// Exactly one static panel document shared between all administrator accounts.
@@ -30,7 +50,10 @@ pub(super) async fn index(
return Html(html).into_response();
}
let snapshot = state.config.load();
panel_response(&headers, &snapshot.envelope.config.administration.panel_compression)
panel_response(
&headers,
&snapshot.envelope.config.administration.panel_compression,
)
}
pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
+9 -1
View File
@@ -69,7 +69,7 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/dashboard-widgets.json"),
");",
include_str!("web/app/preferences.js"),
include_str!("web/app/dashboard-layout-editor.js"),
include_str!("web/app/dashboard-presets.js"),
include_str!("web/app/dashboard-inline-layout.js"),
include_str!("web/app/stream-dashboard.js"),
include_str!("web/app/backend-dashboard.js"),
@@ -249,6 +249,14 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/file-logs", get(file_logs))
.route("/api/file-logs/query", get(file_logs_query))
.route("/api/preferences", get(preferences).put(update_preferences))
.route("/api/dashboard-presets", get(dashboard_presets))
.route(
"/api/dashboard-presets/{name}",
put(save_dashboard_preset)
.patch(rename_dashboard_preset)
.delete(delete_dashboard_preset)
.layer(DefaultBodyLimit::max(10240)),
)
.route("/api/audit", get(audit))
.route("/api/events", get(events))
.route("/metrics", get(metrics))
+85
View File
@@ -1639,6 +1639,87 @@ mod tests {
store.close().await;
}
#[tokio::test]
async fn named_dashboard_presets_are_private_validated_and_persistent() {
let (router, store, _dir, secret, csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let preset = DashboardPresetLayout {
dashboard_widgets: AdminPreferences::default().dashboard_widgets,
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None,
dashboard_density: "compact".to_owned(),
overview_samples: 600,
};
let body = serde_json::to_string(&preset).unwrap();
let anon = router.clone().oneshot(
request("/api/dashboard-presets").body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(anon.status(), StatusCode::UNAUTHORIZED);
let forbidden = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("content-type", "application/json")
.body(Body::from(body.clone())).unwrap()
).await.unwrap();
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
let saved = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.clone())).unwrap()
).await.unwrap();
assert_eq!(saved.status(), StatusCode::NO_CONTENT);
let listing = router.clone().oneshot(
request("/api/dashboard-presets")
.header("cookie", &cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(listing.status(), StatusCode::OK);
let entries = json_body(listing).await;
assert_eq!(entries[0]["name"], "Daily");
assert_eq!(entries[0]["overview_samples"], 600);
assert_eq!(entries[0]["dashboard_widget_sizes"], Value::Null);
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap().len(), 1);
assert!(store.list_admin_dashboard_presets("someone-else").await.unwrap().is_empty());
let rejected = router.clone().oneshot(
request("/api/dashboard-presets/Invalid").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.replace("\"overview_samples\":600", "\"overview_samples\":999"))).unwrap()
).await.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let renamed = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PATCH")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"Nightly"}"#)).unwrap()
).await.unwrap();
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap()[0].0, "Nightly");
let deleted = router.oneshot(
request("/api/dashboard-presets/Nightly").method("DELETE")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
assert!(store.list_admin_dashboard_presets("owner").await.unwrap().is_empty());
store.close().await;
}
#[tokio::test]
async fn admin_preferences_are_account_scoped_and_csrf_guarded() {
let (router, store, _dir, secret, csrf) = fixture().await;
@@ -3010,6 +3091,7 @@ mod tests {
"/api/system-info",
"/api/config",
"/api/preferences",
"/api/dashboard-presets",
"/api/cache",
"/api/cache/bans",
"/api/audit",
@@ -3044,6 +3126,9 @@ mod tests {
("DELETE", "/api/cache/bans/1"),
("PUT", "/api/config/cache"),
("PUT", "/api/preferences"),
("PUT", "/api/dashboard-presets/example"),
("DELETE", "/api/dashboard-presets/example"),
("PATCH", "/api/dashboard-presets/example"),
("POST", "/api/auth/logout-all"),
] {
let no_session = request(path).method(method).body(Body::empty()).unwrap();
+1 -1
View File
@@ -162,7 +162,7 @@ window.rustProxyInlineLayout = (() => {
draft = original = null;
}
async function begin() {
if (draft || opening || saving || $("overview-layout-dialog")?.open) return;
if (draft || opening || saving || $("overview-presets-dialog")?.open) return;
opening = true;
unlock.disabled = true;
try {
+144
View File
@@ -0,0 +1,144 @@
window.rustProxyDashboardLayout = (() => {
const dialog = $("overview-presets-dialog");
const list = $("overview-presets-list");
const input = $("overview-presets-name");
const message = $("overview-presets-message");
const save = $("overview-presets-save");
const factory = $("overview-presets-factory");
const close = $("overview-presets-close");
let presets = [];
let busy = false;
const presetPath = name => `/api/dashboard-presets/${encodeURIComponent(name)}`;
const nameValid = name => name.length > 0 && new TextEncoder().encode(name).length <= 64
&& !/[\x00-\x1f\x7f/]/.test(name);
const snapshot = () => {
const current = window.rustProxyPreferences.current() || ADMIN_APPEARANCE_DEFAULTS;
return {
dashboard_widgets: [...normalizeDashboardWidgets(current.dashboard_widgets)],
dashboard_hidden_widgets: [...normalizeHiddenDashboardWidgets(current.dashboard_hidden_widgets)],
dashboard_widget_sizes: normalizeDashboardWidgetSizes(current.dashboard_widget_sizes),
dashboard_density: normalizeAdminDensity(current.dashboard_density, "compact"),
overview_samples: window.rustProxyOverviewSamples.getLimit(),
};
};
const translateName = (key, name) => t(key).replace("{name}", name);
function notify(key, isError = false) {
message.textContent = isError ? key : t(key);
message.classList.toggle("error", isError);
}
function lock(value) {
busy = value;
for (const control of [save, factory, close, input, ...list.querySelectorAll("button")])
if (control) control.disabled = value;
}
async function run(fn) {
if (busy) return;
lock(true);
try { await fn(); }
catch (error) {
const message = error.status === 409
? (String(error.code || "").includes("limit (16)")
? t("dashboardPresetsLimit") : t("dashboardPresetsConflict"))
: (error.message || String(error));
notify(message, true);
}
finally { lock(false); }
}
async function reload() {
presets = await api("/api/dashboard-presets");
render();
}
function entryButton(key, className, fn, presetName) {
const button = el("button", t(key), className);
button.type = "button";
button.setAttribute("aria-label", `${t(key)}: ${presetName}`);
button.dataset.presetAction = key;
button.addEventListener("click", () => void run(fn));
return button;
}
function render() {
const elements = presets.map(preset => {
const row = el("div", undefined, "overview-presets-row");
row.setAttribute("role", "listitem");
const actions = el("div", undefined, "overview-presets-actions");
actions.append(
entryButton("dashboardPresetsLoad", "primary", async () => {
await apply(preset);
dialog.close();
toast(t("dashboardPresetsLoaded"));
}, preset.name),
entryButton("dashboardPresetsOverwrite", "outline", async () => {
if (!(await appConfirm(translateName("dashboardPresetsOverwriteConfirm", preset.name)))) return;
await api(presetPath(preset.name), { method: "PUT", body: JSON.stringify(snapshot()) });
await reload(); notify("dashboardPresetsSaved");
}, preset.name),
entryButton("dashboardPresetsRename", "quiet", async () => {
const name = input.value.trim();
if (!nameValid(name)) { notify("dashboardPresetsNameRequired"); return; }
if (name === preset.name) return;
await api(presetPath(preset.name), { method: "PATCH", body: JSON.stringify({ name }) });
input.value = "";
await reload(); notify("dashboardPresetsRenamed");
}, preset.name),
entryButton("dashboardPresetsDelete", "quiet", async () => {
if (!(await appConfirm(translateName("dashboardPresetsDeleteConfirm", preset.name), { danger: true }))) return;
await api(presetPath(preset.name), { method: "DELETE" });
await reload(); notify("dashboardPresetsDeleted");
}, preset.name),
);
row.append(el("strong", preset.name), actions);
return row;
});
list.replaceChildren(...(elements.length ? elements : [el("p", t("dashboardPresetsEmpty"), "fine")]));
}
async function apply(layout) {
await window.rustProxyPreferences.update({
dashboard_widgets: [...layout.dashboard_widgets],
dashboard_hidden_widgets: [...layout.dashboard_hidden_widgets],
dashboard_widget_sizes: layout.dashboard_widget_sizes === null ? null : { ...layout.dashboard_widget_sizes },
dashboard_density: layout.dashboard_density,
});
window.rustProxyOverviewSamples.setLimit(layout.overview_samples, true);
}
async function open() {
if (dialog?.open || busy || window.rustProxyInlineLayout?.isEditing?.()) return;
await run(async () => {
await window.rustProxyPreferences.load();
await reload();
input.value = "";
notify("");
dialog.showModal();
input.focus();
});
}
save?.addEventListener("click", () => void run(async () => {
const name = input.value.trim();
if (!nameValid(name)) { notify("dashboardPresetsNameRequired"); return; }
if (presets.length >= 16 && !presets.some(preset => preset.name === name)) {
notify("dashboardPresetsLimit"); return;
}
if (presets.some(preset => preset.name === name)
&& !(await appConfirm(translateName("dashboardPresetsOverwriteConfirm", name)))) return;
await api(presetPath(name), { method: "PUT", body: JSON.stringify(snapshot()) });
input.value = "";
await reload(); notify("dashboardPresetsSaved");
}));
factory?.addEventListener("click", () => void run(async () => {
if (!(await appConfirm(t("dashboardPresetsResetConfirm")))) return;
await apply({
dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS],
dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS],
dashboard_widget_sizes: null,
dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density,
overview_samples: ADMIN_DASHBOARD_DEFAULTS.overview_samples,
});
dialog.close();
toast(t("dashboardPresetsRestored"));
}));
close?.addEventListener("click", () => { if (!busy) dialog.close(); });
dialog?.addEventListener("cancel", event => { if (busy) event.preventDefault(); });
for (const id of ["overview-layout-open", "overview-layout-open-settings"])
$(id)?.addEventListener("click", () => { void open(); });
return { open, refresh: () => { if (dialog?.open) render(); } };
})();
+25 -1055
View File
File diff suppressed because it is too large. Load diff
+373
View File
@@ -0,0 +1,373 @@
<article class="card overview-wide-chart" data-dashboard-widget="wide_protocol_rates" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideProtocolRates">HTTP/1.1 · HTTP/2 · HTTP/3 traffic</h2>
<span class="overview-wide-summary" id="overview-wide-wide_protocol_rates-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="HTTP/1.1 · HTTP/2 · HTTP/3 traffic">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_protocol_rates-h1-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_protocol_rates-h2-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-2" id="overview-wide-wide_protocol_rates-h3-line" d="">
</path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span>H1</span><b
id="overview-wide-wide_protocol_rates-h1-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span>H2</span><b
id="overview-wide-wide_protocol_rates-h2-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span>H3</span><b
id="overview-wide-wide_protocol_rates-h3-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_cpu" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideCpu">System and process CPU</h2>
<span class="overview-wide-summary" id="overview-wide-wide_cpu-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="System and process CPU">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_cpu-system-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_cpu-process-line" d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideSystem">System</span><b
id="overview-wide-wide_cpu-system-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideProcess">Process</span><b
id="overview-wide-wide_cpu-process-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_memory" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideMemory">Process RAM and cache memory</h2>
<span class="overview-wide-summary" id="overview-wide-wide_memory-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Process RAM and cache memory">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_memory-process-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_memory-cache-line" d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideProcess">Process</span><b
id="overview-wide-wide_memory-process-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideCache">Cache</span><b
id="overview-wide-wide_memory-cache-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_cache_activity" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideCacheActivity">Cache hits / misses per second</h2>
<span class="overview-wide-summary" id="overview-wide-wide_cache_activity-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Cache hits / misses per second">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_cache_activity-hits-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_cache_activity-misses-line"
d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideHits">HIT</span><b
id="overview-wide-wide_cache_activity-hits-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideMisses">MISS</span><b
id="overview-wide-wide_cache_activity-misses-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_connections" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideConnections">Connections and WebSocket tunnels</h2>
<span class="overview-wide-summary" id="overview-wide-wide_connections-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Connections and WebSocket tunnels">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_connections-open-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_connections-websockets-line"
d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideOpen">Connections</span><b
id="overview-wide-wide_connections-open-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideWebsockets">WebSockets</span><b
id="overview-wide-wide_connections-websockets-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_backend_health" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideBackendHealth">Backend availability over time</h2>
<span class="overview-wide-summary" id="overview-wide-wide_backend_health-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Backend availability over time">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_backend_health-up-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_backend_health-down-line" d="">
</path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideHealthy">Healthy</span><b
id="overview-wide-wide_backend_health-up-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideUnhealthy">Unhealthy</span><b
id="overview-wide-wide_backend_health-down-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_latency" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideLatency">Response and backend probe latency</h2>
<span class="overview-wide-summary" id="overview-wide-wide_latency-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Response and backend probe latency">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_latency-response-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_latency-probe-line" d="">
</path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideResponse">HTTP</span><b
id="overview-wide-wide_latency-response-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideProbe">Probes</span><b
id="overview-wide-wide_latency-probe-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_transform_activity">
<div class="card-heading">
<h2 data-i18n="overviewWideTransformActivity">Transformation activity</h2>
<span class="overview-wide-summary" id="overview-wide-wide_transform_activity-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
data-i18n-aria="overviewWideTransformActivity" aria-label="Transformation activity">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0"
id="overview-wide-wide_transform_activity-applied-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1"
id="overview-wide-wide_transform_activity-cache_hits-line" d=""></path>
<path class="overview-wide-series overview-wide-series-2"
id="overview-wide-wide_transform_activity-skipped-line" d=""></path>
<path class="overview-wide-series overview-wide-series-3" id="overview-wide-wide_transform_activity-errors-line"
d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewTransformApplied">Applied</span><b
id="overview-wide-wide_transform_activity-applied-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewTransformCacheHits">Cache HIT</span><b
id="overview-wide-wide_transform_activity-cache_hits-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span data-i18n="overviewTransformSkipped">Skipped</span><b
id="overview-wide-wide_transform_activity-skipped-value">-</b></span>
<span><i class="overview-wide-key-3"></i><span data-i18n="overviewTransformErrors">Errors</span><b
id="overview-wide-wide_transform_activity-errors-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_compression_activity">
<div class="card-heading">
<h2 data-i18n="overviewWideCompressionActivity">Compression activity</h2>
<span class="overview-wide-summary" id="overview-wide-wide_compression_activity-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
data-i18n-aria="overviewWideCompressionActivity" aria-label="Compression activity">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_compression_activity-gzip-line"
d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_compression_activity-br-line"
d=""></path>
<path class="overview-wide-series overview-wide-series-2" id="overview-wide-wide_compression_activity-zstd-line"
d=""></path>
<path class="overview-wide-series overview-wide-series-3"
id="overview-wide-wide_compression_activity-deflate-line" d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span>gzip</span><b
id="overview-wide-wide_compression_activity-gzip-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span>Brotli</span><b
id="overview-wide-wide_compression_activity-br-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span>Zstd</span><b
id="overview-wide-wide_compression_activity-zstd-value">-</b></span>
<span><i class="overview-wide-key-3"></i><span>deflate</span><b
id="overview-wide-wide_compression_activity-deflate-value">-</b></span>
</div>
</article>
<!-- RP_CHARTS_SPLIT -->
<article class="card overview-wide-chart" data-dashboard-widget="www_requests" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricRequests">webMetricRequests</h2><span class="overview-wide-summary"
id="www_requests-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Web Server request rate" data-i18n-aria="webMetricRequests">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_requests-all-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_requests-php-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesAll">all</span><b id="www_requests-all-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesPhp">php</span><b
id="www_requests-php-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_latency" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricLatency">webMetricLatency</h2><span class="overview-wide-summary"
id="www_latency-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Web Server header latency" data-i18n-aria="webMetricLatency">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_latency-web-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_latency-php-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesWeb">web</span><b id="www_latency-web-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesPhp">php</span><b
id="www_latency-php-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_failures" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricFailures">webMetricFailures</h2><span class="overview-wide-summary"
id="www_failures-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="PHP FastCGI failures" data-i18n-aria="webMetricFailures">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_failures-timeout-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_failures-connection-line" d=""></path>
<path class="overview-wide-series overview-wide-series-2" id="www_failures-protocol-line" d=""></path>
<path class="overview-wide-series overview-wide-series-3" id="www_failures-capacity-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesTimeout">timeout</span><b id="www_failures-timeout-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesConnection">connection</span><b
id="www_failures-connection-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span data-i18n="webMetricSeriesProtocol">protocol</span><b
id="www_failures-protocol-value">-</b></span>
<span><i class="overview-wide-key-3"></i><span data-i18n="webMetricSeriesCapacity">capacity</span><b
id="www_failures-capacity-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_traffic" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricTraffic">webMetricTraffic</h2><span class="overview-wide-summary"
id="www_traffic-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Web Server body byte rate" data-i18n-aria="webMetricTraffic">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_traffic-nonphp-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_traffic-php-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesNonphp">nonphp</span><b id="www_traffic-nonphp-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesPhp">php</span><b
id="www_traffic-php-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_status_trend" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricStatusTrend">webMetricStatusTrend</h2><span class="overview-wide-summary"
id="www_status_trend-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Web Server HTTP statuses" data-i18n-aria="webMetricStatusTrend">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_status_trend-2xx-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_status_trend-3xx-line" d=""></path>
<path class="overview-wide-series overview-wide-series-2" id="www_status_trend-4xx-line" d=""></path>
<path class="overview-wide-series overview-wide-series-3" id="www_status_trend-5xx-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesTwoxx">2xx</span><b id="www_status_trend-2xx-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesThreexx">3xx</span><b
id="www_status_trend-3xx-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span data-i18n="webMetricSeriesFourxx">4xx</span><b
id="www_status_trend-4xx-value">-</b></span>
<span><i class="overview-wide-key-3"></i><span data-i18n="webMetricSeriesFivexx">5xx</span><b
id="www_status_trend-5xx-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_top_services" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricTopServices">Top Web Services</h2>
<span class="overview-wide-summary" id="www_top_services-value">-</span>
</div>
<div id="www_top_services-bars" class="www-top-bars"></div>
<small class="fine" data-i18n="webMetricTopServicesHelp">Requests per second by Web
Service.</small>
</article>
<article class="card activity-card" data-dashboard-widget="traffic_chart">
<div class="card-heading">
<h2 data-i18n="requestsPerSecond">Requests per second</h2>
<span id="chart-peak">max 0.0 req/s</span>
</div><svg preserveAspectRatio="none" aria-label="Recent request rate" id="traffic-chart" role="img"
viewbox="0 0 600 130">
<path class="chart-grid" d="M0 25H600 M0 65H600 M0 105H600"></path>
<polyline class="traffic-line traffic-line-total" id="sparkline" points="0,120 600,120">
</polyline>
<polyline class="traffic-line traffic-line-h1" id="sparkline-h1" points="0,120 600,120">
</polyline>
<polyline class="traffic-line traffic-line-h2" id="sparkline-h2" points="0,120 600,120">
</polyline>
<polyline class="traffic-line traffic-line-h3" id="sparkline-h3" points="0,120 600,120">
</polyline>
<polyline class="traffic-line traffic-line-transform" id="sparkline-transform" points="0,120 600,120">
</polyline>
</svg>
<div class="traffic-protocol-legend" aria-label="Request rate by HTTP protocol and transformed response rate">
<span class="legend-total"><i></i>Total <b id="rate-total">0.0/s</b></span>
<span class="legend-h1"><i></i>HTTP/1.1 <b id="rate-h1">0.0/s</b></span>
<span class="legend-h2"><i></i>HTTP/2 <b id="rate-h2">0.0/s</b></span>
<span class="legend-h3"><i></i>HTTP/3 <b id="rate-h3">0.0/s</b></span>
<span class="legend-transform"><i></i><span data-i18n="liveTransformed">Transformed</span> <b
id="rate-transform">0.0/s</b></span>
</div>
</article>
<article class="card system-card" data-dashboard-widget="system_chart">
<div class="card-heading">
<div>
<h3 class="overview-chart-heading" data-i18n="systemLoad">System load</h3>
<h2><span id="system-cpu">-</span> <small>CPU</small></h2>
</div><span class="meta" id="system-source">-</span>
</div><svg preserveAspectRatio="none" aria-label="System CPU history" data-i18n-aria="systemChart" id="system-chart"
role="img" viewbox="0 0 300 100">
<path class="chart-grid" d="M0 25H300 M0 50H300 M0 75H300"></path>
<polyline id="system-sparkline" points="0,95 300,95"></polyline>
</svg>
<div class="memory-line"><span data-i18n="systemMemory">System RAM</span><b id="system-memory">-</b></div>
<div class="overview-meter system-memory-meter" id="memory-meter" role="progressbar" aria-label="System RAM"
data-i18n-aria="systemMemory" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="memory-meter-fill"></span>
</div>
<div class="memory-line"><span data-i18n="systemSwap">System swap</span><b id="system-swap">-</b></div>
<div class="chart-footer"><span data-i18n="loadAverage">Load 1 / 5 / 15 min</span><span id="system-load">-</span>
</div>
<div class="chart-footer"><span data-i18n="systemUptime">System uptime</span><span id="system-uptime">-</span></div>
</article>
<article class="card connection-card" data-dashboard-widget="connection_chart">
<div class="card-heading">
<div>
<h3 class="overview-chart-heading" data-i18n="connectionActivity">Connection activity</h3>
<h2><span id="connection-current">0</span> <small data-i18n="connections">Open
connections</small></h2>
</div>
</div>
<svg preserveAspectRatio="none" aria-label="Connection activity history" data-i18n-aria="connectionActivity"
id="connection-chart" role="img" viewbox="0 0 300 100">
<path class="chart-grid" d="M0 25H300 M0 50H300 M0 75H300"></path>
<polyline class="connection-open-line" id="connection-open-sparkline" points="0,95 300,95"></polyline>
<polyline class="connection-exchange-line" id="connection-exchange-sparkline" points="0,95 300,95"></polyline>
</svg>
<div class="connection-legend"><span class="connection-open-key"><i></i><span data-i18n="connections">Open
connections</span> <b id="connection-open-value">0</b></span><span
class="connection-exchange-key"><i></i><span data-i18n="activeExchanges">Active
exchanges</span> <b id="connection-exchange-value">0</b></span></div>
<div class="chart-footer"><span><span data-i18n="backends">Backends</span>: <b id="connection-backends">0</b></span>
</div>
</article>
@@ -0,0 +1,471 @@
<article class="stat" data-dashboard-widget="requests"><span data-i18n="requests">Requests</span><strong
id="metric-requests">0</strong><small id="request-rate">0 req/s</small></article>
<article class="stat" data-dashboard-widget="active"><span data-i18n="activeExchanges">Active
exchanges</span><strong id="metric-active">0</strong><small data-i18n="bodyLifetime">Full body lifetime</small>
</article>
<article class="stat" data-dashboard-widget="bandwidth"><span data-i18n="bandwidth">Payload
transferred</span><strong id="metric-bytes">0 B</strong><small class="payload-split"
id="metric-bytes-split"><span>↓ <b id="metric-bytes-down">0 B</b></span><span>↑ <b id="metric-bytes-up">0
B</b></span></small>
</article>
<article class="stat" data-dashboard-widget="errors"><span data-i18n="errors">Errors</span><strong
id="metric-errors">0</strong><small data-i18n="errorDescription">5xx / body /
cancelled</small></article>
<article class="stat stat-donut" data-dashboard-widget="cache"><span data-i18n="cacheHitRatio">Cache hit
ratio</span>
<div class="overview-donut-row">
<svg class="overview-donut-svg" id="cache-ratio-chart" role="img" aria-label="Cache HIT and MISS distribution"
viewBox="0 0 64 64">
<circle class="overview-donut-track" cx="32" cy="32" r="24"></circle>
<circle class="overview-donut-hit" id="cache-ratio-hit-arc" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100"></circle>
<circle class="overview-donut-miss" id="cache-ratio-miss-arc" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100"></circle>
</svg>
<div class="overview-donut-info">
<strong id="metric-cache-ratio">-</strong>
<small class="overview-donut-legend" id="cache-summary">
<span class="overview-donut-hit-key"><i></i>HIT <b id="cache-ratio-hit-count">0</b></span>
<span class="overview-donut-miss-key"><i></i>MISS <b id="cache-ratio-miss-count">0</b></span>
</small>
</div>
</div>
</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
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>:
<span id="metric-ws-total">0</span></small></article>
<article class="stat" data-dashboard-widget="http2"><span data-i18n="h2Requests">HTTP/2
requests</span><strong id="metric-h2">0</strong><small><span data-i18n="h2ActiveConnections">Active H2
connections</span>: <span id="metric-h2-active">0</span></small></article>
<article class="stat" data-dashboard-widget="http3"><span data-i18n="h3Requests">HTTP/3
requests</span><strong id="metric-h3">0</strong></article>
<article class="stat" data-dashboard-widget="tls"><span data-i18n="tlsFailures">TLS handshake
errors</span><strong id="metric-tls-errors">0</strong></article>
<article class="stat stat-chart" data-dashboard-widget="http_protocol"><span data-i18n="httpProtocolMix">HTTP protocol
mix</span><strong id="metric-http-proto-total">0.0/s</strong><svg preserveAspectRatio="none"
aria-label="HTTP protocol activity" data-i18n-aria="httpProtocolMix" id="http-protocol-chart" role="img"
viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h1-line" id="http-protocol-h1" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line proto-h2-line" id="http-protocol-h2" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line proto-h3-line" id="http-protocol-h3" points="0,55 300,55"></polyline>
</svg><small class="stat-chart-legend"><span class="legend-h1"><i></i>H1 <b
id="metric-http-proto-h1">0.0/s</b></span><span class="legend-h2"><i></i>H2 <b
id="metric-http-proto-h2">0.0/s</b></span><span class="legend-h3"><i></i>H3 <b
id="metric-http-proto-h3">0.0/s</b></span></small></article>
<article class="stat stat-chart" data-dashboard-widget="websocket_protocol"><span data-i18n="wsProtocolMix">WebSocket
protocol
mix</span><strong id="metric-ws-proto-total">0</strong><svg preserveAspectRatio="none"
aria-label="WebSocket protocol activity" data-i18n-aria="wsProtocolMix" id="ws-protocol-chart" role="img"
viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h1-line" id="ws-protocol-h1" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line proto-h2-line" id="ws-protocol-h2" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line proto-h3-line" id="ws-protocol-h3" points="0,55 300,55"></polyline>
</svg><small class="stat-chart-legend"><span class="legend-h1"><i></i>H1 <b
id="metric-ws-proto-h1">0</b></span><span class="legend-h2"><i></i>H2 <b
id="metric-ws-proto-h2">0</b></span><span class="legend-h3"><i></i>H3 <b
id="metric-ws-proto-h3">0</b></span></small></article>
<article class="stat" data-dashboard-widget="stream_overview" hidden>
<span data-i18n="streamWidgetOverview">Stream overview</span><strong id="stream-dashboard-overview">-</strong>
<small id="stream-dashboard-overview-detail" data-i18n="streamWidgetUnavailable">No stream
data</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="stream_traffic" hidden>
<span data-i18n="streamWidgetTraffic">Stream traffic</span><strong id="stream-dashboard-traffic">-</strong>
<svg preserveAspectRatio="none" aria-label="Stream traffic RX/TX" data-i18n-aria="streamWidgetTraffic" role="img"
viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line stream-chart-rx" id="stream-dashboard-rx" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line stream-chart-tx" id="stream-dashboard-tx" points="0,55 300,55"></polyline>
</svg><small class="stat-chart-legend stream-traffic-legend" id="stream-dashboard-traffic-detail">
<span class="legend-rx"><i></i>RX <b id="stream-dashboard-rx-rate">-</b></span>
<span class="legend-tx"><i></i>TX <b id="stream-dashboard-tx-rate">-</b></span>
</small>
</article>
<article class="stat" data-dashboard-widget="stream_sessions" hidden>
<span data-i18n="streamWidgetSessions">Stream sessions</span><strong id="stream-dashboard-sessions">-</strong>
<small id="stream-dashboard-sessions-detail" data-i18n="streamWidgetUnavailable">No stream
data</small>
</article>
<article class="stat" data-dashboard-widget="stream_backend_health" hidden>
<span data-i18n="streamWidgetHealth">Stream backend health</span><strong id="stream-dashboard-health">-</strong>
<small id="stream-dashboard-health-detail" data-i18n="streamWidgetUnavailable">No stream
data</small>
</article>
<article class="stat" data-dashboard-widget="backend_health" hidden>
<span data-i18n="backendWidgetHealth">Backend health</span><strong id="backend-dashboard-health">-</strong>
<small id="backend-dashboard-health-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_activity" hidden>
<span data-i18n="backendWidgetActivity">Active backend requests</span><strong
id="backend-dashboard-activity">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetActivity" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h1-line" id="backend-dashboard-active-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-activity-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_errors" hidden>
<span data-i18n="backendWidgetErrors">Backend errors</span><strong id="backend-dashboard-errors">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetErrors" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h3-line" id="backend-dashboard-error-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-errors-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_latency" hidden>
<span data-i18n="backendWidgetLatency">Health probe RTT</span><strong id="backend-dashboard-latency">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetLatency" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-latency-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-latency-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_traffic" hidden>
<span data-i18n="backendWidgetTraffic">Backend traffic</span><strong id="backend-dashboard-traffic">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetTraffic" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line stream-chart-rx" id="backend-dashboard-in-line" points="0,55 300,55">
</polyline>
<polyline class="diagnostic-line stream-chart-tx" id="backend-dashboard-out-line" points="0,55 300,55">
</polyline>
</svg><small class="stat-chart-legend stream-traffic-legend">
<span class="legend-rx"><i></i>IN <b id="backend-dashboard-in-rate">-</b></span>
<span class="legend-tx"><i></i>OUT <b id="backend-dashboard-out-rate">-</b></span>
</small>
</article>
<article class="stat" data-dashboard-widget="backend_protocols" hidden>
<span data-i18n="backendWidgetProtocols">Configured backend modes</span><strong
id="backend-dashboard-protocols">-</strong>
<div class="backend-mode-bar" role="img" data-i18n-aria="backendWidgetProtocols">
<span class="backend-mode-h1" id="backend-mode-h1"></span>
<span class="backend-mode-auto" id="backend-mode-auto"></span>
<span class="backend-mode-compat" id="backend-mode-compat"></span>
<span class="backend-mode-h2" id="backend-mode-h2"></span>
</div><small id="backend-dashboard-protocols-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_requests" hidden>
<span data-i18n="backendWidgetRequests">Backend request rate</span><strong
id="backend-dashboard-requests">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetRequests" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h1-line" id="backend-dashboard-request-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-requests-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="backend_capacity" hidden>
<span data-i18n="backendWidgetCapacity">Backend capacity</span><strong id="backend-dashboard-capacity">-</strong>
<div class="overview-meter" id="backend-dashboard-capacity-meter" role="progressbar"
data-i18n-aria="backendWidgetCapacity" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="backend-dashboard-capacity-meter-fill"></span>
</div><small id="backend-dashboard-capacity-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_http5xx" hidden>
<span data-i18n="backendWidgetHttp5xx">backendWidgetHttp5xx</span><strong id="backend-dashboard-http5xx">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetHttp5xx" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-5xx-line" points="0,55 300,55"></polyline>
</svg><small id="backend-dashboard-http5xx-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_timeouts" hidden>
<span data-i18n="backendWidgetTimeouts">backendWidgetTimeouts</span><strong
id="backend-dashboard-timeouts">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetTimeouts" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-timeout-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-timeouts-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_network" hidden>
<span data-i18n="backendWidgetNetwork">backendWidgetNetwork</span><strong id="backend-dashboard-network">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetNetwork" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-network-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-network-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_retries" hidden>
<span data-i18n="backendWidgetRetries">backendWidgetRetries</span><strong id="backend-dashboard-retries">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetRetries" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-retry-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-retries-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_queue" hidden>
<span data-i18n="backendWidgetQueue">backendWidgetQueue</span><strong id="backend-dashboard-queue">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetQueue" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-queue-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-queue-detail">-</small>
</article>
<article class="stat" data-dashboard-widget="backend_rotations" hidden>
<span data-i18n="backendWidgetRotations">Backend client pool rotations</span><strong
id="backend-dashboard-rotations">-</strong>
<small id="backend-dashboard-rotations-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_process_cpu" hidden>
<span data-i18n="overviewExtraProcessCpu">overviewExtraProcessCpu</span><strong
id="overview-extra-extra_process_cpu">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraProcessCpu" 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_process_cpu-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_process_cpu-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_process_rss" hidden>
<span data-i18n="overviewExtraProcessRss">overviewExtraProcessRss</span><strong
id="overview-extra-extra_process_rss">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraProcessRss" 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_process_rss-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_process_rss-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_process_fds" hidden>
<span data-i18n="overviewExtraProcessFds">overviewExtraProcessFds</span><strong
id="overview-extra-extra_process_fds">-</strong>
<div class="overview-meter" id="overview-extra-extra_process_fds-meter" role="progressbar"
data-i18n-aria="overviewExtraProcessFds" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_process_fds-meter-fill"></span>
</div><small id="overview-extra-extra_process_fds-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_open_connections" hidden>
<span data-i18n="overviewExtraOpenConnections">overviewExtraOpenConnections</span><strong
id="overview-extra-extra_open_connections">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraOpenConnections" 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_open_connections-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_open_connections-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_websocket_active" hidden>
<span data-i18n="overviewExtraWebsocketActive">overviewExtraWebsocketActive</span><strong
id="overview-extra-extra_websocket_active">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraWebsocketActive" 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_websocket_active-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_websocket_active-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_tls_error_rate" hidden>
<span data-i18n="overviewExtraTlsErrorRate">overviewExtraTlsErrorRate</span><strong
id="overview-extra-extra_tls_error_rate">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraTlsErrorRate" 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_tls_error_rate-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_tls_error_rate-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_request_error_rate" hidden>
<span data-i18n="overviewExtraRequestErrorRate">overviewExtraRequestErrorRate</span><strong
id="overview-extra-extra_request_error_rate">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraRequestErrorRate" 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_request_error_rate-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_request_error_rate-detail">-</small>
</article>
<article class="stat stat-donut" data-dashboard-widget="extra_cache_hit_rate" hidden>
<span data-i18n="overviewExtraCacheHitRate">Cache hits share (recent)</span>
<div class="overview-donut-row">
<svg class="overview-donut-svg" id="extra-cache-ratio-chart" role="img"
aria-label="Recent cache HIT and MISS distribution" viewBox="0 0 64 64">
<circle class="overview-donut-track" cx="32" cy="32" r="24"></circle>
<circle class="overview-donut-hit" id="extra-cache-ratio-hit-arc" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100"></circle>
<circle class="overview-donut-miss" id="extra-cache-ratio-miss-arc" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100"></circle>
</svg>
<div class="overview-donut-info">
<strong id="overview-extra-extra_cache_hit_rate">-</strong>
<small class="overview-donut-legend" id="overview-extra-extra_cache_hit_rate-detail">
<span class="overview-donut-hit-key"><i></i>HIT <b id="extra-cache-ratio-hit-count">0</b></span>
<span class="overview-donut-miss-key"><i></i>MISS <b id="extra-cache-ratio-miss-count">0</b></span>
</small>
</div>
</div>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_cache_memory">
<span data-i18n="overviewExtraCacheMemory">overviewExtraCacheMemory</span><strong
id="overview-extra-extra_cache_memory">-</strong>
<div class="overview-meter" id="overview-extra-extra_cache_memory-meter" role="progressbar"
data-i18n-aria="overviewExtraCacheMemory" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_cache_memory-meter-fill"></span>
</div><small id="overview-extra-extra_cache_memory-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_cold_queue_memory">
<span data-i18n="overviewExtraColdQueueMemory">COLD queue RAM utilization</span>
<strong id="overview-extra-extra_cold_queue_memory">-</strong>
<div class="overview-meter" id="overview-extra-extra_cold_queue_memory-meter" role="progressbar"
data-i18n-aria="overviewExtraColdQueueMemory" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_cold_queue_memory-meter-fill"></span>
</div>
<small id="overview-extra-extra_cold_queue_memory-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_cold_storage">
<span data-i18n="overviewExtraColdStorage">COLD disk utilization</span>
<strong id="overview-extra-extra_cold_storage">-</strong>
<div class="overview-meter" id="overview-extra-extra_cold_storage-meter" role="progressbar"
data-i18n-aria="overviewExtraColdStorage" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_cold_storage-meter-fill"></span>
</div>
<small id="overview-extra-extra_cold_storage-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_h2_share" hidden>
<span data-i18n="overviewExtraH2Share">overviewExtraH2Share</span><strong
id="overview-extra-extra_h2_share">-</strong>
<div class="overview-meter" id="overview-extra-extra_h2_share-meter" role="progressbar"
data-i18n-aria="overviewExtraH2Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_h2_share-meter-fill"></span>
</div><small id="overview-extra-extra_h2_share-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_response_latency" hidden>
<span data-i18n="overviewExtraResponseLatency">overviewExtraResponseLatency</span><strong
id="overview-extra-extra_response_latency">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraResponseLatency" 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_response_latency-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_response_latency-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_backend_unhealthy" hidden>
<span data-i18n="overviewExtraBackendUnhealthy">overviewExtraBackendUnhealthy</span><strong
id="overview-extra-extra_backend_unhealthy">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraBackendUnhealthy" 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_backend_unhealthy-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_backend_unhealthy-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_total_rps" hidden>
<span data-i18n="overviewExtraTotalRps">HTTP requests / s</span><strong
id="overview-extra-extra_total_rps">-</strong>
<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>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_active_exchanges" hidden>
<span data-i18n="overviewExtraActiveExchanges">Active HTTP exchanges</span><strong
id="overview-extra-extra_active_exchanges">-</strong>
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="Active HTTP exchanges">
<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>
</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>
</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>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_cache_miss_rate" hidden>
<span data-i18n="overviewExtraCacheMissRate">Cache misses / s</span><strong
id="overview-extra-extra_cache_miss_rate">-</strong>
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="Cache misses / s">
<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>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_backend_active" hidden>
<span data-i18n="overviewExtraBackendActive">In-flight backend requests</span><strong
id="overview-extra-extra_backend_active">-</strong>
<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>
</article>
<!-- RP_METRICS_SPLIT -->
<article class="stat stat-donut" data-dashboard-widget="www_status" hidden>
<span data-i18n="webMetricStatus">webMetricStatus</span>
<div class="overview-donut-row">
<svg class="overview-donut-svg" id="www_status-chart" role="img" viewBox="0 0 64 64" aria-label="WWW status"
data-i18n-aria="webMetricStatus">
<circle class="overview-donut-track" cx="32" cy="32" r="24"></circle>
<circle class="www-ring-arc www-ring-0" id="www_status-arc-0" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-1" id="www_status-arc-1" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-2" id="www_status-arc-2" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-3" id="www_status-arc-3" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
</svg>
<div class="overview-donut-info"><strong id="www_status-value">-</strong><small
class="overview-donut-legend www-ring-legend"><span><i class="www-dot-0"></i>2xx
<b id="www_status-count-0">0</b></span>
<span><i class="www-dot-1"></i>3xx <b id="www_status-count-1">0</b></span>
<span><i class="www-dot-2"></i>4xx <b id="www_status-count-2">0</b></span>
<span><i class="www-dot-3"></i>5xx <b id="www_status-count-3">0</b></span></small></div>
</div>
</article>
<article class="stat stat-donut" data-dashboard-widget="www_kind" hidden>
<span data-i18n="webMetricKind">webMetricKind</span>
<div class="overview-donut-row">
<svg class="overview-donut-svg" id="www_kind-chart" role="img" viewBox="0 0 64 64" aria-label="Static / PHP"
data-i18n-aria="webMetricKind">
<circle class="overview-donut-track" cx="32" cy="32" r="24"></circle>
<circle class="www-ring-arc www-ring-0" id="www_kind-arc-0" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-1" id="www_kind-arc-1" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-2" id="www_kind-arc-2" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
</svg>
<div class="overview-donut-info"><strong id="www_kind-value">-</strong><small
class="overview-donut-legend www-ring-legend"><span><i class="www-dot-0"></i>Static <b
id="www_kind-count-0">0</b></span>
<span><i class="www-dot-1"></i>PHP <b id="www_kind-count-1">0</b></span>
<span><i class="www-dot-2"></i>Other <b id="www_kind-count-2">0</b></span></small></div>
</div>
</article>
<article class="stat stat-meter" data-dashboard-widget="www_capacity" hidden>
<span data-i18n="webMetricCapacity">webMetricCapacity</span><strong id="www_capacity-value">-</strong>
<div class="overview-meter" id="www_capacity-meter" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuetext="-">
<span class="overview-meter-fill" id="www_capacity-meter-fill"></span>
</div><small id="www_capacity-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="www_error_rate" hidden>
<span data-i18n="webMetricErrorRate">webMetricErrorRate</span><strong id="www_error_rate-value">-</strong>
<div class="overview-meter" id="www_error_rate-meter" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuetext="-">
<span class="overview-meter-fill" id="www_error_rate-meter-fill"></span>
</div><small id="www_error_rate-detail">-</small>
</article>
<article class="stat" data-dashboard-widget="www_methods" hidden>
<span data-i18n="webMetricMethods">HTTP Methods</span><strong id="www_methods-value">-</strong>
<div class="www-segmented" id="www_methods-bar" role="img" aria-label="HTTP method share"
data-i18n-aria="webMetricMethods"><span class="www-segment-0" id="www_methods-segment-0"></span><span
class="www-segment-1" id="www_methods-segment-1"></span><span class="www-segment-2"
id="www_methods-segment-2"></span><span class="www-segment-3" id="www_methods-segment-3"></span><span
class="www-segment-4" id="www_methods-segment-4"></span></div>
<small class="www-method-legend" id="www_methods-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="www_php_requests" hidden>
<span data-i18n="webMetricPhpRequests">PHP requests/s</span><strong id="www_php_requests-value">-</strong>
<svg preserveAspectRatio="none" role="img" viewBox="0 0 300 60" aria-label="PHP requests per second"
data-i18n-aria="webMetricPhpRequests">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="www_php_requests-line" points="0,55 300,55"></polyline>
</svg>
<small data-i18n="webMetricPerSecond">Requests per second</small>
</article>
+347 -2
View File
@@ -3508,6 +3508,10 @@ body[data-density=dense] .stat strong {
margin: 6px 0
}
#panel-cache>article.card+article.card {
margin-top: 10px
}
.cache-table {
font-size: 12px
}
@@ -11210,7 +11214,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
border-radius: var(--rp-radius-surface) !important;
}
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(button:not(.nav):not(.file-log-tab), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-hidden-toggle, .overview-inline-restore) {
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(button:not(.nav):not(.file-log-tab):not(.vhost-backend-trigger), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-hidden-toggle, .overview-inline-restore) {
border-radius: var(--rp-radius-control) !important;
}
@@ -11237,6 +11241,16 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
padding: 3px 7px;
font: 10px var(--mono);
overflow-wrap: anywhere;
text-align: left;
}
.vhost-backend-protocol-badge:is(:hover, [aria-expanded=true]) {
background: var(--bg);
}
.vhost-backend-protocol-badge:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vhost-backend-protocol-badge[data-observed=true] {
@@ -11596,7 +11610,6 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
color: var(--text);
}
/* Web Services reuse the same full-width live tiles and chart as Vhosts. */
#panel-web-server .www-service-panel {
min-width: 0;
margin-top: 10px;
@@ -11813,4 +11826,336 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] {
padding-top: 135px;
}
}
#panel-overview :is(.system-card, .connection-card) :is(.card-heading h2, .card-heading .meta, .memory-line, .chart-footer, .connection-legend) {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
#panel-overview :is(.system-card, .connection-card) .card-heading h2 {
letter-spacing: normal;
}
.overview-presets-dialog {
width: calc(min(730px, 100vw - 24px));
max-height: calc(100dvh - 24px);
overflow-y: auto;
}
.overview-presets-content {
display: grid;
gap: 14px;
padding: 14px;
}
.overview-presets-content>p {
margin: 0;
}
.overview-presets-create {
display: grid;
gap: 7px;
}
.overview-presets-create-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.overview-presets-create-controls input {
min-width: 150px;
flex: 1 1 240px;
}
.overview-presets-list {
display: grid;
gap: 7px;
}
.overview-presets-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius, 8px);
background: var(--panel);
}
.overview-presets-row strong {
font-size: 13px;
overflow-wrap: anywhere;
}
.overview-presets-actions {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.overview-presets-dialog .overview-layout-footer {
flex-wrap: wrap;
}
@media(max-width:480px) {
.overview-presets-create-controls>button {
flex: 1 1 auto;
}
.overview-presets-row {
align-items: flex-start;
flex-direction: column;
}
.overview-presets-actions {
width: 100%;
}
}
#panel-overview .activity-card .card-heading h2,
#panel-overview .overview-wide-chart .card-heading h2,
#panel-overview :is(.stat-chart, .stat-donut)>span:first-child,
#panel-overview :is(.system-card, .connection-card) .overview-chart-heading,
.function-live-chart-heading>strong,
.mini-live-chart>.mini-live-chart-head>span:first-child,
.traffic-chart-card>h3.subheading,
.vhost-chart-head>span:first-child {
font-family: var(--sans);
font-size: 12px;
font-weight: 650;
line-height: 1.35;
letter-spacing: normal;
text-transform: uppercase;
}
html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.activity-card .card-heading h2,
html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.overview-wide-chart .card-heading h2,
html[data-dashboard-density] #panel-overview .stat:is(.stat-chart, .stat-donut)>span:first-child {
font-size: 12px;
}
#panel-overview :is(.system-card, .connection-card) .overview-chart-heading {
color: var(--text);
margin: 0 0 5px;
}
:is(.overview-wide-summary, .overview-wide-legend, .overview-donut-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .traffic-chart-legend, .function-live-chart-legend, .stream-chart-legend, .chart-footer, .vhost-chart-head, .network-interface-rate) {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
#panel-overview .overview-wide-chart>.fine,
#panel-overview .stat-chart>small,
#panel-overview .www-top-row,
.traffic-bar-row {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
#panel-overview :is(.overview-wide-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .stream-traffic-legend),
:is(.overview-wide-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .traffic-chart-legend, .function-live-chart-legend, .stream-chart-legend, .stream-traffic-legend) {
font-family: var(--mono);
font-size: 9px;
font-variant-numeric: tabular-nums;
}
#panel-overview :is(.overview-wide-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .stream-traffic-legend) :is(span, b, strong, small, li, em),
:is(.overview-wide-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .traffic-chart-legend, .function-live-chart-legend, .stream-chart-legend, .stream-traffic-legend) :is(span, b, strong, small, li, em) {
font-size: inherit;
font-family: inherit;
}
html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stream-traffic-legend),
html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stream-traffic-legend) :is(span, b, small, strong, li) {
font-size: 9px;
font-family: var(--mono);
}
.vhost-detail.vhost-backend-trigger,
.vhost-live-metric.vhost-backend-trigger {
appearance: none;
display: block;
width: 100%;
min-width: 0;
margin: 0;
border-radius: 0;
color: var(--text);
text-align: left;
font: inherit;
cursor: pointer;
}
.vhost-detail.vhost-backend-trigger {
border: 0;
background: var(--bg);
}
.vhost-live-metric.vhost-backend-trigger {
border: 1px solid var(--line);
background: transparent;
}
.vhost-backend-trigger:is(:hover, [aria-expanded=true]) {
background: var(--panel2);
}
.vhost-backend-trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vhost-backend-popup {
position: fixed;
z-index: 1600;
max-height: calc(min(360px, 100dvh - 24px));
overflow: auto;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--rp-radius-surface, 0);
box-shadow: 0 12px 35px var(--shadow-strong);
color: var(--text);
font-size: 11px;
}
.vhost-backend-popup-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin: 0 0 10px;
}
.vhost-backend-popup h4 {
margin: 0;
font-size: 12px;
font-weight: 650;
overflow-wrap: anywhere;
}
.vhost-backend-popup-close {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 25px;
height: 25px;
padding: 0;
border: 1px solid var(--line);
background: var(--bg);
color: var(--text);
cursor: pointer;
font: 18px/1 var(--sans);
border-radius: var(--rp-radius-control, 0);
}
.vhost-backend-popup-close:hover {
background: var(--panel2);
}
.vhost-backend-popup-list {
display: grid;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
}
.vhost-backend-popup-row {
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--rp-radius-surface, 0);
background: var(--bg);
min-width: 0;
}
.vhost-backend-popup-name {
display: block;
margin-bottom: 6px;
overflow-wrap: anywhere;
font: 650 11px var(--mono);
}
.vhost-backend-popup-columns {
display: grid;
grid-template-columns: minmax(0, 1.75fr) minmax(0, 1fr);
gap: 6px 10px;
}
.vhost-backend-popup-field {
min-width: 0;
}
.vhost-backend-popup-field>span {
display: block;
}
.vhost-backend-popup-field>span:first-child {
font-size: 9px;
color: var(--muted);
}
.vhost-backend-popup-field>span:last-child {
font: 10px/1.35 var(--mono);
overflow-wrap: anywhere;
}
.vhost-backend-popup-field.has-success>span:last-child {
color: var(--green);
}
.vhost-backend-popup-field.has-error>span:last-child {
color: var(--red);
}
.vhost-backend-popup-hint {
margin: 10px 0 0;
line-height: 1.4;
}
#panel-overview .dashboard-overview-grid>.card .mini-heading>h3,
#panel-overview .dashboard-overview-grid>.card .overview-chart-heading,
#panel-overview .dashboard-overview-grid>.card:is(.overview-wide-chart, .activity-card, .pool-card) .card-heading h2,
#panel-overview .dashboard-overview-grid>.stat>span:first-child {
font-family: var(--sans);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
letter-spacing: normal;
text-transform: none;
}
#panel-overview .dashboard-overview-grid>[data-dashboard-widget] :is(small, .mini-metrics dt, .mini-metrics dd, .mini-live-chart-head, .memory-line, .chart-footer, .small-label, .meta, .fine) {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
text-transform: none;
}
#panel-overview .dashboard-overview-grid .mini-live-chart>.mini-live-chart-head>span:first-child {
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
line-height: 1.35;
letter-spacing: normal;
text-transform: none;
}
html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool-card .card-heading h2 {
font-size: 12px;
}
#panel-cache>.card .card-heading h2,
#panel-overview .dashboard-overview-grid .mini-live-chart-head>span:first-child,
#panel-overview .dashboard-overview-grid>.card .mini-heading>h3,
#panel-overview .dashboard-overview-grid>.card .overview-chart-heading,
#panel-overview .dashboard-overview-grid>.card:not(.system-card):not(.connection-card) .card-heading h2,
#panel-overview .dashboard-overview-grid>.stat>span:first-child,
#panel-transform>.card .card-heading h2,
.function-live-chart-heading>strong,
.traffic-chart-card>h3.subheading,
.vhost-chart-head>span:first-child {
color: var(--muted);
}
+177 -9
View File
@@ -1,3 +1,158 @@
// Shared, anchored Vhost popover for backend quick-view and upstream HTTP
// protocol observations. Both consume existing live status without extra probes.
let activeVhostBackendPopup = null;
function closeVhostBackendPopup(returnFocus = false) {
const current = activeVhostBackendPopup;
if (!current) return;
activeVhostBackendPopup = null;
current.trigger.setAttribute("aria-expanded", "false");
current.node.remove();
if (returnFocus && current.trigger.isConnected) current.trigger.focus();
}
function positionVhostBackendPopup() {
const current = activeVhostBackendPopup;
if (!current) return;
const rect = current.trigger.getBoundingClientRect(), edge = 12, gap = 6;
const width = Math.min(430, Math.max(0, window.innerWidth - edge * 2));
const height = Math.min(current.node.scrollHeight, 360, Math.max(100, window.innerHeight - edge * 2));
const below = window.innerHeight - rect.bottom - edge - gap;
const above = rect.top - edge - gap;
const placeAbove = below < height && above > below;
const desiredTop = placeAbove ? rect.top - height - gap : rect.bottom + gap;
current.node.style.width = `${width}px`;
current.node.style.left = `${Math.max(edge, Math.min(rect.left, window.innerWidth - width - edge))}px`;
current.node.style.top = `${Math.max(edge, Math.min(desiredTop, window.innerHeight - height - edge))}px`;
}
function vhostPopupHeading(label) {
const heading = el("h4", label);
heading.id = "vhost-backend-popup-heading";
const headingRow = el("div", undefined, "vhost-backend-popup-heading");
const closeButton = el("button", "×", "vhost-backend-popup-close");
closeButton.type = "button";
closeButton.setAttribute("aria-label", t("close"));
closeButton.addEventListener("click", () => closeVhostBackendPopup(true));
headingRow.append(heading, closeButton);
return { heading, headingRow };
}
function refreshVhostProtocolPopup(current, vhost, members) {
const backend = (vhost.backends || []).find(item => item.id === current.backendId);
if (!backend) { closeVhostBackendPopup(); return; }
const member = members.get(backend.id);
const observed = upstreamProtocolBadgeText(backend, member);
if (!current.protocolView) {
const { heading, headingRow } = vhostPopupHeading(observed.label);
const info = el("p", "", "fine vhost-backend-popup-hint");
const list = el("ul", undefined, "vhost-backend-popup-list");
const row = el("li", undefined, "vhost-backend-popup-row");
const columns = el("div", undefined, "vhost-backend-popup-columns");
const counters = [];
for (const protocol of ["H1", "H2"]) {
const field = el("div", undefined, "vhost-backend-popup-field");
const value = el("span", "0");
field.append(el("span", protocol), value);
columns.append(field); counters.push(value);
}
row.append(columns); list.append(row);
current.node.append(headingRow, info, list);
current.node.setAttribute("aria-labelledby", heading.id);
current.protocolView = { heading, info, list, counters };
}
const view = current.protocolView;
const explanation = t(observed.recent ? "vhBackendProtocolRecent" : "vhBackendProtocolUnobserved");
if (view.heading.textContent !== observed.label) view.heading.textContent = observed.label;
if (view.info.textContent !== explanation) view.info.textContent = explanation;
view.list.hidden = !observed.recent;
if (observed.recent) {
const totals = [member.upstream_protocol_http1_total || 0, member.upstream_protocol_http2_total || 0];
totals.forEach((total, index) => {
const label = String(total);
if (view.counters[index].textContent !== label) view.counters[index].textContent = label;
});
}
positionVhostBackendPopup();
}
function refreshVhostBackendPopup(status = latestStatus) {
const current = activeVhostBackendPopup;
if (!current) return;
const vhost = (envelope?.config.vhosts || []).find(item => item.id === current.id);
if (!vhost || !current.trigger.isConnected) { closeVhostBackendPopup(); return; }
const pool = (status?.backend_pools || []).find(item => item.id === vhost.id);
const members = new Map((pool?.members || []).map(member => [member.id, member]));
if (current.backendId !== null) {
refreshVhostProtocolPopup(current, vhost, members);
return;
}
const { heading, headingRow } = vhostPopupHeading(`${t("vhBackendPopupTitle")} · ${vhost.id}`);
const list = el("ul", undefined, "vhost-backend-popup-list");
for (const backend of vhost.backends || []) {
const member = members.get(backend.id);
const row = el("li", undefined, "vhost-backend-popup-row");
row.append(el("strong", backend.id, "vhost-backend-popup-name"));
let address = "-", scheme = "-";
try {
const url = new URL(backend.url);
scheme = url.protocol.replace(":", "").toUpperCase();
const port = url.port || (scheme === "HTTPS" ? "443" : scheme === "HTTP" ? "80" : "");
address = `${url.hostname}${port ? `:${port}` : ""}`;
} catch { /* Configuration may have changed while status was refreshing. */ }
const upstream = upstreamProtocolBadgeText(backend, member);
const protocol = `${scheme} · ${upstream.mode}${upstream.observed ? ` → ${upstream.observed}` : ""}`;
const statusCode = Number(member?.last_status);
const hasStatus = member && Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599;
const httpResponse = hasStatus ? `HTTP ${statusCode}` : t("vhBackendPopupNoResponse");
const statusLabel = !member ? t("vhHealthUnknown") : t(({
up: "vhUp", down: "vhDown", disabled: "vhDisabled", checking: "vhChecking", draining: "vhDraining"
})[member.status] || "vhHealthUnknown");
const columns = el("div", undefined, "vhost-backend-popup-columns");
for (const [key, value] of [
["vhBackendPopupAddress", address], ["vhBackendPopupProtocol", protocol],
["vhBackendPopupLastHttp", httpResponse], ["status", statusLabel]
]) {
const field = el("div", undefined, "vhost-backend-popup-field");
field.append(text("span", key), el("span", value));
if (key === "vhBackendPopupLastHttp" && hasStatus) field.classList.add(statusCode >= 500 ? "has-error" : statusCode < 400 ? "has-success" : "");
columns.append(field);
}
row.append(columns); list.append(row);
}
if (!list.children.length) list.append(el("li", t("vhBackendPopupEmpty"), "fine"));
const foot = el("p", t("vhBackendPopupHint"), "fine vhost-backend-popup-hint");
current.node.replaceChildren(headingRow, list, foot);
current.node.setAttribute("aria-labelledby", heading.id);
positionVhostBackendPopup();
}
function toggleVhostBackendPopup(id, trigger, backendId = null) {
if (activeVhostBackendPopup?.trigger === trigger) { closeVhostBackendPopup(); return; }
closeVhostBackendPopup();
const node = el("section", undefined, "vhost-backend-popup");
node.id = "vhost-backend-popup";
node.setAttribute("role", "dialog");
node.setAttribute("aria-modal", "false");
trigger.setAttribute("aria-expanded", "true");
activeVhostBackendPopup = { id, trigger, node, backendId, protocolView: null };
document.body.append(node);
refreshVhostBackendPopup();
}
function enableVhostBackendPopup(trigger, vhost, backendId = null) {
trigger.dataset.vhostBackendTrigger = vhost.id;
trigger.setAttribute("aria-haspopup", "dialog");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-controls", "vhost-backend-popup");
if (backendId === null) trigger.setAttribute("aria-label", `${t("vhBackendPopupOpen")}: ${vhost.id}`);
trigger.addEventListener("click", () => toggleVhostBackendPopup(vhost.id, trigger, backendId));
}
document.addEventListener("pointerdown", event => {
const current = activeVhostBackendPopup;
if (current && !current.node.contains(event.target) && !current.trigger.contains(event.target)) closeVhostBackendPopup();
});
document.addEventListener("keydown", event => {
if (event.key === "Escape" && activeVhostBackendPopup) { event.preventDefault(); event.stopPropagation(); closeVhostBackendPopup(true); }
});
window.addEventListener("resize", positionVhostBackendPopup);
document.addEventListener("scroll", event => {
if (activeVhostBackendPopup && !activeVhostBackendPopup.node.contains(event.target)) positionVhostBackendPopup();
}, true);
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
@@ -107,7 +262,8 @@ function upstreamProtocolBadgeText(backend, member, now = Date.now()) {
const seen = Number(member?.upstream_protocol_last_seen_ms);
const recent = member && (member.upstream_protocol_observed === "h1" || member.upstream_protocol_observed === "h2") &&
Number.isFinite(seen) && seen > 0 && now >= seen && now - seen <= VHOST_PROTOCOL_FRESH_MS;
return { label: `${backend.id} · ${mode} → ${recent ? member.upstream_protocol_observed.toUpperCase() : "?"}`, recent };
const observed = recent ? member.upstream_protocol_observed.toUpperCase() : null;
return { label: `${backend.id} · ${mode} → ${observed || "?"}`, recent, mode, observed };
}
function renderUpstreamProtocolBadges(status) {
const members = new Map((status.backend_pools || []).flatMap(pool => (pool.members || []).map(member =>
@@ -119,13 +275,12 @@ function renderUpstreamProtocolBadges(status) {
if (!backend) return;
const member = members.get(`${vhost}\u0000${id}`);
const observed = upstreamProtocolBadgeText(backend, member, now);
node.textContent = observed.label;
if (node.textContent !== observed.label) node.textContent = observed.label;
node.dataset.observed = String(!!observed.recent);
const counts = member && observed.recent ? ` H1: ${member.upstream_protocol_http1_total || 0}; H2: ${member.upstream_protocol_http2_total || 0}.` : "";
node.title = `${t(observed.recent ? "vhBackendProtocolRecent" : "vhBackendProtocolUnobserved")}${counts}`;
});
}
function render() {
closeVhostBackendPopup();
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
const config = envelope.config;
@@ -209,10 +364,21 @@ function render() {
: t("webServer"))
: `${(v.backends || []).length}`],
];
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
for (const [key, value] of detailItems) {
const isBackend = key === "vhBackendPool";
const item = el(isBackend ? "button" : "div", undefined, `vhost-detail${isBackend ? " vhost-backend-trigger" : ""}`);
if (isBackend) { item.type = "button"; enableVhostBackendPopup(item, v); }
item.append(text("span", key, "small-label"), el("b", value)); details.append(item);
}
box.append(details);
const live = el("div", undefined, "vhost-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
const metric = (key, suffix) => {
const isHealth = key === "status";
const item = el(isHealth ? "button" : "div", undefined, `vhost-live-metric${isHealth ? " vhost-backend-trigger" : ""}`);
if (isHealth) { item.type = "button"; enableVhostBackendPopup(item, v); }
item.append(text("span", key), el("strong", "-"));
item.querySelector("strong").dataset[suffix] = v.id; live.append(item);
};
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
box.append(live);
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
@@ -243,11 +409,12 @@ function render() {
// Retain their own badge class so observed H1/H2 keeps its green styling.
if (hasProxySource) {
for (const backend of v.backends || []) {
const badge = el("span", undefined, "vhost-backend-protocol-badge");
const badge = el("button", undefined, "vhost-backend-protocol-badge");
badge.type = "button";
badge.dataset.upstreamProtocolVhost = v.id;
badge.dataset.upstreamProtocolBackend = backend.id;
badge.textContent = upstreamProtocolBadgeText(backend, null).label;
badge.title = t("vhBackendProtocolUnobserved");
enableVhostBackendPopup(badge, v, backend.id);
policy.append(badge);
}
}
@@ -282,6 +449,7 @@ function vhostTrafficRate(id, metrics, snapshot, now, seen) {
}
function renderStatus(status) {
renderUpstreamProtocolBadges(status);
refreshVhostBackendPopup(status);
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
@@ -415,5 +583,5 @@ $("pool-open").addEventListener("click", () => setPanel("vhosts"));
initPoolTableTools();
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { closeVhostBackendPopup(); dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);