This commit is contained in:
Mateusz Gruszczyński committed 2026-10-13 21:29:57 +02:00
1 parent bb025dcf44
commit b0f211555a
74 files changed
+6089 -556

No files matched your search

+205 -12
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.32.2",
"version": "0.32.3",
"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": [
@@ -1988,9 +1988,11 @@
"enum": [
"http1",
"http2",
"auto"
"auto",
"h2"
],
"default": "http1"
"default": "http1",
"description": "http1: HTTP/1.1 only; auto: negotiate H2/H1 using TLS ALPN (H1 on plaintext); http2: backward-compatible TLS ALPN with H1 fallback or plaintext h2c; h2: strict HTTP/2 over TLS, or h2c on plaintext, with no HTTP/1.1 downgrade."
},
"address_allowlist": {
"type": "array",
@@ -3303,10 +3305,12 @@
"type": "string",
"enum": [
"dark",
"light"
"ultra_dark",
"light",
"cream"
],
"default": "dark",
"description": "Account-scoped administration interface theme. Existing accounts and omitted values default to the current dark theme."
"description": "Account-scoped administration interface theme (dark, ultra_dark, light or cream). Existing accounts and omitted values continue to default to dark."
},
"language": {
"type": "string",
@@ -3355,6 +3359,18 @@
"default": "normal",
"description": "Account-scoped administration button sizing. Normal uses the refreshed controls; Compact restores the smaller source-style button dimensions."
},
"corner_style": {
"type": "string",
"enum": [
"default",
"square",
"subtle",
"rounded",
"extra"
],
"default": "default",
"description": "Account-scoped global corner-rounding preset. Default preserves original geometry for every theme."
},
"table_density": {
"type": "string",
"enum": [
@@ -3378,7 +3394,7 @@
"dashboard_widgets": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"maxItems": 62,
"uniqueItems": true,
"items": {
"type": "string",
@@ -3398,7 +3414,53 @@
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health"
"stream_backend_health",
"backend_health",
"backend_activity",
"backend_errors",
"backend_latency",
"backend_traffic",
"backend_protocols",
"backend_requests",
"backend_capacity",
"backend_http5xx",
"backend_timeouts",
"backend_network",
"backend_retries",
"backend_queue",
"backend_rotations",
"extra_process_cpu",
"extra_process_rss",
"extra_process_fds",
"extra_open_connections",
"extra_websocket_active",
"extra_tls_error_rate",
"extra_request_error_rate",
"extra_cache_hit_rate",
"extra_cache_memory",
"extra_h2_share",
"extra_response_latency",
"extra_backend_unhealthy",
"extra_total_rps",
"extra_active_exchanges",
"extra_h1_share",
"extra_h3_share",
"extra_cache_miss_rate",
"extra_backend_active",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
"wide_cache_activity",
"wide_connections",
"wide_backend_health",
"wide_latency",
"traffic_chart",
"system_chart",
"connection_chart",
"cache_diagnostics",
"process_diagnostics",
"network_diagnostics",
"backend_table"
]
},
"default": [
@@ -3417,13 +3479,59 @@
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health"
"stream_backend_health",
"backend_health",
"backend_activity",
"backend_errors",
"backend_latency",
"backend_traffic",
"backend_protocols",
"backend_requests",
"backend_capacity",
"backend_http5xx",
"backend_timeouts",
"backend_network",
"backend_retries",
"backend_queue",
"backend_rotations",
"extra_process_cpu",
"extra_process_rss",
"extra_process_fds",
"extra_open_connections",
"extra_websocket_active",
"extra_tls_error_rate",
"extra_request_error_rate",
"extra_cache_hit_rate",
"extra_cache_memory",
"extra_h2_share",
"extra_response_latency",
"extra_backend_unhealthy",
"extra_total_rps",
"extra_active_exchanges",
"extra_h1_share",
"extra_h3_share",
"extra_cache_miss_rate",
"extra_backend_active",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
"wide_cache_activity",
"wide_connections",
"wide_backend_health",
"wide_latency",
"traffic_chart",
"system_chart",
"connection_chart",
"cache_diagnostics",
"process_diagnostics",
"network_diagnostics",
"backend_table"
],
"description": "Account-scoped order for configurable Overview telemetry cards."
},
"dashboard_hidden_widgets": {
"type": "array",
"maxItems": 16,
"maxItems": 62,
"uniqueItems": true,
"items": {
"type": "string",
@@ -3443,14 +3551,99 @@
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health"
"stream_backend_health",
"backend_health",
"backend_activity",
"backend_errors",
"backend_latency",
"backend_traffic",
"backend_protocols",
"backend_requests",
"backend_capacity",
"backend_http5xx",
"backend_timeouts",
"backend_network",
"backend_retries",
"backend_queue",
"backend_rotations",
"extra_process_cpu",
"extra_process_rss",
"extra_process_fds",
"extra_open_connections",
"extra_websocket_active",
"extra_tls_error_rate",
"extra_request_error_rate",
"extra_cache_hit_rate",
"extra_cache_memory",
"extra_h2_share",
"extra_response_latency",
"extra_backend_unhealthy",
"extra_total_rps",
"extra_active_exchanges",
"extra_h1_share",
"extra_h3_share",
"extra_cache_miss_rate",
"extra_backend_active",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
"wide_cache_activity",
"wide_connections",
"wide_backend_health",
"wide_latency",
"traffic_chart",
"system_chart",
"connection_chart",
"cache_diagnostics",
"process_diagnostics",
"network_diagnostics",
"backend_table"
]
},
"default": [
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health"
"stream_backend_health",
"backend_health",
"backend_activity",
"backend_errors",
"backend_latency",
"backend_traffic",
"backend_protocols",
"backend_requests",
"backend_capacity",
"backend_http5xx",
"backend_timeouts",
"backend_network",
"backend_retries",
"backend_queue",
"backend_rotations",
"extra_process_cpu",
"extra_process_rss",
"extra_process_fds",
"extra_open_connections",
"extra_websocket_active",
"extra_tls_error_rate",
"extra_request_error_rate",
"extra_cache_hit_rate",
"extra_cache_memory",
"extra_h2_share",
"extra_response_latency",
"extra_backend_unhealthy",
"extra_total_rps",
"extra_active_exchanges",
"extra_h1_share",
"extra_h3_share",
"extra_cache_miss_rate",
"extra_backend_active",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
"wide_cache_activity",
"wide_connections",
"wide_backend_health",
"wide_latency"
],
"description": "Account-scoped Overview telemetry cards hidden from the dashboard."
},
@@ -11677,4 +11870,4 @@
}
}
}
}
}
+57 -22
View File
@@ -18,6 +18,9 @@ fn default_admin_chart_style() -> String {
fn default_admin_button_style() -> String {
"normal".to_owned()
}
fn default_admin_corner_style() -> String {
"default".to_owned()
}
fn default_admin_table_density() -> String {
"normal".to_owned()
}
@@ -27,29 +30,34 @@ fn default_admin_dashboard_density() -> String {
fn default_admin_dashboard_widgets() -> Vec<String> {
DASHBOARD_WIDGETS
.iter()
.map(|value| (*value).to_owned())
.map(|widget| widget.id.clone())
.collect()
}
const STREAM_DASHBOARD_WIDGETS: &[&str] = &[
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health",
];
fn default_hidden_dashboard_widgets() -> Vec<String> {
STREAM_DASHBOARD_WIDGETS
DASHBOARD_WIDGETS
.iter()
.map(|id| (*id).to_owned())
.filter(|widget| widget.default_hidden)
.map(|widget| widget.id.clone())
.collect()
}
// Old accounts stored a complete 12-card order and often an explicit empty
// hidden list. Upgrade it in memory: append opt-in Stream cards as hidden.
fn migrate_dashboard_widgets(prefs: &mut AdminPreferences) {
for id in STREAM_DASHBOARD_WIDGETS {
if !prefs.dashboard_widgets.iter().any(|item| item == id) {
prefs.dashboard_widgets.push((*id).to_owned());
if !prefs.dashboard_hidden_widgets.iter().any(|item| item == id) {
prefs.dashboard_hidden_widgets.push((*id).to_owned());
// Preserve the user's saved order/visibility, drop identifiers unknown to this
// binary, and append new registry widgets according to their opt-in policy.
// Stored preferences from older/newer releases are normalized on read.
fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) {
let mut seen = std::collections::HashSet::new();
prefs.dashboard_widgets.retain(|id| {
DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen.insert(id.clone())
});
let mut seen_hidden = std::collections::HashSet::new();
prefs.dashboard_hidden_widgets.retain(|id| {
DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen_hidden.insert(id.clone())
});
for widget in DASHBOARD_WIDGETS.iter() {
if !seen.contains(&widget.id) {
prefs.dashboard_widgets.push(widget.id.clone());
if widget.default_hidden && seen_hidden.insert(widget.id.clone()) {
prefs.dashboard_hidden_widgets.push(widget.id.clone());
}
}
}
@@ -120,6 +128,8 @@ struct AdminPreferences {
chart_style: String,
#[serde(default = "default_admin_button_style")]
button_style: String,
#[serde(default = "default_admin_corner_style")]
corner_style: String,
#[serde(default = "default_admin_table_density")]
table_density: String,
#[serde(default = "default_admin_dashboard_density")]
@@ -176,6 +186,7 @@ impl Default for AdminPreferences {
time_format: default_admin_time_format(),
chart_style: default_admin_chart_style(),
button_style: default_admin_button_style(),
corner_style: default_admin_corner_style(),
table_density: default_admin_table_density(),
dashboard_density: default_admin_dashboard_density(),
dashboard_widgets: default_admin_dashboard_widgets(),
@@ -246,7 +257,10 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
"invalid stored-log sort column preference".into(),
));
}
if !matches!(preferences.theme.as_str(), "dark" | "light") {
if !matches!(
preferences.theme.as_str(),
"dark" | "ultra_dark" | "light" | "cream"
) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid administration theme preference".into(),
@@ -293,6 +307,15 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
"invalid administration chart style preference".into(),
));
}
if !matches!(
preferences.corner_style.as_str(),
"default" | "square" | "subtle" | "rounded" | "extra"
) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid administration corner style preference".into(),
));
}
if !matches!(preferences.button_style.as_str(), "normal" | "compact") {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -327,7 +350,9 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
}
let mut seen = std::collections::HashSet::new();
for widget in &preferences.dashboard_widgets {
if !DASHBOARD_WIDGETS.contains(&widget.as_str()) || !seen.insert(widget.as_str()) {
if !DASHBOARD_WIDGETS.iter().any(|entry| entry.id == *widget)
|| !seen.insert(widget.as_str())
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid administration dashboard widget order".into(),
@@ -342,7 +367,9 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
}
let mut seen = std::collections::HashSet::new();
for widget in &preferences.dashboard_hidden_widgets {
if !DASHBOARD_WIDGETS.contains(&widget.as_str()) || !seen.insert(widget.as_str()) {
if !DASHBOARD_WIDGETS.iter().any(|entry| entry.id == *widget)
|| !seen.insert(widget.as_str())
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid administration hidden dashboard widgets".into(),
@@ -418,7 +445,7 @@ async fn preferences(
let preferences = match stored {
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
Ok(mut preferences) => {
migrate_dashboard_widgets(&mut preferences);
reconcile_dashboard_widgets(&mut preferences);
if validate_admin_preferences(&preferences).is_ok() {
preferences
} else {
@@ -441,7 +468,15 @@ async fn update_preferences(
Extension(session): Extension<Session>,
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, ApiError> {
let Json(preferences) = input.map_err(public::invalid_json)?;
let Json(mut preferences) = input.map_err(public::invalid_json)?;
// Do not rewrite the caller's saved order on PUT; drop only stale IDs.
// Legacy partial lists are expanded on the next GET, as before.
preferences
.dashboard_widgets
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
preferences
.dashboard_hidden_widgets
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
validate_admin_preferences(&preferences)?;
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
if !state
+12 -18
View File
@@ -75,21 +75,15 @@ const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
"maxConnections",
"idleTimeout",
];
const DASHBOARD_WIDGETS: &[&str] = &[
"requests",
"active",
"bandwidth",
"errors",
"cache",
"memory",
"websocket",
"http2",
"http3",
"tls",
"http_protocol",
"websocket_protocol",
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health",
];
// The same JSON registry is embedded into the authenticated admin JS bundle.
// No independent allowlist is maintained by the browser or backend.
#[derive(serde::Deserialize)]
struct DashboardWidgetDefinition {
id: String,
default_hidden: bool,
}
static DASHBOARD_WIDGETS: std::sync::LazyLock<Vec<DashboardWidgetDefinition>> =
std::sync::LazyLock::new(|| {
serde_json::from_str(include_str!("web/dashboard-widgets.json"))
.expect("invalid bundled Overview Cards registry")
});
+170
View File
@@ -0,0 +1,170 @@
#[test]
fn overview_registry_is_single_source_of_identifiers_and_visibility_defaults() {
let order = default_admin_dashboard_widgets();
let hidden = default_hidden_dashboard_widgets();
let unique: std::collections::HashSet<&str> = order.iter().map(String::as_str).collect();
assert_eq!(unique.len(), order.len());
assert_eq!(order.first().map(String::as_str), Some("requests"));
assert!(order.contains(&"http3".to_owned()));
assert!(hidden.contains(&"stream_overview".to_owned()));
assert!(hidden.contains(&"stream_backend_health".to_owned()));
for id in [
"backend_health",
"backend_activity",
"backend_errors",
"backend_latency",
"backend_traffic",
"backend_protocols",
"backend_requests",
"backend_capacity",
"backend_http5xx",
"backend_timeouts",
"backend_network",
"backend_retries",
"backend_queue",
"backend_rotations",
] {
assert!(order.contains(&id.to_owned()));
assert!(hidden.contains(&id.to_owned()));
}
for id in [
"extra_process_cpu",
"extra_process_rss",
"extra_process_fds",
"extra_open_connections",
"extra_websocket_active",
"extra_tls_error_rate",
"extra_request_error_rate",
"extra_cache_hit_rate",
"extra_cache_memory",
"extra_h2_share",
"extra_response_latency",
"extra_backend_unhealthy",
] {
assert!(order.contains(&id.to_owned()));
assert!(hidden.contains(&id.to_owned()));
}
for id in [
"traffic_chart",
"system_chart",
"connection_chart",
"cache_diagnostics",
"process_diagnostics",
"network_diagnostics",
"backend_table",
] {
assert!(order.contains(&id.to_owned()));
assert!(!hidden.contains(&id.to_owned()));
}
assert!(!hidden.contains(&"requests".to_owned()));
assert!(hidden.iter().all(|id| order.contains(id)));
assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok());
}
#[test]
fn interface_themes_include_optional_black_and_cream_without_changing_default() {
let mut preferences = AdminPreferences::default();
assert_eq!(preferences.theme, "dark");
for theme in ["dark", "ultra_dark", "light", "cream"] {
preferences.theme = theme.to_owned();
assert!(validate_admin_preferences(&preferences).is_ok(), "{theme}");
}
preferences.theme = "sepia".to_owned();
assert!(validate_admin_preferences(&preferences).is_err());
}
#[test]
fn corner_style_defaults_and_rejects_unknown_presets() {
let mut prefs = AdminPreferences::default();
assert_eq!(prefs.corner_style, "default");
for preset in ["default", "square", "subtle", "rounded", "extra"] {
prefs.corner_style = preset.to_string();
assert!(validate_admin_preferences(&prefs).is_ok(), "{preset}");
}
prefs.corner_style = "oversized".to_string();
assert!(validate_admin_preferences(&prefs).is_err());
let old_json = r#"{"theme":"ultra_dark"}"#;
let old: AdminPreferences = serde_json::from_str(old_json).unwrap();
assert_eq!(old.corner_style, "default");
assert!(validate_admin_preferences(&old).is_ok());
}
#[test]
fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() {
let mut prefs = AdminPreferences::default();
prefs.dashboard_widgets = vec![
"errors".into(),
"from_future_release".into(),
"requests".into(),
"errors".into(),
];
prefs.dashboard_hidden_widgets = vec![
"errors".into(),
"from_future_release".into(),
"errors".into(),
];
reconcile_dashboard_widgets(&mut prefs);
assert_eq!(prefs.dashboard_widgets[0], "errors");
assert_eq!(prefs.dashboard_widgets[1], "requests");
assert!(!prefs
.dashboard_widgets
.contains(&"from_future_release".into()));
assert!(!prefs
.dashboard_hidden_widgets
.contains(&"from_future_release".into()));
assert_eq!(
prefs
.dashboard_hidden_widgets
.iter()
.filter(|id| *id == "errors")
.count(),
1
);
assert!(prefs
.dashboard_hidden_widgets
.contains(&"stream_overview".into()));
assert!(prefs
.dashboard_hidden_widgets
.contains(&"stream_backend_health".into()));
assert!(prefs
.dashboard_hidden_widgets
.contains(&"backend_health".into()));
assert!(prefs
.dashboard_hidden_widgets
.contains(&"backend_protocols".into()));
assert!(prefs
.dashboard_hidden_widgets
.contains(&"backend_requests".into()));
assert!(prefs
.dashboard_hidden_widgets
.contains(&"backend_capacity".into()));
assert!(prefs
.dashboard_hidden_widgets
.contains(&"extra_process_cpu".into()));
assert!(prefs.dashboard_widgets.contains(&"backend_table".into()));
assert!(!prefs
.dashboard_hidden_widgets
.contains(&"backend_table".into()));
for id in [
"backend_http5xx",
"backend_timeouts",
"backend_network",
"backend_retries",
"backend_queue",
"backend_rotations",
] {
assert!(prefs.dashboard_hidden_widgets.contains(&id.into()));
}
assert!(validate_admin_preferences(&prefs).is_ok());
}
#[test]
fn explicit_visible_stream_opt_in_is_not_reset_when_normalizing_saved_order() {
let mut prefs = AdminPreferences::default();
prefs.dashboard_widgets = default_admin_dashboard_widgets();
prefs.dashboard_hidden_widgets.clear();
reconcile_dashboard_widgets(&mut prefs);
assert!(prefs.dashboard_hidden_widgets.is_empty());
assert_eq!(prefs.dashboard_widgets, default_admin_dashboard_widgets());
assert!(validate_admin_preferences(&prefs).is_ok());
}
+10
View File
@@ -56,8 +56,17 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/app/core.js"),
include_str!("web/app/session-resume.js"),
include_str!("web/app/refresh.js"),
"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(",
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-inline-layout.js"),
include_str!("web/app/stream-dashboard.js"),
include_str!("web/app/backend-dashboard.js"),
include_str!("web/app/overview-extra-metrics.js"),
include_str!("web/app/overview-wide-metrics.js"),
include_str!("web/app/overview-masonry.js"),
include_str!("web/app/config-history.js"),
include_str!("web/app/runtime.js"),
include_str!("web/app/settings.js"),
@@ -89,6 +98,7 @@ fn admin_vhosts_javascript() -> &'static str {
[
"(() => {",
include_str!("web/vhosts/core.js"),
include_str!("web/vhosts/protocol.js"),
include_str!("web/vhosts/health.js"),
include_str!("web/vhosts/rates.js"),
include_str!("web/vhosts/editor.js"),
+18
View File
@@ -1673,6 +1673,7 @@ mod tests {
assert_eq!(initial["time_format"], "24h");
assert_eq!(initial["chart_style"], "default");
assert_eq!(initial["button_style"], "normal");
assert_eq!(initial["corner_style"], "default");
assert_eq!(
initial["file_log_columns"].as_array().unwrap().len(),
default_file_log_columns().len()
@@ -1708,6 +1709,7 @@ mod tests {
assert_eq!(legacy["time_format"], "24h");
assert_eq!(legacy["chart_style"], "default");
assert_eq!(legacy["button_style"], "normal");
assert_eq!(legacy["corner_style"], "default");
assert_eq!(
legacy["file_log_columns"],
json!(default_file_log_columns())
@@ -1728,6 +1730,7 @@ mod tests {
"time_format":"12h",
"chart_style":"neon",
"button_style":"compact",
"corner_style":"rounded",
"table_density":"ultra",
"dashboard_density":"ultra",
"dashboard_widgets":["errors","requests","active","bandwidth","cache","memory","websocket","http2","http3","tls","http_protocol","websocket_protocol"],
@@ -1776,6 +1779,20 @@ mod tests {
"stream_traffic",
"stream_sessions",
"stream_backend_health",
"backend_health",
"backend_activity",
"backend_errors",
"backend_latency",
"backend_traffic",
"backend_protocols",
"backend_requests",
"backend_capacity",
"backend_http5xx",
"backend_timeouts",
"backend_network",
"backend_retries",
"backend_queue",
"backend_rotations",
] {
upgraded["dashboard_widgets"]
.as_array_mut()
@@ -3337,4 +3354,5 @@ mod tests {
assert_eq!(result.status(), StatusCode::FORBIDDEN);
store.close().await;
}
include!("preferences_tests.rs");
}
+219
View File
@@ -0,0 +1,219 @@
// Optional HTTP origin diagnostics. Reuse status.backend_pools already present
// in live SSE/polling; never issue one request per tile or per member.
window.rustProxyBackendDashboard = (() => {
const ids = ["backend_health", "backend_activity", "backend_errors", "backend_latency", "backend_traffic", "backend_protocols", "backend_requests", "backend_capacity", "backend_http5xx", "backend_timeouts", "backend_network", "backend_retries", "backend_queue", "backend_rotations"];
const charts = new Set(["backend_activity", "backend_errors", "backend_latency", "backend_traffic", "backend_requests", "backend_capacity", "backend_http5xx", "backend_timeouts", "backend_network", "backend_retries", "backend_queue"]);
const samples = { active: [], errors: [], latency: [], inbound: [], outbound: [], requests: [], capacity: [], http5xx: [], timeouts: [], network: [], retries: [], queue: [] };
const tracked = ["requests", "errors", "inbound", "outbound", "http5xx", "timeouts", "network", "retries", "queue"];
let previous = null;
let previousRevision = null;
const card = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
const visible = id => card(id) && !card(id).hidden;
const set = (id, value) => { const node = $(id); if (node) node.textContent = String(value); };
const count = value => Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) : 0;
const counter = value => Math.max(0, Math.trunc(count(value)));
const shortRate = value => `${value.toFixed(1)}/s`;
function reset() {
previous = null;
previousRevision = null;
for (const series of Object.values(samples)) series.length = 0;
}
function syncVisibility(hiddenOverride = null) {
const prefs = window.rustProxyPreferences?.current?.();
const hidden = new Set(hiddenOverride ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
if (![...charts].some(visible)) reset();
}
function line(series, peak) {
if (!series.length) return "0,55 300,55";
return series.map((value, i) => `${i * 300 / Math.max(1, series.length - 1)},${55 - value / peak * 50}`).join(" ");
}
function paintSeries(name, id, scale) {
const node = $(id);
if (node) node.setAttribute("points", line(samples[name], scale));
}
function push(name, value) {
samples[name].push(Math.max(0, value));
if (samples[name].length > 30) samples[name].shift();
}
function unavailable() {
for (const id of ids) {
if (!visible(id)) continue;
set(`backend-dashboard-${id.slice("backend_".length)}`, "-");
set(`backend-dashboard-${id.slice("backend_".length)}-detail`, t("backendWidgetUnavailable"));
}
for (const id of ["backend-dashboard-in-rate", "backend-dashboard-out-rate"]) set(id, "-");
for (const id of ["backend-dashboard-active-line", "backend-dashboard-error-line", "backend-dashboard-latency-line", "backend-dashboard-in-line", "backend-dashboard-out-line", "backend-dashboard-request-line", "backend-dashboard-capacity-line", "backend-dashboard-5xx-line", "backend-dashboard-timeout-line", "backend-dashboard-network-line", "backend-dashboard-retry-line", "backend-dashboard-queue-line"])
$(id)?.setAttribute("points", "0,55 300,55");
const bar = $("backend-mode-h1");
if (bar) for (const item of bar.parentElement.children) item.style.flexGrow = "0";
reset();
}
// Counter deltas are matched to stable (pool, member) IDs, not aggregate
// totals: removed/rebuilt pools must never become negative or spike rates.
function memberCounters(members) {
const values = new Map();
for (const { pool, member } of members) {
const key = JSON.stringify([pool.id, member.id]);
values.set(key, {
requests: counter(member.requests), errors: counter(member.errors),
inbound: counter(member.bytes_in), outbound: counter(member.bytes_out),
http5xx: counter(member.http_5xx_total), timeouts: counter(member.timeouts_total),
network: ["connect_errors_total", "dns_errors_total", "tls_errors_total", "io_errors_total"].reduce((n, key) => n + counter(member[key]), 0),
retries: counter(member.retry_attempts_total),
queue: counter(member.queue_timeouts_total) + counter(member.capacity_rejections_total),
});
}
return values;
}
function deltas(current, earlier) {
const total = Object.fromEntries(tracked.map(field => [field, 0]));
if (!earlier) return total;
for (const [id, next] of current) {
const old = earlier.get(id);
if (!old) continue;
// A generation reset on a member invalidates its entire interval.
if (tracked.some(field => next[field] < old[field])) continue;
for (const field of tracked) total[field] += next[field] - old[field];
}
return total;
}
function render(status, hiddenOverride = null, recordSample = true) {
syncVisibility(hiddenOverride);
if (!status || currentPanel !== "overview" || ids.every(id => !visible(id))) return;
if (!Array.isArray(status.backend_pools)) { unavailable(); return; }
const members = status.backend_pools.flatMap(pool => (pool.members || []).map(member => ({ pool, member })));
if (!members.length) { unavailable(); return; }
if (visible("backend_health")) {
const statuses = { up: 0, down: 0, checking: 0, draining: 0, disabled: 0 };
for (const { member } of members) if (Object.hasOwn(statuses, member.status)) statuses[member.status]++;
const eligible = statuses.up + statuses.down + statuses.checking;
set("backend-dashboard-health", `${statuses.up} / ${eligible}`);
set("backend-dashboard-health-detail", `${t("backendWidgetDown")}: ${statuses.down} · ${t("backendWidgetChecking")}: ${statuses.checking} · ${t("backendWidgetOther")}: ${statuses.draining + statuses.disabled}`);
}
if (visible("backend_protocols")) {
const modes = { http1: 0, auto: 0, http2: 0, h2: 0, unknown: 0 };
let h2c = 0;
for (const { member } of members) {
const mode = member.protocol;
if (Object.hasOwn(modes, mode)) modes[mode]++;
else modes.unknown++;
if ((mode === "http2" || mode === "h2") && /^http:\/\//i.test(member.url || "")) h2c++;
}
set("backend-dashboard-protocols", fmt(members.length));
set("backend-dashboard-protocols-detail", `H1 ${modes.http1} · Auto ${modes.auto} · ${t("backendWidgetModeCompat")} ${modes.http2} · ${t("backendWidgetModeStrict")} ${modes.h2} · ${t("backendWidgetH2c")}: ${h2c}${modes.unknown ? ` · ${t("backendWidgetModeUnknown")}: ${modes.unknown}` : ""}`);
const bar = $("backend-mode-h1")?.parentElement;
if (bar) {
for (const [name, count] of [["h1", modes.http1], ["auto", modes.auto], ["compat", modes.http2], ["h2", modes.h2]])
$("backend-mode-" + name).style.flexGrow = String(count);
bar.setAttribute("aria-label", `${t("backendWidgetProtocols")}: H1 ${modes.http1}, Auto ${modes.auto}, ${t("backendWidgetModeCompat")} ${modes.http2}, ${t("backendWidgetModeStrict")} ${modes.h2}`);
}
}
if (visible("backend_rotations")) {
const rotations = members.reduce((n, { member }) => n + counter(member.upstream_client_rotations_total), 0);
const generation = Math.max(0, ...members.map(({ member }) => counter(member.upstream_client_generation)));
const age = Math.max(0, ...members.map(({ member }) => counter(member.upstream_client_generation_age_seconds)));
set("backend-dashboard-rotations", fmt(rotations));
set("backend-dashboard-rotations-detail", `${t("backendWidgetPoolGeneration")}: ${fmt(generation)} · ${t("backendWidgetPoolAge")}: ${fmt(age)}s`);
}
if (![...charts].some(visible)) return;
const active = members.reduce((total, { member }) => total + counter(member.active), 0);
const probes = members.map(({ member }) => member).filter(member => member.status === "up" && member.health_mode !== "off" && count(member.last_probe_ms) > 0 && Number.isFinite(Number(member.latency_ms)));
const latency = probes.length ? probes.reduce((total, member) => total + count(member.latency_ms), 0) / probes.length : null;
const totalErrors = members.reduce((total, { member }) => total + counter(member.errors), 0);
const totalRequests = members.reduce((total, { member }) => total + counter(member.requests), 0);
const limited = members.map(({ member }) => member)
.filter(member => counter(member.max_concurrent_requests) > 0);
const capacityUsed = limited.reduce((sum, member) => sum + counter(member.active), 0);
const capacityLimit = limited.reduce((sum, member) => sum + counter(member.max_concurrent_requests), 0);
const capacityPercent = capacityLimit ? capacityUsed * 100 / capacityLimit : null;
const capacityRejects = limited.reduce((sum, member) => sum + counter(member.capacity_rejections_total), 0);
const revision = status.revision ?? null;
if (previousRevision !== revision) { reset(); previousRevision = revision; }
let requestsPerSecond = samples.requests.at(-1) ?? 0;
let errorsPerSecond = samples.errors.at(-1) ?? 0;
let inPerSecond = samples.inbound.at(-1) ?? 0;
let outPerSecond = samples.outbound.at(-1) ?? 0;
const additional = { http5xx: 0, timeouts: 0, network: 0, retries: 0, queue: 0 };
let fraction = null;
if (recordSample) {
const now = performance.now();
const next = memberCounters(members);
const elapsed = previous ? (now - previous.when) / 1000 : 0;
if (elapsed > 0) {
const diff = deltas(next, previous.counters);
requestsPerSecond = diff.requests / elapsed;
errorsPerSecond = diff.errors / elapsed;
inPerSecond = diff.inbound / elapsed;
outPerSecond = diff.outbound / elapsed;
fraction = diff.requests ? 100 * diff.errors / diff.requests : null;
for (const name of Object.keys(additional)) additional[name] = diff[name] / elapsed;
} else { requestsPerSecond = 0; errorsPerSecond = 0; inPerSecond = 0; outPerSecond = 0; }
previous = { when: now, counters: next };
if (visible("backend_activity")) push("active", active);
if (visible("backend_requests")) push("requests", requestsPerSecond);
if (visible("backend_capacity") && capacityPercent !== null) push("capacity", capacityPercent);
if (visible("backend_errors")) push("errors", errorsPerSecond);
if (visible("backend_latency") && latency !== null) push("latency", latency);
if (visible("backend_traffic")) { push("inbound", inPerSecond); push("outbound", outPerSecond); }
for (const name of Object.keys(additional)) if (visible(`backend_${name === "http5xx" ? "http5xx" : name}`)) push(name, additional[name]);
}
if (!recordSample) for (const name of Object.keys(additional)) additional[name] = samples[name].at(-1) ?? 0;
if (visible("backend_activity")) {
set("backend-dashboard-activity", fmt(active));
set("backend-dashboard-activity-detail", t("backendWidgetInFlight"));
paintSeries("active", "backend-dashboard-active-line", Math.max(1, ...samples.active));
}
if (visible("backend_requests")) {
set("backend-dashboard-requests", shortRate(requestsPerSecond));
set("backend-dashboard-requests-detail", `${t("backendWidgetRequestsTotal")}: ${fmt(totalRequests)}`);
paintSeries("requests", "backend-dashboard-request-line", Math.max(1, ...samples.requests));
}
if (visible("backend_capacity")) {
set("backend-dashboard-capacity", capacityPercent === null ? "-" : `${fmt(capacityPercent, 1)}%`);
set("backend-dashboard-capacity-detail", capacityPercent === null ? t("backendWidgetCapacityNone") :
`${t("backendWidgetCapacityLimits")}: ${limited.length} · ${capacityUsed}/${capacityLimit} · ${t("backendWidgetCapacityRejects")}: ${fmt(capacityRejects)}`);
paintSeries("capacity", "backend-dashboard-capacity-line", Math.max(100, ...samples.capacity));
}
if (visible("backend_errors")) {
set("backend-dashboard-errors", fmt(totalErrors));
set("backend-dashboard-errors-detail", `${t("backendWidgetErrorsRate")}: ${shortRate(errorsPerSecond)}${fraction === null ? "" : ` · ${fraction.toFixed(1)}% ${t("backendWidgetErrorPercent")}`}`);
paintSeries("errors", "backend-dashboard-error-line", Math.max(1, ...samples.errors));
}
if (visible("backend_latency")) {
set("backend-dashboard-latency", latency === null ? "-" : `${fmt(latency, 1)} ms`);
set("backend-dashboard-latency-detail", `${t("backendWidgetProbeCount")}: ${probes.length}`);
paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency));
}
if (visible("backend_traffic")) {
set("backend-dashboard-traffic", `${bytes(Math.round(inPerSecond + outPerSecond))}/s`);
set("backend-dashboard-in-rate", `${bytes(Math.round(inPerSecond))}/s`);
set("backend-dashboard-out-rate", `${bytes(Math.round(outPerSecond))}/s`);
const peak = Math.max(1, ...samples.inbound, ...samples.outbound);
paintSeries("inbound", "backend-dashboard-in-line", peak);
paintSeries("outbound", "backend-dashboard-out-line", peak);
}
const diagnostics = [
["http5xx", "http_5xx_total", "backend-dashboard-http5xx", "backend-dashboard-5xx-line"],
["timeouts", "timeouts_total", "backend-dashboard-timeouts", "backend-dashboard-timeout-line"],
["network", null, "backend-dashboard-network", "backend-dashboard-network-line"],
["retries", "retry_attempts_total", "backend-dashboard-retries", "backend-dashboard-retry-line"],
["queue", null, "backend-dashboard-queue", "backend-dashboard-queue-line"],
];
for (const [name, counterName, field, lineId] of diagnostics) {
if (!visible(`backend_${name}`)) continue;
const total = members.reduce((n, { member }) => n + (counterName ? counter(member[counterName]) :
name === "network" ? ["connect_errors_total", "dns_errors_total", "tls_errors_total", "io_errors_total"].reduce((v, key) => v + counter(member[key]), 0) :
counter(member.queue_timeouts_total) + counter(member.capacity_rejections_total)), 0);
set(field, fmt(total));
let detail = `${t("backendWidgetRateDetail")}: ${shortRate(additional[name])}`;
if (name === "retries") detail += ` · ${t("backendWidgetRetriesSuccess")}: ${fmt(members.reduce((n, { member }) => n + counter(member.retry_successes_total), 0))}`;
if (name === "queue") detail += ` · ${t("backendWidgetQueueRejected")}: ${fmt(members.reduce((n, { member }) => n + counter(member.capacity_rejections_total), 0))}`;
if (name === "network") detail += ` · ${t("backendWidgetNetworkBreakdown")}`;
set(`${field}-detail`, detail);
paintSeries(name, lineId, Math.max(1, ...samples[name]));
}
}
return { syncVisibility, render, reset };
})();
+7 -6
View File
@@ -59,7 +59,7 @@ function translate() {
window.rustProxyDrafts?.refresh();
window.rustProxyPreferences?.refreshAppearanceUi?.();
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); } if (lastLogs) renderLogs(lastLogs);
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
syncSettingsDirtyIndicators();
}
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
@@ -151,9 +151,10 @@ async function api(path, options = {}) {
function setPanel(name, navigate = true) {
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
const previousPanel = currentPanel;
const previousPanel = currentPanel; if (name !== "overview" && previousPanel === "overview") window.rustProxyInlineLayout?.cancel?.();
currentPanel = name;
$("overview-refresh").hidden = name !== "overview";
for (const id of ["overview-layout-open", "overview-layout-unlock"]) if ($(id)) $(id).hidden = name !== "overview";
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
@@ -163,7 +164,7 @@ function setPanel(name, navigate = true) {
// Paint Stream tiles immediately on return from Appearance using cached status.
// Do not create an extra sample/poll on navigation.
if (name === "overview" && previousPanel !== name && typeof latestStatus !== "undefined" && latestStatus)
window.rustProxyStreamDashboard?.render?.(latestStatus, null, false);
{ window.rustProxyStreamDashboard?.render?.(latestStatus, null, false); window.rustProxyBackendDashboard?.render?.(latestStatus, null, false); window.rustProxyExtraOverview?.render?.(latestStatus, false); window.rustProxyWideOverview?.render?.(latestStatus, false); }
// Read the cached Docker status on each visit, independent of the Live-view toggle.
// This is GET /api/docker, not the POST that triggers an agent poll.
if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker");
@@ -266,7 +267,7 @@ function renderStatus(s, systemSample = false) {
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s);
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s); window.rustProxyBackendDashboard?.render(s); window.rustProxyExtraOverview?.render(s); window.rustProxyWideOverview?.render(s);
}
function renderFooter(s) {
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
@@ -371,10 +372,10 @@ function authReturnDestination() {
const tab = window.rustProxySettings?.current?.() || new URLSearchParams(location.search).get("tab");
return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings";
}
function lock(notify = true) {
function lock(notify = true) { window.rustProxyInlineLayout?.cancel?.();
window.rustProxyRefresh?.stop();
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
@@ -0,0 +1,200 @@
window.rustProxyInlineLayout = (() => {
const grid = $("dashboard-overview-grid");
const unlock = $("overview-layout-unlock");
const actions = $("overview-inline-actions");
const saveButton = $("overview-inline-save");
const cancelButton = $("overview-inline-cancel");
const hint = $("overview-inline-hint");
const hiddenShelf = $("overview-inline-hidden");
const hiddenList = $("overview-inline-hidden-list");
const hiddenCount = $("overview-inline-hidden-count");
const modalButton = $("overview-layout-open");
let draft = null;
let original = null;
let dragging = null;
let opening = false;
let saving = false;
const groupFor = id => window.rustProxyMasonry?.groupFor?.(id) ?? 0;
const visibleIds = () => draft.order.filter(id => !draft.hidden.has(id))
.sort((a, b) => groupFor(a) - groupFor(b));
const nodes = () => [...grid.children].filter(node => node.dataset.dashboardWidget);
const nodeFor = id => nodes().find(node => node.dataset.dashboardWidget === id);
const label = id => dashboardWidgetLabel(id);
const dirty = () => draft && original && (draft.order.some((id, i) => id !== original.order[i])
|| [...draft.hidden].some(id => !original.hidden.has(id)) || [...original.hidden].some(id => !draft.hidden.has(id)));
function renderHidden() {
if (!hiddenList || !draft) return;
const entries = draft.order.filter(id => draft.hidden.has(id));
if (hiddenCount) hiddenCount.textContent = `(${entries.length})`;
hiddenList.replaceChildren(...(entries.length ? entries.map(id => {
const add = control("+", `${t("overviewLayoutInlineRestore")}: ${label(id)}`, "overview-inline-restore", () => {
draft.hidden.delete(id);
paint();
nodeFor(id)?.querySelector(".overview-inline-grip")?.focus();
});
const item = el("div", undefined, "overview-inline-hidden-item");
item.append(el("span", label(id)), dashboardWidgetTypeBadge(id), add);
return item;
}) : [el("span", t("overviewLayoutInlineHiddenEmpty"), "fine")]));
}
function hide(id) {
if (!draft || !visibleIds().includes(id)) return;
draft.hidden.add(id);
paint();
if (hiddenShelf) { hiddenShelf.open = true; hiddenShelf.querySelector("summary")?.focus(); }
}
function paint() {
applyDashboardLayout(draft.order, [...draft.hidden]);
renderHidden();
const visible = visibleIds();
for (const node of nodes()) {
const i = visible.indexOf(node.dataset.dashboardWidget);
const up = node.querySelector(".overview-inline-up");
const down = node.querySelector(".overview-inline-down");
if (up) up.disabled = i <= 0 || groupFor(visible[i - 1]) !== groupFor(node.dataset.dashboardWidget);
if (down) down.disabled = i < 0 || i === visible.length - 1 ||
groupFor(visible[i + 1]) !== groupFor(node.dataset.dashboardWidget);
}
}
function clearDrag() {
dragging = null;
for (const node of nodes()) node.classList.remove("is-overview-dragging", "is-overview-drop-before", "is-overview-drop-after");
}
function move(id, target, after = false) {
if (!draft || id === target || groupFor(id) !== groupFor(target)) return;
const visible = visibleIds();
if (!visible.includes(id) || !visible.includes(target)) return;
visible.splice(visible.indexOf(id), 1);
visible.splice(visible.indexOf(target) + (after ? 1 : 0), 0, id);
const iter = visible.values();
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
paint();
}
function nudge(id, delta) {
const visible = visibleIds();
const index = visible.indexOf(id), target = visible[index + delta];
if (!target || groupFor(id) !== groupFor(target)) return;
move(id, target, delta > 0);
nodeFor(id)?.querySelector(delta < 0 ? ".overview-inline-up" : ".overview-inline-down")?.focus();
}
function control(text, title, className, run) {
const button = el("button", text, `quiet ${className}`);
button.type = "button";
button.setAttribute("aria-label", title);
button.title = title;
button.addEventListener("click", run);
return button;
}
function toolbar(node) {
const id = node.dataset.dashboardWidget;
const title = label(id);
const bar = el("div", undefined, "overview-inline-toolbar");
const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { });
grip.draggable = true;
grip.dataset.overviewDragHandle = id;
bar.append(grip, dashboardWidgetTypeBadge(id),
control("←", `${t("moveUp")}: ${title}`, "overview-inline-up", () => nudge(id, -1)),
control("→", `${t("moveDown")}: ${title}`, "overview-inline-down", () => nudge(id, 1)),
control("×", `${t("overviewLayoutInlineHide")}: ${title}`, "overview-inline-hide", () => hide(id)));
node.append(bar);
}
function showEditing() {
grid.classList.add("is-unlocked");
unlock.setAttribute("aria-pressed", "true");
unlock.dataset.i18n = "overviewLayoutLock";
unlock.textContent = t("overviewLayoutLock");
actions.hidden = false;
hint.hidden = false;
hint.textContent = t("overviewLayoutInlineHint");
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
if (modalButton) modalButton.disabled = true;
for (const node of nodes()) toolbar(node);
paint();
}
function finish(preserveSaved = false) {
clearDrag();
for (const node of nodes()) node.querySelector(".overview-inline-toolbar")?.remove();
grid.classList.remove("is-unlocked");
unlock.setAttribute("aria-pressed", "false");
unlock.dataset.i18n = "overviewLayoutUnlock";
unlock.textContent = t("overviewLayoutUnlock");
actions.hidden = true;
hint.hidden = true;
if (hiddenShelf) { hiddenShelf.open = false; hiddenShelf.hidden = true; }
if (hiddenList) hiddenList.replaceChildren();
if (modalButton) modalButton.disabled = false;
if (!preserveSaved && original) applyDashboardLayout(original.order, [...original.hidden]);
draft = original = null;
}
async function begin() {
if (draft || opening || saving || $("overview-layout-dialog")?.open) return;
opening = true;
unlock.disabled = true;
try {
await window.rustProxyPreferences?.load?.();
const saved = window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS;
const order = normalizeDashboardWidgets(saved.dashboard_widgets);
const hidden = new Set(normalizeHiddenDashboardWidgets(saved.dashboard_hidden_widgets, saved.dashboard_widgets));
original = { order: [...order], hidden: new Set(hidden) };
draft = { order: [...order], hidden: new Set(hidden) };
showEditing();
} catch (error) { if (error?.status !== 401) toast(error.message, true); }
finally { opening = false; unlock.disabled = false; }
}
async function save() {
if (!draft || saving) return;
if (!dirty()) { finish(true); return; }
saving = true;
saveButton.disabled = cancelButton.disabled = unlock.disabled = true;
try {
await window.rustProxyPreferences.update({
dashboard_widgets: [...draft.order], dashboard_hidden_widgets: [...draft.hidden],
});
finish(true);
toast(t("overviewLayoutEditorSaved"));
} catch (error) { hint.textContent = error.message || t("overviewLayoutInlineError"); }
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
}
function cancel() { if (draft && !saving) finish(false); }
function dropTarget(event) {
const widget = event.target?.closest?.("[data-dashboard-widget]");
return widget && grid.contains(widget) && !widget.hidden && visibleIds().includes(widget.dataset.dashboardWidget) ? widget : null;
}
function positionAfter(widget, event) {
const rect = widget.getBoundingClientRect();
return event.clientX >= rect.left + rect.width / 2;
}
grid?.addEventListener("dragstart", event => {
const handle = event.target?.closest?.("[data-overview-drag-handle]");
if (!draft || !handle || !grid.contains(handle)) { event.preventDefault(); return; }
dragging = handle.dataset.overviewDragHandle;
event.dataTransfer?.setData("text/plain", dragging);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
nodeFor(dragging)?.classList.add("is-overview-dragging");
});
grid?.addEventListener("dragover", event => {
if (!draft || !dragging) return;
const target = dropTarget(event);
if (!target || target.dataset.dashboardWidget === dragging ||
groupFor(target.dataset.dashboardWidget) !== groupFor(dragging)) return;
event.preventDefault();
for (const node of nodes()) node.classList.remove("is-overview-drop-before", "is-overview-drop-after");
target.classList.add(positionAfter(target, event) ? "is-overview-drop-after" : "is-overview-drop-before");
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
});
grid?.addEventListener("drop", event => {
if (!draft || !dragging) return;
event.preventDefault();
const target = dropTarget(event), from = dragging;
clearDrag();
if (target && target.dataset.dashboardWidget !== from)
move(from, target.dataset.dashboardWidget, positionAfter(target, event));
});
grid?.addEventListener("dragend", clearDrag);
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
saveButton?.addEventListener("click", () => { void save(); });
cancelButton?.addEventListener("click", cancel);
return { isEditing: () => !!draft, cancel };
})();
@@ -0,0 +1,239 @@
window.rustProxyDashboardLayout = (() => {
const dialog = $("overview-layout-dialog");
const visibleRoot = $("overview-layout-visible");
const hiddenRoot = $("overview-layout-hidden");
const saveButton = $("overview-layout-save");
const cancelButton = $("overview-layout-cancel");
const search = $("overview-layout-search");
const typeFilter = $("overview-layout-type-filter");
const message = $("overview-layout-message");
const defaultButton = $("overview-layout-default");
const resetButton = $("overview-layout-reset");
const resetConfirm = $("overview-reset-confirm");
const resetCancel = $("overview-reset-cancel");
const resetApply = $("overview-reset-apply");
let draft = null;
let dragging = null;
let saving = false;
let opening = false;
function initialDraft() {
const prefs = window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS;
const order = normalizeDashboardWidgets(prefs.dashboard_widgets);
return { order, hidden: new Set(normalizeHiddenDashboardWidgets(prefs.dashboard_hidden_widgets, prefs.dashboard_widgets)) };
}
function defaultDraft() {
return {
order: [...ADMIN_DASHBOARD_WIDGETS],
hidden: new Set(ADMIN_DEFAULT_HIDDEN_WIDGETS),
};
}
function label(id) {
const widget = ADMIN_DASHBOARD_BY_ID.get(id);
return widget ? t(widget.label_key) : id;
}
const groupFor = id => window.rustProxyMasonry?.groupFor?.(id) ?? 0;
function visibleIds() {
return draft.order.filter(id => !draft.hidden.has(id))
.sort((a, b) => groupFor(a) - groupFor(b));
}
function reorderVisible(nextVisible) {
const iter = nextVisible.values();
draft.order = draft.order.map(id => draft.hidden.has(id) ? id : iter.next().value);
render();
}
function move(id, target, after = false) {
const next = visibleIds();
if (id === target || !next.includes(id) || !next.includes(target) || groupFor(id) !== groupFor(target)) return;
next.splice(next.indexOf(id), 1);
next.splice(next.indexOf(target) + (after ? 1 : 0), 0, id);
reorderVisible(next);
}
function nudge(id, direction) {
const next = visibleIds(), index = next.indexOf(id), other = index + direction;
if (index < 0 || other < 0 || other >= next.length || groupFor(id) !== groupFor(next[other])) return;
[next[index], next[other]] = [next[other], next[index]];
reorderVisible(next);
visibleRoot.querySelectorAll(".overview-layout-tile").forEach(node => {
if (node.dataset.previewWidget === id) node.querySelector(".overview-layout-up")?.focus();
});
}
function preview(id) {
const original = document.querySelector(`#panel-overview [data-dashboard-widget="${id}"]`);
if (!original) return el("article", label(id), "stat");
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") {
const card = el("article", undefined, "overview-layout-panel-preview");
card.append(el("strong", label(id)));
const chart = id === "backend_table" ? null : original.querySelector?.("svg");
if (chart) {
const graphic = chart.cloneNode(true);
for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id");
graphic.removeAttribute("data-i18n-aria");
graphic.setAttribute("aria-hidden", "true");
card.append(graphic);
}
const detailKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
if (detailKey) card.append(el("small", t(detailKey)));
card.setAttribute("aria-hidden", "true");
return card;
}
const copy = original.cloneNode(true);
copy.hidden = false;
copy.removeAttribute("data-dashboard-widget");
for (const node of [copy, ...copy.querySelectorAll("[id]")]) node.removeAttribute("id");
for (const node of copy.querySelectorAll("[aria-labelledby], [aria-describedby]")) {
node.removeAttribute("aria-labelledby"); node.removeAttribute("aria-describedby");
}
copy.classList.add("overview-layout-card-preview");
copy.setAttribute("aria-hidden", "true");
return copy;
}
function render() {
if (!draft || !dialog) return;
const visible = visibleIds();
const tiles = visible.map((id, index) => {
const tile = el("div", undefined, "overview-layout-tile");
tile.dataset.previewWidget = id;
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") tile.classList.add("is-panel");
tile.draggable = true;
tile.setAttribute("role", "listitem");
tile.setAttribute("aria-label", label(id));
const toolbar = el("div", undefined, "overview-layout-tile-toolbar");
const grip = el("span", "⠿", "overview-layout-grip");
grip.setAttribute("aria-hidden", "true");
const up = el("button", "←", "quiet overview-layout-up");
up.type = "button"; up.disabled = index === 0 || groupFor(id) !== groupFor(visible[index - 1]);
up.setAttribute("aria-label", `${t("moveUp")}: ${label(id)}`);
up.addEventListener("click", () => nudge(id, -1));
const down = el("button", "→", "quiet overview-layout-down");
down.type = "button"; down.disabled = index === visible.length - 1 || groupFor(id) !== groupFor(visible[index + 1]);
down.setAttribute("aria-label", `${t("moveDown")}: ${label(id)}`);
down.addEventListener("click", () => nudge(id, 1));
const hide = el("button", t("overviewLayoutEditorHide"), "quiet overview-layout-hide");
hide.type = "button";
hide.setAttribute("aria-label", `${t("overviewLayoutEditorHide")}: ${label(id)}`);
hide.addEventListener("click", () => { draft.hidden.add(id); render(); });
const controls = el("span", undefined, "overview-layout-controls");
controls.append(up, down, hide);
toolbar.append(grip, dashboardWidgetTypeBadge(id), controls);
tile.append(toolbar, preview(id));
tile.addEventListener("dragstart", event => {
if (event.target !== tile) return;
dragging = id;
event.dataTransfer?.setData("text/plain", id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
tile.classList.add("is-dragging");
});
tile.addEventListener("dragover", event => {
if (!dragging || dragging === id || groupFor(dragging) !== groupFor(id)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
tile.classList.add("is-drop-target");
});
tile.addEventListener("dragleave", () => tile.classList.remove("is-drop-target"));
tile.addEventListener("drop", event => {
event.preventDefault();
tile.classList.remove("is-drop-target");
if (dragging && dragging !== id) {
const startIndex = visibleIds().indexOf(dragging);
const targetIndex = visibleIds().indexOf(id);
move(dragging, id, startIndex < targetIndex);
}
dragging = null;
});
tile.addEventListener("dragend", () => { dragging = null; tile.classList.remove("is-dragging", "is-drop-target"); });
return tile;
});
visibleRoot.replaceChildren(...(tiles.length ? tiles : [el("p", t("overviewLayoutEditorEmpty"), "fine")]));
const query = (search?.value || "").trim().toLocaleLowerCase();
const hidden = draft.order.filter(id => draft.hidden.has(id)
&& (typeFilter?.value === "all" || !typeFilter?.value || dashboardWidgetType(id) === typeFilter.value)
&& (`${label(id)} ${dashboardWidgetTypeLabel(id)}`).toLocaleLowerCase().includes(query));
const hiddenItems = hidden.map(id => {
const item = el("div", undefined, "overview-layout-hidden-item");
item.setAttribute("role", "listitem");
const name = el("span", label(id));
const meta = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
if (meta) name.title = t(meta);
const add = el("button", t("overviewLayoutEditorShow"), "outline");
add.type = "button";
add.setAttribute("aria-label", `${t("overviewLayoutEditorShow")}: ${label(id)}`);
add.addEventListener("click", () => { draft.hidden.delete(id); render(); });
item.append(name, dashboardWidgetTypeBadge(id), add);
return item;
});
hiddenRoot.replaceChildren(...(hiddenItems.length || query ? hiddenItems : [el("p", t("overviewLayoutEditorHiddenEmpty"), "fine")]));
}
async function open() {
if (dialog?.open || opening || window.rustProxyInlineLayout?.isEditing?.()) return;
opening = true;
try { await window.rustProxyPreferences?.load?.(); }
catch (error) { if (error?.status === 401) { opening = false; return; } }
opening = false;
draft = initialDraft();
dragging = null;
search.value = "";
if (typeFilter) typeFilter.value = "all";
message.textContent = "";
render();
dialog.showModal();
cancelButton.focus();
}
function setSaving(value) {
saving = value;
for (const control of [saveButton, cancelButton, defaultButton, resetButton, resetCancel, resetApply])
if (control) control.disabled = value;
}
function restoreDefaultPreview() {
if (saving || !draft) return;
draft = defaultDraft();
dragging = null;
if (search) search.value = "";
if (typeFilter) typeFilter.value = "all";
message.textContent = t("overviewLayoutDefaultLoaded");
render();
}
async function resetToDefault() {
if (saving || !draft) return;
setSaving(true);
message.textContent = t("overviewLayoutEditorSaving");
const defaults = defaultDraft();
try {
await window.rustProxyPreferences.update({
dashboard_widgets: [...defaults.order], dashboard_hidden_widgets: [...defaults.hidden],
});
resetConfirm.close();
dialog.close();
toast(t("overviewLayoutResetSaved"));
} catch (error) { message.textContent = error.message; }
finally { setSaving(false); }
}
async function save() {
if (saving || !draft) return;
setSaving(true);
message.textContent = t("overviewLayoutEditorSaving");
try {
await window.rustProxyPreferences.update({
dashboard_widgets: [...draft.order], dashboard_hidden_widgets: [...draft.hidden],
});
dialog.close();
toast(t("overviewLayoutEditorSaved"));
} catch (error) { message.textContent = error.message; }
finally { setSaving(false); }
}
for (const id of ["overview-layout-open", "overview-layout-open-settings"]) $(id)?.addEventListener("click", () => { void open(); });
cancelButton.addEventListener("click", () => { if (!saving) dialog.close(); });
saveButton.addEventListener("click", () => { void save(); });
defaultButton?.addEventListener("click", restoreDefaultPreview);
resetButton?.addEventListener("click", () => {
if (!saving && draft && !resetConfirm.open) resetConfirm.showModal();
});
resetCancel?.addEventListener("click", () => { if (!saving) resetConfirm.close(); });
resetApply?.addEventListener("click", () => { void resetToDefault(); });
resetConfirm?.addEventListener("cancel", event => { if (saving) event.preventDefault(); });
search.addEventListener("input", render);
typeFilter?.addEventListener("change", render);
dialog.addEventListener("cancel", event => { if (saving || resetConfirm.open) event.preventDefault(); });
dialog.addEventListener("close", () => { draft = null; dragging = null; message.textContent = ""; });
return { open, refresh: () => { if (dialog.open) render(); } };
})();
+128
View File
@@ -0,0 +1,128 @@
window.rustProxyExtraOverview = (() => {
const limit = 30;
const names = [
"extra_process_cpu", "extra_process_rss", "extra_process_fds", "extra_open_connections",
"extra_websocket_active", "extra_tls_error_rate", "extra_request_error_rate",
"extra_cache_hit_rate", "extra_cache_memory", "extra_h2_share",
"extra_response_latency", "extra_backend_unhealthy",
"extra_total_rps", "extra_active_exchanges", "extra_h1_share", "extra_h3_share",
"extra_cache_miss_rate", "extra_backend_active",
];
const histories = new Map();
const lastCounter = new Map();
const lastValue = new Map();
const lastUnits = new Map();
const positive = value => value != null && Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) : null;
const node = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
function elapsedDelta(id, counters, now) {
const old = lastCounter.get(id);
const next = { values: counters, at: now };
lastCounter.set(id, next);
if (!old || now <= old.at || counters.some((v, i) => v < old.values[i])) return null;
return { deltas: counters.map((v, i) => v - old.values[i]), seconds: (now - old.at) / 1000 };
}
function read(id, status, now, record) {
const m = status.metrics || {}, d = status.system || {}, c = status.cache || {};
const direct = {
extra_process_cpu: d.process_cpu_percent,
extra_process_rss: d.process_rss_bytes,
extra_process_fds: d.open_fds,
extra_open_connections: m.active_connections,
extra_websocket_active: m.websocket_active,
extra_response_latency: m.mean_duration_ms,
extra_active_exchanges: m.active,
};
if (Object.hasOwn(direct, id)) {
const numeric = positive(direct[id]);
return {
value: numeric, unit: id === "extra_process_cpu" ? "percent" : id === "extra_process_rss" ? "bytes" : id === "extra_response_latency" ? "ms" : "count",
detail: id === "extra_process_fds" && positive(d.max_fds) ? `${t("overviewExtraFdLimit")}: ${fmt(d.max_fds)}` : ""
};
}
if (id === "extra_backend_active") {
const members = Array.isArray(status.backend_pools) ? status.backend_pools.flatMap(pool => pool.members || []) : null;
return {
value: members ? members.reduce((sum, member) => sum + (positive(member.active) ?? 0), 0) : null,
unit: "count", detail: ""
};
}
if (id === "extra_backend_unhealthy") {
return {
value: Array.isArray(status.backend_pools) ? status.backend_pools.reduce((sum, pool) =>
sum + (pool.members || []).filter(member => member.status === "down").length, 0) : null, unit: "count", detail: ""
};
}
if (id === "extra_cache_memory") {
const bytes = positive(c.resident_bytes), budget = positive(c.hot_limit_bytes);
const percent = budget && bytes !== null ? bytes / budget * 100 : null;
return {
value: percent === null ? bytes : percent, unit: percent === null ? "bytes" : "percent",
detail: bytes === null ? "" : `${fmtBytes(bytes)}${budget ? ` / ${fmtBytes(budget)}` : ""}`
};
}
if (!["extra_tls_error_rate", "extra_request_error_rate", "extra_cache_hit_rate", "extra_h2_share",
"extra_total_rps", "extra_h1_share", "extra_h3_share", "extra_cache_miss_rate"].includes(id))
return { value: null, unit: "count", detail: "" };
const fields = ({
extra_tls_error_rate: [m.tls_handshake_errors],
extra_request_error_rate: [m.errors],
extra_cache_hit_rate: [c.hits, c.misses],
extra_h2_share: [m.http2_requests, m.requests],
extra_h1_share: [m.http1_requests, m.requests],
extra_h3_share: [m.http3_requests, m.requests],
extra_total_rps: [m.requests],
extra_cache_miss_rate: [c.misses],
})[id].map(positive);
const unit = ["extra_cache_hit_rate", "extra_h2_share", "extra_h1_share", "extra_h3_share"].includes(id)
? "percent" : "rate";
if (fields.some(v => v === null)) { lastCounter.delete(id); return { value: null, unit, detail: "" }; }
if (!record) return { value: lastValue.get(id) ?? null, unit, detail: "" };
const result = elapsedDelta(id, fields, now);
if (!result) return { value: null, unit, detail: "" };
const { deltas, seconds } = result;
if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" };
const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1];
return { value: sum ? Math.min(100, deltas[0] / sum * 100) : null, unit, detail: "" };
}
function format(value, unit) {
if (value == null) return "-";
if (unit === "percent") return `${fmt(value, 1)}%`;
if (unit === "bytes") return fmtBytes(value);
if (unit === "ms") return `${fmt(value, 1)} ms`;
if (unit === "rate") return `${fmt(value, 1)}/s`;
return fmt(value);
}
function points(history, unit) {
if (!history.length) return "0,55 300,55";
const peak = unit === "percent" ? Math.max(100, ...history) : Math.max(1, ...history);
return history.map((v, i) => `${i * 300 / Math.max(1, history.length - 1)},${55 - v / peak * 50}`).join(" ");
}
function render(status, record = true) {
if (!status || currentPanel !== "overview") return;
const now = performance.now();
for (const id of names) {
const tile = node(id);
if (!tile || tile.hidden) { lastCounter.delete(id); continue; }
const metric = read(id, status, now, record);
const value = metric.value;
if (lastUnits.get(id) !== metric.unit) {
histories.delete(id);
lastUnits.set(id, metric.unit);
}
const history = histories.get(id) || [];
if (record && value !== null) {
history.push(value);
if (history.length > limit) history.shift();
histories.set(id, history);
}
if (record) lastValue.set(id, value);
const field = $(`overview-extra-${id}`);
if (field) field.textContent = format(value, metric.unit);
const detail = $(`overview-extra-${id}-detail`);
if (detail) detail.textContent = metric.detail || t("overviewExtraRecentSamples");
$(`overview-extra-${id}-line`)?.setAttribute("points", points(history, metric.unit));
}
}
function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); }
return { render, reset };
})();
+38
View File
@@ -0,0 +1,38 @@
window.rustProxyMasonry = (() => {
const grid = document.getElementById("dashboard-overview-grid");
if (!grid) return { refresh() { } };
const widgets = () => [...grid.children].filter(node => node.dataset.dashboardWidget);
const group = node => node.classList.contains("pool-card") ? 4
: node.classList.contains("stat") ? 0
: node.classList.contains("overview-wide-chart") ? 2
: node.dataset.dashboardWidget?.endsWith("_diagnostics") ? 3 : 1;
function refresh() {
const previous = widgets();
const ordered = previous.map((node, index) => ({ node, index }))
.sort((a, b) => group(a.node) - group(b.node) || a.index - b.index)
.map(item => item.node);
if (ordered.some((node, i) => node !== previous[i])) grid.append(...ordered);
let lastGroup = -1;
for (const node of ordered) {
for (const key of ["position", "left", "top", "width", "minHeight", "gridColumnStart",
"gridColumnEnd", "gridRowStart", "gridRowEnd", "alignSelf"]) node.style[key] = "";
node.classList.remove("overview-row-start");
if (node.hidden) continue;
const current = group(node);
if (current !== lastGroup) node.classList.add("overview-row-start");
lastGroup = current;
}
grid.classList.remove("is-packed");
grid.classList.add("is-aligned");
grid.style.height = "";
}
refresh();
return {
refresh,
groupFor(id) {
const node = widgets().find(item => item.dataset.dashboardWidget === id);
return node ? group(node) : -1;
},
};
})();
+106
View File
@@ -0,0 +1,106 @@
window.rustProxyWideOverview = (() => {
const limit = 60;
const configurations = {
wide_protocol_rates: {
unit: "rate", names: ["h1", "h2", "h3"], counters: s =>
[s.metrics?.http1_requests, s.metrics?.http2_requests, s.metrics?.http3_requests]
},
wide_cpu: {
unit: "percent", names: ["system", "process"], values: s =>
[s.system?.cpu_percent, s.system?.process_cpu_percent]
},
wide_memory: {
unit: "bytes", names: ["process", "cache"], values: s =>
[s.system?.process_rss_bytes, s.cache?.resident_bytes]
},
wide_cache_activity: {
unit: "rate", names: ["hits", "misses"], counters: s =>
[s.cache?.hits, s.cache?.misses]
},
wide_connections: {
unit: "count", names: ["open", "websockets"], values: s =>
[s.metrics?.active_connections, s.metrics?.websocket_active]
},
wide_backend_health: {
unit: "count", names: ["up", "down"], values: s => {
if (!Array.isArray(s.backend_pools)) return [null, null];
const members = s.backend_pools.flatMap(pool => pool.members || []);
return [members.filter(m => m.status === "up").length, members.filter(m => m.status === "down").length];
}
},
wide_latency: {
unit: "ms", names: ["response", "probe"], values: s => {
if (!Array.isArray(s.backend_pools)) return [s.metrics?.mean_duration_ms, null];
const members = s.backend_pools.flatMap(pool => pool.members || []);
const probes = members.filter(m => m.status === "up" && m.health_mode !== "off"
&& finite(m.last_probe_ms) !== null && finite(m.latency_ms) !== null
&& Number(m.last_probe_ms) > 0);
return [s.metrics?.mean_duration_ms,
probes.length ? probes.reduce((total, m) => total + Number(m.latency_ms), 0) / probes.length : null];
}
},
};
const history = new Map(), baselines = new Map(), last = new Map();
let revision = null;
const finite = value => value != null && Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) : null;
const format = (n, unit) => n === null ? "-" : unit === "bytes" ? fmtBytes(n)
: unit === "percent" ? `${fmt(n, 1)}%` : unit === "ms" ? `${fmt(n, 1)} ms`
: unit === "rate" ? `${fmt(n, 1)}/s` : fmt(n);
const tile = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
function counterRates(id, values, now, record) {
const counters = values.map(finite);
if (counters.some(n => n === null)) { baselines.delete(id); return counters.map(() => null); }
if (!record) return last.get(id) || counters.map(() => null);
const previous = baselines.get(id);
baselines.set(id, { counters, now });
if (!previous || now <= previous.now || counters.some((n, i) => n < previous.counters[i]))
return counters.map(() => null);
const elapsed = (now - previous.now) / 1000;
return counters.map((n, i) => (n - previous.counters[i]) / elapsed);
}
function path(values, peak) {
const count = values.length;
let result = "", drawing = false;
for (let i = 0; i < count; i++) {
const value = values[i];
if (value === null) { drawing = false; continue; }
const x = Math.round(600 * i / Math.max(1, count - 1));
const y = Math.round(137 - 120 * value / peak);
result += `${drawing ? " L" : " M"}${x} ${y}`;
drawing = true;
}
return result.trim();
}
function render(status, record = true) {
if (!status || currentPanel !== "overview") return;
if (revision !== status.revision) { reset(); revision = status.revision; }
const now = performance.now();
for (const [id, config] of Object.entries(configurations)) {
const node = tile(id);
if (!node || node.hidden) { baselines.delete(id); continue; }
const values = config.counters ? counterRates(id, config.counters(status), now, record)
: config.values(status).map(finite);
const series = history.get(id) || config.names.map(() => []);
if (record) {
values.forEach((value, i) => {
series[i].push(value);
if (series[i].length > limit) series[i].shift();
});
history.set(id, series);
last.set(id, values);
}
const peak = Math.max(config.unit === "percent" ? 100 : 1,
...series.flat().filter(n => n !== null));
config.names.forEach((key, i) => {
const value = values[i];
const field = $(`overview-wide-${id}-${key}-value`);
if (field) field.textContent = format(value, config.unit);
$(`overview-wide-${id}-${key}-line`)?.setAttribute("d", path(series[i], peak));
});
const summary = $(`overview-wide-${id}-summary`);
if (summary) summary.textContent = format(values[0], config.unit);
}
}
function reset() { history.clear(); baselines.clear(); last.clear(); revision = null; }
return { render, reset };
})();
+52 -13
View File
@@ -1,12 +1,15 @@
const ADMIN_THEMES = new Set(["dark", "light"]);
const ADMIN_THEMES = new Set(["dark", "ultra_dark", "light", "cream"]);
const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]);
const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]);
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol", "stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"]);
const ADMIN_CORNER_STYLES = new Set(["default", "square", "subtle", "rounded", "extra"]);
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id));
const ADMIN_DASHBOARD_BY_ID = new Map(ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => [widget.id, widget]));
const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze(ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden).map(widget => widget.id));
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"],
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS],
});
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
@@ -17,6 +20,7 @@ function normalizeAdminUiScale(value) { const numeric = Number(value); return Nu
function normalizeAdminTimeFormat(value) { return ADMIN_TIME_FORMATS.has(value) ? value : "24h"; }
function normalizeAdminChartStyle(value) { return ADMIN_CHART_STYLES.has(value) ? value : "default"; }
function normalizeAdminButtonStyle(value) { return ADMIN_BUTTON_STYLES.has(value) ? value : "normal"; }
function normalizeAdminCornerStyle(value) { return ADMIN_CORNER_STYLES.has(value) ? value : "default"; }
function normalizeAdminDensity(value, fallback = "normal") { return ADMIN_DENSITIES.has(value) ? value : fallback; }
function normalizeDashboardWidgets(value) {
const result = [], seen = new Set();
@@ -28,7 +32,7 @@ function normalizeHiddenDashboardWidgets(value, savedOrder = ADMIN_DASHBOARD_WID
const seen = new Set();
const hidden = (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
// Also protect preferences written by 0.31.x with an explicit empty hidden list.
for (const id of ADMIN_DASHBOARD_WIDGETS.filter(id => id.startsWith("stream_"))) {
for (const id of ADMIN_DEFAULT_HIDDEN_WIDGETS) {
if (!savedOrder?.includes(id) && !seen.has(id)) { hidden.push(id); seen.add(id); }
}
return hidden;
@@ -73,6 +77,13 @@ function applyAdminButtonStyle(value) {
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
const select = $("ui-button-style"); if (select) select.value = style; return style;
}
function applyAdminCornerStyle(value, cache = true) {
const style = normalizeAdminCornerStyle(value);
document.documentElement.dataset.cornerStyle = style;
const select = $("ui-corner-style"); if (select) select.value = style;
if (cache) { try { localStorage.setItem("rp-corner-style", style); } catch { } }
return style;
}
function applyAdminTableDensity(value) {
const density = normalizeAdminDensity(value); document.documentElement.dataset.tableDensity = density;
const select = $("ui-table-density"); if (select) select.value = density; return density;
@@ -87,13 +98,29 @@ function applyDashboardLayout(order, hidden) {
const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node]));
for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); }
window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview")
window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false);
window.rustProxyBackendDashboard?.syncVisibility?.([...hiddenSet]);
window.rustProxyMasonry?.refresh?.();
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview") { window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false); window.rustProxyBackendDashboard?.render?.(latestStatus, [...hiddenSet], false); window.rustProxyExtraOverview?.render?.(latestStatus, false); window.rustProxyWideOverview?.render?.(latestStatus, false); }
return { order: normalizedOrder, hidden: [...hiddenSet] };
}
function dashboardWidgetLabel(id) {
const node = document.querySelector(`[data-dashboard-widget="${CSS.escape(id)}"]`);
return node?.querySelector("[data-i18n]")?.textContent?.trim() || id;
const widget = ADMIN_DASHBOARD_BY_ID.get(id);
return widget ? t(widget.label_key) : id;
}
const DASHBOARD_DISPLAY_TYPE_KEYS = Object.freeze({
value: "overviewBlockTypeValue", value_chart: "overviewBlockTypeValueChart",
chart: "overviewBlockTypeChart", panel: "overviewBlockTypePanel", table: "overviewBlockTypeTable",
});
function dashboardWidgetType(id) {
return ADMIN_DASHBOARD_BY_ID.get(id)?.display_type || "value";
}
function dashboardWidgetTypeLabel(id) {
return t(DASHBOARD_DISPLAY_TYPE_KEYS[dashboardWidgetType(id)] || "overviewBlockTypeValue");
}
function dashboardWidgetTypeBadge(id) {
const tag = el("span", dashboardWidgetTypeLabel(id), "overview-widget-type");
tag.dataset.blockType = dashboardWidgetType(id);
return tag;
}
function renderDashboardWidgetSettings() {
const list = $("dashboard-widget-list"); if (!list) return;
@@ -102,12 +129,14 @@ function renderDashboardWidgetSettings() {
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
const visible = !hidden.has(id);
const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
const descriptionKey = ({ stream_overview: "streamWidgetOverviewDescription", stream_traffic: "streamWidgetTrafficDescription", stream_sessions: "streamWidgetSessionsDescription", stream_backend_health: "streamWidgetHealthDescription" })[id];
const descriptionKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
if (descriptionKey) {
const description = t(descriptionKey);
row.title = description;
toggle.setAttribute("aria-description", description);
}
// Type badges belong in the visual layout editor, not the long
// Appearance settings list where they crowd the visibility controls.
toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
const actions = el("span", undefined, "dashboard-widget-setting-actions");
const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp"));
@@ -136,7 +165,7 @@ function refreshChartStylePalette(active = adminPreferences?.chart_style || "def
});
target.replaceChildren(...buttons);
}
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); renderRefreshPreferences(adminPreferences?.refresh); window.rustProxySettings?.refreshSearch?.(); }
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); renderRefreshPreferences(adminPreferences?.refresh); window.rustProxyDashboardLayout?.refresh?.(); window.rustProxySettings?.refreshSearch?.(); }
function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
function applyAdminPreferences(value, cache = true) {
@@ -146,7 +175,7 @@ function applyAdminPreferences(value, cache = true) {
...value,
refresh: normalizeRefreshPreferences(value.refresh),
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), table_density: applyAdminTableDensity(value.table_density),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: dashboard.order, dashboard_hidden_widgets: dashboard.hidden,
};
adminPreferences = normalized;
@@ -162,6 +191,7 @@ function applyAdminPreferences(value, cache = true) {
}
try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); }
try { applyAdminUiScale(localStorage.getItem("rp-ui-scale") || 100, false); } catch { applyAdminUiScale(100, false); }
try { applyAdminCornerStyle(localStorage.getItem("rp-corner-style") || "default", false); } catch { applyAdminCornerStyle("default", false); }
async function loadAdminPreferences(force = false) {
if (!authenticated) return adminPreferences;
@@ -193,7 +223,7 @@ function updateAdminPreferences(patch) {
const next = { ...current, ...patch };
next.refresh = normalizeRefreshPreferences(next.refresh);
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.table_density = normalizeAdminDensity(next.table_density);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets);
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
if (!authenticated || epoch !== authEpoch) return adminPreferences;
@@ -273,6 +303,15 @@ $("ui-button-style")?.addEventListener("change", () => {
const control = $("ui-button-style"), previous = adminPreferences?.button_style || "normal", next = normalizeAdminButtonStyle(control.value);
applyAdminButtonStyle(next); if (adminPreferences) adminPreferences.button_style = next; void saveAppearancePreference({ button_style: next }, control, () => { if (adminPreferences) adminPreferences.button_style = previous; applyAdminButtonStyle(previous); });
});
$("ui-corner-style")?.addEventListener("change", () => {
const control = $("ui-corner-style"), previous = adminPreferences?.corner_style || "default", next = normalizeAdminCornerStyle(control.value);
applyAdminCornerStyle(next);
if (adminPreferences) adminPreferences.corner_style = next;
void saveAppearancePreference({ corner_style: next }, control, () => {
if (adminPreferences) adminPreferences.corner_style = previous;
applyAdminCornerStyle(previous);
});
});
$("ui-table-density")?.addEventListener("change", () => {
const control = $("ui-table-density"), previous = adminPreferences?.table_density || "normal", next = normalizeAdminDensity(control.value);
applyAdminTableDensity(next); if (adminPreferences) adminPreferences.table_density = next; void saveAppearancePreference({ table_density: next }, control, () => { if (adminPreferences) adminPreferences.table_density = previous; applyAdminTableDensity(previous); });
@@ -291,7 +330,7 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
}
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
const patches = {
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] },
};
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
-1
View File
@@ -260,7 +260,6 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("metric-h3").textContent = fmt(m.http3_requests);
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`;
$("cache-summary").textContent = `${fmt(c.hits)} HIT / ${fmt(c.misses)} MISS`;
$("metric-rss").closest("article.stat").hidden = d.process_rss_bytes == null;
if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes);
$("process-summary").hidden = d.process_threads == null;
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
+684
View File
@@ -0,0 +1,684 @@
[
{
"id": "requests",
"label_key": "requests",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"requests\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "active",
"label_key": "activeExchanges",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"active\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "bandwidth",
"label_key": "bandwidth",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"bandwidth\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "errors",
"label_key": "errors",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"errors\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "cache",
"label_key": "cacheHitRatio",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"cache\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "memory",
"label_key": "processMemory",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"memory\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "websocket",
"label_key": "wsActive",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"websocket\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "http2",
"label_key": "h2Requests",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"http2\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "http3",
"label_key": "h3Requests",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"http3\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "tls",
"label_key": "tlsFailures",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"tls\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value"
},
{
"id": "http_protocol",
"label_key": "httpProtocolMix",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"http_protocol\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value_chart"
},
{
"id": "websocket_protocol",
"label_key": "wsProtocolMix",
"description_key": null,
"element_selector": "[data-dashboard-widget=\"websocket_protocol\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value_chart"
},
{
"id": "stream_overview",
"label_key": "streamWidgetOverview",
"description_key": "streamWidgetOverviewDescription",
"element_selector": "[data-dashboard-widget=\"stream_overview\"]",
"data_source": "stream_status",
"no_data": "neutral_dash",
"category": "streams",
"default_hidden": true,
"display_type": "value"
},
{
"id": "stream_traffic",
"label_key": "streamWidgetTraffic",
"description_key": "streamWidgetTrafficDescription",
"element_selector": "[data-dashboard-widget=\"stream_traffic\"]",
"data_source": "stream_status",
"no_data": "neutral_dash",
"category": "streams",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "stream_sessions",
"label_key": "streamWidgetSessions",
"description_key": "streamWidgetSessionsDescription",
"element_selector": "[data-dashboard-widget=\"stream_sessions\"]",
"data_source": "stream_status",
"no_data": "neutral_dash",
"category": "streams",
"default_hidden": true,
"display_type": "value"
},
{
"id": "stream_backend_health",
"label_key": "streamWidgetHealth",
"description_key": "streamWidgetHealthDescription",
"element_selector": "[data-dashboard-widget=\"stream_backend_health\"]",
"data_source": "stream_status",
"no_data": "neutral_dash",
"category": "streams",
"default_hidden": true,
"display_type": "value"
},
{
"id": "backend_health",
"label_key": "backendWidgetHealth",
"description_key": "backendWidgetHealthDescription",
"element_selector": "[data-dashboard-widget=\"backend_health\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value"
},
{
"id": "backend_activity",
"label_key": "backendWidgetActivity",
"description_key": "backendWidgetActivityDescription",
"element_selector": "[data-dashboard-widget=\"backend_activity\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_errors",
"label_key": "backendWidgetErrors",
"description_key": "backendWidgetErrorsDescription",
"element_selector": "[data-dashboard-widget=\"backend_errors\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_latency",
"label_key": "backendWidgetLatency",
"description_key": "backendWidgetLatencyDescription",
"element_selector": "[data-dashboard-widget=\"backend_latency\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_traffic",
"label_key": "backendWidgetTraffic",
"description_key": "backendWidgetTrafficDescription",
"element_selector": "[data-dashboard-widget=\"backend_traffic\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_protocols",
"label_key": "backendWidgetProtocols",
"description_key": "backendWidgetProtocolsDescription",
"element_selector": "[data-dashboard-widget=\"backend_protocols\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_requests",
"label_key": "backendWidgetRequests",
"description_key": "backendWidgetRequestsDescription",
"element_selector": "[data-dashboard-widget=\"backend_requests\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_capacity",
"label_key": "backendWidgetCapacity",
"description_key": "backendWidgetCapacityDescription",
"element_selector": "[data-dashboard-widget=\"backend_capacity\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_http5xx",
"label_key": "backendWidgetHttp5xx",
"description_key": "backendWidgetHttp5xxDescription",
"element_selector": "[data-dashboard-widget=\"backend_http5xx\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_timeouts",
"label_key": "backendWidgetTimeouts",
"description_key": "backendWidgetTimeoutsDescription",
"element_selector": "[data-dashboard-widget=\"backend_timeouts\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_network",
"label_key": "backendWidgetNetwork",
"description_key": "backendWidgetNetworkDescription",
"element_selector": "[data-dashboard-widget=\"backend_network\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_retries",
"label_key": "backendWidgetRetries",
"description_key": "backendWidgetRetriesDescription",
"element_selector": "[data-dashboard-widget=\"backend_retries\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_queue",
"label_key": "backendWidgetQueue",
"description_key": "backendWidgetQueueDescription",
"element_selector": "[data-dashboard-widget=\"backend_queue\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "backend_rotations",
"label_key": "backendWidgetRotations",
"description_key": "backendWidgetRotationsDescription",
"element_selector": "[data-dashboard-widget=\"backend_rotations\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": true,
"display_type": "value"
},
{
"id": "extra_process_cpu",
"label_key": "overviewExtraProcessCpu",
"description_key": "overviewExtraProcessCpuDescription",
"element_selector": "[data-dashboard-widget=\"extra_process_cpu\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_process_rss",
"label_key": "overviewExtraProcessRss",
"description_key": "overviewExtraProcessRssDescription",
"element_selector": "[data-dashboard-widget=\"extra_process_rss\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_process_fds",
"label_key": "overviewExtraProcessFds",
"description_key": "overviewExtraProcessFdsDescription",
"element_selector": "[data-dashboard-widget=\"extra_process_fds\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_open_connections",
"label_key": "overviewExtraOpenConnections",
"description_key": "overviewExtraOpenConnectionsDescription",
"element_selector": "[data-dashboard-widget=\"extra_open_connections\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_websocket_active",
"label_key": "overviewExtraWebsocketActive",
"description_key": "overviewExtraWebsocketActiveDescription",
"element_selector": "[data-dashboard-widget=\"extra_websocket_active\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_tls_error_rate",
"label_key": "overviewExtraTlsErrorRate",
"description_key": "overviewExtraTlsErrorRateDescription",
"element_selector": "[data-dashboard-widget=\"extra_tls_error_rate\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_request_error_rate",
"label_key": "overviewExtraRequestErrorRate",
"description_key": "overviewExtraRequestErrorRateDescription",
"element_selector": "[data-dashboard-widget=\"extra_request_error_rate\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_cache_hit_rate",
"label_key": "overviewExtraCacheHitRate",
"description_key": "overviewExtraCacheHitRateDescription",
"element_selector": "[data-dashboard-widget=\"extra_cache_hit_rate\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_cache_memory",
"label_key": "overviewExtraCacheMemory",
"description_key": "overviewExtraCacheMemoryDescription",
"element_selector": "[data-dashboard-widget=\"extra_cache_memory\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_h2_share",
"label_key": "overviewExtraH2Share",
"description_key": "overviewExtraH2ShareDescription",
"element_selector": "[data-dashboard-widget=\"extra_h2_share\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_response_latency",
"label_key": "overviewExtraResponseLatency",
"description_key": "overviewExtraResponseLatencyDescription",
"element_selector": "[data-dashboard-widget=\"extra_response_latency\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_backend_unhealthy",
"label_key": "overviewExtraBackendUnhealthy",
"description_key": "overviewExtraBackendUnhealthyDescription",
"element_selector": "[data-dashboard-widget=\"extra_backend_unhealthy\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_total_rps",
"label_key": "overviewExtraTotalRps",
"description_key": "overviewExtraTotalRpsDetail",
"element_selector": "[data-dashboard-widget=\"extra_total_rps\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_active_exchanges",
"label_key": "overviewExtraActiveExchanges",
"description_key": "overviewExtraActiveExchangesDetail",
"element_selector": "[data-dashboard-widget=\"extra_active_exchanges\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_h1_share",
"label_key": "overviewExtraH1Share",
"description_key": "overviewExtraH1ShareDetail",
"element_selector": "[data-dashboard-widget=\"extra_h1_share\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_h3_share",
"label_key": "overviewExtraH3Share",
"description_key": "overviewExtraH3ShareDetail",
"element_selector": "[data-dashboard-widget=\"extra_h3_share\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_cache_miss_rate",
"label_key": "overviewExtraCacheMissRate",
"description_key": "overviewExtraCacheMissRateDetail",
"element_selector": "[data-dashboard-widget=\"extra_cache_miss_rate\"]",
"data_source": "cache_status",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "extra_backend_active",
"label_key": "overviewExtraBackendActive",
"description_key": "overviewExtraBackendActiveDetail",
"element_selector": "[data-dashboard-widget=\"extra_backend_active\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": true,
"display_type": "value_chart"
},
{
"id": "wide_protocol_rates",
"label_key": "overviewWideProtocolRates",
"description_key": "overviewWideProtocolRatesDetail",
"element_selector": "[data-dashboard-widget=\"wide_protocol_rates\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": true,
"display_type": "chart"
},
{
"id": "wide_cpu",
"label_key": "overviewWideCpu",
"description_key": "overviewWideCpuDetail",
"element_selector": "[data-dashboard-widget=\"wide_cpu\"]",
"data_source": "system_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": true,
"display_type": "chart"
},
{
"id": "wide_memory",
"label_key": "overviewWideMemory",
"description_key": "overviewWideMemoryDetail",
"element_selector": "[data-dashboard-widget=\"wide_memory\"]",
"data_source": "system_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": true,
"display_type": "chart"
},
{
"id": "wide_cache_activity",
"label_key": "overviewWideCacheActivity",
"description_key": "overviewWideCacheActivityDetail",
"element_selector": "[data-dashboard-widget=\"wide_cache_activity\"]",
"data_source": "cache_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": true,
"display_type": "chart"
},
{
"id": "wide_connections",
"label_key": "overviewWideConnections",
"description_key": "overviewWideConnectionsDetail",
"element_selector": "[data-dashboard-widget=\"wide_connections\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": true,
"display_type": "chart"
},
{
"id": "wide_backend_health",
"label_key": "overviewWideBackendHealth",
"description_key": "overviewWideBackendHealthDetail",
"element_selector": "[data-dashboard-widget=\"wide_backend_health\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": true,
"display_type": "chart"
},
{
"id": "wide_latency",
"label_key": "overviewWideLatency",
"description_key": "overviewWideLatencyDetail",
"element_selector": "[data-dashboard-widget=\"wide_latency\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": true,
"display_type": "chart"
},
{
"id": "traffic_chart",
"label_key": "requestsPerSecond",
"description_key": "overviewPanelTrafficDescription",
"element_selector": "[data-dashboard-widget=\"traffic_chart\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": false,
"display_type": "chart"
},
{
"id": "system_chart",
"label_key": "systemLoad",
"description_key": "overviewPanelSystemDescription",
"element_selector": "[data-dashboard-widget=\"system_chart\"]",
"data_source": "system_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": false,
"display_type": "chart"
},
{
"id": "connection_chart",
"label_key": "connectionActivity",
"description_key": "overviewPanelConnectionsDescription",
"element_selector": "[data-dashboard-widget=\"connection_chart\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": false,
"display_type": "chart"
},
{
"id": "cache_diagnostics",
"label_key": "cacheOverview",
"description_key": "overviewPanelCacheDescription",
"element_selector": "[data-dashboard-widget=\"cache_diagnostics\"]",
"data_source": "cache_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": false,
"display_type": "panel"
},
{
"id": "process_diagnostics",
"label_key": "processDiagnostics",
"description_key": "overviewPanelProcessDescription",
"element_selector": "[data-dashboard-widget=\"process_diagnostics\"]",
"data_source": "system_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": false,
"display_type": "panel"
},
{
"id": "network_diagnostics",
"label_key": "networkDiagnostics",
"description_key": "overviewPanelNetworkDescription",
"element_selector": "[data-dashboard-widget=\"network_diagnostics\"]",
"data_source": "system_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": false,
"display_type": "panel"
},
{
"id": "backend_table",
"label_key": "vhHealthTitle",
"description_key": "overviewPanelBackendsDescription",
"element_selector": "[data-dashboard-widget=\"backend_table\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": false,
"display_type": "table"
}
]
+665 -112
View File
@@ -8,12 +8,10 @@
<title>RustProxy / Control room</title>
<link href="/favicon.svg" rel="icon" />
<link href="/assets/style___RUSTPROXY_ASSET_ID__.css" rel="stylesheet" />
<!-- One ordered JS payload: no race between module fetches and session/config API. -->
<script defer="" src="/assets/app___RUSTPROXY_ASSET_ID__.js"></script>
</head>
<body>
<!-- Visible even when the dashboard JavaScript fails to initialize. -->
<div class="app-startup" id="app-startup" role="status" aria-live="polite">
<div class="app-startup-card">
<div class="app-startup-brand" aria-hidden="true">r<span>/</span>p</div>
@@ -121,12 +119,30 @@
<h1 id="page-title">Traffic, in focus.</h1>
<button class="quiet overview-refresh" data-i18n="refreshNow" id="overview-refresh"
type="button" hidden>Refresh now</button>
<button class="quiet overview-layout-open" data-i18n="overviewLayoutEditorOpen"
id="overview-layout-open" type="button" hidden>Arrange cards visually</button>
<button class="outline" id="overview-layout-unlock" type="button" hidden
data-i18n="overviewLayoutUnlock" aria-pressed="false">Unlock layout</button>
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
<button class="primary" id="overview-inline-save" type="button"
data-i18n="overviewLayoutInlineSave">Save and lock</button>
<button class="quiet" id="overview-inline-cancel" type="button"
data-i18n="overviewLayoutInlineCancel">Cancel changes</button>
</div>
</div>
<p class="lead" id="page-description"></p>
</div>
</section>
<section class="panel" id="panel-overview">
<div class="stats-grid">
<p class="fine overview-inline-hint" id="overview-inline-hint" role="status" aria-live="polite"
hidden data-i18n="overviewLayoutInlineHint">Layout unlocked: reorder blocks within size groups,
hide with × and save or cancel.</p>
<details class="overview-inline-hidden" id="overview-inline-hidden" hidden>
<summary><span data-i18n="overviewLayoutInlineHidden">Hidden blocks</span> <span
id="overview-inline-hidden-count"></span></summary>
<div class="overview-inline-hidden-list" id="overview-inline-hidden-list"></div>
</details>
<div class="stats-grid dashboard-overview-grid" id="dashboard-overview-grid">
<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>
@@ -234,9 +250,457 @@
<small id="stream-dashboard-health-detail" data-i18n="streamWidgetUnavailable">No stream
data</small>
</article>
</div>
<div class="overview-grid">
<article class="card activity-card">
<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 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 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 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 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 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-chart" data-dashboard-widget="backend_capacity" hidden>
<span data-i18n="backendWidgetCapacity">Backend capacity</span><strong
id="backend-dashboard-capacity">-</strong>
<svg data-i18n-aria="backendWidgetCapacity" 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-capacity-line"
points="0,55 300,55"></polyline>
</svg><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 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 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 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 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 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 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 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-chart" data-dashboard-widget="extra_process_fds" hidden>
<span data-i18n="overviewExtraProcessFds">overviewExtraProcessFds</span><strong
id="overview-extra-extra_process_fds">-</strong>
<svg data-i18n-aria="overviewExtraProcessFds" 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_fds-line"
points="0,55 300,55"></polyline>
</svg><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 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 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 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 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-chart" data-dashboard-widget="extra_cache_hit_rate" hidden>
<span data-i18n="overviewExtraCacheHitRate">overviewExtraCacheHitRate</span><strong
id="overview-extra-extra_cache_hit_rate">-</strong>
<svg data-i18n-aria="overviewExtraCacheHitRate" 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_cache_hit_rate-line"
points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_cache_hit_rate-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_cache_memory" hidden>
<span data-i18n="overviewExtraCacheMemory">overviewExtraCacheMemory</span><strong
id="overview-extra-extra_cache_memory">-</strong>
<svg data-i18n-aria="overviewExtraCacheMemory" 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_cache_memory-line"
points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_cache_memory-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_h2_share" hidden>
<span data-i18n="overviewExtraH2Share">overviewExtraH2Share</span><strong
id="overview-extra-extra_h2_share">-</strong>
<svg data-i18n-aria="overviewExtraH2Share" 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_h2_share-line"
points="0,55 300,55"></polyline>
</svg><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 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 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 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 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-chart" data-dashboard-widget="extra_h1_share" hidden>
<span data-i18n="overviewExtraH1Share">HTTP/1.1 share</span><strong
id="overview-extra-extra_h1_share">-</strong>
<svg viewBox="0 0 300 60" role="img" aria-label="HTTP/1.1 share">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_h1_share-line"
points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_h1_share-detail"
data-i18n="overviewExtraRecentSamples">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_h3_share" hidden>
<span data-i18n="overviewExtraH3Share">HTTP/3 share</span><strong
id="overview-extra-extra_h3_share">-</strong>
<svg viewBox="0 0 300 60" role="img" aria-label="HTTP/3 share">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_h3_share-line"
points="0,55 300,55"></polyline>
</svg><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 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 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>
<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 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 37H600 M0 75H600 M0 113H600"></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 class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="System and process CPU">
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></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 class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Process RAM and cache memory">
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></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 class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Cache hits / misses per second">
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></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 class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Connections and WebSocket tunnels">
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></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 class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Backend availability over time">
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></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 class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Response and backend probe latency">
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></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 activity-card" data-dashboard-widget="traffic_chart">
<div class="card-heading">
<div><span class="small-label" data-i18n="liveTraffic">LIVE TRAFFIC</span>
<h2 data-i18n="requestsPerSecond">Requests per second</h2>
@@ -269,7 +733,7 @@
<div class="chart-footer"><span data-i18n="lastMinute">Recent samples / resets on
reload</span><span id="chart-peak">peak 0 req/s</span></div>
</article>
<article class="card system-card">
<article class="card system-card" data-dashboard-widget="system_chart">
<div class="card-heading">
<div><span class="small-label" data-i18n="systemLoad">SYSTEM LOAD</span>
<h2><span id="system-cpu">-</span> <small>CPU</small></h2>
@@ -289,7 +753,7 @@
<div class="chart-footer"><span data-i18n="systemUptime">System uptime</span><span
id="system-uptime">-</span></div>
</article>
<article class="card connection-card">
<article class="card connection-card" data-dashboard-widget="connection_chart">
<div class="card-heading">
<div><span class="small-label" data-i18n="connectionActivity">CONNECTION ACTIVITY</span>
<h2><span id="connection-current">0</span> <small data-i18n="connections">Open
@@ -312,9 +776,7 @@
<div class="chart-footer"><span><span data-i18n="backends">Backends</span>: <b
id="connection-backends">0</b></span></div>
</article>
</div>
<div class="diagnostics-grid">
<article class="card">
<article class="card" data-dashboard-widget="cache_diagnostics">
<div class="mini-heading">
<h3 data-i18n="cacheOverview">RAM cache</h3><span class="pill" id="cache-state">-</span>
</div>
@@ -409,7 +871,7 @@
</div>
</div>
</article>
<article class="card">
<article class="card" data-dashboard-widget="process_diagnostics">
<div class="mini-heading">
<h3 data-i18n="processDiagnostics">Process</h3>
<div class="mini-heading-actions">
@@ -521,7 +983,7 @@
</div>
</div>
</article>
<article class="card">
<article class="card" data-dashboard-widget="network_diagnostics">
<div class="mini-heading">
<h3 data-i18n="networkDiagnostics">Network and responses</h3><span class="meta"
data-i18n="hostScope">OS scope</span>
@@ -581,106 +1043,117 @@
</div>
</div>
</article>
</div>
<article class="card pool-card" id="backend-diagnostics-card">
<div class="card-heading backend-card-heading">
<h2 data-i18n="vhHealthTitle">Backends</h2>
<div class="backend-table-tools">
<label class="sr-only" data-i18n="backendSearch" for="pool-search">Search
backends</label>
<input autocomplete="off" data-i18n-aria="backendSearch"
data-i18n-placeholder="backendSearchPlaceholder" id="pool-search" type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="pool-sort">
<option data-i18n="vhVhost" value="vhost">Vhost</option>
<option data-i18n="backend" value="backend">Backend</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="vhActive" value="active">Active</option>
<option data-i18n="vhRequests" value="requests">Requests</option>
<option data-i18n="vhErrors" value="errors">Errors</option>
<option data-i18n="vhLatency" value="latency">Latency</option>
<option data-i18n="vhTraffic" value="traffic">Traffic/s</option>
<option data-i18n="vhClientGeneration" value="generation">Client gen.</option>
<option data-i18n="vhGenerationRequests" value="generationRequests">Gen.
requests</option>
<option data-i18n="vhGenerationAge" value="generationAge">Gen. age</option>
<option data-i18n="vhClientRotations" value="rotations">Rotations</option>
<option data-i18n="vhLastFailure" value="lastFailure">Last failure</option>
<option data-i18n="vhLastRecovery" value="lastRecovery">Last recovery</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="pool-sort-direction" type="button">↑</button>
<details class="backend-column-picker">
<summary class="quiet" data-i18n="backendColumns">Columns</summary>
<div class="backend-column-options">
<label class="check"><input checked data-pool-column-toggle="vhost"
type="checkbox" /><span data-i18n="vhVhost">Vhost</span></label>
<label class="check"><input checked data-pool-column-toggle="backend"
type="checkbox" /><span data-i18n="backend">Backend</span></label>
<label class="check"><input checked data-pool-column-toggle="status"
type="checkbox" /><span data-i18n="status">Status</span></label>
<label class="check"><input checked data-pool-column-toggle="active"
type="checkbox" /><span data-i18n="vhActive">Active</span></label>
<label class="check"><input checked data-pool-column-toggle="requests"
type="checkbox" /><span data-i18n="vhRequests">Requests</span></label>
<label class="check"><input checked data-pool-column-toggle="errors"
type="checkbox" /><span data-i18n="vhErrors">Errors</span></label>
<label class="check"><input checked data-pool-column-toggle="latency"
type="checkbox" /><span data-i18n="vhLatency">Latency</span></label>
<label class="check"><input checked data-pool-column-toggle="traffic"
type="checkbox" /><span data-i18n="vhTraffic">Traffic/s</span></label>
<label class="check"><input checked data-pool-column-toggle="generation"
type="checkbox" /><span data-i18n="vhClientGeneration">Client
gen.</span></label>
<label class="check"><input checked data-pool-column-toggle="generationRequests"
type="checkbox" /><span data-i18n="vhGenerationRequests">Gen.
requests</span></label>
<label class="check"><input checked data-pool-column-toggle="generationAge"
type="checkbox" /><span data-i18n="vhGenerationAge">Gen.
age</span></label>
<label class="check"><input checked data-pool-column-toggle="rotations"
type="checkbox" /><span
data-i18n="vhClientRotations">Rotations</span></label>
<label class="check"><input checked data-pool-column-toggle="lastFailure"
type="checkbox" /><span data-i18n="vhLastFailure">Last
failure</span></label>
<label class="check"><input checked data-pool-column-toggle="lastRecovery"
type="checkbox" /><span data-i18n="vhLastRecovery">Last
recovery</span></label>
</div>
</details>
<button class="quiet" data-i18n="vhManage" id="pool-open" type="button">Manage
vhosts</button>
<article class="card pool-card" id="backend-diagnostics-card"
data-dashboard-widget="backend_table">
<div class="card-heading backend-card-heading">
<h2 data-i18n="vhHealthTitle">Backends</h2>
<div class="backend-table-tools">
<label class="sr-only" data-i18n="backendSearch" for="pool-search">Search
backends</label>
<input autocomplete="off" data-i18n-aria="backendSearch"
data-i18n-placeholder="backendSearchPlaceholder" id="pool-search"
type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="pool-sort">
<option data-i18n="vhVhost" value="vhost">Vhost</option>
<option data-i18n="backend" value="backend">Backend</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="vhActive" value="active">Active</option>
<option data-i18n="vhRequests" value="requests">Requests</option>
<option data-i18n="vhErrors" value="errors">Errors</option>
<option data-i18n="vhLatency" value="latency">Latency</option>
<option data-i18n="vhTraffic" value="traffic">Traffic/s</option>
<option data-i18n="vhClientGeneration" value="generation">Client gen.
</option>
<option data-i18n="vhGenerationRequests" value="generationRequests">Gen.
requests</option>
<option data-i18n="vhGenerationAge" value="generationAge">Gen. age</option>
<option data-i18n="vhClientRotations" value="rotations">Rotations</option>
<option data-i18n="vhLastFailure" value="lastFailure">Last failure</option>
<option data-i18n="vhLastRecovery" value="lastRecovery">Last recovery
</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="pool-sort-direction" type="button">↑</button>
<details class="backend-column-picker">
<summary class="quiet" data-i18n="backendColumns">Columns</summary>
<div class="backend-column-options">
<label class="check"><input checked data-pool-column-toggle="vhost"
type="checkbox" /><span data-i18n="vhVhost">Vhost</span></label>
<label class="check"><input checked data-pool-column-toggle="backend"
type="checkbox" /><span data-i18n="backend">Backend</span></label>
<label class="check"><input checked data-pool-column-toggle="status"
type="checkbox" /><span data-i18n="status">Status</span></label>
<label class="check"><input checked data-pool-column-toggle="active"
type="checkbox" /><span data-i18n="vhActive">Active</span></label>
<label class="check"><input checked data-pool-column-toggle="requests"
type="checkbox" /><span
data-i18n="vhRequests">Requests</span></label>
<label class="check"><input checked data-pool-column-toggle="errors"
type="checkbox" /><span data-i18n="vhErrors">Errors</span></label>
<label class="check"><input checked data-pool-column-toggle="latency"
type="checkbox" /><span data-i18n="vhLatency">Latency</span></label>
<label class="check"><input checked data-pool-column-toggle="traffic"
type="checkbox" /><span
data-i18n="vhTraffic">Traffic/s</span></label>
<label class="check"><input checked data-pool-column-toggle="generation"
type="checkbox" /><span data-i18n="vhClientGeneration">Client
gen.</span></label>
<label class="check"><input checked
data-pool-column-toggle="generationRequests" type="checkbox" /><span
data-i18n="vhGenerationRequests">Gen.
requests</span></label>
<label class="check"><input checked data-pool-column-toggle="generationAge"
type="checkbox" /><span data-i18n="vhGenerationAge">Gen.
age</span></label>
<label class="check"><input checked data-pool-column-toggle="rotations"
type="checkbox" /><span
data-i18n="vhClientRotations">Rotations</span></label>
<label class="check"><input checked data-pool-column-toggle="lastFailure"
type="checkbox" /><span data-i18n="vhLastFailure">Last
failure</span></label>
<label class="check"><input checked data-pool-column-toggle="lastRecovery"
type="checkbox" /><span data-i18n="vhLastRecovery">Last
recovery</span></label>
</div>
</details>
<button class="quiet" data-i18n="vhManage" id="pool-open" type="button">Manage
vhosts</button>
</div>
</div>
</div>
<div class="table-scroll">
<table class="pool-table">
<thead>
<tr>
<th data-i18n="vhVhost" data-pool-column="vhost">Vhost</th>
<th data-i18n="backend" data-pool-column="backend">Backend</th>
<th data-i18n="status" data-pool-column="status">Status</th>
<th data-i18n="vhActive" data-pool-column="active">Active</th>
<th data-i18n="vhRequests" data-pool-column="requests">Requests</th>
<th data-i18n="vhErrors" data-pool-column="errors">Errors</th>
<th data-i18n="vhLatency" data-pool-column="latency">Latency</th>
<th data-i18n="vhTraffic" data-pool-column="traffic">Traffic/s</th>
<th data-i18n="vhClientGeneration" data-pool-column="generation">Client gen.
</th>
<th data-i18n="vhGenerationRequests" data-pool-column="generationRequests">Gen.
requests</th>
<th data-i18n="vhGenerationAge" data-pool-column="generationAge">Gen. age</th>
<th data-i18n="vhClientRotations" data-pool-column="rotations">Rotations</th>
<th data-i18n="vhLastFailure" data-pool-column="lastFailure">Last failure</th>
<th data-i18n="vhLastRecovery" data-pool-column="lastRecovery">Last recovery
</th>
</tr>
</thead>
<tbody id="pool-rows"></tbody>
</table>
</div>
<p class="fine" data-i18n="vhHealthEmpty" id="pool-empty">No backend diagnostics yet.</p>
</article>
<div class="table-scroll">
<table class="pool-table">
<thead>
<tr>
<th data-i18n="vhVhost" data-pool-column="vhost">Vhost</th>
<th data-i18n="backend" data-pool-column="backend">Backend</th>
<th data-i18n="status" data-pool-column="status">Status</th>
<th data-i18n="vhActive" data-pool-column="active">Active</th>
<th data-i18n="vhRequests" data-pool-column="requests">Requests</th>
<th data-i18n="vhErrors" data-pool-column="errors">Errors</th>
<th data-i18n="vhLatency" data-pool-column="latency">Latency</th>
<th data-i18n="vhTraffic" data-pool-column="traffic">Traffic/s</th>
<th data-i18n="vhClientGeneration" data-pool-column="generation">Client gen.
</th>
<th data-i18n="vhGenerationRequests" data-pool-column="generationRequests">
Gen.
requests</th>
<th data-i18n="vhGenerationAge" data-pool-column="generationAge">Gen. age
</th>
<th data-i18n="vhClientRotations" data-pool-column="rotations">Rotations
</th>
<th data-i18n="vhLastFailure" data-pool-column="lastFailure">Last failure
</th>
<th data-i18n="vhLastRecovery" data-pool-column="lastRecovery">Last recovery
</th>
</tr>
</thead>
<tbody id="pool-rows"></tbody>
</table>
</div>
<p class="fine" data-i18n="vhHealthEmpty" id="pool-empty">No backend diagnostics yet.</p>
</article>
</div>
</section>
<section class="panel" hidden="" id="panel-vhosts">
<p class="notice fine" data-i18n="vhIntro">Each vhost owns its bound IPs, paths and backends.
@@ -1899,7 +2372,10 @@
<div class="appearance-settings-grid">
<label><span data-i18n="theme">Theme</span><select id="ui-theme">
<option data-i18n="themeDark" value="dark">Dark</option>
<option data-i18n="themeUltraDark" value="ultra_dark">Ultra dark ·
near-black</option>
<option data-i18n="themeLight" value="light">Light</option>
<option data-i18n="themeCream" value="cream">Warm cream</option>
</select></label>
<label><span data-i18n="language">Language</span><select aria-label="Language"
data-i18n-aria="language" id="language"></select></label>
@@ -1922,6 +2398,18 @@
</select><small class="fine" data-i18n="buttonStyleHelp">Normal uses the
refreshed controls; Compact restores the smaller source-style buttons
across all actions and dialog tabs.</small></label>
<label><span data-i18n="cornerStyle">Corner rounding</span><select
id="ui-corner-style">
<option data-i18n="cornerStyleDefault" value="default">Original
(theme-independent)</option>
<option data-i18n="cornerStyleSquare" value="square">Square</option>
<option data-i18n="cornerStyleSubtle" value="subtle">Subtle</option>
<option data-i18n="cornerStyleRounded" value="rounded">Rounded</option>
<option data-i18n="cornerStyleExtra" value="extra">Extra rounded
</option>
</select><small class="fine" data-i18n="cornerStyleHelp">Controls corners
throughout the interface independently of the theme. Original keeps the
previous geometry.</small></label>
</div>
</section>
<section class="appearance-section">
@@ -2002,6 +2490,10 @@
data-i18n="dashboardWidgets">Overview cards</span><small class="fine"
data-i18n="dashboardWidgetsHelp">Show, hide and reorder the telemetry
cards at the top of Overview.</small>
<button class="outline dashboard-layout-open-settings" type="button"
id="overview-layout-open-settings"
data-i18n="overviewLayoutEditorOpenSettings">Visual layout
editor</button>
<div class="dashboard-widget-list" id="dashboard-widget-list"></div>
<small class="fine" data-i18n="streamWidgetSettingsHelp">Stream cards are
opt-in and only change Dashboard presentation, not Stream routing or
@@ -3328,6 +3820,67 @@
type="button">Cancel</button></div>
</form>
</dialog>
<dialog aria-labelledby="overview-layout-dialog-title" aria-modal="true" class="audit-dialog overview-layout-dialog"
id="overview-layout-dialog">
<div class="window-heading">
<div><span class="small-label" data-i18n="dashboardWidgets">Overview cards</span>
<h2 id="overview-layout-dialog-title" data-i18n="overviewLayoutEditorTitle">Arrange Overview cards</h2>
</div>
</div>
<div class="overview-layout-body">
<p class="fine" data-i18n="overviewLayoutEditorHelp">Drag visible cards to change their order. Changes are
saved only when you select Save.</p>
<section aria-labelledby="overview-layout-visible-title">
<h3 id="overview-layout-visible-title" data-i18n="overviewLayoutEditorVisible">Visible cards</h3>
<div class="overview-layout-grid" id="overview-layout-visible" role="list"></div>
</section>
<section aria-labelledby="overview-layout-hidden-title">
<div class="overview-layout-section-heading">
<h3 id="overview-layout-hidden-title" data-i18n="overviewLayoutEditorHidden">Hidden cards</h3>
<div class="overview-layout-filters">
<select id="overview-layout-type-filter" data-i18n-aria="overviewLayoutTypeFilter"
aria-label="Filter blocks by type">
<option value="all" data-i18n="overviewBlockTypeAll">All block types</option>
<option value="value" data-i18n="overviewBlockTypeValue">Number</option>
<option value="value_chart" data-i18n="overviewBlockTypeValueChart">Number + chart</option>
<option value="chart" data-i18n="overviewBlockTypeChart">Chart</option>
<option value="panel" data-i18n="overviewBlockTypePanel">Diagnostics panel</option>
<option value="table" data-i18n="overviewBlockTypeTable">Table</option>
</select>
<input id="overview-layout-search" type="search"
data-i18n-placeholder="overviewLayoutEditorSearch" aria-label="Find hidden card"
data-i18n-aria="overviewLayoutEditorSearch" autocomplete="off" />
</div>
</div>
<div class="overview-layout-hidden-list" id="overview-layout-hidden" role="list"></div>
</section>
</div>
<div class="overview-layout-footer">
<p id="overview-layout-message" role="alert" aria-live="polite"></p>
<button class="quiet" id="overview-layout-default" type="button"
data-i18n="overviewLayoutDefaultPreview">Default view</button>
<button class="outline" id="overview-layout-reset" type="button" data-i18n="overviewLayoutReset">Reset
layout</button>
<button class="quiet" id="overview-layout-cancel" type="button"
data-i18n="overviewLayoutEditorCancel">Cancel</button>
<button class="primary" id="overview-layout-save" type="button" data-i18n="overviewLayoutEditorSave">Save
layout</button>
</div>
</dialog>
<dialog aria-labelledby="overview-reset-confirm-title" aria-modal="true" class="audit-dialog overview-reset-confirm"
id="overview-reset-confirm">
<div class="window-heading">
<h2 id="overview-reset-confirm-title" data-i18n="overviewLayoutResetTitle">Reset dashboard layout?</h2>
</div>
<p data-i18n="overviewLayoutResetConfirm">This restores the default order and visibility of all Overview blocks.
Other settings remain unchanged.</p>
<div class="overview-layout-footer">
<button class="quiet" id="overview-reset-cancel" type="button"
data-i18n="overviewLayoutEditorCancel">Cancel</button>
<button class="primary" id="overview-reset-apply" type="button" data-i18n="overviewLayoutResetApply">Reset
and save</button>
</div>
</dialog>
<dialog aria-labelledby="tunnels-dialog-title" class="audit-dialog" id="tunnels-dialog">
<div class="window-heading">
<div><span class="small-label">WEBSOCKET / CONNECT</span>
+3 -1
View File
@@ -1,8 +1,10 @@
"use strict";
(() => {
const node = id => document.getElementById(id);
const themes = new Set(["dark", "light"]);
const themes = new Set(["dark", "ultra_dark", "light", "cream"]);
const corners = new Set(["default", "square", "subtle", "rounded", "extra"]);
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
try { const style = localStorage.getItem("rp-corner-style"); document.documentElement.dataset.cornerStyle = corners.has(style) ? style : "default"; } catch { document.documentElement.dataset.cornerStyle = "default"; }
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/logs", "/settings"]);
const settingsTabs = new Set(["appearance", "listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "geoip", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
+10 -3
View File
@@ -2,7 +2,12 @@
(() => {
const buttons = [...document.querySelectorAll("[data-settings-tab]")];
const search = $("settings-search"), searchStatus = $("settings-search-status");
const defaultTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "listeners";
const legacyTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "appearance";
// Explicit URL tabs win. Otherwise reopen the last chosen Settings tab,
// falling back to Appearance for new browsers and invalid stored values.
let rememberedTab = null;
try { rememberedTab = localStorage.getItem("rp-settings-last-tab"); } catch { }
const defaultTab = legacyTab === "manual-config" ? legacyTab : (rememberedTab || "appearance");
const requestedTab = typeof location !== "undefined" && location.pathname === "/logging"
? "logging"
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
@@ -19,7 +24,8 @@
host && (!pinned || host.replace(/\.$/, "").toLowerCase() !== pinned));
}
const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab;
let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab;
const validTab = tab => buttons.some(button => button.dataset.settingsTab === tab);
let active = validTab(normalizedRequestedTab) ? normalizedRequestedTab : (validTab(defaultTab) ? defaultTab : "appearance");
function applySettingsSearch() {
const query = String(search?.value || "").trim().toLocaleLowerCase();
const matches = [];
@@ -41,6 +47,7 @@
const target = buttons.find(button => button.dataset.settingsTab === name); if (!target) return;
if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
active = name;
if (persist) { rememberedTab = name; try { localStorage.setItem("rp-settings-last-tab", name); } catch { } }
if (typeof window !== "undefined") window.rustProxyRefresh?.setSettingsTab(name);
for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); }
document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name);
@@ -277,6 +284,6 @@
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
});
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select(validTab(rememberedTab) ? rememberedTab : "appearance", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
+521 -5
View File
@@ -132,6 +132,135 @@ html[data-theme="light"] {
--scrollbar-thumb-hover: #879684
}
/* Optional AMOLED palette: near-black surfaces, readable borders and restrained
mint/coral emphasis. Does not change the established dark appearance. */
html[data-theme="ultra_dark"] {
color-scheme: dark;
--bg: #030404;
--panel: #0b0d0e;
--panel2: #121516;
--line: #292f30;
--muted: #a4afb0;
--text: #f1f4f2;
--accent: #93dfc8;
--logo-accent: #f69b74;
--logo-secondary: #cfe0ac;
--green: #b1dcb0;
--red: #ff9899;
--sidebar-bg: #000000;
--brand-secondary: #a9cfb5;
--nav-active-bg: #172422;
--nav-hover-bg: #121819;
--text-subtle: #879695;
--separator: #526261;
--border-hover: #7c9f96;
--field-bg: #090b0c;
--field-line: #445352;
--placeholder: #879593;
--row-line: #202627;
--outline-line: #79968f;
--chart-grid: #303839;
--code-bg: #0e1313;
--code-alt-bg: #101a1d;
--code-alt-text: #d5eae9;
--nav-selected-bg: #213730;
--accent-ink: #071814;
--accent-hover: #b6f2dd;
--selected-ink: #071814;
--notice-text: #e6c99c;
--notice-line: #66533a;
--notice-bg: #201a11;
--pill-line: #625544;
--pill-live-line: #506c59;
--toast-bg: #bcf3da;
--toast-text: #10221a;
--toast-line: #d8ffe8;
--toast-error-bg: #ffd5d0;
--toast-error-line: #ff9c96;
--toast-error-text: #472625;
--danger-line: #754245;
--danger-line-strong: #965257;
--warn-line: #89754c;
--blue: #8bbdfb;
--transform-chart: #cfaeff;
--backdrop: rgb(0 0 0 / .86);
--backdrop-soft: rgb(0 0 0 / .77);
--shadow-mid: rgb(0 0 0 / .64);
--shadow-strong: rgb(0 0 0 / .84);
--neutral-soft: rgb(132 171 158 / .045);
--scrollbar-track: #060808;
--scrollbar-thumb: #364243;
--scrollbar-thumb-hover: #617373;
}
/* Warm, muted ivory. No pure-white canvas; numeric values and charts retain
clear contrast against their slightly lighter, neutral card surfaces. */
html[data-theme="cream"] {
color-scheme: light;
--bg: #f2ede3;
--panel: #fcf9f2;
--panel2: #eae3d6;
--line: #d5cabb;
--muted: #6b6257;
--text: #302c27;
--accent: #8c5942;
--green: #5e7655;
--red: #a54645;
--sidebar-bg: #eae2d5;
--brand-secondary: #668367;
--nav-active-bg: #e0d3c0;
--nav-hover-bg: #e6dccc;
--text-subtle: #786e61;
--separator: #afa494;
--border-hover: #968776;
--field-bg: #fffcf7;
--field-line: #baad9a;
--placeholder: #807466;
--row-line: #e2d9cb;
--outline-line: #9c8d7c;
--chart-grid: #ddd3c4;
--code-bg: #f0e8da;
--code-alt-bg: #e7ebe9;
--code-alt-text: #364f4e;
--nav-selected-bg: #d9cbb7;
--accent-ink: #ffffff;
--accent-hover: #774632;
--selected-ink: #ffffff;
--notice-text: #765e40;
--notice-line: #d9c3a1;
--notice-bg: #f8efdf;
--pill-line: #c2a78d;
--pill-live-line: #b0bd9d;
--toast-bg: #d6ead2;
--toast-text: #263d29;
--toast-line: #b8d5b6;
--toast-error-bg: #f8ded7;
--toast-error-line: #dca99c;
--toast-error-text: #60352f;
--danger-line: #d2a29a;
--danger-line-strong: #b97870;
--warn-line: #b49c6b;
--blue: #4d7292;
--transform-chart: #83659a;
--backdrop: rgb(37 27 16 / .44);
--backdrop-soft: rgb(47 35 21 / .48);
--shadow-mid: rgb(64 49 34 / .13);
--shadow-strong: rgb(64 49 34 / .24);
--neutral-soft: rgb(104 91 69 / .045);
--scrollbar-track: #ece4d7;
--scrollbar-thumb: #b5a997;
--scrollbar-thumb-hover: #978a76;
}
/* Theme palettes change color only; corners are controlled independently. */
html[data-theme="ultra_dark"] :is(.card, .stat, dialog) {
box-shadow: 0 6px 24px rgb(0 0 0 / .18);
}
html[data-theme="cream"] :is(.card, .stat, dialog) {
box-shadow: 0 6px 20px rgb(78 58 38 / .055);
}
* {
box-sizing: border-box;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
@@ -198,7 +327,7 @@ html:not(.app-loading) .app-startup {
}
.app-startup-brand span {
color: var(--accent, #65cfa2)
color: var(--logo-secondary, var(--accent, #65cfa2))
}
.app-startup-spinner {
@@ -361,8 +490,8 @@ h2 {
.brand-mark {
font: 700 33px var(--mono);
border: 1px solid var(--accent);
color: var(--accent);
border: 1px solid var(--logo-accent, var(--accent));
color: var(--logo-accent, var(--accent));
place-content: center;
position: relative
}
@@ -371,7 +500,7 @@ h2 {
font-size: 22px;
left: 29px;
top: 19px;
color: var(--brand-secondary)
color: var(--logo-secondary, var(--brand-secondary))
}
.edition {
@@ -10175,4 +10304,391 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
#panel-overview .stats-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
}
/* Optional HTTP backend mode distribution (configured modes, not negotiated ALPN). */
#panel-overview .backend-mode-bar {
display: flex;
height: 7px;
width: 100%;
overflow: hidden;
gap: 2px;
border-radius: 5px;
background: var(--line);
margin: 7px 0 5px;
}
#panel-overview .backend-mode-bar span { min-width: 0; }
#panel-overview .backend-mode-h1 { background: var(--traffic-h1); }
#panel-overview .backend-mode-auto { background: var(--traffic-h2); }
#panel-overview .backend-mode-compat { background: var(--traffic-h2); opacity: .5; }
#panel-overview .backend-mode-h2 { background: var(--traffic-h3); }
#panel-overview .backend-mode-bar[aria-label] { background-clip: padding-box; }
.vh-origin-protocol-info { color: var(--muted); }
.vh-origin-protocol-warning { color: var(--muted); }
/* Overview arrangement dialog: visual clones never participate in metric updates. */
.overview-layout-dialog {
width: min(1180px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: auto;
}
.overview-layout-dialog > .window-heading {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.overview-layout-body { display: grid; gap: 16px; padding: 14px; }
.overview-layout-body h3 { font-size: 14px; margin: 0 0 10px; }
.overview-layout-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--ui-gap, 10px); }
.overview-layout-tile { min-width: 0; border: 1px solid var(--line); background: var(--panel); cursor: grab; }
.overview-layout-tile:active { cursor: grabbing; }
.overview-layout-tile.is-dragging { opacity: .4; }
.overview-layout-tile.is-drop-target { outline: 2px solid var(--accent); outline-offset: -2px; }
.overview-layout-tile-toolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border-bottom: 1px solid var(--line); }
.overview-layout-tile-toolbar button { min-height: 28px; min-width: 28px; padding: 3px 7px; }
.overview-layout-grip { padding: 0 5px; color: var(--muted); user-select: none; }
.overview-layout-hide { margin-inline-start: auto; font-size: 11px; }
.overview-layout-grid .stat { border: 0; padding: 12px; min-height: 98px; pointer-events: none; }
.overview-layout-grid .stat > span { font-size: 11px; }
.overview-layout-grid .stat strong { font-size: 24px; margin-top: 7px; overflow-wrap: anywhere; }
.overview-layout-grid .stat small { display: block; font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; }
.overview-layout-grid .stat svg { width: 100%; height: 53px; }
.overview-layout-grid .stat-chart-legend { display: flex; flex-wrap: wrap; gap: 8px; }
.overview-layout-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.overview-layout-section-heading input { max-width: 230px; }
.overview-layout-hidden-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.overview-layout-hidden-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--line); padding: 8px; background: var(--panel); min-width: 0; }
.overview-layout-hidden-item span { font-size: 12px; min-width: 0; overflow-wrap: anywhere; }
.overview-layout-hidden-item button { flex-shrink: 0; }
.overview-layout-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px; border-top: 1px solid var(--line); background: var(--bg); z-index: 2; }
.overview-layout-footer p { margin: 0 auto 0 0; font-size: 11px; overflow-wrap: anywhere; }
.dashboard-layout-open-settings { margin: 5px 0; justify-self: start; }
@media (max-width: 850px) {
.overview-layout-grid, .overview-layout-hidden-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 550px) {
.overview-layout-grid, .overview-layout-hidden-list { grid-template-columns: minmax(0, 1fr); }
.overview-layout-section-heading { align-items: stretch; flex-direction: column; }
.overview-layout-section-heading input { max-width: none; width: 100%; }
.overview-layout-footer { flex-wrap: wrap; }
.overview-layout-footer p { width: 100%; }
}
/* One rearrangeable Overview canvas. Existing large charts, diagnostics and the
backend table keep their original nodes/IDs and their data update handlers. */
#panel-overview .dashboard-overview-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--ui-gap);
align-items: stretch;
}
#panel-overview .dashboard-overview-grid > [data-dashboard-widget] { min-width: 0; }
#panel-overview .dashboard-overview-grid > [hidden] { display: none !important; }
#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: span 2; }
#panel-overview .dashboard-overview-grid > .pool-card { grid-column: 1 / -1; margin-bottom: 0; }
#panel-overview .dashboard-overview-grid > .activity-card svg { min-width: 0; }
@media (max-width: 1200px) {
#panel-overview .dashboard-overview-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
#panel-overview .dashboard-overview-grid > .stat { grid-column: span 2; }
#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: span 2; }
}
@media (max-width: 850px) {
#panel-overview .dashboard-overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#panel-overview .dashboard-overview-grid > .stat { grid-column: span 1; }
#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: 1 / -1; }
}
@media (max-width: 550px) {
#panel-overview .dashboard-overview-grid { grid-template-columns: minmax(0, 1fr); }
#panel-overview .dashboard-overview-grid > .stat { grid-column: 1; }
}
.overview-layout-tile.is-panel { grid-column: span 2; }
.overview-layout-grid .overview-layout-panel-preview {
display: grid;
gap: 7px;
padding: 12px;
min-height: 98px;
background: var(--panel);
pointer-events: none;
}
.overview-layout-panel-preview > strong { font-size: 13px; }
.overview-layout-panel-preview svg { width: 100%; height: 80px; }
.overview-layout-panel-preview small { color: var(--muted); }
.overview-layout-footer #overview-layout-default { margin-right: auto; }
.overview-reset-confirm { width: min(460px, calc(100vw - 24px)); padding: 16px; }
.overview-reset-confirm > p { line-height: 1.5; }
@media (max-width: 850px) { .overview-layout-tile.is-panel { grid-column: span 2; } }
@media (max-width: 550px) { .overview-layout-tile.is-panel { grid-column: span 1; } }
/* Overview block kinds come from the same server-validated registry as the
saved order. Labels distinguish numbers, plots, tables and diagnostic panels. */
.overview-widget-type {
display: inline-block;
width: fit-content;
max-width: 100%;
padding: 2px 6px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--panel2);
color: var(--muted);
font-size: 10px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.dashboard-widget-setting .dashboard-widget-toggle { flex-wrap: wrap; }
.dashboard-widget-setting .overview-widget-type { margin-left: 15px; }
.overview-layout-tile-toolbar .overview-widget-type { margin-left: 3px; }
.overview-layout-hidden-item .overview-widget-type { flex: 0 0 auto; }
.overview-layout-filters { display: flex; flex-wrap: wrap; gap: 8px; }
.overview-layout-filters select, .overview-layout-filters input { width: min(220px, 100%); max-width: 100%; }
/* Inline editing of the actual Overview blocks; live charts and original DOM
IDs remain in place. Dragging is enabled only on the explicit grip button. */
.overview-inline-actions { display: inline-flex; gap: 7px; align-items: center; flex-wrap: wrap; }
.overview-inline-actions[hidden], .overview-inline-hint[hidden] { display: none !important; }
.overview-inline-hint { margin: 0 0 10px; }
#panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] {
position: relative;
outline: 1px dashed var(--accent);
outline-offset: -2px;
padding-top: 66px;
}
#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-dragging { opacity: .45; }
#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-drop-before {
box-shadow: inset 4px 0 var(--accent);
}
#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-drop-after {
box-shadow: inset -4px 0 var(--accent);
}
.overview-inline-toolbar {
position: absolute;
top: 3px;
left: 4px;
right: 4px;
z-index: 4;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
pointer-events: auto;
}
#panel-overview .overview-inline-toolbar button {
min-width: 28px;
min-height: 27px;
padding: 2px 7px;
font-size: 12px;
}
#panel-overview .overview-inline-toolbar .overview-inline-grip { cursor: grab; user-select: none; }
#panel-overview .overview-inline-toolbar .overview-inline-grip:active { cursor: grabbing; }
#panel-overview .overview-inline-toolbar .overview-widget-type { margin-left: auto; }
@media (max-width: 600px) {
.overview-layout-hidden-item { flex-wrap: wrap; }
.overview-layout-filters select, .overview-layout-filters input { width: 100%; }
#panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { padding-top: 68px; }
}
/* Direct Overview editing: hide is reversible until Save and remains
recoverable later from the block selector or the compact hidden shelf. */
#panel-overview .overview-inline-toolbar .overview-inline-hide {
color: var(--red);
border: 1px solid var(--danger-line);
background: var(--panel);
/* Use the same .quiet radius/corner preset as the arrow buttons. */
font-size: 18px;
line-height: 1;
}
#panel-overview .overview-inline-toolbar .overview-inline-hide:hover,
#panel-overview .overview-inline-toolbar .overview-inline-hide:focus-visible {
border-color: var(--danger-line-strong);
background: var(--notice-bg);
}
.overview-inline-hidden {
margin: 0 0 10px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
color: var(--text);
}
.overview-inline-hidden > summary {
padding: 9px 12px;
cursor: pointer;
color: var(--muted);
font-size: 12px;
}
.overview-inline-hidden > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.overview-inline-hidden-list {
display: flex;
flex-wrap: wrap;
gap: 7px;
max-height: min(30vh, 240px);
overflow-y: auto;
padding: 10px 12px;
border-top: 1px solid var(--line);
}
.overview-inline-hidden-item {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
padding: 4px 7px;
border: 1px solid var(--line);
border-radius: 7px;
color: var(--text);
background: var(--panel2);
font-size: 12px;
}
.overview-inline-hidden-item > span:not(.overview-widget-type) { overflow-wrap: anywhere; }
.overview-inline-hidden-item > button {
flex: 0 0 auto;
min-width: 27px;
min-height: 27px;
padding: 1px 7px;
border-radius: 5px;
border: 1px solid var(--field-line);
color: var(--green);
background: var(--panel);
}
.overview-inline-hidden-item > button:hover { border-color: var(--accent); }
@media (max-width: 600px) {
#panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { padding-top: 82px; }
.overview-inline-hidden-item { max-width: 100%; }
}
/* The original fixed Overview grid, also used for personalized layouts.
Metrics, wide charts, diagnostic panels and tables are separate rows;
every tile in a row stretches to the SAME height. This intentionally
favors symmetry over masonry or floating short cards beside tall panels.
Existing density controls define the identical horizontal/vertical gap. */
#panel-overview { container: overview-canvas / inline-size; }
#panel-overview .dashboard-overview-grid.is-aligned {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: auto;
align-items: stretch;
align-content: start;
gap: var(--ui-gap);
}
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget] {
grid-column: auto / span 2;
grid-row: auto;
align-self: stretch;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
margin-block: 0;
position: relative;
}
#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: auto / span 4; }
#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-wide-chart { grid-column: auto / span 6; }
#panel-overview .dashboard-overview-grid.is-aligned > .pool-card { grid-column: 1 / -1; }
/* Each size category starts at the first track, even after drag/drop. */
#panel-overview .dashboard-overview-grid.is-aligned > .overview-row-start,
#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-row-start { grid-column-start: 1; }
@container overview-canvas (max-width: 1080px) {
#panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(6, minmax(0, 1fr)); }
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 2; }
#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: auto / span 3; }
#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-wide-chart,
#panel-overview .dashboard-overview-grid.is-aligned > .pool-card { grid-column: 1 / -1; }
}
@container overview-canvas (max-width: 700px) {
#panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 1; }
#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: 1 / -1; }
}
@container overview-canvas (max-width: 440px) {
#panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: minmax(0, 1fr); }
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: 1; }
}
/* Keep category boundaries intact at every responsive breakpoint. */
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].overview-row-start { grid-column-start: 1; }
/* Full-size optional charts: static SVG geometry and bounded historical data. */
#panel-overview .overview-wide-chart { padding: 15px; overflow: hidden; }
#panel-overview .overview-wide-chart .card-heading { min-width: 0; align-items: baseline; gap: 10px; }
#panel-overview .overview-wide-chart h2 { min-width: 0; overflow-wrap: anywhere; }
#panel-overview .overview-wide-summary { color: var(--accent); font: 500 12px var(--mono); white-space: nowrap; }
#panel-overview .overview-wide-svg { display: block; width: 100%; height: clamp(125px, 13vw, 190px); margin-block: 13px 9px; }
#panel-overview .overview-wide-svg .chart-grid { stroke: var(--line); fill: none; stroke-width: 1; opacity: .6; }
#panel-overview .overview-wide-series { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
#panel-overview .overview-wide-series-0 { stroke: var(--chart-accent); }
#panel-overview .overview-wide-series-1 { stroke: var(--chart-secondary); }
#panel-overview .overview-wide-series-2 { stroke: var(--chart-tertiary); }
#panel-overview .overview-wide-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; color: var(--muted); font-size: 11px; }
#panel-overview .overview-wide-legend > span { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
#panel-overview .overview-wide-legend i { width: 8px; height: 8px; border-radius: 100%; background: var(--chart-accent); }
#panel-overview .overview-wide-legend .overview-wide-key-1 { background: var(--chart-secondary); }
#panel-overview .overview-wide-legend .overview-wide-key-2 { background: var(--chart-tertiary); }
#panel-overview .overview-wide-legend b { font: 500 11px var(--mono); color: var(--text); }
/* Block metadata stays to the left of every action group. */
.dashboard-widget-setting .dashboard-widget-toggle { flex-wrap: nowrap; }
.dashboard-widget-setting .dashboard-widget-toggle .overview-widget-type { margin: 0 0 0 auto; flex: 0 0 auto; }
.dashboard-widget-setting .dashboard-widget-setting-actions { justify-self: end; flex-shrink: 0; }
.overview-layout-tile-toolbar { flex-wrap: wrap; }
.overview-layout-tile-toolbar .overview-widget-type { margin: 0; }
.overview-layout-tile-toolbar .overview-layout-controls { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; }
.overview-layout-hidden-item > button { margin-left: auto; }
.overview-layout-hidden-item > .overview-widget-type { margin: 0; }
#panel-overview .overview-inline-toolbar .overview-widget-type { margin: 0 auto 0 0; }
#panel-overview .overview-inline-toolbar .overview-inline-hide { margin-left: 0; }
.overview-inline-hidden-item > .overview-widget-type { margin-right: 0; }
.overview-inline-hidden-item > .overview-inline-restore { order: 3; margin-left: auto; }
/* Appearance selector: always show the block-kind label, independently from
the visibility button, with actions pinned to the right edge. */
.dashboard-widget-list {
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
}
.dashboard-widget-setting {
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
}
.dashboard-widget-setting .dashboard-widget-toggle {
width: 100%;
overflow: hidden;
min-width: 0;
flex-wrap: nowrap;
}
.dashboard-widget-setting .dashboard-widget-setting-label {
flex: 1 1 auto;
min-width: 0;
}
.dashboard-widget-setting > .overview-widget-type {
flex: none;
margin: 0;
white-space: nowrap;
overflow-wrap: normal;
}
.dashboard-widget-setting > .dashboard-widget-setting-actions {
justify-self: end;
white-space: nowrap;
}
/* Global shape preference. "default" applies NO overrides so existing dark,
light, cream and Ultra Dark keep the same established corner geometry.
Circular status dots, spinners, switches and chart markers retain their
semantic shape; underline tabs and navigation accent strips stay intact. */
html[data-corner-style="square"] { --rp-radius-surface: 0px; --rp-radius-control: 0px; }
html[data-corner-style="subtle"] { --rp-radius-surface: 5px; --rp-radius-control: 4px; }
html[data-corner-style="rounded"] { --rp-radius-surface: 12px; --rp-radius-control: 8px; }
html[data-corner-style="extra"] { --rp-radius-surface: 18px; --rp-radius-control: 12px; }
html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is(
.card, .stat, dialog, .login-card, .appearance-section, .settings-tabs,
.dashboard-widget-setting, .overview-layout-tile, .overview-layout-hidden-item,
.overview-inline-hidden-item, .notice, .toast,
.chart-style-choice, .stream-dashboard-card, .pool-card,
.file-log-advanced-parser, .table-container, .metric-info-popover
) { 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, .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; }
/* Appearance stays badge-free; badges remain useful in the visual editor. */
#settings-page-appearance .dashboard-widget-setting .overview-widget-type { display: none; }
+2 -1
View File
@@ -198,7 +198,8 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"], ["h2", "vhProtocolH2Strict"]] });
attachOriginProtocolDetails(main, protocol, url);
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
const controls = {};
+20
View File
@@ -0,0 +1,20 @@
function attachOriginProtocolDetails(main, protocol, url) {
const transportInfo = el("small", undefined, "fine vh-wide vh-origin-protocol-info");
const compatibility = el("small", undefined, "fine vh-wide vh-origin-protocol-warning");
compatibility.dataset.i18n = "vhProtocolCompatibilityWarning";
compatibility.textContent = t("vhProtocolCompatibilityWarning");
main.append(transportInfo, compatibility);
const explainProtocol = () => {
const https = /^https:\/\//i.test(url.value.trim());
const name = protocol.value === "http1" ? "vhProtocolHttp1Details" :
protocol.value === "auto" ? (https ? "vhProtocolAutoHttpsDetails" : "vhProtocolAutoHttpDetails") :
protocol.value === "http2" ? (https ? "vhProtocolLegacyHttp2HttpsDetails" : "vhProtocolLegacyHttp2HttpDetails") :
(https ? "vhProtocolH2HttpsDetails" : "vhProtocolH2HttpDetails");
transportInfo.dataset.i18n = name;
transportInfo.textContent = t(name);
compatibility.hidden = protocol.value !== "http2" || !https;
};
protocol.addEventListener("change", explainProtocol);
url.addEventListener("input", explainProtocol);
explainProtocol();
}