v0.26.3
This commit is contained in:
1 parent
32e6875980
commit
cf25423dde
68 files changed
+2314
-422
No files matched your search
@@ -399,6 +399,14 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"/docs/V0.25.1_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.26.3_IMPLEMENTATION.md",
|
||||
"technical",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.26.3_IMPLEMENTATION.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.26.2_IMPLEMENTATION.md",
|
||||
"technical",
|
||||
|
||||
+135
-17
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.26.2",
|
||||
"version": "0.26.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": [
|
||||
@@ -2912,23 +2912,12 @@
|
||||
"file_log_columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 11,
|
||||
"maxItems": 64,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"time",
|
||||
"event",
|
||||
"status",
|
||||
"client_ip",
|
||||
"vhost",
|
||||
"transport",
|
||||
"matched_by",
|
||||
"sni",
|
||||
"authority",
|
||||
"record",
|
||||
"source"
|
||||
]
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"default": [
|
||||
"time",
|
||||
@@ -2939,7 +2928,7 @@
|
||||
"record",
|
||||
"source"
|
||||
],
|
||||
"description": "Account-scoped Stored Logs explorer columns shown in the dashboard."
|
||||
"description": "Account-scoped Stored Logs explorer columns shown in the dashboard. Field names may be discovered dynamically from bounded JSON/JSONL records."
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
@@ -2950,6 +2939,135 @@
|
||||
"default": "dark",
|
||||
"description": "Account-scoped administration interface theme. Existing accounts and omitted values default to the current dark theme."
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"default": "auto",
|
||||
"description": "Account-scoped administration language id, or auto to follow the browser language when a matching language pack exists."
|
||||
},
|
||||
"ui_scale": {
|
||||
"type": "integer",
|
||||
"minimum": 70,
|
||||
"maximum": 125,
|
||||
"default": 100,
|
||||
"description": "Account-scoped administration workspace scale in percent."
|
||||
},
|
||||
"time_format": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"24h",
|
||||
"12h"
|
||||
],
|
||||
"default": "24h",
|
||||
"description": "Account-scoped administration timestamp display preference used by dashboard log and diagnostics views."
|
||||
},
|
||||
"chart_style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"default",
|
||||
"soft",
|
||||
"technical",
|
||||
"high_contrast",
|
||||
"neon",
|
||||
"monochrome",
|
||||
"warm",
|
||||
"cool"
|
||||
],
|
||||
"default": "default",
|
||||
"description": "Account-scoped visual presentation preset applied consistently to administration charts."
|
||||
},
|
||||
"button_style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"normal",
|
||||
"compact"
|
||||
],
|
||||
"default": "normal",
|
||||
"description": "Account-scoped administration button sizing. Normal uses the refreshed controls; Compact restores the smaller source-style button dimensions."
|
||||
},
|
||||
"table_density": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"normal",
|
||||
"compact",
|
||||
"ultra"
|
||||
],
|
||||
"default": "normal",
|
||||
"description": "Account-scoped table and log row density independent from whole-interface scale."
|
||||
},
|
||||
"dashboard_density": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"normal",
|
||||
"compact",
|
||||
"ultra"
|
||||
],
|
||||
"default": "compact",
|
||||
"description": "Account-scoped Overview telemetry-card density."
|
||||
},
|
||||
"dashboard_widgets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 12,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"requests",
|
||||
"active",
|
||||
"bandwidth",
|
||||
"errors",
|
||||
"cache",
|
||||
"memory",
|
||||
"websocket",
|
||||
"http2",
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol"
|
||||
]
|
||||
},
|
||||
"default": [
|
||||
"requests",
|
||||
"active",
|
||||
"bandwidth",
|
||||
"errors",
|
||||
"cache",
|
||||
"memory",
|
||||
"websocket",
|
||||
"http2",
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol"
|
||||
],
|
||||
"description": "Account-scoped order for configurable Overview telemetry cards."
|
||||
},
|
||||
"dashboard_hidden_widgets": {
|
||||
"type": "array",
|
||||
"maxItems": 12,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"requests",
|
||||
"active",
|
||||
"bandwidth",
|
||||
"errors",
|
||||
"cache",
|
||||
"memory",
|
||||
"websocket",
|
||||
"http2",
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol"
|
||||
]
|
||||
},
|
||||
"default": [],
|
||||
"description": "Account-scoped Overview telemetry cards hidden from the dashboard."
|
||||
},
|
||||
"backend_table_columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
@@ -9840,4 +9958,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,51 @@ const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
||||
"lastFailure",
|
||||
"lastRecovery",
|
||||
];
|
||||
const DASHBOARD_WIDGETS: &[&str] = &[
|
||||
"requests",
|
||||
"active",
|
||||
"bandwidth",
|
||||
"errors",
|
||||
"cache",
|
||||
"memory",
|
||||
"websocket",
|
||||
"http2",
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol",
|
||||
];
|
||||
|
||||
fn default_admin_theme() -> String {
|
||||
"dark".to_owned()
|
||||
}
|
||||
fn default_admin_language() -> String {
|
||||
"auto".to_owned()
|
||||
}
|
||||
fn default_admin_ui_scale() -> u16 {
|
||||
100
|
||||
}
|
||||
fn default_admin_time_format() -> String {
|
||||
"24h".to_owned()
|
||||
}
|
||||
fn default_admin_chart_style() -> String {
|
||||
"default".to_owned()
|
||||
}
|
||||
fn default_admin_button_style() -> String {
|
||||
"normal".to_owned()
|
||||
}
|
||||
fn default_admin_table_density() -> String {
|
||||
"normal".to_owned()
|
||||
}
|
||||
fn default_admin_dashboard_density() -> String {
|
||||
"compact".to_owned()
|
||||
}
|
||||
fn default_admin_dashboard_widgets() -> Vec<String> {
|
||||
DASHBOARD_WIDGETS
|
||||
.iter()
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect()
|
||||
}
|
||||
fn default_file_log_columns() -> Vec<String> {
|
||||
[
|
||||
"time",
|
||||
@@ -64,6 +105,24 @@ struct AdminPreferences {
|
||||
live_log_columns: Vec<String>,
|
||||
#[serde(default = "default_admin_theme")]
|
||||
theme: String,
|
||||
#[serde(default = "default_admin_language")]
|
||||
language: String,
|
||||
#[serde(default = "default_admin_ui_scale")]
|
||||
ui_scale: u16,
|
||||
#[serde(default = "default_admin_time_format")]
|
||||
time_format: String,
|
||||
#[serde(default = "default_admin_chart_style")]
|
||||
chart_style: String,
|
||||
#[serde(default = "default_admin_button_style")]
|
||||
button_style: String,
|
||||
#[serde(default = "default_admin_table_density")]
|
||||
table_density: String,
|
||||
#[serde(default = "default_admin_dashboard_density")]
|
||||
dashboard_density: String,
|
||||
#[serde(default = "default_admin_dashboard_widgets")]
|
||||
dashboard_widgets: Vec<String>,
|
||||
#[serde(default)]
|
||||
dashboard_hidden_widgets: Vec<String>,
|
||||
#[serde(default = "default_file_log_columns")]
|
||||
file_log_columns: Vec<String>,
|
||||
#[serde(default = "default_backend_table_columns")]
|
||||
@@ -82,6 +141,15 @@ impl Default for AdminPreferences {
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect(),
|
||||
theme: default_admin_theme(),
|
||||
language: default_admin_language(),
|
||||
ui_scale: default_admin_ui_scale(),
|
||||
time_format: default_admin_time_format(),
|
||||
chart_style: default_admin_chart_style(),
|
||||
button_style: default_admin_button_style(),
|
||||
table_density: default_admin_table_density(),
|
||||
dashboard_density: default_admin_dashboard_density(),
|
||||
dashboard_widgets: default_admin_dashboard_widgets(),
|
||||
dashboard_hidden_widgets: Vec::new(),
|
||||
file_log_columns: default_file_log_columns(),
|
||||
backend_table_columns: default_backend_table_columns(),
|
||||
backend_table_sort: default_backend_table_sort(),
|
||||
@@ -135,6 +203,103 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
|
||||
"invalid administration theme preference".into(),
|
||||
));
|
||||
}
|
||||
if preferences.language != "auto"
|
||||
&& (preferences.language.is_empty()
|
||||
|| preferences.language.len() > 64
|
||||
|| !preferences
|
||||
.language
|
||||
.chars()
|
||||
.all(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '-')))
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration language preference".into(),
|
||||
));
|
||||
}
|
||||
if !(70..=125).contains(&preferences.ui_scale) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration interface scale preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(preferences.time_format.as_str(), "24h" | "12h") {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration time format preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
preferences.chart_style.as_str(),
|
||||
"default"
|
||||
| "soft"
|
||||
| "technical"
|
||||
| "high_contrast"
|
||||
| "neon"
|
||||
| "monochrome"
|
||||
| "warm"
|
||||
| "cool"
|
||||
) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration chart style preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(preferences.button_style.as_str(), "normal" | "compact") {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration button style preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
preferences.table_density.as_str(),
|
||||
"normal" | "compact" | "ultra"
|
||||
) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration table density preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
preferences.dashboard_density.as_str(),
|
||||
"normal" | "compact" | "ultra"
|
||||
) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration dashboard density preference".into(),
|
||||
));
|
||||
}
|
||||
if preferences.dashboard_widgets.is_empty()
|
||||
|| preferences.dashboard_widgets.len() > DASHBOARD_WIDGETS.len()
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration dashboard widget order".into(),
|
||||
));
|
||||
}
|
||||
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()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration dashboard widget order".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if preferences.dashboard_hidden_widgets.len() > DASHBOARD_WIDGETS.len() {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration hidden dashboard widgets".into(),
|
||||
));
|
||||
}
|
||||
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()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration hidden dashboard widgets".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if preferences.backend_table_columns.is_empty()
|
||||
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
static ASSET: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||
[
|
||||
include_str!("web/app/core.js"),
|
||||
include_str!("web/app/preferences.js"),
|
||||
include_str!("web/app/config-history.js"),
|
||||
include_str!("web/app/runtime.js"),
|
||||
include_str!("web/app/settings.js"),
|
||||
@@ -148,6 +149,15 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/drafts.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/drafts.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/integrations.js",
|
||||
get(|| async {
|
||||
|
||||
@@ -5,4 +5,5 @@ mod tests {
|
||||
include!("tests/part_04.rs");
|
||||
include!("tests/part_05.rs");
|
||||
include!("tests/part_06.rs");
|
||||
include!("tests/part_07.rs");
|
||||
}
|
||||
+41
-59
@@ -15,11 +15,13 @@
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let initial = json_body(response).await;
|
||||
assert_eq!(
|
||||
initial["live_log_columns"].as_array().unwrap().len(),
|
||||
LIVE_LOG_COLUMNS.len()
|
||||
);
|
||||
assert_eq!(initial["live_log_columns"].as_array().unwrap().len(), LIVE_LOG_COLUMNS.len());
|
||||
assert_eq!(initial["theme"], "dark");
|
||||
assert_eq!(initial["language"], "auto");
|
||||
assert_eq!(initial["ui_scale"], 100);
|
||||
assert_eq!(initial["time_format"], "24h");
|
||||
assert_eq!(initial["chart_style"], "default");
|
||||
assert_eq!(initial["button_style"], "normal");
|
||||
assert_eq!(
|
||||
initial["file_log_columns"].as_array().unwrap().len(),
|
||||
default_file_log_columns().len()
|
||||
@@ -49,6 +51,11 @@
|
||||
let legacy = json_body(response).await;
|
||||
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
|
||||
assert_eq!(legacy["theme"], "dark");
|
||||
assert_eq!(legacy["language"], "auto");
|
||||
assert_eq!(legacy["ui_scale"], 100);
|
||||
assert_eq!(legacy["time_format"], "24h");
|
||||
assert_eq!(legacy["chart_style"], "default");
|
||||
assert_eq!(legacy["button_style"], "normal");
|
||||
assert_eq!(legacy["file_log_columns"], json!(default_file_log_columns()));
|
||||
assert_eq!(
|
||||
legacy["backend_table_columns"].as_array().unwrap().len(),
|
||||
@@ -60,6 +67,15 @@
|
||||
let selected = json!({
|
||||
"live_log_columns":["time","request","status","upstream_error"],
|
||||
"theme":"light",
|
||||
"language":"pl_pl",
|
||||
"ui_scale":70,
|
||||
"time_format":"12h",
|
||||
"chart_style":"neon",
|
||||
"button_style":"compact",
|
||||
"table_density":"ultra",
|
||||
"dashboard_density":"ultra",
|
||||
"dashboard_widgets":["errors","requests","active","bandwidth","cache","memory","websocket","http2","http3","tls","http_protocol","websocket_protocol"],
|
||||
"dashboard_hidden_widgets":["http3","tls"],
|
||||
"file_log_columns":["time","event","client_ip","record"],
|
||||
"backend_table_columns":["vhost","backend","status","requests","latency"],
|
||||
"backend_table_sort":"requests",
|
||||
@@ -140,6 +156,26 @@
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"live_log_columns":["time"],"ui_scale":150}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
@@ -320,6 +356,7 @@
|
||||
"/assets/langs.json",
|
||||
"/assets/app.js",
|
||||
"/assets/management.js",
|
||||
"/assets/drafts.js",
|
||||
"/assets/integrations.js",
|
||||
"/assets/traffic-panel.js",
|
||||
"/assets/cache-panel.js",
|
||||
@@ -408,58 +445,3 @@
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn public_document_and_discovery_contain_only_login_information() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for (path, expected) in [
|
||||
("/healthz", json!({"status":"ok"})),
|
||||
("/api/auth/status", json!({"setup_required":false})),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await, expected);
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 100_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(text.contains("/assets/login.js"));
|
||||
for absent in [
|
||||
"/assets/app.js",
|
||||
"id=\"workspace\"",
|
||||
"loginLead",
|
||||
"config-editor",
|
||||
"version",
|
||||
] {
|
||||
assert!(!text.contains(absent), "unexpected public content {absent}");
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/assets/login-langs.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bundle = json_body(response).await;
|
||||
let keys: Vec<String> = serde_json::from_str(include_str!("../web/login-keys.json")).unwrap();
|
||||
for language in bundle["languages"].as_array().unwrap() {
|
||||
assert!(language["translations"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.all(|key| keys.contains(key)));
|
||||
assert!(language["translations"].get("cacheTitle").is_none());
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#[tokio::test]
|
||||
async fn public_document_and_discovery_contain_only_login_information() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for (path, expected) in [
|
||||
("/healthz", json!({"status":"ok"})),
|
||||
("/api/auth/status", json!({"setup_required":false})),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await, expected);
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 100_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(text.contains("/assets/login.js"));
|
||||
for absent in [
|
||||
"/assets/app.js",
|
||||
"id=\"workspace\"",
|
||||
"loginLead",
|
||||
"config-editor",
|
||||
"version",
|
||||
] {
|
||||
assert!(!text.contains(absent), "unexpected public content {absent}");
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/assets/login-langs.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bundle = json_body(response).await;
|
||||
let keys: Vec<String> = serde_json::from_str(include_str!("../web/login-keys.json")).unwrap();
|
||||
for language in bundle["languages"].as_array().unwrap() {
|
||||
assert!(language["translations"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.all(|key| keys.contains(key)));
|
||||
assert!(language["translations"].get("cacheTitle").is_none());
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
renewalLogs = logs || {};
|
||||
const list = $("renewal-list"); list.replaceChildren();
|
||||
const certificates = envelope?.config.tls?.certificates || [];
|
||||
const stamp = value => value ? new Date(value * 1000).toLocaleString(locale()) : "-";
|
||||
const stamp = value => value ? formatDateTime(value * 1000) : "-";
|
||||
for (const cert of certificates.filter(c => c.issuer)) {
|
||||
const state = values[cert.id], enabled = cert.issuer.auto_renew !== false;
|
||||
const entry = el("div", undefined, "renewal-entry"); entry.dataset.state = state?.status || "idle";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
let configHistoryEntries = [], configHistorySelectedRevision = null;
|
||||
function historyDate(value, options = {}) {
|
||||
try { return new Intl.DateTimeFormat(locale(), options).format(new Date(Number(value) * 1000)); }
|
||||
catch { return new Date(Number(value) * 1000).toLocaleString(); }
|
||||
const effective = { ...options };
|
||||
if (Object.hasOwn(effective, "timeStyle") || Object.hasOwn(effective, "hour")) effective.hour12 = (adminPreferences?.time_format || "24h") === "12h";
|
||||
try { return new Intl.DateTimeFormat(locale(), effective).format(new Date(Number(value) * 1000)); }
|
||||
catch { return formatDateTime(Number(value) * 1000); }
|
||||
}
|
||||
function historyDayKey(value) {
|
||||
const date = new Date(Number(value) * 1000);
|
||||
|
||||
+11
-57
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let language = null;
|
||||
try { language = localStorage.getItem("rp-language"); } catch { }
|
||||
let language = null, languagePreference = null;
|
||||
try { languagePreference = localStorage.getItem("rp-language"); language = languagePreference; } catch { }
|
||||
let csrf = "", authenticated = false, setupRequired = false, sessionInfo = null;
|
||||
const ORIGIN_RECOVERY_KEY = "rp-origin-recovery-v1";
|
||||
function recoverInvalidOrigin(error) {
|
||||
@@ -20,47 +20,6 @@ function recoverInvalidOrigin(error) {
|
||||
function clearInvalidOriginRecovery() {
|
||||
try { sessionStorage.removeItem(ORIGIN_RECOVERY_KEY); } catch { recoverInvalidOrigin.attempted = false; }
|
||||
}
|
||||
const ADMIN_THEMES = new Set(["dark", "light"]);
|
||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve();
|
||||
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
||||
function applyAdminTheme(value, cache = true) {
|
||||
const theme = normalizeAdminTheme(value);
|
||||
document.documentElement.dataset.theme = theme;
|
||||
const select = $("ui-theme"); if (select) select.value = theme;
|
||||
if (cache) { try { localStorage.setItem("rp-theme", theme); } catch { } }
|
||||
return theme;
|
||||
}
|
||||
try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); }
|
||||
async function loadAdminPreferences(force = false) {
|
||||
if (!authenticated) return adminPreferences;
|
||||
if (adminPreferences && !force) return adminPreferences;
|
||||
if (adminPreferencesLoading) return adminPreferencesLoading;
|
||||
const epoch = authEpoch;
|
||||
adminPreferencesLoading = (async () => {
|
||||
const value = await api("/api/preferences");
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
adminPreferences = { ...value, theme: applyAdminTheme(value?.theme) };
|
||||
return adminPreferences;
|
||||
})();
|
||||
try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; }
|
||||
}
|
||||
function updateAdminPreferences(patch) {
|
||||
const epoch = authEpoch;
|
||||
const task = adminPreferencesWrite.then(async () => {
|
||||
if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||
const current = await loadAdminPreferences();
|
||||
if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||
const next = { ...current, ...patch }; next.theme = normalizeAdminTheme(next.theme);
|
||||
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
adminPreferences = { ...saved, theme: applyAdminTheme(saved?.theme) };
|
||||
return adminPreferences;
|
||||
});
|
||||
adminPreferencesWrite = task.catch(() => undefined);
|
||||
return task;
|
||||
}
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
|
||||
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme };
|
||||
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", filelogs: "/logs", settings: "/settings" };
|
||||
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
|
||||
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
||||
@@ -83,8 +42,9 @@ async function loadLanguages() {
|
||||
const packs = [...languagePacks.values()];
|
||||
const selected = candidates.map(value => packs.find(p => p.id.replaceAll("_", "-").toLowerCase() === value || p.meta.locale.toLowerCase() === value) || packs.find(p => p.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean);
|
||||
language = selected?.id || defaultLanguage;
|
||||
$("language").replaceChildren(...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
||||
$("language").value = language;
|
||||
const automatic = el("option", t("languageAutomatic")); automatic.value = "auto"; automatic.dataset.i18n = "languageAutomatic";
|
||||
$("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
||||
$("language").value = languagePreference === "auto" ? "auto" : language;
|
||||
}
|
||||
let latestStatus = null, certificateInventory = new Map();
|
||||
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
||||
@@ -94,7 +54,9 @@ function translate() {
|
||||
document.querySelectorAll("[data-i18n]").forEach(e => e.textContent = t(e.dataset.i18n));
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach(e => e.placeholder = t(e.dataset.i18nPlaceholder));
|
||||
document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria)));
|
||||
$("language").value = language; setPanel(currentPanel, false); syncAuthForm(); renderSession();
|
||||
$("language").value = languagePreference === "auto" ? "auto" : language; setPanel(currentPanel, false); syncAuthForm(); renderSession();
|
||||
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); } if (lastLogs) renderLogs(lastLogs);
|
||||
syncSettingsDirtyIndicators();
|
||||
@@ -332,7 +294,7 @@ async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true);
|
||||
async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); }
|
||||
async function saveSection(section, value) { return saveRequest(`/api/config/${section}`, { expected_revision: envelope?.revision, value }); }
|
||||
function syncAuthForm() { $("setup-fields").hidden = !setupRequired; $("confirm-field").hidden = !setupRequired; $("setup-token").required = setupRequired; $("password-confirm").required = setupRequired; $("password").minLength = setupRequired ? 12 : 1; $("password").autocomplete = setupRequired ? "new-password" : "current-password"; $("auth-title").textContent = t(setupRequired ? "setupTitle" : "signIn"); $("login-submit").textContent = t(setupRequired ? "createAndSignIn" : "signIn"); $("auth-badge").textContent = setupRequired ? "SETUP" : "LOCAL"; }
|
||||
function renderSession() { $("session-user").textContent = sessionInfo?.username || ""; $("session-expiry").textContent = sessionInfo ? t("expires") + new Date(sessionInfo.expires_at * 1000).toLocaleString(locale()) : ""; }
|
||||
function renderSession() { $("session-user").textContent = sessionInfo?.username || ""; $("session-expiry").textContent = sessionInfo ? t("expires") + formatDateTime(sessionInfo.expires_at * 1000) : ""; }
|
||||
function authReturnDestination() {
|
||||
if (typeof location === "undefined") return "/";
|
||||
if (location.pathname === "/logging") return "/settings?tab=logging";
|
||||
@@ -344,7 +306,7 @@ function authReturnDestination() {
|
||||
}
|
||||
function lock(notify = true) {
|
||||
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(); $("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(); 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(); 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 = ""; });
|
||||
@@ -356,7 +318,7 @@ function lock(notify = true) {
|
||||
}
|
||||
|
||||
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-menu").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
|
||||
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [new Date(item.time * 1000).toLocaleString(locale()), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
|
||||
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
|
||||
let tunnelRefreshTimer = null;
|
||||
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
|
||||
async function loadTunnels() {
|
||||
@@ -394,14 +356,6 @@ $("login-form").addEventListener("submit", async event => { event.preventDefault
|
||||
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
||||
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||
$("about-close").addEventListener("click", () => $("about-dialog").close());
|
||||
$("language").addEventListener("change", () => { language = $("language").value; try { localStorage.setItem("rp-language", language); } catch { } translate(); });
|
||||
$("ui-theme").addEventListener("change", async () => {
|
||||
const select = $("ui-theme"), previousTheme = adminPreferences?.theme || document.documentElement.dataset.theme || "dark", nextTheme = normalizeAdminTheme(select.value);
|
||||
applyAdminTheme(nextTheme); select.disabled = true;
|
||||
try { await updateAdminPreferences({ theme: nextTheme }); }
|
||||
catch (error) { applyAdminTheme(previousTheme); if (error.status !== 401) toast(error.message, true); }
|
||||
finally { select.disabled = false; }
|
||||
});
|
||||
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
|
||||
function setMobileMenu(open) {
|
||||
if (!mobileMenuToggle || !dashboardSidebar) return;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
const ADMIN_THEMES = new Set(["dark", "light"]);
|
||||
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"]);
|
||||
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: [],
|
||||
});
|
||||
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();
|
||||
|
||||
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
||||
function normalizeAdminLanguage(value) { return value === "auto" || /^[A-Za-z0-9_-]{1,64}$/.test(value || "") ? value : "auto"; }
|
||||
function normalizeAdminUiScale(value) { const numeric = Number(value); return Number.isInteger(numeric) ? Math.min(125, Math.max(70, numeric)) : 100; }
|
||||
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 normalizeAdminDensity(value, fallback = "normal") { return ADMIN_DENSITIES.has(value) ? value : fallback; }
|
||||
function normalizeDashboardWidgets(value) {
|
||||
const result = [], seen = new Set();
|
||||
for (const id of Array.isArray(value) ? value : []) if (ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id)) { seen.add(id); result.push(id); }
|
||||
for (const id of ADMIN_DASHBOARD_WIDGETS) if (!seen.has(id)) result.push(id);
|
||||
return result;
|
||||
}
|
||||
function normalizeHiddenDashboardWidgets(value) {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
|
||||
}
|
||||
function browserLanguage() {
|
||||
const packs = [...languagePacks.values()], candidates = (navigator.languages || [navigator.language]).filter(Boolean).map(value => value.replaceAll("_", "-").toLowerCase());
|
||||
return candidates.map(value => packs.find(pack => pack.meta.locale.toLowerCase() === value || pack.id.replaceAll("_", "-").toLowerCase() === value) || packs.find(pack => pack.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean)?.id || defaultLanguage;
|
||||
}
|
||||
function applyAdminTheme(value, cache = true) {
|
||||
const theme = normalizeAdminTheme(value); document.documentElement.dataset.theme = theme;
|
||||
const select = $("ui-theme"); if (select) select.value = theme;
|
||||
if (cache) { try { localStorage.setItem("rp-theme", theme); } catch { } }
|
||||
return theme;
|
||||
}
|
||||
function applyAdminLanguage(value, cache = true, rerender = true) {
|
||||
const preference = normalizeAdminLanguage(value); languagePreference = preference;
|
||||
if (languagePacks.size) language = preference === "auto" ? browserLanguage() : (languagePacks.has(preference) ? preference : browserLanguage());
|
||||
const select = $("language"); if (select) select.value = preference === "auto" ? "auto" : language;
|
||||
if (cache) { try { localStorage.setItem("rp-language", preference); } catch { } }
|
||||
if (rerender && languagePacks.size) translate();
|
||||
return preference;
|
||||
}
|
||||
function applyAdminUiScale(value, cache = true) {
|
||||
const scale = normalizeAdminUiScale(value), root = document.documentElement, fieldScale = Math.min(1.1, Math.max(.9, 1 + (scale - 100) / 300));
|
||||
root.style.setProperty("--ui-scale", String(scale / 100));
|
||||
root.style.setProperty("--ui-field-font-size", `${(12 * fieldScale).toFixed(2)}px`);
|
||||
root.style.setProperty("--ui-field-pad-y", `${(6 * fieldScale).toFixed(2)}px`);
|
||||
root.style.setProperty("--ui-field-pad-x", `${(8 * fieldScale).toFixed(2)}px`);
|
||||
root.style.setProperty("--ui-field-min-height", `${(27 * fieldScale).toFixed(2)}px`);
|
||||
root.style.setProperty("--ui-check-size", `${(15 * fieldScale).toFixed(2)}px`);
|
||||
root.style.setProperty("--ui-range-height", `${(18 * fieldScale).toFixed(2)}px`);
|
||||
const slider = $("ui-scale"), output = $("ui-scale-value"); if (slider) slider.value = String(scale); if (output) output.textContent = `${scale}%`;
|
||||
if (cache) { try { localStorage.setItem("rp-ui-scale", String(scale)); } catch { } }
|
||||
return scale;
|
||||
}
|
||||
function applyAdminTimeFormat(value) { const format = normalizeAdminTimeFormat(value); const select = $("ui-time-format"); if (select) select.value = format; return format; }
|
||||
function applyAdminChartStyle(value) {
|
||||
const style = normalizeAdminChartStyle(value); document.documentElement.dataset.chartStyle = style;
|
||||
const select = $("ui-chart-style"); if (select) select.value = style; refreshChartStylePalette(style); return style;
|
||||
}
|
||||
function applyAdminButtonStyle(value) {
|
||||
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
|
||||
const select = $("ui-button-style"); if (select) select.value = style; 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;
|
||||
}
|
||||
function applyAdminDashboardDensity(value) {
|
||||
const density = normalizeAdminDensity(value, "compact"); document.documentElement.dataset.dashboardDensity = density;
|
||||
const select = $("ui-dashboard-density"); if (select) select.value = density; return density;
|
||||
}
|
||||
function applyDashboardLayout(order, hidden) {
|
||||
const normalizedOrder = normalizeDashboardWidgets(order), hiddenSet = new Set(normalizeHiddenDashboardWidgets(hidden));
|
||||
const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] };
|
||||
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); }
|
||||
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;
|
||||
}
|
||||
function renderDashboardWidgetSettings() {
|
||||
const list = $("dashboard-widget-list"); if (!list) return;
|
||||
const current = adminPreferences || ADMIN_APPEARANCE_DEFAULTS, order = normalizeDashboardWidgets(current.dashboard_widgets), hidden = new Set(normalizeHiddenDashboardWidgets(current.dashboard_hidden_widgets));
|
||||
const rows = order.map((id, index) => {
|
||||
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
|
||||
const visible = document.createElement("input"); visible.type = "checkbox"; visible.checked = !hidden.has(id); visible.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
|
||||
const label = 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"));
|
||||
const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown"));
|
||||
visible.addEventListener("change", () => {
|
||||
const previous = [...hidden], next = new Set(hidden); if (visible.checked) next.delete(id); else next.add(id);
|
||||
if (adminPreferences) adminPreferences.dashboard_hidden_widgets = [...next]; applyDashboardLayout(order, [...next]); renderDashboardWidgetSettings();
|
||||
void saveAppearancePreference({ dashboard_hidden_widgets: [...next] }, visible, () => { if (adminPreferences) adminPreferences.dashboard_hidden_widgets = previous; applyDashboardLayout(order, previous); renderDashboardWidgetSettings(); });
|
||||
});
|
||||
const move = delta => {
|
||||
const previous = [...order], next = [...order], at = next.indexOf(id), target = at + delta; if (at < 0 || target < 0 || target >= next.length) return;
|
||||
[next[at], next[target]] = [next[target], next[at]]; if (adminPreferences) adminPreferences.dashboard_widgets = next;
|
||||
applyDashboardLayout(next, [...hidden]); renderDashboardWidgetSettings();
|
||||
void saveAppearancePreference({ dashboard_widgets: next }, null, () => { if (adminPreferences) adminPreferences.dashboard_widgets = previous; applyDashboardLayout(previous, [...hidden]); renderDashboardWidgetSettings(); });
|
||||
};
|
||||
up.addEventListener("click", () => move(-1)); down.addEventListener("click", () => move(1)); actions.append(up, down); row.append(visible, label, actions); return row;
|
||||
});
|
||||
list.replaceChildren(...rows);
|
||||
}
|
||||
function refreshChartStylePalette(active = adminPreferences?.chart_style || "default") {
|
||||
const target = $("chart-style-palette"); if (!target || !languagePacks.size) return;
|
||||
const buttons = [...ADMIN_CHART_STYLES].map(style => {
|
||||
const button = el("button", undefined, "chart-style-choice"); button.type = "button"; button.dataset.chartPreset = style; button.classList.toggle("active", style === active); button.setAttribute("aria-pressed", String(style === active));
|
||||
const swatches = el("span", undefined, "chart-style-swatches"); for (let i = 1; i <= 4; i++) swatches.append(el("i"));
|
||||
button.append(swatches, el("span", t(CHART_STYLE_KEYS[style]))); button.addEventListener("click", () => { const select = $("ui-chart-style"); if (!select) return; select.value = style; select.dispatchEvent(new Event("change", { bubbles: true })); }); return button;
|
||||
});
|
||||
target.replaceChildren(...buttons);
|
||||
}
|
||||
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); 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) {
|
||||
if (!value) return value;
|
||||
const dashboard = applyDashboardLayout(value.dashboard_widgets, value.dashboard_hidden_widgets);
|
||||
const normalized = {
|
||||
...value,
|
||||
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),
|
||||
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: dashboard.order, dashboard_hidden_widgets: dashboard.hidden,
|
||||
};
|
||||
adminPreferences = normalized;
|
||||
if (languagePacks.size) translate();
|
||||
refreshAppearanceUi(); return normalized;
|
||||
}
|
||||
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); }
|
||||
|
||||
async function loadAdminPreferences(force = false) {
|
||||
if (!authenticated) return adminPreferences;
|
||||
if (adminPreferences && !force) return adminPreferences;
|
||||
if (adminPreferencesLoading) return adminPreferencesLoading;
|
||||
const epoch = authEpoch;
|
||||
adminPreferencesLoading = (async () => {
|
||||
const value = await api("/api/preferences");
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
return applyAdminPreferences(value);
|
||||
})();
|
||||
try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; }
|
||||
}
|
||||
function updateAdminPreferences(patch) {
|
||||
const epoch = authEpoch;
|
||||
const task = adminPreferencesWrite.then(async () => {
|
||||
if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||
const current = await loadAdminPreferences();
|
||||
if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||
const next = { ...current, ...patch };
|
||||
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.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;
|
||||
return applyAdminPreferences(saved);
|
||||
});
|
||||
adminPreferencesWrite = task.catch(() => undefined); return task;
|
||||
}
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
|
||||
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme, refreshAppearanceUi };
|
||||
|
||||
async function saveAppearancePreference(patch, control, rollback) {
|
||||
if (control) control.disabled = true;
|
||||
try { await updateAdminPreferences(patch); }
|
||||
catch (error) { rollback?.(); if (error.status !== 401) toast(error.message, true); }
|
||||
finally { if (control) control.disabled = false; }
|
||||
}
|
||||
$("language")?.addEventListener("change", () => {
|
||||
const control = $("language"), previous = adminPreferences?.language || "auto", next = normalizeAdminLanguage(control.value);
|
||||
applyAdminLanguage(next); void saveAppearancePreference({ language: next }, control, () => applyAdminLanguage(previous));
|
||||
});
|
||||
$("ui-theme")?.addEventListener("change", () => {
|
||||
const control = $("ui-theme"), previous = adminPreferences?.theme || "dark", next = normalizeAdminTheme(control.value);
|
||||
applyAdminTheme(next); void saveAppearancePreference({ theme: next }, control, () => applyAdminTheme(previous));
|
||||
});
|
||||
$("ui-scale")?.addEventListener("input", () => applyAdminUiScale($("ui-scale").value));
|
||||
$("ui-scale")?.addEventListener("change", () => {
|
||||
const control = $("ui-scale"), previous = adminPreferences?.ui_scale || 100, next = normalizeAdminUiScale(control.value);
|
||||
void saveAppearancePreference({ ui_scale: next }, control, () => applyAdminUiScale(previous));
|
||||
});
|
||||
$("ui-time-format")?.addEventListener("change", () => {
|
||||
const control = $("ui-time-format"), previous = adminPreferences?.time_format || "24h", next = normalizeAdminTimeFormat(control.value);
|
||||
applyAdminTimeFormat(next); if (adminPreferences) adminPreferences.time_format = next; translate();
|
||||
void saveAppearancePreference({ time_format: next }, control, () => { if (adminPreferences) adminPreferences.time_format = previous; applyAdminTimeFormat(previous); translate(); });
|
||||
});
|
||||
$("ui-chart-style")?.addEventListener("change", () => {
|
||||
const control = $("ui-chart-style"), previous = adminPreferences?.chart_style || "default", next = normalizeAdminChartStyle(control.value);
|
||||
applyAdminChartStyle(next); if (adminPreferences) adminPreferences.chart_style = next; void saveAppearancePreference({ chart_style: next }, control, () => { if (adminPreferences) adminPreferences.chart_style = previous; applyAdminChartStyle(previous); });
|
||||
});
|
||||
$("ui-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-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); });
|
||||
});
|
||||
$("ui-dashboard-density")?.addEventListener("change", () => {
|
||||
const control = $("ui-dashboard-density"), previous = adminPreferences?.dashboard_density || "compact", next = normalizeAdminDensity(control.value, "compact");
|
||||
applyAdminDashboardDensity(next); if (adminPreferences) adminPreferences.dashboard_density = next; void saveAppearancePreference({ dashboard_density: next }, control, () => { if (adminPreferences) adminPreferences.dashboard_density = previous; applyAdminDashboardDensity(previous); });
|
||||
});
|
||||
async function resetAppearancePatch(patch, control, messageKey = "appearanceSectionResetDone") {
|
||||
if (control) control.disabled = true; let previous = null;
|
||||
try {
|
||||
const current = await loadAdminPreferences(); previous = current ? { ...current, dashboard_widgets: [...current.dashboard_widgets], dashboard_hidden_widgets: [...current.dashboard_hidden_widgets] } : null;
|
||||
if (current) applyAdminPreferences({ ...current, ...patch }); await updateAdminPreferences(patch); toast(t(messageKey));
|
||||
} catch (error) { if (previous) applyAdminPreferences(previous); if (error.status !== 401) toast(error.message, true); }
|
||||
finally { if (control) control.disabled = false; }
|
||||
}
|
||||
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" },
|
||||
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] },
|
||||
};
|
||||
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
||||
}));
|
||||
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] }, $("appearance-reset"), "appearanceResetDone"));
|
||||
refreshAppearanceUi();
|
||||
@@ -62,7 +62,7 @@ function renderCertificates() {
|
||||
const details = el("details", undefined, "certificate-details");
|
||||
details.append(el("summary", t("certificateDetails")));
|
||||
const detailBody = el("div", undefined, "certificate-details-body");
|
||||
const stamp = value => Number(value) ? new Date(Number(value) * 1000).toLocaleString(locale()) : "-";
|
||||
const stamp = value => Number(value) ? formatDateTime(Number(value) * 1000) : "-";
|
||||
detailBody.append(
|
||||
el("p", `${t("certificateBindings")}: ${bindings.join(", ") || "-"}`, "entity-details"),
|
||||
el("p", `${t("certificateFiles")}: ${cert.cert_file} / ${cert.key_file}`, "entity-details"),
|
||||
@@ -304,7 +304,7 @@ function renderDiagnostics(status, appendSample = true) {
|
||||
setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads));
|
||||
const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
|
||||
setOptionalMetric("process-virtual-memory", virtualMemory != null, virtualMemory);
|
||||
setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : new Date(Number(d.process_start_time_seconds) * 1000).toLocaleString(locale()));
|
||||
setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : formatDateTime(Number(d.process_start_time_seconds) * 1000));
|
||||
setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
|
||||
setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
|
||||
const memory = status.memory_accounting || {};
|
||||
|
||||
@@ -6,7 +6,7 @@ function renderSettings() {
|
||||
if (!settingsDirty.has("response-form")) {
|
||||
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
||||
$("expose-request-id").checked = !!diagnostics.request_id;
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.2";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.3";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
const bans = data.bans || [];
|
||||
$("cache-ban-rows").replaceChildren(...bans.map(rule => {
|
||||
const tr = el("tr");
|
||||
for (const value of [String(rule.id), rule.selector, rule.value, rule.host || "*", rule.vhost || "*", new Date(rule.expires_at_ms).toLocaleString(locale())]) tr.append(el("td", value));
|
||||
for (const value of [String(rule.id), rule.selector, rule.value, rule.host || "*", rule.vhost || "*", formatDateTime(rule.expires_at_ms)]) tr.append(el("td", value));
|
||||
const action = el("td"), button = el("button", t("cacheRemoveBan"), "quiet"); button.type = "button"; button.addEventListener("click", () => void removeBan(rule.id)); action.append(button); tr.append(action); return tr;
|
||||
}));
|
||||
$("cache-bans-empty").hidden = !!bans.length;
|
||||
@@ -164,7 +164,7 @@
|
||||
$("cache-objects-empty").hidden = !!d.objects?.length;
|
||||
$("cache-page").textContent = `${d.matching_objects ? offset + 1 : 0}-${Math.min(offset + (d.objects?.length || 0), d.matching_objects || 0)} / ${number(d.matching_objects)}`;
|
||||
$("cache-prev").disabled = offset === 0; $("cache-next").disabled = offset + 50 >= (d.matching_objects || 0);
|
||||
$("cache-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
|
||||
$("cache-panel-state").textContent = `${t("cacheUpdated")}: ${formatTime(new Date())}`;
|
||||
$("cache-panel-cleanup").disabled = !data.settings?.cold_enabled;
|
||||
}
|
||||
async function runInvalidation(payload, messageTarget = "cache-invalidation-status") {
|
||||
|
||||
@@ -166,9 +166,11 @@
|
||||
async function boot() {
|
||||
const mine = ++epoch; try {
|
||||
const info = await get("/api/auth/session"); if (mine !== epoch) return;
|
||||
const [documentSpec, bundle, docBundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json"), get("/docs/content.json")]); if (mine !== epoch) return;
|
||||
const packs = bundle.languages || []; fallback = packs.find(p => p.id === bundle.default)?.translations || {}; let selected = null; try { selected = localStorage.getItem("rp-language"); } catch { }
|
||||
const pack = packs.find(p => p.id === selected) || packs.find(p => p.id === bundle.default); translations = pack?.translations || {};
|
||||
const [documentSpec, bundle, docBundle, preferences] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json"), get("/docs/content.json"), get("/api/preferences")]); if (mine !== epoch) return;
|
||||
const packs = bundle.languages || []; fallback = packs.find(p => p.id === bundle.default)?.translations || {};
|
||||
const selected = preferences?.language || "auto", candidates = selected === "auto" ? (navigator.languages || [navigator.language]) : [selected];
|
||||
const normalized = candidates.filter(Boolean).map(value => value.replaceAll("_", "-").toLowerCase());
|
||||
const pack = normalized.map(value => packs.find(p => p.id.replaceAll("_", "-").toLowerCase() === value || p.meta.locale.toLowerCase() === value) || packs.find(p => p.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean) || packs.find(p => p.id === bundle.default); translations = pack?.translations || {};
|
||||
window.document.documentElement.lang = pack?.meta.locale || "und"; window.document.documentElement.dir = pack?.meta.direction || "ltr";
|
||||
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n)); window.document.querySelectorAll("[data-i18n-placeholder]").forEach(n => n.placeholder = t(n.dataset.i18nPlaceholder));
|
||||
session = info; spec = documentSpec; documents = Array.isArray(docBundle.documents) ? docBundle.documents : []; docsByPath = new Map(documents.map(item => [item.path, item]));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
const editors = new Map();
|
||||
const drafts = [];
|
||||
let sequence = 0, collapsed = false;
|
||||
const MAX_DRAFTS = 12;
|
||||
|
||||
const dock = el("aside", undefined, "draft-dock"); dock.id = "draft-dock"; dock.hidden = true;
|
||||
const heading = el("div", undefined, "draft-dock-heading");
|
||||
const title = el("strong", t("draftDockTitle")); title.dataset.i18n = "draftDockTitle";
|
||||
const headingActions = el("div", undefined, "draft-dock-heading-actions");
|
||||
const count = el("span", "0", "pill");
|
||||
const toggle = el("button", "−", "draft-dock-toggle"); toggle.type = "button"; toggle.setAttribute("aria-controls", "draft-dock-list");
|
||||
headingActions.append(count, toggle); heading.append(title, headingActions);
|
||||
const list = el("div", undefined, "draft-dock-list"); list.id = "draft-dock-list"; dock.append(heading, list); document.body.append(dock);
|
||||
|
||||
function editorLabel(type) { const key = editors.get(type)?.labelKey; return key ? t(key) : type; }
|
||||
function draftTitle(value) { return String(value || t("draftUntitled")).trim() || t("draftUntitled"); }
|
||||
function draftAge(createdAt) {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - createdAt) / 1000));
|
||||
if (seconds < 60) return t("draftAgeNow");
|
||||
const minutes = Math.floor(seconds / 60); if (minutes < 60) return t("draftAgeMinutes").replace("{count}", minutes);
|
||||
const hours = Math.floor(minutes / 60); if (hours < 24) return t("draftAgeHours").replace("{count}", hours);
|
||||
return t("draftAgeDays").replace("{count}", Math.floor(hours / 24));
|
||||
}
|
||||
function addDraft(type, packed) {
|
||||
if (!packed?.state) return;
|
||||
drafts.push({ id: ++sequence, type, title: draftTitle(packed.title), state: packed.state, createdAt: Date.now() });
|
||||
while (drafts.length > MAX_DRAFTS) drafts.shift();
|
||||
render(); toast(t("draftMinimized"));
|
||||
}
|
||||
function stashOpen() {
|
||||
for (const [type, api] of editors) {
|
||||
if (!api.isOpen?.()) continue;
|
||||
if (api.isDirty?.()) addDraft(type, api.suspend());
|
||||
else api.close?.();
|
||||
}
|
||||
}
|
||||
async function restore(id) {
|
||||
const index = drafts.findIndex(item => item.id === id); if (index < 0) return;
|
||||
const draft = drafts[index], api = editors.get(draft.type); if (!api?.restore) return;
|
||||
stashOpen(); drafts.splice(index, 1); render();
|
||||
try { api.restore(draft.state); }
|
||||
catch (error) { drafts.splice(Math.min(index, drafts.length), 0, draft); render(); toast(error.message || String(error), true); }
|
||||
}
|
||||
async function discard(id) {
|
||||
const index = drafts.findIndex(item => item.id === id); if (index < 0) return;
|
||||
if (!(await appConfirm(t("draftDiscardConfirm"), { danger: true, confirmLabel: t("discardAction") }))) return;
|
||||
drafts.splice(index, 1); render();
|
||||
}
|
||||
function render() {
|
||||
dock.hidden = drafts.length === 0; count.textContent = String(drafts.length); dock.classList.toggle("is-collapsed", collapsed);
|
||||
list.hidden = collapsed; toggle.textContent = collapsed ? "+" : "−"; toggle.setAttribute("aria-expanded", String(!collapsed)); toggle.setAttribute("aria-label", t(collapsed ? "draftDockExpand" : "draftDockCollapse"));
|
||||
list.replaceChildren();
|
||||
for (const draft of drafts) {
|
||||
const row = el("div", undefined, "draft-dock-item");
|
||||
const open = el("button", undefined, "draft-dock-open"); open.type = "button"; open.setAttribute("aria-label", t("draftRestore"));
|
||||
const identity = el("span", undefined, "draft-dock-identity"), meta = el("span", undefined, "draft-dock-meta");
|
||||
meta.append(el("small", editorLabel(draft.type)), el("time", draftAge(draft.createdAt)));
|
||||
identity.append(meta, el("b", draft.title)); open.append(identity); open.addEventListener("click", () => void restore(draft.id));
|
||||
const remove = el("button", "×", "draft-dock-remove"); remove.type = "button"; remove.setAttribute("aria-label", t("draftDiscard")); remove.addEventListener("click", () => void discard(draft.id));
|
||||
row.append(open, remove); list.append(row);
|
||||
}
|
||||
}
|
||||
toggle.addEventListener("click", () => { collapsed = !collapsed; render(); });
|
||||
function register(type, api) { editors.set(type, api); render(); }
|
||||
function dockEditor(type) {
|
||||
const api = editors.get(type); if (!api?.isOpen?.()) return false;
|
||||
if (api.isDirty?.()) addDraft(type, api.suspend()); else api.close?.();
|
||||
return true;
|
||||
}
|
||||
function prepare() { stashOpen(); }
|
||||
function reset() { for (const api of editors.values()) api.close?.(); drafts.splice(0); collapsed = false; render(); }
|
||||
|
||||
function captureControls(form) {
|
||||
return [...form.elements].map((control, index) => ({
|
||||
index, id: control.id || "", name: control.name || "", type: control.type || control.tagName.toLowerCase(),
|
||||
value: control.value, checked: "checked" in control ? control.checked : null,
|
||||
}));
|
||||
}
|
||||
function restoreControls(form, state) {
|
||||
const controls = [...form.elements];
|
||||
for (const saved of state || []) {
|
||||
let control = saved.id ? form.querySelector(`#${CSS.escape(saved.id)}`) : null;
|
||||
if (!control && saved.name) {
|
||||
const same = [...form.querySelectorAll(`[name="${CSS.escape(saved.name)}"]`)]; control = same.find(item => controls.indexOf(item) === saved.index) || same[0];
|
||||
}
|
||||
if (!control) control = controls[saved.index]; if (!control) continue;
|
||||
if (saved.checked !== null && "checked" in control) control.checked = saved.checked;
|
||||
if (control.type !== "file") control.value = saved.value;
|
||||
}
|
||||
}
|
||||
const takeChildren = node => { const children = [...node.childNodes]; node.replaceChildren(); return children; };
|
||||
const restoreChildren = (node, children) => node.replaceChildren(...(children || []));
|
||||
if (typeof setInterval === "function") setInterval(() => { if (drafts.length && !collapsed) render(); }, 60000);
|
||||
|
||||
window.rustProxyDrafts = { register, dock: dockEditor, prepare, reset, refresh: render, captureControls, restoreControls, takeChildren, restoreChildren };
|
||||
})();
|
||||
@@ -37,7 +37,7 @@
|
||||
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
|
||||
|
||||
function entryTime(entry) {
|
||||
if (entry.time_ms) return new Date(Number(entry.time_ms)).toLocaleString(locale());
|
||||
if (entry.time_ms) return formatDateTime(Number(entry.time_ms));
|
||||
if (entry.timestamp) return String(entry.timestamp);
|
||||
return "—";
|
||||
}
|
||||
|
||||
+181
-39
@@ -10,6 +10,7 @@
|
||||
<link href="/assets/style.css" rel="stylesheet" />
|
||||
<script defer="" src="/assets/app.js"></script>
|
||||
<script defer="" src="/assets/management.js"></script>
|
||||
<script defer="" src="/assets/drafts.js"></script>
|
||||
<script defer="" src="/assets/integrations.js"></script>
|
||||
<script defer="" src="/assets/vhosts.js"></script>
|
||||
<script defer="" src="/assets/web-server.js"></script>
|
||||
@@ -71,8 +72,7 @@
|
||||
id="file-logs-open" type="button">Stored logs</button><button class="quiet"
|
||||
data-i18n="liveLogs" hidden="" id="logs-open" type="button">Live access logs</button>
|
||||
</div>
|
||||
</details><select aria-label="Language" class="language-select" data-i18n-aria="language"
|
||||
id="language"></select><button class="quiet" data-i18n="about" id="about-open"
|
||||
</details><button class="quiet" data-i18n="about" id="about-open"
|
||||
type="button">About</button><button class="quiet" data-i18n="logout" hidden=""
|
||||
id="logout">Lock</button>
|
||||
</div>
|
||||
@@ -117,24 +117,30 @@
|
||||
</section>
|
||||
<section class="panel" id="panel-overview">
|
||||
<div class="stats-grid">
|
||||
<article class="stat"><span data-i18n="requests">Requests</span><strong
|
||||
id="metric-requests">0</strong><small id="request-rate">0 req/s</small></article>
|
||||
<article class="stat"><span data-i18n="activeExchanges">Active exchanges</span><strong
|
||||
id="metric-active">0</strong><small data-i18n="bodyLifetime">Full body lifetime</small>
|
||||
<article class="stat" data-dashboard-widget="requests"><span
|
||||
data-i18n="requests">Requests</span><strong id="metric-requests">0</strong><small
|
||||
id="request-rate">0 req/s</small></article>
|
||||
<article class="stat" data-dashboard-widget="active"><span data-i18n="activeExchanges">Active
|
||||
exchanges</span><strong id="metric-active">0</strong><small
|
||||
data-i18n="bodyLifetime">Full body lifetime</small>
|
||||
</article>
|
||||
<article class="stat"><span data-i18n="bandwidth">Payload transferred</span><strong
|
||||
id="metric-bytes">0 B</strong><small class="payload-split"
|
||||
<article class="stat" data-dashboard-widget="bandwidth"><span data-i18n="bandwidth">Payload
|
||||
transferred</span><strong id="metric-bytes">0 B</strong><small class="payload-split"
|
||||
id="metric-bytes-split"><span>↓ <b id="metric-bytes-down">0 B</b></span><span>↑ <b
|
||||
id="metric-bytes-up">0 B</b></span></small>
|
||||
</article>
|
||||
<article class="stat"><span data-i18n="errors">Errors</span><strong
|
||||
id="metric-errors">0</strong><small data-i18n="errorDescription">5xx / body /
|
||||
<article class="stat" data-dashboard-widget="errors"><span
|
||||
data-i18n="errors">Errors</span><strong id="metric-errors">0</strong><small
|
||||
data-i18n="errorDescription">5xx / body /
|
||||
cancelled</small></article>
|
||||
<article class="stat"><span data-i18n="cacheHitRatio">Cache hit ratio</span><strong
|
||||
id="metric-cache-ratio">-</strong><small id="cache-summary">-</small></article>
|
||||
<article class="stat"><span data-i18n="processMemory">Process memory</span><strong
|
||||
id="metric-rss">-</strong><small id="process-summary">-</small></article>
|
||||
<article class="stat stat-with-action"><span data-i18n="wsActive">WebSocket
|
||||
<article class="stat" data-dashboard-widget="cache"><span data-i18n="cacheHitRatio">Cache hit
|
||||
ratio</span><strong id="metric-cache-ratio">-</strong><small
|
||||
id="cache-summary">-</small></article>
|
||||
<article class="stat" data-dashboard-widget="memory"><span data-i18n="processMemory">Process
|
||||
memory</span><strong id="metric-rss">-</strong><small id="process-summary">-</small>
|
||||
</article>
|
||||
<article class="stat stat-with-action" data-dashboard-widget="websocket"><span
|
||||
data-i18n="wsActive">WebSocket
|
||||
tunnels</span><button class="quiet stat-corner-action" data-i18n="tunnelsOpen"
|
||||
id="tunnels-open" type="button">Active tunnels</button><strong
|
||||
id="metric-ws">0</strong><small
|
||||
@@ -144,14 +150,16 @@
|
||||
RFC 9220</span> <span id="metric-ws-h3">0</span><br><span
|
||||
data-i18n="wsEstablished">Established</span>: <span
|
||||
id="metric-ws-total">0</span></small></article>
|
||||
<article class="stat"><span data-i18n="h2Requests">HTTP/2 requests</span><strong
|
||||
id="metric-h2">0</strong><small><span data-i18n="h2ActiveConnections">Active H2
|
||||
<article class="stat" data-dashboard-widget="http2"><span data-i18n="h2Requests">HTTP/2
|
||||
requests</span><strong id="metric-h2">0</strong><small><span
|
||||
data-i18n="h2ActiveConnections">Active H2
|
||||
connections</span>: <span id="metric-h2-active">0</span></small></article>
|
||||
<article class="stat"><span data-i18n="h3Requests">HTTP/3 requests</span><strong
|
||||
id="metric-h3">0</strong></article>
|
||||
<article class="stat"><span data-i18n="tlsFailures">TLS handshake errors</span><strong
|
||||
id="metric-tls-errors">0</strong></article>
|
||||
<article class="stat stat-chart"><span data-i18n="httpProtocolMix">HTTP protocol
|
||||
<article class="stat" data-dashboard-widget="http3"><span data-i18n="h3Requests">HTTP/3
|
||||
requests</span><strong id="metric-h3">0</strong></article>
|
||||
<article class="stat" data-dashboard-widget="tls"><span data-i18n="tlsFailures">TLS handshake
|
||||
errors</span><strong id="metric-tls-errors">0</strong></article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="http_protocol"><span
|
||||
data-i18n="httpProtocolMix">HTTP protocol
|
||||
mix</span><strong id="metric-http-proto-total">0.0/s</strong><svg
|
||||
aria-label="HTTP protocol activity" data-i18n-aria="httpProtocolMix"
|
||||
id="http-protocol-chart" role="img" viewBox="0 0 300 60">
|
||||
@@ -166,7 +174,8 @@
|
||||
id="metric-http-proto-h1">0.0/s</b></span><span class="legend-h2"><i></i>H2 <b
|
||||
id="metric-http-proto-h2">0.0/s</b></span><span class="legend-h3"><i></i>H3 <b
|
||||
id="metric-http-proto-h3">0.0/s</b></span></small></article>
|
||||
<article class="stat stat-chart"><span data-i18n="wsProtocolMix">WebSocket protocol
|
||||
<article class="stat stat-chart" data-dashboard-widget="websocket_protocol"><span
|
||||
data-i18n="wsProtocolMix">WebSocket protocol
|
||||
mix</span><strong id="metric-ws-proto-total">0</strong><svg
|
||||
aria-label="WebSocket protocol activity" data-i18n-aria="wsProtocolMix"
|
||||
id="ws-protocol-chart" role="img" viewBox="0 0 300 60">
|
||||
@@ -1569,21 +1578,21 @@
|
||||
response policies also have per-vhost controls.</p>
|
||||
</div><button class="outline" data-i18n="security" id="settings-security-open"
|
||||
type="button">Security</button><button class="quiet" data-i18n="vhosts"
|
||||
id="settings-vhost-open" type="button">Vhosts</button><label
|
||||
class="ui-preference-control"><span data-i18n="theme">Theme</span><select id="ui-theme">
|
||||
<option data-i18n="themeDark" value="dark">Dark</option>
|
||||
<option data-i18n="themeLight" value="light">Light</option>
|
||||
</select></label><label class="ui-preference-control"><span data-i18n="density">Interface
|
||||
density</span><select id="ui-density">
|
||||
<option data-i18n="densityCompact" value="compact">Compact</option>
|
||||
<option data-i18n="densityDense" value="dense">Dense</option>
|
||||
<option data-i18n="densityComfortable" value="comfortable">Comfortable</option>
|
||||
</select></label>
|
||||
id="settings-vhost-open" type="button">Vhosts</button>
|
||||
</div>
|
||||
<div class="settings-filter-row">
|
||||
<label class="settings-filter"><span class="small-label" data-i18n="settingsSearch">Find
|
||||
settings</span><input autocomplete="off"
|
||||
data-i18n-placeholder="settingsSearchPlaceholder" id="settings-search"
|
||||
placeholder="Search settings..." type="search" /></label>
|
||||
<span class="fine" id="settings-search-status" role="status"></span>
|
||||
</div>
|
||||
<nav aria-label="Settings" class="settings-tabs" id="settings-tabs" role="tablist"><button
|
||||
aria-controls="settings-page-listeners" aria-selected="false" data-i18n="listeners"
|
||||
data-settings-tab="listeners" id="settings-tab-listeners" role="tab" tabindex="-1"
|
||||
type="button"></button><button aria-controls="settings-page-connections"
|
||||
aria-controls="settings-page-appearance" aria-selected="false" data-i18n="appearance"
|
||||
data-settings-tab="appearance" id="settings-tab-appearance" role="tab" tabindex="-1"
|
||||
type="button"></button><button aria-controls="settings-page-listeners" aria-selected="false"
|
||||
data-i18n="listeners" data-settings-tab="listeners" id="settings-tab-listeners" role="tab"
|
||||
tabindex="-1" type="button"></button><button aria-controls="settings-page-connections"
|
||||
aria-selected="false" data-i18n="httpConnections" data-settings-tab="connections"
|
||||
id="settings-tab-connections" role="tab" tabindex="-1" type="button"></button><button
|
||||
aria-controls="settings-page-docker-agents" aria-selected="false" data-i18n="dockerAgents"
|
||||
@@ -1622,6 +1631,139 @@
|
||||
data-settings-tab="security" id="settings-tab-security" role="tab" tabindex="-1"
|
||||
type="button"></button></nav>
|
||||
<div class="settings-pages" id="settings-pages">
|
||||
<div aria-labelledby="settings-tab-appearance" class="settings-page"
|
||||
data-settings-page="appearance" hidden="" id="settings-page-appearance" role="tabpanel">
|
||||
<div class="card edit-form appearance-settings-card">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="appearanceEyebrow">INTERFACE</span>
|
||||
<h2 data-i18n="appearance">Appearance</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="fine" data-i18n="appearanceHelp">These preferences are saved for your
|
||||
administrator account and follow you between sessions.</p>
|
||||
<section class="appearance-section">
|
||||
<div class="appearance-section-heading">
|
||||
<div><span class="small-label"
|
||||
data-i18n="appearanceInterfaceSection">INTERFACE</span>
|
||||
<h3 data-i18n="appearanceInterfaceTitle">Interface</h3>
|
||||
</div><button class="quiet appearance-section-reset"
|
||||
data-appearance-reset="interface" data-i18n="resetSection"
|
||||
type="button">Reset section</button>
|
||||
</div>
|
||||
<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="themeLight" value="light">Light</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="language">Language</span><select aria-label="Language"
|
||||
data-i18n-aria="language" id="language"></select></label>
|
||||
<label class="appearance-scale-control"><span><span
|
||||
data-i18n="interfaceScale">Interface scale</span>
|
||||
<output id="ui-scale-value" for="ui-scale">100%</output></span>
|
||||
<input id="ui-scale" max="125" min="70" step="5" type="range" value="100" />
|
||||
<small class="fine" data-i18n="interfaceScaleHelp">Scale the entire
|
||||
administration interface, including dialogs and overlays, without
|
||||
changing browser zoom.</small></label>
|
||||
<label><span data-i18n="timeFormat">Time format</span><select
|
||||
id="ui-time-format">
|
||||
<option data-i18n="timeFormat24" value="24h">24-hour</option>
|
||||
<option data-i18n="timeFormat12" value="12h">12-hour</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="buttonStyle">Button style</span><select
|
||||
id="ui-button-style">
|
||||
<option data-i18n="buttonStyleNormal" value="normal">Normal</option>
|
||||
<option data-i18n="buttonStyleCompact" value="compact">Compact</option>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
<section class="appearance-section">
|
||||
<div class="appearance-section-heading">
|
||||
<div><span class="small-label" data-i18n="appearanceChartsSection">CHARTS</span>
|
||||
<h3 data-i18n="chartStyle">Chart style</h3>
|
||||
</div><button class="quiet appearance-section-reset"
|
||||
data-appearance-reset="charts" data-i18n="resetSection" type="button">Reset
|
||||
section</button>
|
||||
</div>
|
||||
<div class="appearance-settings-grid appearance-chart-settings">
|
||||
<label><span data-i18n="chartStyle">Chart style</span><select
|
||||
id="ui-chart-style">
|
||||
<option data-i18n="chartStyleDefault" value="default">Default</option>
|
||||
<option data-i18n="chartStyleSoft" value="soft">Soft</option>
|
||||
<option data-i18n="chartStyleTechnical" value="technical">Technical
|
||||
</option>
|
||||
<option data-i18n="chartStyleHighContrast" value="high_contrast">High
|
||||
contrast</option>
|
||||
<option data-i18n="chartStyleNeon" value="neon">Neon</option>
|
||||
<option data-i18n="chartStyleMonochrome" value="monochrome">Monochrome
|
||||
</option>
|
||||
<option data-i18n="chartStyleWarm" value="warm">Warm</option>
|
||||
<option data-i18n="chartStyleCool" value="cool">Cool</option>
|
||||
</select></label>
|
||||
<div class="appearance-chart-preview" aria-hidden="true">
|
||||
<svg viewBox="0 0 300 74" preserveAspectRatio="none">
|
||||
<path class="chart-grid" d="M0 18H300 M0 37H300 M0 56H300"></path>
|
||||
<polyline class="appearance-chart-primary"
|
||||
points="0,58 48,42 92,50 138,25 182,35 228,16 300,29"></polyline>
|
||||
<polyline class="appearance-chart-secondary"
|
||||
points="0,63 48,56 92,38 138,45 182,27 228,37 300,19"></polyline>
|
||||
</svg>
|
||||
<span data-i18n="chartStylePreview">Preview</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-style-palette" id="chart-style-palette"
|
||||
aria-label="Chart style palettes" data-i18n-aria="chartStylePalettes"></div>
|
||||
</section>
|
||||
<section class="appearance-section">
|
||||
<div class="appearance-section-heading">
|
||||
<div><span class="small-label" data-i18n="appearanceDataSection">TABLES /
|
||||
LOGS</span>
|
||||
<h3 data-i18n="tableDensity">Tables and logs density</h3>
|
||||
</div><button class="quiet appearance-section-reset"
|
||||
data-appearance-reset="tables" data-i18n="resetSection" type="button">Reset
|
||||
section</button>
|
||||
</div>
|
||||
<div class="appearance-settings-grid">
|
||||
<label><span data-i18n="tableDensity">Tables and logs density</span><select
|
||||
id="ui-table-density">
|
||||
<option data-i18n="densityNormal" value="normal">Normal</option>
|
||||
<option data-i18n="densityCompact" value="compact">Compact</option>
|
||||
<option data-i18n="densityUltra" value="ultra">Ultra compact</option>
|
||||
</select><small class="fine" data-i18n="tableDensityHelp">Changes row height
|
||||
and log spacing independently from interface scale.</small></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="appearance-section">
|
||||
<div class="appearance-section-heading">
|
||||
<div><span class="small-label"
|
||||
data-i18n="appearanceDashboardSection">DASHBOARD</span>
|
||||
<h3 data-i18n="dashboardLayout">Dashboard layout</h3>
|
||||
</div><button class="quiet appearance-section-reset"
|
||||
data-appearance-reset="dashboard" data-i18n="resetSection"
|
||||
type="button">Reset section</button>
|
||||
</div>
|
||||
<div class="appearance-settings-grid">
|
||||
<label><span data-i18n="dashboardDensity">Dashboard density</span><select
|
||||
id="ui-dashboard-density">
|
||||
<option data-i18n="densityNormal" value="normal">Normal</option>
|
||||
<option data-i18n="densityCompact" value="compact">Compact</option>
|
||||
<option data-i18n="densityUltra" value="ultra">Ultra compact</option>
|
||||
</select><small class="fine" data-i18n="dashboardDensityHelp">Controls
|
||||
Overview telemetry card size without changing the rest of the
|
||||
interface.</small></label>
|
||||
<div class="dashboard-widget-settings"><span
|
||||
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>
|
||||
<div class="dashboard-widget-list" id="dashboard-widget-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="appearance-actions"><button class="outline" data-i18n="appearanceReset"
|
||||
id="appearance-reset" type="button">Reset appearance</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div aria-labelledby="settings-tab-listeners" class="settings-page"
|
||||
data-settings-page="listeners" hidden="" id="settings-page-listeners" role="tabpanel">
|
||||
<form class="card edit-form" id="listeners-form">
|
||||
@@ -1735,7 +1877,7 @@
|
||||
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
|
||||
</select>
|
||||
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.2"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.3"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -2534,7 +2676,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.26.2</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.26.3</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3245,7 +3387,7 @@ max_execution_time = 30</code></pre>
|
||||
<article class="card about-card">
|
||||
<div class="about-product">
|
||||
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.26.2</strong></div>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.26.3</strong></div>
|
||||
</div>
|
||||
<dl class="about-meta">
|
||||
<div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const themes = new Set(["dark", "light"]);
|
||||
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
|
||||
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(["listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
|
||||
const settingsTabs = new Set(["appearance", "listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
|
||||
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
|
||||
try { language = localStorage.getItem("rp-language"); } catch { }
|
||||
const t = key => packs.get(language)?.translations?.[key] ?? packs.get(fallback)?.translations?.[key] ?? key;
|
||||
|
||||
@@ -39,9 +39,6 @@
|
||||
bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages");
|
||||
$("settings-security-open").addEventListener("click", () => setPanel("security"));
|
||||
$("settings-vhost-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
const density = $("ui-density"); let chosen = "compact"; try { chosen = localStorage.getItem("rp-density") || chosen; } catch { }
|
||||
if (!["compact", "dense", "comfortable"].includes(chosen)) chosen = "compact"; density.value = chosen; document.body.dataset.density = chosen;
|
||||
density.addEventListener("change", () => { document.body.dataset.density = density.value; try { localStorage.setItem("rp-density", density.value); } catch { } });
|
||||
if (envelope) render();
|
||||
})();
|
||||
|
||||
@@ -49,7 +46,7 @@
|
||||
const dialog = $("logs-dialog"); let paused = null, expanded = null, docked = false, pinning = false, preferencesLoaded = false, preferencesLoading = null, preferenceSaveRunning = false, preferenceSaveQueued = false;
|
||||
const filters = ["log-vhost", "log-method", "log-status", "log-outcome", "log-filter"];
|
||||
const columns = [
|
||||
{ id: "time", label: "time", value: e => new Date(e.time_ms).toLocaleTimeString(locale()) },
|
||||
{ id: "time", label: "time", value: e => formatTime(e.time_ms) },
|
||||
{ id: "request", label: "request", value: e => `${e.method} ${e.path}` },
|
||||
{ id: "host", label: "logHost", value: e => e.host },
|
||||
{ id: "source_ip", label: "sourceIp", value: e => e.client_ip || "-" },
|
||||
|
||||
@@ -74,6 +74,21 @@
|
||||
const get = name => box.querySelector(`[data-field="${name}"]`);
|
||||
return { id: get("id").value.trim(), enabled: get("enabled").checked, source_scheme: get("source_scheme").value, match_type: get("match_type").value, path_prefix: get("path_prefix").value.trim(), target_type: get("target_type").value, target: get("target").value.trim(), target_scheme: get("target_scheme").value, path_mode: get("path_mode").value, query_mode: get("query_mode").value, status_code: Number(get("status_code").value), cache_seconds: Number(get("cache_seconds").value) || 0 };
|
||||
}
|
||||
function suspendDraft() {
|
||||
const state = {
|
||||
editing, revision, label: $("redirect-editing").textContent, controls: window.rustProxyDrafts.captureControls(form),
|
||||
hosts: window.rustProxyDrafts.takeChildren($("redirect-hosts-editor")), listeners: window.rustProxyDrafts.takeChildren($("redirect-listeners")), rules: window.rustProxyDrafts.takeChildren(rules),
|
||||
readRedirectBindings, readRedirectHosts
|
||||
};
|
||||
const title = value("redirect-id") || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
window.rustProxyDrafts.restoreChildren($("redirect-hosts-editor"), state.hosts); window.rustProxyDrafts.restoreChildren($("redirect-listeners"), state.listeners); window.rustProxyDrafts.restoreChildren(rules, state.rules);
|
||||
readRedirectBindings = state.readRedirectBindings; readRedirectHosts = state.readRedirectHosts; editing = state.editing; revision = state.revision; dirty = true; $("redirect-editing").textContent = state.label;
|
||||
window.rustProxyDrafts.restoreControls(form, state.controls); if (!dialog.open) dialog.showModal();
|
||||
}
|
||||
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("redirect"); if (dialog.open) dialog.close(); return true; }
|
||||
|
||||
function collect() {
|
||||
const id = value("redirect-id"), name = value("redirect-name") || id, hosts = readRedirectHosts();
|
||||
const listener_ids = readRedirectBindings();
|
||||
@@ -87,7 +102,7 @@
|
||||
list.replaceChildren(...entries.map(item => {
|
||||
const card = el("article", undefined, "vhost-card redirect-card"); const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
|
||||
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => load(structuredClone(item))), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
|
||||
const targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))];
|
||||
const targetSummary = targets.slice(0, 2).join(" · ") + (targets.length > 2 ? ` · +${targets.length - 2}` : "");
|
||||
const detail = el("div", undefined, "vhost-detail-strip"); for (const [k, v] of [["redirectTarget", targetSummary || "-"], ["redirectRules", String((item.rules || []).length)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(k), "small-label"), el("b", v)); detail.append(node); } card.append(detail); return card;
|
||||
@@ -100,9 +115,10 @@
|
||||
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
|
||||
try { const item = collect(), next = structuredClone(envelope.config.redirects || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("redirectDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("redirects", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); }
|
||||
});
|
||||
$("redirect-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
|
||||
$("redirect-close").addEventListener("click", async () => { if (await allowDiscard()) dialog.close(); }); $("redirect-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.redirects || []).find(item => item.id === editing) || defaults()) : defaults()); });
|
||||
dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
|
||||
$("redirect-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
|
||||
$("redirect-close").addEventListener("click", closeEditor); $("redirect-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.redirects || []).find(item => item.id === editing) || defaults()) : defaults()); });
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
window.rustProxyDrafts.register("redirect", { labelKey: "redirects", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyRedirects = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; } };
|
||||
if (envelope) render();
|
||||
})();
|
||||
@@ -2,6 +2,7 @@
|
||||
// Persistent tab DOM: switching tabs never discards unsaved form values.
|
||||
(() => {
|
||||
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 requestedTab = typeof location !== "undefined" && location.pathname === "/logging"
|
||||
? "logging"
|
||||
@@ -9,6 +10,17 @@
|
||||
const tabAliases = { http2: "connections" };
|
||||
const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab;
|
||||
let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab;
|
||||
function applySettingsSearch() {
|
||||
const query = String(search?.value || "").trim().toLocaleLowerCase();
|
||||
const matches = [];
|
||||
for (const button of buttons) {
|
||||
const page = document.querySelector(`[data-settings-page="${CSS.escape(button.dataset.settingsTab)}"]`);
|
||||
const haystack = `${button.textContent || ""} ${page?.textContent || ""}`.toLocaleLowerCase();
|
||||
const match = !query || haystack.includes(query); button.hidden = !match; if (match) matches.push(button);
|
||||
}
|
||||
if (searchStatus) searchStatus.textContent = query ? `${t("settingsSearchResults")}: ${matches.length}` : "";
|
||||
if (query && !matches.some(button => button.dataset.settingsTab === active) && matches[0]) select(matches[0].dataset.settingsTab, false, false);
|
||||
}
|
||||
function syncLocation() {
|
||||
if (typeof location === "undefined" || !["/settings", "/configuration", "/logging"].includes(location.pathname) || !["http:", "https:"].includes(location.protocol)) return;
|
||||
const url = new URL(location.href); const alreadyCanonical = url.pathname === "/settings" && url.searchParams.get("tab") === active; if (alreadyCanonical) return;
|
||||
@@ -16,18 +28,21 @@
|
||||
history.replaceState({ ...(history.state || {}), panel: "settings", settingsTab: active }, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
function select(name, focus = false, persist = true) {
|
||||
if (!buttons.some(b => b.dataset.settingsTab === name)) return; active = name;
|
||||
const target = buttons.find(button => button.dataset.settingsTab === name); if (!target) return;
|
||||
if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
|
||||
active = 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);
|
||||
if (persist) syncLocation();
|
||||
}
|
||||
for (const b of buttons) {
|
||||
b.addEventListener("click", () => select(b.dataset.settingsTab)); b.addEventListener("keydown", event => {
|
||||
const index = buttons.indexOf(b); let next;
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % buttons.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + buttons.length - 1) % buttons.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = buttons.length - 1; else return;
|
||||
event.preventDefault(); select(buttons[next].dataset.settingsTab, true);
|
||||
const visible = buttons.filter(button => !button.hidden), index = visible.indexOf(b); let next;
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % visible.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + visible.length - 1) % visible.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = visible.length - 1; else return;
|
||||
if (!visible.length) return; event.preventDefault(); select(visible[next].dataset.settingsTab, true);
|
||||
});
|
||||
}
|
||||
search?.addEventListener("input", applySettingsSearch);
|
||||
const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")];
|
||||
function selectCacheTab(name, focus = false) {
|
||||
if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return;
|
||||
@@ -101,7 +116,7 @@ curl --fail --show-error --request POST \
|
||||
const info = el("div", undefined, "certificate-api-credential-info");
|
||||
info.append(el("b", credential.key_id || "-", "mono"));
|
||||
if (credential.comment) info.append(el("span", credential.comment, "fine"));
|
||||
const lastUsed = credential.last_used_at ? new Date(Number(credential.last_used_at) * 1000).toLocaleString() : t("certificateApiNeverUsed");
|
||||
const lastUsed = credential.last_used_at ? formatDateTime(Number(credential.last_used_at) * 1000) : t("certificateApiNeverUsed");
|
||||
info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine"));
|
||||
const remove = action(t("certificateApiRevoke"), async () => {
|
||||
if (!await appConfirm(t("certificateApiRevokeOneConfirm").replace("{key}", credential.key_id), { danger: true })) return;
|
||||
@@ -206,6 +221,6 @@ curl --fail --show-error --request POST \
|
||||
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, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("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("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
select(active); render();
|
||||
})();
|
||||
@@ -83,7 +83,15 @@
|
||||
idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections")
|
||||
};
|
||||
}
|
||||
async function closeEditor() { if (!(await allowDiscard())) return false; dialog.close(); return true; }
|
||||
function suspendDraft() {
|
||||
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen };
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen;
|
||||
editing = state.editing; revision = state.revision; initialized = true; dirty = true; $("stream-editing").textContent = state.label; if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("stream"); if (dialog.open) dialog.close(); return true; }
|
||||
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
@@ -100,7 +108,7 @@
|
||||
if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill"));
|
||||
identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
actions.append(button("edit", async () => { if (await allowDiscard()) load(structuredClone(stream)); }), button("remove", async () => {
|
||||
actions.append(button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("streamDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.streams = (next.streams || []).filter(item => item.id !== stream.id); if (await saveSection("streams", next.streams || [])) { if (editing === stream.id) load(); render(); }
|
||||
}));
|
||||
head.append(actions); box.append(head);
|
||||
@@ -166,12 +174,13 @@
|
||||
if (await saveSection("streams", next.streams || [])) { dirty = false; load(value, false); dialog.close(); render(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
$("stream-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
|
||||
$("stream-listener-add").addEventListener("click", async () => { if (await allowDiscard()) load(); });
|
||||
$("stream-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
|
||||
$("stream-listener-add").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
|
||||
$("stream-search").addEventListener("input", render);
|
||||
$("stream-close").addEventListener("click", closeEditor);
|
||||
$("stream-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.streams || []).find(item => item.id === editing) || defaults()) : defaults()); });
|
||||
dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
window.rustProxyDrafts.register("stream", { labelKey: "streams", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyStreams = { render, renderStatus, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
})();
|
||||
+874
-54
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
let loading = false, timer = null, lastData = null, activeTab = "overview";
|
||||
const chartPalette = ["var(--accent)", "var(--green)", "var(--blue)", "var(--transform-chart)", "var(--red)", "var(--notice-text)", "var(--border-hover)", "var(--separator)"];
|
||||
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
|
||||
const scope = () => $("traffic-scope")?.value || "*";
|
||||
const number = value => fmt(Number(value) || 0);
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0);
|
||||
totalNode.textContent = number(total); if (empty) empty.hidden = total > 0;
|
||||
if (!total) {
|
||||
chart.style.background = "var(--panel2)"; chart.setAttribute("aria-label", t("trafficNoData"));
|
||||
chart.style.background = "var(--chart-surface)"; chart.setAttribute("aria-label", t("trafficNoData"));
|
||||
legend.replaceChildren(el("li", t("trafficNoData"), "fine")); return;
|
||||
}
|
||||
const shown = values.slice(0, 7).map(item => ({ value: item.value || "-", count: Number(item.count) || 0 }));
|
||||
@@ -98,7 +98,7 @@
|
||||
{ value: t("trafficRequestBytes"), count: Number(stats.request_bytes) || 0 },
|
||||
{ value: t("trafficResponseBytes"), count: Number(stats.response_bytes) || 0 },
|
||||
], true, fmtBytes);
|
||||
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
|
||||
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${formatTime(new Date())}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
@@ -113,7 +113,7 @@
|
||||
document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren());
|
||||
for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) {
|
||||
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
|
||||
if (chart) chart.style.background = "var(--panel2)"; legend?.replaceChildren(); if (total) total.textContent = "-";
|
||||
if (chart) chart.style.background = "var(--chart-surface)"; legend?.replaceChildren(); if (total) total.textContent = "-";
|
||||
}
|
||||
selectTab("overview");
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
$("tp-inflight").textContent = Number.isFinite(inflight) ? number(inflight) : "-";
|
||||
$("tp-inflight-detail").textContent = Number.isFinite(maxInflight) ? `${t("cacheLimitShort")}: ${number(maxInflight)}` : "-";
|
||||
renderTypes(counters); renderReasons(counters); renderVhosts(data);
|
||||
$("transform-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
|
||||
$("transform-panel-state").textContent = `${t("cacheUpdated")}: ${formatTime(new Date())}`;
|
||||
}
|
||||
async function refresh() {
|
||||
if (!authenticated || currentPanel !== "transform" || loading) return;
|
||||
|
||||
@@ -132,8 +132,29 @@
|
||||
note(parent, "locationHeadersHelp");
|
||||
return { read: () => validateHeaders(JSON.parse(target.value)) };
|
||||
}
|
||||
async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; }
|
||||
$("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void closeEditor(); });
|
||||
function suspendDraft() {
|
||||
const state = {
|
||||
editing, revision, label: $("vhost-editing").textContent,
|
||||
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor,
|
||||
stagedCertificates, uploading
|
||||
};
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled");
|
||||
draftEpoch++; editing = null; revision = null; dirty = false; stagedCertificates = []; uploading = false; dialog.close();
|
||||
return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
|
||||
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor;
|
||||
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
|
||||
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function closeEditor() {
|
||||
if (dirty) return window.rustProxyDrafts.dock("vhost");
|
||||
draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); if (dialog.open) dialog.close(); return true;
|
||||
}
|
||||
$("vhost-close").addEventListener("click", closeEditor); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
function administrationListenCandidates() {
|
||||
return [
|
||||
envelope?.config.administration?.listen,
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
openButton.disabled = !browseHost || v.enabled === false;
|
||||
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
|
||||
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
|
||||
actions.append(quickToggle, openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
|
||||
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
||||
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
||||
@@ -225,8 +225,8 @@
|
||||
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
|
||||
const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
|
||||
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
const generationAgeRaw = member.upstream_client_generation_age_seconds;
|
||||
const generationAge = Math.max(0, Number(generationAgeRaw));
|
||||
@@ -322,7 +322,7 @@
|
||||
if (saved) { load(value, [], false); dialog.close(); render(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
$("vhost-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
|
||||
$("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
|
||||
$("vhost-search").addEventListener("input", render);
|
||||
$("vhost-reset").addEventListener("click", async () => {
|
||||
if (!(await allowDiscard())) return;
|
||||
@@ -332,5 +332,6 @@
|
||||
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
initPoolTableTools();
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences: applyPoolPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; applyPoolTableView(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
+27
-11
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
let editing = null;
|
||||
let editing = null, dirty = false;
|
||||
const form = $("web-service-form"), dialog = $("web-service-dialog");
|
||||
const lines = value => value.split(/[\n,]+/).map(item => item.trim()).filter(Boolean);
|
||||
const strictLines = value => value.split(/\n+/).map(item => item.trim()).filter(Boolean);
|
||||
@@ -280,7 +280,7 @@
|
||||
preset.hardening.block_common_attacks = $("web-service-hardening-attacks").checked;
|
||||
preset.hardening.strip_unnecessary_headers = $("web-service-hardening-headers").checked;
|
||||
populateSettings(preset);
|
||||
updatePresetSummary();
|
||||
updatePresetSummary(); dirty = true;
|
||||
toast(t("webServicePresetApplied"));
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
@@ -310,11 +310,24 @@
|
||||
$("web-service-preset").value = "custom";
|
||||
updatePresetSummary();
|
||||
$("web-service-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
activateTab("general");
|
||||
activateTab("general"); dirty = false;
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
function close() { editing = null; form.reset(); if (dialog.open) dialog.close(); }
|
||||
function clearEditor() { editing = null; dirty = false; form.reset(); if (dialog.open) dialog.close(); }
|
||||
function suspendDraft() {
|
||||
const activeTab = $("web-service-tabs").querySelector("button.active")?.dataset.tab || "general";
|
||||
const state = { editing, activeTab, label: $("web-service-editing").textContent, controls: window.rustProxyDrafts.captureControls(form) };
|
||||
const title = $("web-service-id").value.trim() || $("web-service-name").value.trim() || editing || t("draftUntitled");
|
||||
editing = null; dirty = false; form.reset(); dialog.close(); return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
const saved = state.editing ? (envelope?.config.web_services || []).find(item => item.id === state.editing) : null;
|
||||
open(saved ? structuredClone(saved) : (state.editing ? { id: state.editing } : null));
|
||||
window.rustProxyDrafts.restoreControls(form, state.controls); editing = state.editing; $("web-service-editing").textContent = state.label;
|
||||
syncPhp(); syncPrecompressed(); syncHtaccess(); updatePresetSummary(); activateTab(state.activeTab || "general"); dirty = true;
|
||||
}
|
||||
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("web-server"); clearEditor(); return true; }
|
||||
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
@@ -327,7 +340,7 @@
|
||||
title.append(el("strong", service.name || service.id, "entity-title"), el("span", t("webServer"), "pill live"));
|
||||
identity.append(title, el("div", service.document_root, "vhost-hosts"));
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
const edit = el("button", t("edit"), "quiet"); edit.type = "button"; edit.addEventListener("click", () => open(structuredClone(service)));
|
||||
const edit = el("button", t("edit"), "quiet"); edit.type = "button"; edit.addEventListener("click", () => { window.rustProxyDrafts.prepare(); open(structuredClone(service)); });
|
||||
const remove = el("button", t("remove"), "quiet"); remove.type = "button"; remove.addEventListener("click", async () => {
|
||||
const used = usage(service.id);
|
||||
if (used.length) { toast(`${t("webServiceInUse")}: ${used.join(", ")}`, true); return; }
|
||||
@@ -412,13 +425,15 @@
|
||||
if (duplicate) { toast(t("webServiceDuplicate"), true); return; }
|
||||
const position = next.findIndex(service => service.id === editing);
|
||||
if (position >= 0) next[position] = item; else next.push(item);
|
||||
if (await saveSection("web-services", next)) { close(); render(); }
|
||||
if (await saveSection("web-services", next)) { dirty = false; clearEditor(); render(); }
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
initTabs();
|
||||
form.addEventListener("input", () => { dirty = true; });
|
||||
form.addEventListener("change", () => { dirty = true; });
|
||||
$("web-service-php-enabled").addEventListener("change", syncPhp);
|
||||
$("web-service-precompressed").addEventListener("change", syncPrecompressed);
|
||||
$("web-service-htaccess-enabled").addEventListener("change", syncHtaccess);
|
||||
@@ -428,11 +443,12 @@
|
||||
$("htaccess-help-close").addEventListener("click", () => $("htaccess-help-dialog").close());
|
||||
$("web-service-php-help").addEventListener("click", () => { const help = $("php-help-dialog"); if (!help.open) help.showModal(); });
|
||||
$("php-help-close").addEventListener("click", () => $("php-help-dialog").close());
|
||||
$("web-service-new").addEventListener("click", () => open());
|
||||
$("web-service-close").addEventListener("click", close);
|
||||
$("web-service-reset").addEventListener("click", close);
|
||||
$("web-service-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); open(); });
|
||||
$("web-service-close").addEventListener("click", closeEditor);
|
||||
$("web-service-reset").addEventListener("click", async () => { if (!dirty || await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") })) clearEditor(); });
|
||||
$("web-service-search").addEventListener("input", render);
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); close(); });
|
||||
window.rustProxyWebServer = { render, reset: close };
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
window.rustProxyDrafts.register("web-server", { labelKey: "webServer", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: clearEditor });
|
||||
window.rustProxyWebServer = { render, reset: clearEditor };
|
||||
if (envelope) render();
|
||||
})();
|
||||
Reference in new issue
Block a user