v0.31.0
This commit is contained in:
1 parent
f213d853e2
commit
76b8bb7ee2
78 files changed
+7750
-5204
No files matched your search
@@ -233,6 +233,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"/docs/SECURITY_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
reader_doc!("docs/SECURITY_AUDIT_0.31.0.md", "audits"),
|
||||
(
|
||||
"docs/SETTINGS.md",
|
||||
"guides",
|
||||
@@ -243,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
|
||||
),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.0.md", "technical"),
|
||||
(
|
||||
"docs/TEST_REPORT.md",
|
||||
"guides",
|
||||
|
||||
@@ -294,7 +294,21 @@ async fn audit(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
||||
async fn metrics(State(state): State<Arc<App>>) -> Response {
|
||||
crate::prometheus::response(&state)
|
||||
}
|
||||
async fn events(State(state): State<Arc<App>>, headers: HeaderMap) -> Result<Response, ApiError> {
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct EventsQuery {
|
||||
interval: Option<u64>,
|
||||
}
|
||||
|
||||
async fn events(
|
||||
State(state): State<Arc<App>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<EventsQuery>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let interval = query.interval.unwrap_or(2);
|
||||
if ![1, 2, 5, 10].contains(&interval) {
|
||||
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "invalid SSE interval".into()));
|
||||
}
|
||||
let secret = auth::cookie_secret(&headers)
|
||||
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?;
|
||||
let permit = state
|
||||
@@ -310,10 +324,10 @@ async fn events(State(state): State<Arc<App>>, headers: HeaderMap) -> Result<Res
|
||||
// One bounded snapshot, produced only when the consumer polls. No per-client queue.
|
||||
// Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions.
|
||||
let stream = futures_util::stream::unfold(
|
||||
(state, secret, permit, false),
|
||||
|(state, secret, permit, delayed)| async move {
|
||||
(state, secret, permit, false, interval),
|
||||
|(state, secret, permit, delayed, interval)| async move {
|
||||
if delayed {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||
}
|
||||
let session_seconds = state
|
||||
.config
|
||||
@@ -331,11 +345,11 @@ async fn events(State(state): State<Arc<App>>, headers: HeaderMap) -> Result<Res
|
||||
_ => return None,
|
||||
}
|
||||
let payload =
|
||||
json!({"status":status_payload(&state), "logs":logs_payload(&state)}).to_string();
|
||||
json!({"status":live_status_payload(&state), "logs":logs_payload(&state)}).to_string();
|
||||
let event = Event::default().event("snapshot").data(payload);
|
||||
Some((
|
||||
Ok::<Event, Infallible>(event),
|
||||
(state, secret, permit, true),
|
||||
(state, secret, permit, true, interval),
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
+167
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.30.1",
|
||||
"version": "0.31.0",
|
||||
"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": [
|
||||
@@ -772,6 +772,25 @@
|
||||
"maximum": 30,
|
||||
"default": 30,
|
||||
"description": "Absolute browser administrator-session lifetime in days. Applies immediately and does not require restart."
|
||||
},
|
||||
"panel_compression": {
|
||||
"type": "object",
|
||||
"description": "Negotiated compression for authenticated panel HTML and embedded CSS/JS/translation assets; takes effect without restart. At equal client q: zstd > br > gzip. Disable all for identity. Independent from public proxy response compression.",
|
||||
"properties": {
|
||||
"zstd": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"br": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"gzip": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1859,6 +1878,16 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Suppress RustProxy cache diagnostic headers on MISS/BYPASS. Cache HIT diagnostics remain governed by expose_* flags. The independently configured Via header is unaffected."
|
||||
},
|
||||
"http_invalidation_allowed_cidrs": {
|
||||
"type": "array",
|
||||
"description": "Optional IP/CIDR allowlist for HTTP BAN and PURGE on matched public vhosts. Empty disables all HTTP invalidation. Max 32 entries; /0 networks forbidden. Trusted reverse proxies must provide an authenticated forwarding chain.",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -3527,6 +3556,60 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Account-scoped sort direction for the Streams list."
|
||||
},
|
||||
"refresh": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Account-scoped polling preferences; omitted fields use defaults. Zero disables only that source automatically.",
|
||||
"properties": {
|
||||
"live_enabled": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"overview_seconds": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
1,
|
||||
2,
|
||||
5,
|
||||
10
|
||||
],
|
||||
"default": 2
|
||||
},
|
||||
"traffic_seconds": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
0,
|
||||
3,
|
||||
5,
|
||||
10,
|
||||
30
|
||||
],
|
||||
"default": 5
|
||||
},
|
||||
"cache_seconds": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
0,
|
||||
3,
|
||||
5,
|
||||
10,
|
||||
30
|
||||
],
|
||||
"default": 5
|
||||
},
|
||||
"system_info_seconds": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
0,
|
||||
15,
|
||||
30,
|
||||
60,
|
||||
300
|
||||
],
|
||||
"default": 60
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -5777,6 +5860,27 @@
|
||||
"$ref": "#/components/schemas/WebService/properties/php"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SystemInfo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"system": {
|
||||
"type": "object",
|
||||
"description": "Cached system and process metrics",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"runtime": {
|
||||
"type": "object",
|
||||
"description": "Cached platform and ACME runtime metadata",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"memory_accounting": {
|
||||
"type": "object",
|
||||
"description": "Cached process/cache and transform memory accounting",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6691,7 +6795,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "view",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Omit for the backward-compatible full status; use live for the compact dynamic-only fallback.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"live"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "Without view, returns the historical full status including runtime and system diagnostics. view=live returns compact live-only telemetry; the browser fetches system diagnostics separately."
|
||||
}
|
||||
},
|
||||
"/api/backends": {
|
||||
@@ -8228,7 +8347,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bounded snapshot every approximately 2 seconds. Session/revocation rechecked before every snapshot. Not executed in Swagger because it is an unbounded stream."
|
||||
"description": "SSE snapshot events contain compact volatile status and bounded logs. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/5/10 second pacing; invalid values return 422.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "interval",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Per-client snapshot interval (seconds), never a global SSE rate change.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
1,
|
||||
2,
|
||||
5,
|
||||
10
|
||||
],
|
||||
"default": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/metrics": {
|
||||
@@ -10727,6 +10864,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/system-info": {
|
||||
"get": {
|
||||
"summary": "Cached system and runtime information for the administration panel",
|
||||
"tags": [
|
||||
"Monitoring"
|
||||
],
|
||||
"operationId": "get_api_system_info",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Read-only cached system info; no full configuration or secrets",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SystemInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"description": "Invalid origin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-9
@@ -79,6 +79,8 @@ async fn cache_purge(
|
||||
));
|
||||
}
|
||||
}
|
||||
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
|
||||
let detail = format!(
|
||||
"scope={}; in-flight fills invalidated",
|
||||
scope.vhost.as_deref().unwrap_or("all")
|
||||
@@ -88,10 +90,15 @@ async fn cache_purge(
|
||||
.audit(&session.username, "cache.purge", &detail)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let purged = match &scope.vhost {
|
||||
Some(id) => state.cache.purge_scope(id),
|
||||
None => state.cache.purge(None),
|
||||
};
|
||||
let cache = state.cache.clone();
|
||||
let vhost = scope.vhost.clone();
|
||||
let purged = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
match &vhost {
|
||||
Some(id) => cache.purge_scope(id),
|
||||
None => cache.purge(None),
|
||||
}
|
||||
}).await.map_err(auth::internal)?;
|
||||
Ok(Json(
|
||||
json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}),
|
||||
))
|
||||
@@ -119,6 +126,8 @@ async fn cache_invalidate(
|
||||
));
|
||||
}
|
||||
}
|
||||
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
|
||||
let detail = format!(
|
||||
"operation={:?}; selector={:?}; vhost={}; host={}; value={:?}",
|
||||
request.operation,
|
||||
@@ -132,10 +141,12 @@ async fn cache_invalidate(
|
||||
.audit(&session.username, "cache.invalidate", &detail)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let result = state
|
||||
.cache
|
||||
.invalidate(request)
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
|
||||
let cache = state.cache.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
cache.invalidate(request)
|
||||
}).await.map_err(auth::internal)?
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
|
||||
Ok(Json(json!({
|
||||
"result": result,
|
||||
"metrics": state.cache.view(),
|
||||
@@ -152,7 +163,9 @@ async fn cache_ban_delete(
|
||||
Extension(session): Extension<Session>,
|
||||
Path(id): Path<u64>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !state.cache.remove_ban(id) {
|
||||
// This is a destructive operation: fail closed if the audit store is
|
||||
// unavailable, rather than removing a ban and only then trying to log it.
|
||||
if !state.cache.bans().iter().any(|rule| rule.id == id) {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown cache BAN rule".into(),
|
||||
@@ -167,6 +180,9 @@ async fn cache_ban_delete(
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
if !state.cache.remove_ban(id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into()));
|
||||
}
|
||||
Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Fixed private admin assets, shared by all admins. The existing route/access
|
||||
// allowlist decides which paths are reachable. Do not expand routes dynamically.
|
||||
use super::panel_content::PanelContent;
|
||||
use crate::runtime::App;
|
||||
use axum::{extract::{Request, State}, response::{IntoResponse, Response}};
|
||||
use http::StatusCode;
|
||||
use std::{collections::HashMap, sync::{Arc, LazyLock, OnceLock}};
|
||||
|
||||
struct Asset {
|
||||
mime: &'static str,
|
||||
raw: &'static str,
|
||||
content: OnceLock<PanelContent>,
|
||||
}
|
||||
|
||||
const JS: &str = "text/javascript; charset=utf-8";
|
||||
const JSON: &str = "application/json; charset=utf-8";
|
||||
const CSS: &str = "text/css; charset=utf-8";
|
||||
const SVG: &str = "image/svg+xml";
|
||||
|
||||
static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
|
||||
let entries: [(&'static str, &'static str, &'static str); 22] = [
|
||||
("/assets/login.js", JS, include_str!("web/login.js")),
|
||||
("/assets/login-langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/login-langs.json"))),
|
||||
("/assets/langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/langs.json"))),
|
||||
("/assets/app.js", JS, super::admin_app_javascript()),
|
||||
("/assets/management.js", JS, include_str!("web/management.js")),
|
||||
("/assets/drafts.js", JS, include_str!("web/drafts.js")),
|
||||
("/assets/integrations.js", JS, include_str!("web/integrations.js")),
|
||||
("/assets/traffic-panel.js", JS, include_str!("web/traffic-panel.js")),
|
||||
("/assets/cache-panel.js", JS, include_str!("web/cache-panel.js")),
|
||||
("/assets/transform-panel.js", JS, include_str!("web/transform-panel.js")),
|
||||
("/assets/file-logs.js", JS, include_str!("web/file-logs.js")),
|
||||
("/assets/settings.js", JS, include_str!("web/settings.js")),
|
||||
("/assets/acme.js", JS, include_str!("web/acme.js")),
|
||||
("/assets/vhosts.js", JS, super::admin_vhosts_javascript()),
|
||||
("/assets/web-server-profiles.js", JS, include_str!("web/web-server-profiles.js")),
|
||||
("/assets/web-server.js", JS, include_str!("web/web-server.js")),
|
||||
("/assets/docker.js", JS, include_str!("web/docker.js")),
|
||||
("/assets/streams.js", JS, include_str!("web/streams.js")),
|
||||
("/assets/redirects.js", JS, include_str!("web/redirects.js")),
|
||||
("/assets/host404.js", JS, include_str!("web/host404.js")),
|
||||
("/assets/style.css", CSS, include_str!("web/style.css")),
|
||||
("/favicon.svg", SVG, include_str!("web/favicon.svg")),
|
||||
];
|
||||
entries.into_iter().map(|(path, mime, raw)| (path, Asset { mime, raw, content: OnceLock::new() }))
|
||||
.collect()
|
||||
});
|
||||
|
||||
pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Response {
|
||||
let Some(asset) = ASSETS.get(request.uri().path()) else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let snapshot = state.config.load();
|
||||
let codecs = &snapshot.envelope.config.administration.panel_compression;
|
||||
asset.content.get_or_init(|| PanelContent::new(asset.raw))
|
||||
.response(request.headers(), codecs, asset.mime)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Shared immutable precompressed responses for private panel HTML and assets.
|
||||
// Each embedded document is encoded once on first request, never per visitor.
|
||||
use crate::settings::PanelCompression;
|
||||
use axum::{body::Body, response::Response};
|
||||
use bytes::Bytes;
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use http::{header, HeaderMap, HeaderValue};
|
||||
use std::io::Write;
|
||||
|
||||
pub(super) struct PanelContent {
|
||||
plain: Bytes,
|
||||
gzip: Bytes,
|
||||
brotli: Bytes,
|
||||
zstd: Bytes,
|
||||
}
|
||||
|
||||
impl PanelContent {
|
||||
pub(super) fn new(raw: &'static str) -> Self {
|
||||
let mut gzip = GzEncoder::new(Vec::new(), Compression::default());
|
||||
gzip.write_all(raw.as_bytes()).expect("compress panel gzip");
|
||||
let gzip = Bytes::from(gzip.finish().expect("finish panel gzip"));
|
||||
let mut br = Vec::new();
|
||||
{
|
||||
let mut writer = brotli::CompressorWriter::new(&mut br, 4096, 5, 22);
|
||||
writer.write_all(raw.as_bytes()).expect("compress panel Brotli");
|
||||
}
|
||||
let zstd = zstd::stream::encode_all(raw.as_bytes(), 3).expect("compress panel zstd");
|
||||
Self { plain: Bytes::from_static(raw.as_bytes()), gzip, brotli: Bytes::from(br), zstd: Bytes::from(zstd) }
|
||||
}
|
||||
|
||||
pub(super) fn response(&self, headers: &HeaderMap, codecs: &PanelCompression, mime: &'static str) -> Response {
|
||||
let settings = crate::compression::Settings {
|
||||
gzip: codecs.gzip, br: codecs.br, zstd: codecs.zstd,
|
||||
deflate: false, min_bytes: 1024,
|
||||
};
|
||||
let encoding = if crate::compression::no_transform(headers) {
|
||||
None
|
||||
} else {
|
||||
crate::compression::preferred_encoding(headers, &settings)
|
||||
};
|
||||
let (body, name) = match encoding.map(|codec| codec.as_str()) {
|
||||
Some("zstd") => (self.zstd.clone(), Some("zstd")),
|
||||
Some("br") => (self.brotli.clone(), Some("br")),
|
||||
Some("gzip") => (self.gzip.clone(), Some("gzip")),
|
||||
_ => (self.plain.clone(), None),
|
||||
};
|
||||
let mut response = Response::new(Body::from(body.clone()));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
|
||||
response.headers_mut().insert(header::CONTENT_LENGTH, HeaderValue::from_str(&body.len().to_string()).expect("valid length"));
|
||||
if let Some(name) = name {
|
||||
response.headers_mut().insert(header::CONTENT_ENCODING, HeaderValue::from_static(name));
|
||||
}
|
||||
response.headers_mut().insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||
response
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,4 @@
|
||||
const LIVE_LOG_COLUMNS: &[&str] = &[
|
||||
"time",
|
||||
"request",
|
||||
"host",
|
||||
"source_ip",
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"duration",
|
||||
"cache",
|
||||
"outcome",
|
||||
"transport",
|
||||
"websocket",
|
||||
"upstream_error",
|
||||
];
|
||||
const FILE_LOG_COLUMN_MAX: usize = 64;
|
||||
const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"active",
|
||||
"requests",
|
||||
"errors",
|
||||
"latency",
|
||||
"traffic",
|
||||
"generation",
|
||||
"generationRequests",
|
||||
"generationAge",
|
||||
"rotations",
|
||||
"lastFailure",
|
||||
"lastRecovery",
|
||||
];
|
||||
const VHOST_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"id",
|
||||
"hostname",
|
||||
"status",
|
||||
"listeners",
|
||||
"certificate",
|
||||
"backends",
|
||||
];
|
||||
const DOCKER_VHOST_SORT_COLUMNS: &[&str] = &[
|
||||
"service",
|
||||
"hostname",
|
||||
"status",
|
||||
"backend",
|
||||
"scheme",
|
||||
"published",
|
||||
];
|
||||
const REDIRECT_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"id",
|
||||
"name",
|
||||
"hostname",
|
||||
"status",
|
||||
"target",
|
||||
"rules",
|
||||
"listeners",
|
||||
"certificate",
|
||||
];
|
||||
const NOTFOUND_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"id",
|
||||
"name",
|
||||
"hostname",
|
||||
"status",
|
||||
"mode",
|
||||
"listeners",
|
||||
"certificate",
|
||||
];
|
||||
const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"name",
|
||||
"id",
|
||||
"protocol",
|
||||
"listen",
|
||||
"upstream",
|
||||
"status",
|
||||
"maxConnections",
|
||||
"idleTimeout",
|
||||
];
|
||||
const DASHBOARD_WIDGETS: &[&str] = &[
|
||||
"requests",
|
||||
"active",
|
||||
"bandwidth",
|
||||
"errors",
|
||||
"cache",
|
||||
"memory",
|
||||
"websocket",
|
||||
"http2",
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol",
|
||||
];
|
||||
include!("preferences_columns.rs");
|
||||
|
||||
fn default_admin_theme() -> String {
|
||||
"dark".to_owned()
|
||||
@@ -159,9 +69,13 @@ fn default_stream_list_sort() -> String {
|
||||
"name".to_owned()
|
||||
}
|
||||
|
||||
include!("refresh_preferences.rs");
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AdminPreferences {
|
||||
#[serde(default)]
|
||||
refresh: RefreshPreferences,
|
||||
#[serde(default = "default_live_log_columns")]
|
||||
live_log_columns: Vec<String>,
|
||||
#[serde(default = "default_admin_theme")]
|
||||
theme: String,
|
||||
@@ -216,6 +130,7 @@ struct AdminPreferences {
|
||||
impl Default for AdminPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
refresh: RefreshPreferences::default(),
|
||||
live_log_columns: LIVE_LOG_COLUMNS
|
||||
.iter()
|
||||
.map(|value| (*value).to_owned())
|
||||
@@ -249,6 +164,7 @@ impl Default for AdminPreferences {
|
||||
}
|
||||
|
||||
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
|
||||
validate_refresh_preferences(&preferences.refresh)?;
|
||||
if preferences.live_log_columns.is_empty()
|
||||
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
|
||||
{
|
||||
@@ -490,3 +406,4 @@ async fn update_preferences(
|
||||
}
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
const LIVE_LOG_COLUMNS: &[&str] = &[
|
||||
"time",
|
||||
"request",
|
||||
"host",
|
||||
"source_ip",
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"duration",
|
||||
"cache",
|
||||
"outcome",
|
||||
"transport",
|
||||
"websocket",
|
||||
"upstream_error",
|
||||
];
|
||||
const FILE_LOG_COLUMN_MAX: usize = 64;
|
||||
const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"active",
|
||||
"requests",
|
||||
"errors",
|
||||
"latency",
|
||||
"traffic",
|
||||
"generation",
|
||||
"generationRequests",
|
||||
"generationAge",
|
||||
"rotations",
|
||||
"lastFailure",
|
||||
"lastRecovery",
|
||||
];
|
||||
const VHOST_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"id",
|
||||
"hostname",
|
||||
"status",
|
||||
"listeners",
|
||||
"certificate",
|
||||
"backends",
|
||||
];
|
||||
const DOCKER_VHOST_SORT_COLUMNS: &[&str] = &[
|
||||
"service",
|
||||
"hostname",
|
||||
"status",
|
||||
"backend",
|
||||
"scheme",
|
||||
"published",
|
||||
];
|
||||
const REDIRECT_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"id",
|
||||
"name",
|
||||
"hostname",
|
||||
"status",
|
||||
"target",
|
||||
"rules",
|
||||
"listeners",
|
||||
"certificate",
|
||||
];
|
||||
const NOTFOUND_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"id",
|
||||
"name",
|
||||
"hostname",
|
||||
"status",
|
||||
"mode",
|
||||
"listeners",
|
||||
"certificate",
|
||||
];
|
||||
const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"name",
|
||||
"id",
|
||||
"protocol",
|
||||
"listen",
|
||||
"upstream",
|
||||
"status",
|
||||
"maxConnections",
|
||||
"idleTimeout",
|
||||
];
|
||||
const DASHBOARD_WIDGETS: &[&str] = &[
|
||||
"requests",
|
||||
"active",
|
||||
"bandwidth",
|
||||
"errors",
|
||||
"cache",
|
||||
"memory",
|
||||
"websocket",
|
||||
"http2",
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol",
|
||||
];
|
||||
|
||||
+31
-14
@@ -1,21 +1,35 @@
|
||||
//! Deliberately small anonymous surface. Operational templates/assets are private.
|
||||
use crate::{auth::Session, runtime::ApiError};
|
||||
use axum::{extract::rejection::JsonRejection, response::Html, Extension};
|
||||
use http::StatusCode;
|
||||
use std::sync::OnceLock;
|
||||
use crate::{auth::Session, runtime::{ApiError, App}, settings::PanelCompression};
|
||||
use axum::{extract::{rejection::JsonRejection, State}, response::{Html, IntoResponse, Response}, Extension};
|
||||
use http::{HeaderMap, StatusCode};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
static ADMIN_INDEX: OnceLock<String> = OnceLock::new();
|
||||
use super::panel_content::PanelContent;
|
||||
|
||||
pub(super) async fn index(session: Option<Extension<Session>>) -> Html<&'static str> {
|
||||
if session.is_some() {
|
||||
Html(ADMIN_INDEX.get_or_init(|| {
|
||||
include_str!("web/index.html")
|
||||
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"))
|
||||
}).as_str())
|
||||
} else {
|
||||
Html(include_str!("web/login.html"))
|
||||
}
|
||||
static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
|
||||
|
||||
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
|
||||
let content = ADMIN_INDEX.get_or_init(|| {
|
||||
let html = include_str!("web/index.html")
|
||||
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"));
|
||||
// Exactly one static panel document shared between all administrator accounts.
|
||||
PanelContent::new(Box::leak(html.into_boxed_str()))
|
||||
});
|
||||
content.response(headers, compression, "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
pub(super) async fn index(
|
||||
State(state): State<Arc<App>>,
|
||||
session: Option<Extension<Session>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if session.is_none() {
|
||||
return Html(include_str!("web/login.html")).into_response();
|
||||
}
|
||||
let snapshot = state.config.load();
|
||||
panel_response(&headers, &snapshot.envelope.config.administration.panel_compression)
|
||||
}
|
||||
|
||||
pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
|
||||
// Keep useful protocol statuses without returning serde's input excerpts.
|
||||
let status = match error.status() {
|
||||
@@ -25,3 +39,6 @@ pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
|
||||
};
|
||||
ApiError(status, "auth.invalid_request".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,57 @@
|
||||
use super::*;
|
||||
use flate2::read::GzDecoder;
|
||||
use http_body_util::BodyExt;
|
||||
use std::io::Read;
|
||||
|
||||
fn headers(value: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(header::ACCEPT_ENCODING, value.parse().unwrap());
|
||||
h
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn panel_chooses_zstd_brotli_gzip_by_quality_and_support() {
|
||||
let all = PanelCompression::default();
|
||||
let plain = panel_response(&headers("identity"), &all);
|
||||
assert!(!plain.headers().contains_key(header::CONTENT_ENCODING));
|
||||
let source = plain.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(std::str::from_utf8(&source).unwrap().contains("id=\"app-startup\""));
|
||||
assert!(std::str::from_utf8(&source).unwrap().contains(env!("CARGO_PKG_VERSION")));
|
||||
for (accept, selected) in [
|
||||
("gzip, br, zstd", "zstd"),
|
||||
("gzip, br", "br"),
|
||||
("gzip", "gzip"),
|
||||
("zstd;q=0.1, br;q=0.9, gzip;q=0.2", "br"),
|
||||
("zstd;q=0, br;q=0, gzip;q=1", "gzip"),
|
||||
("*;q=0.5", "zstd"),
|
||||
] {
|
||||
let response = panel_response(&headers(accept), &all);
|
||||
assert_eq!(response.headers()[header::CONTENT_ENCODING], selected, "{accept}");
|
||||
assert_eq!(response.headers()[header::VARY], "Accept-Encoding");
|
||||
let size: usize = response.headers()[header::CONTENT_LENGTH].to_str().unwrap().parse().unwrap();
|
||||
let encoded = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(size, encoded.len());
|
||||
let decoded = match selected {
|
||||
"gzip" => { let mut bytes = Vec::new(); GzDecoder::new(encoded.as_ref()).read_to_end(&mut bytes).unwrap(); bytes },
|
||||
"br" => { let mut bytes = Vec::new(); brotli::Decompressor::new(encoded.as_ref(), 4096).read_to_end(&mut bytes).unwrap(); bytes },
|
||||
_ => zstd::stream::decode_all(encoded.as_ref()).unwrap(),
|
||||
};
|
||||
assert_eq!(decoded.as_slice(), source.as_ref());
|
||||
}
|
||||
let disabled = PanelCompression { gzip: false, br: false, zstd: false };
|
||||
let response = panel_response(&headers("zstd, br, gzip"), &disabled);
|
||||
assert!(!response.headers().contains_key(header::CONTENT_ENCODING));
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), source);
|
||||
let only_gzip = PanelCompression { gzip: true, br: false, zstd: false };
|
||||
assert_eq!(panel_response(&headers("zstd, br, gzip"), &only_gzip).headers()[header::CONTENT_ENCODING], "gzip");
|
||||
let mut no_transform = headers("zstd, br, gzip");
|
||||
no_transform.insert("cache-control", "no-transform".parse().unwrap());
|
||||
assert!(!panel_response(&no_transform, &all).headers().contains_key(header::CONTENT_ENCODING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn administration_codec_defaults_keep_older_configuration_compatible() {
|
||||
let admin: crate::settings::Administration = serde_json::from_str("{\"session_days\":30}").unwrap();
|
||||
assert_eq!(admin.panel_compression, PanelCompression::default());
|
||||
let saved = serde_json::to_value(&admin).unwrap();
|
||||
assert_eq!(saved["panel_compression"]["zstd"], true);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Stored inside the existing per-account administration preferences document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct RefreshPreferences {
|
||||
live_enabled: bool,
|
||||
overview_seconds: u16,
|
||||
traffic_seconds: u16,
|
||||
cache_seconds: u16,
|
||||
system_info_seconds: u16,
|
||||
}
|
||||
|
||||
impl Default for RefreshPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
live_enabled: true,
|
||||
overview_seconds: 2,
|
||||
traffic_seconds: 5,
|
||||
cache_seconds: 5,
|
||||
system_info_seconds: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiError> {
|
||||
if ![1, 2, 5, 10].contains(&refresh.overview_seconds)
|
||||
|| ![0, 3, 5, 10, 30].contains(&refresh.traffic_seconds)
|
||||
|| ![0, 3, 5, 10, 30].contains(&refresh.cache_seconds)
|
||||
|| ![0, 15, 30, 60, 300].contains(&refresh.system_info_seconds)
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration refresh interval".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_live_log_columns() -> Vec<String> {
|
||||
LIVE_LOG_COLUMNS.iter().map(|column| (*column).to_owned()).collect()
|
||||
}
|
||||
|
||||
+26
-194
@@ -1,5 +1,7 @@
|
||||
mod documentation;
|
||||
mod public;
|
||||
mod panel_content;
|
||||
mod panel_assets;
|
||||
use crate::{
|
||||
auth::{self, Session},
|
||||
config::Update,
|
||||
@@ -33,6 +35,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/refresh.js"),
|
||||
include_str!("web/app/preferences.js"),
|
||||
include_str!("web/app/config-history.js"),
|
||||
include_str!("web/app/runtime.js"),
|
||||
@@ -87,24 +90,8 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
.route("/logging", get(public::index))
|
||||
.route("/logs", get(public::index))
|
||||
.route("/settings", get(public::index))
|
||||
.route(
|
||||
"/assets/login.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/login.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/login-langs.json",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "application/json; charset=utf-8")],
|
||||
include_str!(concat!(env!("OUT_DIR"), "/login-langs.json")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route("/assets/login.js", get(panel_assets::serve))
|
||||
.route("/assets/login-langs.json", get(panel_assets::serve))
|
||||
.route("/docs", get(documentation::index))
|
||||
.route("/docs/", get(documentation::index))
|
||||
.route("/docs/openapi.json", get(documentation::schema))
|
||||
@@ -123,183 +110,29 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
"/docs/licenses.txt",
|
||||
get(|| async { documentation::asset("licenses.txt") }),
|
||||
)
|
||||
.route(
|
||||
"/assets/langs.json",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "application/json; charset=utf-8")],
|
||||
include_str!(concat!(env!("OUT_DIR"), "/langs.json")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/app.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
admin_app_javascript(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/management.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/management.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/drafts.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/drafts.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/integrations.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/integrations.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/traffic-panel.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/traffic-panel.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/cache-panel.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/cache-panel.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/transform-panel.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/transform-panel.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/file-logs.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/file-logs.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/settings.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/settings.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/acme.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/acme.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/vhosts.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
admin_vhosts_javascript(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route("/assets/web-server-profiles.js", get(|| async {
|
||||
([ ("content-type", "text/javascript; charset=utf-8") ],
|
||||
include_str!("web/web-server-profiles.js"))
|
||||
}))
|
||||
.route(
|
||||
"/assets/web-server.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/web-server.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/docker.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/docker.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/streams.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/streams.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/redirects.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/redirects.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/host404.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/host404.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/style.css",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/css; charset=utf-8")],
|
||||
include_str!("web/style.css"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/favicon.svg",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "image/svg+xml")],
|
||||
include_str!("web/favicon.svg"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route("/assets/langs.json", get(panel_assets::serve))
|
||||
.route("/assets/app.js", get(panel_assets::serve))
|
||||
.route("/assets/management.js", get(panel_assets::serve))
|
||||
.route("/assets/drafts.js", get(panel_assets::serve))
|
||||
.route("/assets/integrations.js", get(panel_assets::serve))
|
||||
.route("/assets/traffic-panel.js", get(panel_assets::serve))
|
||||
.route("/assets/cache-panel.js", get(panel_assets::serve))
|
||||
.route("/assets/transform-panel.js", get(panel_assets::serve))
|
||||
.route("/assets/file-logs.js", get(panel_assets::serve))
|
||||
.route("/assets/settings.js", get(panel_assets::serve))
|
||||
.route("/assets/acme.js", get(panel_assets::serve))
|
||||
.route("/assets/vhosts.js", get(panel_assets::serve))
|
||||
.route("/assets/web-server-profiles.js", get(panel_assets::serve))
|
||||
.route("/assets/web-server.js", get(panel_assets::serve))
|
||||
.route("/assets/docker.js", get(panel_assets::serve))
|
||||
.route("/assets/streams.js", get(panel_assets::serve))
|
||||
.route("/assets/redirects.js", get(panel_assets::serve))
|
||||
.route("/assets/host404.js", get(panel_assets::serve))
|
||||
.route("/assets/style.css", get(panel_assets::serve))
|
||||
.route("/favicon.svg", get(panel_assets::serve))
|
||||
.route("/healthz", get(|| async { Json(json!({"status":"ok"})) }))
|
||||
.route("/api/status", get(status))
|
||||
.route("/api/system-info", get(system_info))
|
||||
.route("/api/backends", get(backend_status))
|
||||
.route("/api/docker", get(docker_status))
|
||||
.route("/api/docker/refresh", post(docker_refresh))
|
||||
@@ -481,7 +314,6 @@ enum CertificateApiOperation {
|
||||
ReloadAll,
|
||||
ReloadOne(String),
|
||||
}
|
||||
|
||||
fn certificate_api_operation(method: &Method, path: &str) -> Option<CertificateApiOperation> {
|
||||
if method != Method::POST {
|
||||
return None;
|
||||
|
||||
+8
-64
@@ -1,32 +1,4 @@
|
||||
// Reuse sysinfo's native OS APIs on Linux, BSD, macOS and Windows. The
|
||||
// diagnostics endpoint must never spawn system commands or parse platform files.
|
||||
// Host identification is cached because it changes only after a reboot.
|
||||
fn valid_host_text(value: String) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn os_release() -> Option<&'static str> {
|
||||
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
VALUE.get_or_init(|| {
|
||||
sysinfo::System::long_os_version()
|
||||
.or_else(|| {
|
||||
let name = sysinfo::System::name()?;
|
||||
Some(match sysinfo::System::os_version() {
|
||||
Some(version) if !version.is_empty() => format!("{name} {version}"),
|
||||
_ => name,
|
||||
})
|
||||
})
|
||||
.and_then(valid_host_text)
|
||||
}).as_deref()
|
||||
}
|
||||
|
||||
fn kernel_release() -> Option<&'static str> {
|
||||
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
VALUE.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
|
||||
.as_deref()
|
||||
}
|
||||
include!("status_system.rs");
|
||||
|
||||
fn status_payload(state: &App) -> Value {
|
||||
let snapshot = state.config.load_full();
|
||||
@@ -187,38 +159,10 @@ fn status_payload(state: &App) -> Value {
|
||||
payload["cache"] = json!(&cache_view);
|
||||
payload["transform_resources"] = json!(&transform_resources);
|
||||
payload["geoip"] = json!(state.geoip.load().view());
|
||||
let system = state.diagnostics.view();
|
||||
let cache_ram_bytes =
|
||||
(cache_view.hot_allocated_bytes as u64).saturating_add(cache_view.transient_bytes);
|
||||
let non_cache_rss_bytes = system
|
||||
.process_rss_bytes
|
||||
.map(|rss| rss.saturating_sub(cache_ram_bytes));
|
||||
payload["memory_accounting"] = json!({
|
||||
"process_rss_bytes": system.process_rss_bytes,
|
||||
"cache_hot_bytes": cache_view.hot_allocated_bytes,
|
||||
"cache_hot_limit_bytes": cache_view.hot_limit_bytes,
|
||||
"cache_transient_bytes": cache_view.transient_bytes,
|
||||
"cache_transient_limit_bytes": cache_view.transient_limit_bytes,
|
||||
"cache_total_bytes": cache_ram_bytes,
|
||||
"transform_reserved_bytes": transform_resources.memory_reserved_bytes,
|
||||
"transform_budget_bytes": transform_resources.memory_budget_bytes,
|
||||
"transform_inflight": transform_resources.inflight,
|
||||
"transform_max_inflight": transform_resources.max_inflight,
|
||||
"non_cache_rss_bytes": non_cache_rss_bytes,
|
||||
"non_cache_rss_includes_transform": true,
|
||||
"method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate"
|
||||
});
|
||||
payload["runtime"] = json!({
|
||||
"platform": system.platform,
|
||||
"operating_system": os_release(),
|
||||
"kernel_version": kernel_release(),
|
||||
"architecture": std::env::consts::ARCH,
|
||||
"statistics_source": system.source,
|
||||
"data_directory": state.policy.data_dir.as_ref().map(|path| path.display().to_string()),
|
||||
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string()),
|
||||
"certificate_runtime": state.acme.runtime_view()
|
||||
});
|
||||
payload["system"] = json!(system);
|
||||
let system_info = system_info_payload(state);
|
||||
for field in ["system", "runtime", "memory_accounting"] {
|
||||
payload[field] = system_info[field].clone();
|
||||
}
|
||||
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
|
||||
payload["vhost_metrics"] = json!(snapshot
|
||||
.vhost_metrics
|
||||
@@ -281,9 +225,9 @@ fn status_payload(state: &App) -> Value {
|
||||
});
|
||||
payload
|
||||
}
|
||||
async fn status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
Json(status_payload(&state))
|
||||
}
|
||||
// SSE and emergency polling use only volatile telemetry. Never serialize the
|
||||
// whole configuration, OS diagnostics or certificate metadata on each tick.
|
||||
include!("status_live.rs");
|
||||
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = state.config.load_full();
|
||||
Json(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
fn live_status_payload(state: &App) -> Value {
|
||||
let snapshot = state.config.load_full();
|
||||
let stream_metrics = state.active_streams.iter().filter_map(|stream| {
|
||||
state.stream_metrics.get(&stream.id).map(|metrics| (stream.id.clone(), metrics.view()))
|
||||
}).collect::<std::collections::BTreeMap<_, _>>();
|
||||
json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
"revision": snapshot.envelope.revision,
|
||||
"listeners": {
|
||||
"active_endpoints": state.active_listeners,
|
||||
"desired_endpoints": snapshot.listeners,
|
||||
"restart_required": snapshot.listeners != state.active_listeners
|
||||
},
|
||||
"metrics": state.metrics.view(),
|
||||
"cache": state.cache.view(),
|
||||
"transform_metrics": state.transform_metrics.total(),
|
||||
"logs_dropped": state.logs.dropped.load(Ordering::Relaxed),
|
||||
"backends": snapshot.envelope.config.vhosts.iter().map(|v| v.backends.len()).sum::<usize>(),
|
||||
"backend_pools": snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>(),
|
||||
"vhost_metrics": snapshot.vhost_metrics.iter().map(|(id, metrics)| (id.clone(), metrics.view())).collect::<std::collections::BTreeMap<_, _>>(),
|
||||
"streams": {"metrics": stream_metrics},
|
||||
"logging": {"sinks": state.logs.stats(), "error_file": crate::logging::error_log_stats()}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct StatusQuery {
|
||||
view: Option<String>,
|
||||
}
|
||||
|
||||
async fn status(State(state): State<Arc<App>>, Query(query): Query<StatusQuery>) -> Result<Json<Value>, ApiError> {
|
||||
match query.view.as_deref() {
|
||||
None => Ok(Json(status_payload(&state))),
|
||||
Some("live") => Ok(Json(live_status_payload(&state))),
|
||||
_ => Err(ApiError(StatusCode::BAD_REQUEST, "invalid status view".into())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn system_info(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
Json(system_info_payload(&state))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Reuse sysinfo's native OS APIs on Linux, BSD, macOS and Windows. The
|
||||
// diagnostics endpoint must never spawn system commands or parse platform files.
|
||||
// Host identification is cached because it changes only after a reboot.
|
||||
fn valid_host_text(value: String) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn os_release() -> Option<&'static str> {
|
||||
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
VALUE.get_or_init(|| {
|
||||
sysinfo::System::long_os_version()
|
||||
.or_else(|| {
|
||||
let name = sysinfo::System::name()?;
|
||||
Some(match sysinfo::System::os_version() {
|
||||
Some(version) if !version.is_empty() => format!("{name} {version}"),
|
||||
_ => name,
|
||||
})
|
||||
})
|
||||
.and_then(valid_host_text)
|
||||
}).as_deref()
|
||||
}
|
||||
|
||||
fn kernel_release() -> Option<&'static str> {
|
||||
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
VALUE.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
// Cached diagnostics and ACME metadata are requested separately from high-rate SSE.
|
||||
// Preserve the legacy full /api/status shape for existing API clients.
|
||||
fn system_info_payload(state: &App) -> Value {
|
||||
let cache_view = state.cache.view();
|
||||
let transform_resources = crate::transform::resource_usage(&state.config.load().envelope.config.transform);
|
||||
let mut payload = json!({});
|
||||
let system = state.diagnostics.view();
|
||||
let cache_ram_bytes =
|
||||
(cache_view.hot_allocated_bytes as u64).saturating_add(cache_view.transient_bytes);
|
||||
let non_cache_rss_bytes = system
|
||||
.process_rss_bytes
|
||||
.map(|rss| rss.saturating_sub(cache_ram_bytes));
|
||||
payload["memory_accounting"] = json!({
|
||||
"process_rss_bytes": system.process_rss_bytes,
|
||||
"cache_hot_bytes": cache_view.hot_allocated_bytes,
|
||||
"cache_hot_limit_bytes": cache_view.hot_limit_bytes,
|
||||
"cache_transient_bytes": cache_view.transient_bytes,
|
||||
"cache_transient_limit_bytes": cache_view.transient_limit_bytes,
|
||||
"cache_total_bytes": cache_ram_bytes,
|
||||
"transform_reserved_bytes": transform_resources.memory_reserved_bytes,
|
||||
"transform_budget_bytes": transform_resources.memory_budget_bytes,
|
||||
"transform_inflight": transform_resources.inflight,
|
||||
"transform_max_inflight": transform_resources.max_inflight,
|
||||
"non_cache_rss_bytes": non_cache_rss_bytes,
|
||||
"non_cache_rss_includes_transform": true,
|
||||
"method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate"
|
||||
});
|
||||
payload["runtime"] = json!({
|
||||
"platform": system.platform,
|
||||
"operating_system": os_release(),
|
||||
"kernel_version": kernel_release(),
|
||||
"architecture": std::env::consts::ARCH,
|
||||
"statistics_source": system.source,
|
||||
"data_directory": state.policy.data_dir.as_ref().map(|path| path.display().to_string()),
|
||||
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string()),
|
||||
"certificate_runtime": state.acme.runtime_view()
|
||||
});
|
||||
payload["system"] = json!(system);
|
||||
payload
|
||||
}
|
||||
@@ -7,4 +7,6 @@ mod tests {
|
||||
include!("tests/part_06.rs");
|
||||
include!("tests/part_07.rs");
|
||||
include!("tests/part_08.rs");
|
||||
include!("tests/part_09.rs");
|
||||
include!("tests/part_10.rs");
|
||||
}
|
||||
@@ -16,6 +16,10 @@
|
||||
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["refresh"], json!({
|
||||
"live_enabled":true, "overview_seconds":2, "traffic_seconds":5,
|
||||
"cache_seconds":5, "system_info_seconds":60
|
||||
}));
|
||||
assert_eq!(initial["theme"], "dark");
|
||||
assert_eq!(initial["language"], "auto");
|
||||
assert_eq!(initial["ui_scale"], 100);
|
||||
@@ -50,6 +54,7 @@
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let legacy = json_body(response).await;
|
||||
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
|
||||
assert_eq!(legacy["refresh"], initial["refresh"]);
|
||||
assert_eq!(legacy["theme"], "dark");
|
||||
assert_eq!(legacy["language"], "auto");
|
||||
assert_eq!(legacy["ui_scale"], 100);
|
||||
@@ -65,6 +70,7 @@
|
||||
assert_eq!(legacy["backend_table_descending"], false);
|
||||
|
||||
let selected = json!({
|
||||
"refresh":{"live_enabled":false,"overview_seconds":10,"traffic_seconds":0,"cache_seconds":3,"system_info_seconds":300},
|
||||
"live_log_columns":["time","request","status","upstream_error"],
|
||||
"theme":"light",
|
||||
"language":"pl_pl",
|
||||
@@ -103,6 +109,20 @@
|
||||
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(saved, selected);
|
||||
// Preferences must also survive a subsequent authenticated read.
|
||||
let persisted = router.clone().oneshot(request("/api/preferences")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(json_body(persisted).await, selected);
|
||||
// Wrong allowlist value is rejected even if the rest is valid.
|
||||
let mut invalid = selected.clone();
|
||||
invalid["refresh"]["traffic_seconds"] = json!(4);
|
||||
let rejected = 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(invalid.to_string())).unwrap()).await.unwrap();
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
@@ -327,6 +347,7 @@
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for path in [
|
||||
"/api/status",
|
||||
"/api/system-info",
|
||||
"/api/config",
|
||||
"/api/config/history",
|
||||
"/api/backends",
|
||||
@@ -446,3 +467,7 @@
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#[tokio::test]
|
||||
async fn system_info_and_compact_live_status_reuse_existing_admin_session() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let info = router.clone().oneshot(request("/api/system-info")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(info.status(), StatusCode::OK);
|
||||
let info = json_body(info).await;
|
||||
assert!(info.get("system").is_some());
|
||||
assert!(info.get("runtime").is_some());
|
||||
assert!(info.get("memory_accounting").is_some());
|
||||
assert!(info.get("config").is_none());
|
||||
let live = router.clone().oneshot(request("/api/status?view=live")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(live.status(), StatusCode::OK);
|
||||
let live = json_body(live).await;
|
||||
assert!(live.get("metrics").is_some());
|
||||
assert!(live.get("backend_pools").is_some());
|
||||
assert!(live["listeners"].get("active_endpoints").is_some());
|
||||
assert!(live.get("system").is_none());
|
||||
assert!(live.get("memory_accounting").is_none());
|
||||
assert!(live.get("administration").is_none());
|
||||
let complete = router.clone().oneshot(request("/api/status")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(complete.status(), StatusCode::OK);
|
||||
assert!(json_body(complete).await.get("system").is_some());
|
||||
let invalid = router.clone().oneshot(request("/api/status?view=unknown")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
||||
let bad_sse = router.oneshot(request("/api/events?interval=4")
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn defaults_and_partial_legacy_refresh_documents() {
|
||||
let previous = serde_json::to_value(AdminPreferences::default()).unwrap();
|
||||
let mut legacy = previous.as_object().unwrap().clone();
|
||||
legacy.remove("refresh");
|
||||
let loaded: AdminPreferences = serde_json::from_value(Value::Object(legacy)).unwrap();
|
||||
assert_eq!(loaded.refresh, RefreshPreferences::default());
|
||||
let partial: RefreshPreferences = serde_json::from_str(r#"{"traffic_seconds":0}"#).unwrap();
|
||||
assert_eq!(partial.traffic_seconds, 0);
|
||||
assert_eq!(partial.system_info_seconds, 60);
|
||||
assert!(validate_admin_preferences(&loaded).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_values_are_allowlisted() {
|
||||
let mut value = RefreshPreferences::default();
|
||||
for seconds in [0, 4, 11, 65000] {
|
||||
value.overview_seconds = seconds;
|
||||
assert!(validate_refresh_preferences(&value).is_err());
|
||||
}
|
||||
value.overview_seconds = 1;
|
||||
for seconds in [1, 2, 4, 31] {
|
||||
value.traffic_seconds = seconds;
|
||||
assert!(validate_refresh_preferences(&value).is_err());
|
||||
}
|
||||
value.traffic_seconds = 0;
|
||||
value.cache_seconds = 0;
|
||||
value.system_info_seconds = 0;
|
||||
assert!(validate_refresh_preferences(&value).is_ok());
|
||||
value.system_info_seconds = 16;
|
||||
assert!(validate_refresh_preferences(&value).is_err());
|
||||
assert!(serde_json::from_str::<RefreshPreferences>(r#"{"unexpected":1}"#).is_err());
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#[tokio::test]
|
||||
async fn cache_system_events_and_static_private_assets_reject_anonymous_requests() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
for path in [
|
||||
"/api/status", "/api/status?view=live", "/api/system-info", "/api/config",
|
||||
"/api/preferences", "/api/cache", "/api/cache/bans", "/api/audit",
|
||||
"/api/logs", "/api/traffic", "/api/events", "/api/live-logs",
|
||||
"/api/file-logs", "/api/certificate-api", "/docs/openapi.json",
|
||||
"/assets/app.js", "/assets/cache-panel.js", "/assets/langs.json",
|
||||
] {
|
||||
let response = router.clone().oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "anonymous GET {path}");
|
||||
assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY");
|
||||
assert!(response.headers().get("content-security-policy").is_some());
|
||||
}
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for (method, path) in [
|
||||
("POST", "/api/cache/purge"),
|
||||
("POST", "/api/cache/invalidate"),
|
||||
("DELETE", "/api/cache/bans/1"),
|
||||
("PUT", "/api/config/cache"),
|
||||
("PUT", "/api/preferences"),
|
||||
("POST", "/api/auth/logout-all"),
|
||||
] {
|
||||
let no_session = request(path).method(method).body(Body::empty()).unwrap();
|
||||
let response = router.clone().oneshot(no_session).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "anonymous {method} {path}");
|
||||
let without_csrf = request(path).method(method)
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.body(Body::empty()).unwrap();
|
||||
let response = router.clone().oneshot(without_csrf).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "CSRF {method} {path}");
|
||||
}
|
||||
// The valid session and CSRF continue to work for a benign mutation,
|
||||
// proving the failed requests above did not revoke the cookie.
|
||||
let response = router.oneshot(request("/api/auth/logout")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_origin_host_and_fetch_metadata_are_checked_even_with_valid_cookie() {
|
||||
let (router, store, _dir, secret, _csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let evil_origin = request("/api/status").header("cookie", &cookie)
|
||||
.header("origin", "https://another.example")
|
||||
.body(Body::empty()).unwrap();
|
||||
let response = router.clone().oneshot(evil_origin).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let mut repeated_host = request("/api/status").header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap();
|
||||
repeated_host.headers_mut().append("host", "other.example".parse().unwrap());
|
||||
let response = router.clone().oneshot(repeated_host).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let cross_site = request("/api/status").header("cookie", &cookie)
|
||||
.header("sec-fetch-site", "cross-site").body(Body::empty()).unwrap();
|
||||
let response = router.clone().oneshot(cross_site).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
store.close().await;
|
||||
}
|
||||
+14
-6
@@ -155,15 +155,21 @@
|
||||
if (!list.children.length) list.append(el("p", t("renewalNone"), "empty"));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!authenticated || currentPanel !== "tls" || loading) return; loading = true; const epoch = authEpoch;
|
||||
async function loadAcme({ signal, isCurrent }) {
|
||||
if (!authenticated || currentPanel !== "tls" || loading) return;
|
||||
loading = true;
|
||||
const epoch = authEpoch;
|
||||
try {
|
||||
const data = await api("/api/acme");
|
||||
if (authenticated && epoch === authEpoch) { renderRuntime(data.runtime); render(data.job); renderRenewals(data.renewals, data.log_directories); }
|
||||
const data = await api("/api/acme", { signal });
|
||||
if (isCurrent() && epoch === authEpoch) { renderRuntime(data.runtime); render(data.job); renderRenewals(data.renewals, data.log_directories); }
|
||||
} catch (error) {
|
||||
if (epoch === authEpoch) { toast(error.message, true); if (error.status === 401) lock(); }
|
||||
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||
toast(error.message, true);
|
||||
if (error.status === 401) lock();
|
||||
}
|
||||
} finally { loading = false; }
|
||||
}
|
||||
function refresh() { return rustProxyRefresh.manual("acme"); }
|
||||
$("acme-environment").addEventListener("change", () => { $("acme-confirm-production").required = $("acme-environment").value === "production"; });
|
||||
$("acme-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!authenticated || pending || job?.status === "running" || !runtimeAvailable) return;
|
||||
@@ -197,7 +203,9 @@
|
||||
else next.tls.certificates.push(cert);
|
||||
if (await saveSection("tls", next.tls || {})) { toast(t("acmeImported")); $("acme-import").hidden = true; selectTab("installed"); }
|
||||
});
|
||||
setInterval(() => { if (!document.hidden) void refresh(); }, 3000);
|
||||
rustProxyRefresh.register("acme", {
|
||||
interval: () => 3, active: () => currentPanel === "tls", run: loadAcme
|
||||
});
|
||||
window.rustProxyAcme = {
|
||||
refresh,
|
||||
select: selectTab,
|
||||
|
||||
+107
-29
@@ -23,8 +23,8 @@ function clearInvalidOriginRecovery() {
|
||||
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;
|
||||
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
|
||||
let liveEvents = null, liveConnected = false, authEpoch = 0, pendingAuthDestination = null;
|
||||
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null;
|
||||
let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null;
|
||||
let lastApiLatencyMs = null;
|
||||
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
|
||||
// Language content is discovered by build.rs from langs/*.json, never compiled
|
||||
@@ -152,14 +152,16 @@ function setPanel(name, navigate = true) {
|
||||
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
|
||||
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
|
||||
currentPanel = name;
|
||||
$("overview-refresh").hidden = name !== "overview";
|
||||
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
|
||||
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
|
||||
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
|
||||
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
|
||||
if (name === "settings") window.rustProxySettings?.syncLocation?.();
|
||||
window.rustProxyRefresh?.setPanel(name);
|
||||
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
|
||||
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
|
||||
if (name === "traffic") void window.rustProxyTrafficPanel?.refresh(); if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
|
||||
if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); });
|
||||
}
|
||||
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
|
||||
function uptime(value) {
|
||||
@@ -192,8 +194,14 @@ function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
|
||||
}
|
||||
window.rustProxyEnableControl = enhanceEnabledControl;
|
||||
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
|
||||
function renderStatus(s) {
|
||||
const firstStatus = latestStatus === null; latestStatus = s; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
||||
function renderStatus(s, systemSample = false) {
|
||||
const firstStatus = latestStatus === null;
|
||||
latestStatus = { ...(latestStatus || {}), ...s,
|
||||
streams: s.streams ? { ...(latestStatus?.streams || {}), ...s.streams } : latestStatus?.streams,
|
||||
listeners: s.listeners ? { ...(latestStatus?.listeners || {}), ...s.listeners } : latestStatus?.listeners,
|
||||
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
|
||||
};
|
||||
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
|
||||
$("metric-ws").textContent = m.websocket_active || 0;
|
||||
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
|
||||
@@ -250,7 +258,7 @@ function renderStatus(s) {
|
||||
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
|
||||
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
|
||||
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
|
||||
renderFooter(s); renderDiagnostics(s); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
|
||||
}
|
||||
function renderFooter(s) {
|
||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||
@@ -273,9 +281,40 @@ function renderFooter(s) {
|
||||
}
|
||||
let lastLogs = null;
|
||||
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
|
||||
async function refresh(force = false) { if (!authenticated || polling || (!force && liveConnected)) return; polling = true; const epoch = authEpoch; try { const [status, logs] = await Promise.all([api("/api/status"), api("/api/logs")]); if (!authenticated || epoch !== authEpoch) return; renderStatus(status); renderLogs(logs); } catch (e) { connected = false; $("connection-label").textContent = t("disconnected"); if (e.status === 401) lock(); } finally { polling = false; } }
|
||||
function stopLive() { if (liveEvents) liveEvents.close(); liveEvents = null; liveConnected = false; }
|
||||
function startLive() { stopLive(); if (!authenticated || document.hidden || typeof EventSource === "undefined") return; const epoch = authEpoch; const stream = new EventSource("/api/events"); liveEvents = stream; stream.addEventListener("snapshot", event => { if (!authenticated || epoch !== authEpoch) return; try { const payload = JSON.parse(event.data); liveConnected = true; renderStatus(payload.status); renderLogs(payload.logs); } catch { liveConnected = false; } }); stream.onerror = () => { if (epoch !== authEpoch) return; liveConnected = false; void refresh(); }; }
|
||||
async function refresh(force = false) {
|
||||
if (!authenticated) return;
|
||||
if (force) return Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback")]);
|
||||
return rustProxyRefresh.manual("status-fallback");
|
||||
}
|
||||
function renderLiveStatus(value) { renderStatus(value); }
|
||||
function stopLive() {
|
||||
if (liveEvents) { liveEvents.close(); liveEvents = null; }
|
||||
liveConnected = false; liveFallback = false; liveInterval = null;
|
||||
}
|
||||
function startLive() {
|
||||
if (!authenticated || document.hidden || typeof EventSource === "undefined") return;
|
||||
const seconds = window.rustProxyRefresh?.current().overview_seconds || 2;
|
||||
if (liveEvents && liveInterval === seconds) return;
|
||||
stopLive();
|
||||
liveInterval = seconds;
|
||||
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}`);
|
||||
liveEvents = stream;
|
||||
stream.addEventListener("snapshot", event => {
|
||||
if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (!payload.status || !payload.logs) throw new Error("Invalid SSE payload");
|
||||
liveConnected = true; liveFallback = false;
|
||||
renderLiveStatus(payload.status); renderLogs(payload.logs);
|
||||
window.rustProxyRefresh?.sync();
|
||||
} catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); }
|
||||
});
|
||||
stream.onerror = () => {
|
||||
if (epoch !== authEpoch || stream !== liveEvents) return;
|
||||
liveConnected = false; liveFallback = true;
|
||||
window.rustProxyRefresh?.sync();
|
||||
};
|
||||
}
|
||||
async function refreshCertificateInventory() {
|
||||
if (!authenticated) return;
|
||||
const epoch = authEpoch;
|
||||
@@ -322,6 +361,7 @@ function authReturnDestination() {
|
||||
return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings";
|
||||
}
|
||||
function lock(notify = true) {
|
||||
window.rustProxyRefresh?.stop();
|
||||
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); 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(); }
|
||||
@@ -334,16 +374,52 @@ function lock(notify = true) {
|
||||
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
||||
}
|
||||
|
||||
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); window.rustProxyDocker?.applyPreferences(preferences); window.rustProxyRedirects?.applyPreferences(preferences); window.rustProxyNotFound?.applyPreferences(preferences); window.rustProxyStreams?.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 activate(info) {
|
||||
pendingAuthDestination = null; authMessage("");
|
||||
for (const id of ["api-docs", "logs-menu", "logs-open", "file-logs-open"]) $(id).hidden = false;
|
||||
const epoch = ++authEpoch;
|
||||
sessionInfo = info; csrf = info.csrf_token; authenticated = true;
|
||||
for (const id of ["password", "password-confirm", "setup-token"]) $(id).value = "";
|
||||
$("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false;
|
||||
renderSession();
|
||||
// Paint a usable shell immediately. Preferences, configuration and initial
|
||||
// telemetry are network requests and must not block the first content.
|
||||
document.documentElement.classList.remove("app-loading");
|
||||
document.documentElement.classList.remove("app-startup-failed");
|
||||
rustProxyRefresh.start();
|
||||
const preferences = await loadAdminPreferences();
|
||||
if (!authenticated || epoch !== authEpoch) return;
|
||||
window.rustProxyLogs?.applyPreferences(preferences);
|
||||
window.rustProxyFileLogs?.applyPreferences(preferences);
|
||||
window.rustProxyVhosts?.applyPreferences(preferences);
|
||||
window.rustProxyDocker?.applyPreferences(preferences);
|
||||
window.rustProxyRedirects?.applyPreferences(preferences);
|
||||
window.rustProxyNotFound?.applyPreferences(preferences);
|
||||
window.rustProxyStreams?.applyPreferences(preferences);
|
||||
// The live SSE stream is already running. Configuration can load behind the
|
||||
// visible shell; it does not belong in the live refresh cycle.
|
||||
try { await loadConfig(); }
|
||||
catch (error) {
|
||||
if (authenticated && epoch === authEpoch) toast(error.message, true);
|
||||
return;
|
||||
}
|
||||
if (!authenticated || epoch !== authEpoch) return;
|
||||
// A single full snapshot seeds non-live diagnostics; subsequent updates
|
||||
// continue over SSE rather than polling full status on a timer.
|
||||
void refresh(true).catch(error => {
|
||||
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.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() {
|
||||
if (!authenticated) return;
|
||||
let tunnelsLoading = false;
|
||||
async function loadTunnels(context = null) {
|
||||
if (!authenticated || tunnelsLoading) return;
|
||||
tunnelsLoading = true;
|
||||
const epoch = authEpoch;
|
||||
try {
|
||||
const data = await api("/api/tunnels");
|
||||
if (!authenticated || epoch !== authEpoch) return;
|
||||
const data = await api("/api/tunnels", context?.signal ? { signal: context.signal } : {});
|
||||
if (!authenticated || epoch !== authEpoch || !$("tunnels-dialog")?.open || (context?.isCurrent && !context.isCurrent())) return;
|
||||
const rows = (data.tunnels || []).map(item => {
|
||||
const tr = el("tr");
|
||||
for (const value of [item.id, item.client, item.host, item.upstream, item.protocol, `${fmt((Number(item.duration_ms) || 0) / 1000, 1)} s`, fmtBytes(item.rx_bytes), fmtBytes(item.tx_bytes)]) {
|
||||
@@ -365,10 +441,11 @@ async function loadTunnels() {
|
||||
actions.append(kill); tr.append(actions); return tr;
|
||||
});
|
||||
$("tunnels-rows").replaceChildren(...rows); $("tunnels-empty").hidden = rows.length > 0;
|
||||
} catch (error) { if (authenticated && epoch === authEpoch) tunnelMessage(error.message, true); }
|
||||
} catch (error) { if (error?.name !== "AbortError" && authenticated && epoch === authEpoch && $("tunnels-dialog")?.open) tunnelMessage(error.message, true); }
|
||||
finally { tunnelsLoading = false; }
|
||||
}
|
||||
function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefreshTimer); tunnelRefreshTimer = null; }
|
||||
function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); }
|
||||
function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
||||
$("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(); });
|
||||
@@ -390,9 +467,9 @@ $("revoke-sessions").addEventListener("click", async () => { if (!(await appConf
|
||||
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
|
||||
$("audit-dialog-close").addEventListener("click", () => $("audit-dialog").close());
|
||||
$("reload-audit").addEventListener("click", () => void loadAudit());
|
||||
$("tunnels-open").addEventListener("click", () => { const dialog = $("tunnels-dialog"); tunnelMessage(); if (!dialog.open) dialog.showModal(); void loadTunnels(); startTunnelRefresh(); });
|
||||
$("tunnels-close").addEventListener("click", () => { stopTunnelRefresh(); $("tunnels-dialog").close(); });
|
||||
$("tunnels-reload").addEventListener("click", () => void loadTunnels());
|
||||
$("tunnels-open").addEventListener("click", () => { const dialog = $("tunnels-dialog"); tunnelMessage(); if (!dialog.open) dialog.showModal(); void rustProxyRefresh.manual("tunnels"); startTunnelRefresh(); });
|
||||
$("tunnels-close").addEventListener("click", () => { $("tunnels-dialog").close(); stopTunnelRefresh(); });
|
||||
$("tunnels-reload").addEventListener("click", () => void rustProxyRefresh.manual("tunnels"));
|
||||
$("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
||||
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
||||
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
||||
@@ -404,18 +481,19 @@ async function revalidateSession() {
|
||||
clearInvalidOriginRecovery();
|
||||
if (epoch !== authEpoch) return;
|
||||
if (!authenticated) { await activate(info); return; }
|
||||
sessionInfo = info; csrf = info.csrf_token; renderSession(); await refresh(true); startLive();
|
||||
sessionInfo = info; csrf = info.csrf_token; renderSession(); window.rustProxyRefresh?.sync();
|
||||
} catch (error) { if (recoverInvalidOrigin(error)) return; if (error.status !== 401) toast(error.message, true); }
|
||||
}
|
||||
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } });
|
||||
setInterval(() => { if (!document.hidden) void refresh(); }, 5000);
|
||||
function showStartupFailure(error) {
|
||||
// index.html is served only after the server has accepted the browser session.
|
||||
// A module/configuration error must therefore never masquerade as a logout.
|
||||
// Never expose a blank page or an incomplete workspace after startup fails.
|
||||
// The authenticated shell keeps its own retry link; 401 still redirects to login.
|
||||
$("login").hidden = true;
|
||||
$("workspace").hidden = false;
|
||||
$("workspace").hidden = true;
|
||||
$("logout").hidden = !authenticated;
|
||||
document.documentElement.classList.remove("app-loading");
|
||||
toast(error?.message || String(error), true);
|
||||
document.documentElement.classList.add("app-loading", "app-startup-failed");
|
||||
$("app-startup-title").textContent = languagePacks.size ? t("startupFailed") : "Could not load RustProxy";
|
||||
const detail = $("app-startup-detail");
|
||||
detail.textContent = error?.message || String(error);
|
||||
detail.hidden = false;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
|
||||
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();
|
||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
||||
|
||||
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"; }
|
||||
@@ -122,7 +122,7 @@ function refreshChartStylePalette(active = adminPreferences?.chart_style || "def
|
||||
});
|
||||
target.replaceChildren(...buttons);
|
||||
}
|
||||
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); window.rustProxySettings?.refreshSearch?.(); }
|
||||
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); renderRefreshPreferences(adminPreferences?.refresh); window.rustProxySettings?.refreshSearch?.(); }
|
||||
function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
||||
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
||||
function applyAdminPreferences(value, cache = true) {
|
||||
@@ -130,11 +130,14 @@ function applyAdminPreferences(value, cache = true) {
|
||||
const dashboard = applyDashboardLayout(value.dashboard_widgets, value.dashboard_hidden_widgets);
|
||||
const normalized = {
|
||||
...value,
|
||||
refresh: normalizeRefreshPreferences(value.refresh),
|
||||
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
|
||||
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), table_density: applyAdminTableDensity(value.table_density),
|
||||
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: dashboard.order, dashboard_hidden_widgets: dashboard.hidden,
|
||||
};
|
||||
adminPreferences = normalized;
|
||||
window.rustProxyRefresh?.update(normalized.refresh);
|
||||
renderRefreshPreferences(normalized.refresh);
|
||||
window.rustProxyVhosts?.applyPreferences(normalized);
|
||||
window.rustProxyDocker?.applyPreferences(normalized);
|
||||
window.rustProxyRedirects?.applyPreferences(normalized);
|
||||
@@ -152,9 +155,18 @@ async function loadAdminPreferences(force = false) {
|
||||
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 {
|
||||
const value = await api("/api/preferences");
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
adminPreferencesUnavailable = false;
|
||||
return applyAdminPreferences(value);
|
||||
} catch (error) {
|
||||
if (error.status === 401 || !authenticated || epoch !== authEpoch) throw error;
|
||||
// Preferences are optional for startup. Keep UI and live telemetry usable.
|
||||
console.warn("Preferences unavailable; using defaults", error);
|
||||
adminPreferencesUnavailable = true;
|
||||
return applyAdminPreferences({ ...ADMIN_APPEARANCE_DEFAULTS, refresh: REFRESH_DEFAULTS });
|
||||
}
|
||||
})();
|
||||
try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; }
|
||||
}
|
||||
@@ -162,9 +174,11 @@ 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();
|
||||
const current = await loadAdminPreferences(adminPreferencesUnavailable);
|
||||
if (adminPreferencesUnavailable) throw new Error("Cannot persist preferences while the preference service is unavailable");
|
||||
if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||
const next = { ...current, ...patch };
|
||||
next.refresh = normalizeRefreshPreferences(next.refresh);
|
||||
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
|
||||
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.table_density = normalizeAdminDensity(next.table_density);
|
||||
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets);
|
||||
@@ -174,7 +188,44 @@ function updateAdminPreferences(patch) {
|
||||
});
|
||||
adminPreferencesWrite = task.catch(() => undefined); return task;
|
||||
}
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
|
||||
function renderRefreshPreferences(value = REFRESH_DEFAULTS) {
|
||||
const refresh = normalizeRefreshPreferences(value);
|
||||
const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled;
|
||||
for (const field of Object.keys(REFRESH_CHOICES)) {
|
||||
const input = $(`refresh-${field.replaceAll("_", "-")}`);
|
||||
if (input) input.value = String(refresh[field]);
|
||||
}
|
||||
const manual = refresh.system_info_seconds === 0 || !refresh.live_enabled;
|
||||
const hint = $("system-info-refresh-note");
|
||||
if (hint) hint.textContent = t(manual ? "refreshManualHint" : "refreshSystemHint");
|
||||
}
|
||||
for (const control of document.querySelectorAll("[data-refresh-preference]")) {
|
||||
control.addEventListener("change", () => {
|
||||
const key = control.dataset.refreshPreference;
|
||||
const previous = normalizeRefreshPreferences(adminPreferences?.refresh);
|
||||
const value = key === "live_enabled" ? control.checked : Number(control.value);
|
||||
if (key !== "live_enabled" && !REFRESH_CHOICES[key]?.includes(value)) {
|
||||
renderRefreshPreferences(previous); return;
|
||||
}
|
||||
const next = { ...previous, [key]: value };
|
||||
if (adminPreferences) adminPreferences.refresh = next;
|
||||
window.rustProxyRefresh?.update(next); renderRefreshPreferences(next);
|
||||
void saveAppearancePreference({ refresh: next }, control, () => {
|
||||
if (adminPreferences) adminPreferences.refresh = previous;
|
||||
window.rustProxyRefresh?.update(previous); renderRefreshPreferences(previous);
|
||||
});
|
||||
});
|
||||
}
|
||||
$("refresh-reset")?.addEventListener("click", () => {
|
||||
const previous = normalizeRefreshPreferences(adminPreferences?.refresh), next = { ...REFRESH_DEFAULTS };
|
||||
if (adminPreferences) adminPreferences.refresh = next;
|
||||
window.rustProxyRefresh?.update(next); renderRefreshPreferences(next);
|
||||
void saveAppearancePreference({ refresh: next }, $("refresh-reset"), () => {
|
||||
if (adminPreferences) adminPreferences.refresh = previous;
|
||||
window.rustProxyRefresh?.update(previous); renderRefreshPreferences(previous);
|
||||
});
|
||||
});
|
||||
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme, refreshAppearanceUi };
|
||||
|
||||
async function saveAppearancePreference(patch, control, rollback) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Single scheduler for all authenticated dashboard polling. SSE is controlled here
|
||||
// but remains one native EventSource with its own browser reconnect behavior.
|
||||
const REFRESH_DEFAULTS = Object.freeze({
|
||||
live_enabled: true, overview_seconds: 2, traffic_seconds: 5,
|
||||
cache_seconds: 5, system_info_seconds: 60
|
||||
});
|
||||
const REFRESH_CHOICES = Object.freeze({
|
||||
overview_seconds: [1, 2, 5, 10], traffic_seconds: [0, 3, 5, 10, 30],
|
||||
cache_seconds: [0, 3, 5, 10, 30], system_info_seconds: [0, 15, 30, 60, 300]
|
||||
});
|
||||
function normalizeRefreshPreferences(input = {}) {
|
||||
const value = { ...REFRESH_DEFAULTS };
|
||||
if (typeof input.live_enabled === "boolean") value.live_enabled = input.live_enabled;
|
||||
for (const [key, allowed] of Object.entries(REFRESH_CHOICES)) {
|
||||
if (allowed.includes(input[key])) value[key] = input[key];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const rustProxyRefresh = (() => {
|
||||
const tasks = new Map();
|
||||
let ready = false, options = { ...REFRESH_DEFAULTS }, panel = currentPanel;
|
||||
let settingsTab = "listeners", watchingVisibility = false;
|
||||
const visible = () => !document.hidden;
|
||||
function allowed(task) {
|
||||
return ready && authenticated && visible() && options.live_enabled &&
|
||||
task.interval() > 0 && (!task.active || task.active());
|
||||
}
|
||||
function clear(task, abort = false) {
|
||||
if (task.timer !== null) clearTimeout(task.timer);
|
||||
task.timer = null;
|
||||
if (abort) {
|
||||
++task.generation;
|
||||
task.controller?.abort();
|
||||
}
|
||||
}
|
||||
function schedule(task, delay) {
|
||||
if (task.timer !== null || task.inFlight || !allowed(task)) return;
|
||||
task.timer = setTimeout(() => { task.timer = null; void execute(task, false); }, delay);
|
||||
}
|
||||
function execute(task, manual) {
|
||||
if (!authenticated || (!manual && !allowed(task))) return Promise.resolve();
|
||||
if (task.inFlight) {
|
||||
if (manual) task.queued = true;
|
||||
return task.inFlight;
|
||||
}
|
||||
clear(task);
|
||||
const controller = new AbortController(), generation = task.generation, epoch = authEpoch;
|
||||
const panelAtStart = panel, tabAtStart = settingsTab;
|
||||
task.controller = controller;
|
||||
// A manual request remains valid with Live switched off, but not in a stale view.
|
||||
const isCurrent = () => authenticated && epoch === authEpoch && !controller.signal.aborted &&
|
||||
generation === task.generation && (manual ? panel === panelAtStart && settingsTab === tabAtStart : allowed(task));
|
||||
const promise = Promise.resolve().then(() => task.run({ signal: controller.signal, isCurrent }));
|
||||
task.inFlight = promise.catch(error => {
|
||||
if (error?.name !== "AbortError" && isCurrent()) {
|
||||
// A failure is isolated to this source; other timers continue.
|
||||
if (task.onError) task.onError(error);
|
||||
else console.warn(`Refresh ${task.id} failed:`, error);
|
||||
}
|
||||
}).finally(() => {
|
||||
task.inFlight = null;
|
||||
if (task.controller === controller) task.controller = null;
|
||||
if (task.queued && authenticated) {
|
||||
task.queued = false;
|
||||
void execute(task, true);
|
||||
} else {
|
||||
task.queued = false;
|
||||
schedule(task, task.interval() * 1000);
|
||||
}
|
||||
});
|
||||
return task.inFlight;
|
||||
}
|
||||
function register(id, spec) {
|
||||
if (tasks.has(id)) throw new Error(`Duplicate refresh task: ${id}`);
|
||||
if (typeof spec.run !== "function" || typeof spec.interval !== "function") throw new Error(`Invalid refresh task: ${id}`);
|
||||
const task = { ...spec, id, timer: null, inFlight: null, controller: null, generation: 0, queued: false, enabled: false, seconds: null };
|
||||
tasks.set(id, task);
|
||||
sync();
|
||||
return () => { clear(task, true); tasks.delete(id); };
|
||||
}
|
||||
function sync() {
|
||||
for (const task of tasks.values()) {
|
||||
const shouldRun = allowed(task), seconds = task.interval();
|
||||
if (!shouldRun) {
|
||||
if (task.enabled) clear(task, true);
|
||||
task.enabled = false;
|
||||
} else if (!task.enabled || task.seconds !== seconds) {
|
||||
clear(task);
|
||||
task.enabled = true;
|
||||
schedule(task, 0);
|
||||
}
|
||||
task.seconds = seconds;
|
||||
}
|
||||
if (ready && authenticated && visible() && options.live_enabled) startLive();
|
||||
else stopLive();
|
||||
}
|
||||
function onVisibility() { sync(); if (visible() && ready && authenticated) void revalidateSession(); }
|
||||
function start() {
|
||||
ready = true; panel = currentPanel;
|
||||
settingsTab = window.rustProxySettings?.current?.() || "listeners";
|
||||
if (!watchingVisibility) { document.addEventListener("visibilitychange", onVisibility); watchingVisibility = true; }
|
||||
sync();
|
||||
}
|
||||
function stop() {
|
||||
ready = false;
|
||||
for (const task of tasks.values()) {
|
||||
clear(task, true); task.queued = false; task.enabled = false;
|
||||
}
|
||||
if (watchingVisibility) { document.removeEventListener("visibilitychange", onVisibility); watchingVisibility = false; }
|
||||
stopLive();
|
||||
}
|
||||
function update(value) { options = normalizeRefreshPreferences(value); sync(); }
|
||||
function setPanel(value) {
|
||||
panel = value; settingsTab = window.rustProxySettings?.current?.() || settingsTab;
|
||||
for (const task of tasks.values()) if (task.active && !task.active()) task.queued = false;
|
||||
sync();
|
||||
}
|
||||
function setSettingsTab(value) {
|
||||
settingsTab = value;
|
||||
for (const task of tasks.values()) if (task.active && !task.active()) task.queued = false;
|
||||
sync();
|
||||
}
|
||||
function manual(id) {
|
||||
const task = tasks.get(id);
|
||||
if (!task) return Promise.reject(new Error(`Unknown refresh task: ${id}`));
|
||||
return execute(task, true);
|
||||
}
|
||||
register("status-full", { interval: () => 0, run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status", { signal });
|
||||
if (isCurrent()) renderStatus(value, true);
|
||||
}});
|
||||
register("status-fallback", { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status?view=live", { signal });
|
||||
if (isCurrent()) renderLiveStatus(value);
|
||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }});
|
||||
register("logs-fallback", { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/logs", { signal });
|
||||
if (isCurrent()) renderLogs(value);
|
||||
}});
|
||||
register("system-info", { interval: () => options.system_info_seconds,
|
||||
active: () => panel === "overview" || (panel === "settings" && ["system-info", "cache"].includes(settingsTab)),
|
||||
run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/system-info", { signal });
|
||||
if (!isCurrent()) return;
|
||||
latestStatus = { ...(latestStatus || {}), ...value };
|
||||
if (latestStatus.metrics) renderDiagnostics(latestStatus, false, true);
|
||||
window.rustProxySettings?.render();
|
||||
}
|
||||
});
|
||||
register("tunnels", { interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
|
||||
run: async context => { await loadTunnels(context); } });
|
||||
return { register, manual, start, stop, sync, update, setPanel, setSettingsTab,
|
||||
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null })) };
|
||||
})();
|
||||
window.rustProxyRefresh = rustProxyRefresh;
|
||||
|
||||
$("overview-refresh")?.addEventListener("click", () => {
|
||||
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback"), rustProxyRefresh.manual("system-info")]);
|
||||
});
|
||||
$("system-info-refresh")?.addEventListener("click", () => void rustProxyRefresh.manual("system-info"));
|
||||
@@ -242,7 +242,7 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
|
||||
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty"));
|
||||
}
|
||||
}
|
||||
function renderDiagnostics(status, appendSample = true) {
|
||||
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
|
||||
const d = status.system || {}, c = status.cache || {}, m = status.metrics || {};
|
||||
$("metric-h3").textContent = fmt(m.http3_requests);
|
||||
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`;
|
||||
@@ -257,7 +257,7 @@ function renderDiagnostics(status, appendSample = true) {
|
||||
const sourceSupported = !!d.source && d.source !== "unsupported";
|
||||
$("system-source").hidden = !sourceSupported;
|
||||
if (sourceSupported) $("system-source").textContent = t("realSample");
|
||||
if (appendSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); }
|
||||
if (systemSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); }
|
||||
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
|
||||
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
|
||||
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
|
||||
@@ -294,6 +294,8 @@ function renderDiagnostics(status, appendSample = true) {
|
||||
cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60);
|
||||
lastCacheCounters = currentCounters;
|
||||
}
|
||||
}
|
||||
if (systemSample) {
|
||||
if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); }
|
||||
if (d.process_rss_bytes != null) { processRssSamples.push(Number(d.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); }
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ function renderSettings() {
|
||||
$("cache-sie-timeout").checked = c.stale_if_error_timeout !== false; $("cache-sie-connection").checked = c.stale_if_error_connection !== false; $("cache-sie-dns").checked = c.stale_if_error_dns !== false; $("cache-sie-tls").checked = c.stale_if_error_tls !== false; $("cache-sie-statuses").value = (c.stale_if_error_statuses || [500, 502, 503, 504]).join(", ");
|
||||
$("cache-proactive-enabled").checked = c.proactive_refresh_enabled === true; $("cache-proactive-before").value = c.proactive_refresh_before_expiry_seconds ?? 15; $("cache-proactive-percent").value = c.proactive_refresh_percent ?? 10; $("cache-proactive-inflight").value = c.proactive_refresh_max_inflight ?? 4;
|
||||
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
|
||||
$("cache-http-invalidation-cidrs").value = (c.http_invalidation_allowed_cidrs || []).join("\n");
|
||||
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
|
||||
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age; $("cache-only-hits").checked = !!c.expose_only_hits;
|
||||
const viaHeader = c.via_header === undefined ? "RustProxy" : c.via_header;
|
||||
@@ -285,7 +286,7 @@ bindSettingsForm("response-form", next => {
|
||||
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
|
||||
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
|
||||
bindSettingsForm("cache-form", next => {
|
||||
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
|
||||
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
|
||||
}, "cache");
|
||||
bindSettingsForm("transform-form", next => {
|
||||
next.transform = {
|
||||
|
||||
@@ -217,13 +217,24 @@
|
||||
generation++; await refresh();
|
||||
} catch (error) { operationMessage("cache-bans-status", error.message, true); }
|
||||
}
|
||||
async function refresh() {
|
||||
async function loadCache({ signal, isCurrent }) {
|
||||
if (!authenticated || !envelope || currentPanel !== "cache" || loading) return;
|
||||
loading = true; const epoch = authEpoch, token = generation; const query = new URLSearchParams({ offset: String(offset), limit: "50" }); if (scope() !== "*") query.set("vhost", scope());
|
||||
try { const data = await api(`/api/cache?${query}`); if (epoch !== authEpoch || token !== generation || !authenticated) return; render(data); }
|
||||
catch (error) { if (epoch === authEpoch) { $("cache-panel-state").textContent = error.message; if (error.status === 401) lock(); } }
|
||||
finally { loading = false; if (token !== generation && authenticated && currentPanel === "cache") void refresh(); }
|
||||
loading = true;
|
||||
const epoch = authEpoch, token = generation;
|
||||
const query = new URLSearchParams({ offset: String(offset), limit: "50" });
|
||||
if (scope() !== "*") query.set("vhost", scope());
|
||||
try {
|
||||
const data = await api(`/api/cache?${query}`, { signal });
|
||||
if (isCurrent() && token === generation) render(data);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||
$("cache-panel-state").textContent = error.message;
|
||||
if (error.status === 401) lock();
|
||||
}
|
||||
} finally { loading = false; }
|
||||
}
|
||||
function refresh() { return rustProxyRefresh.manual("cache"); }
|
||||
|
||||
initializeLiveChartLimit();
|
||||
$("cache-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
|
||||
$("cache-scope").addEventListener("change", () => { offset = 0; resetLiveChart(); generation++; void refresh(); });
|
||||
@@ -319,7 +330,12 @@
|
||||
offset = 0; generation++; await refresh();
|
||||
} catch (error) { if (epoch === authEpoch) operationMessage("cache-toolbar-operation-status", error.message, true); } finally { button.disabled = false; }
|
||||
});
|
||||
setInterval(() => { if (!document.hidden && $("cache-live").checked) void refresh(); }, 3000);
|
||||
rustProxyRefresh.register("cache", {
|
||||
interval: () => rustProxyRefresh.current().cache_seconds,
|
||||
active: () => currentPanel === "cache" && !!$("cache-live")?.checked,
|
||||
run: loadCache
|
||||
});
|
||||
$("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
||||
window.rustProxyCachePanel = {
|
||||
refresh, configure, reason, reset() {
|
||||
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans();
|
||||
|
||||
+35
-6
@@ -363,14 +363,39 @@
|
||||
}
|
||||
function syncLiveSwitch() { const control = $("docker-live-refresh"); if (control) control.checked = liveEnabled(); }
|
||||
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); }
|
||||
async function refresh(force = false) {
|
||||
if (!authenticated) return; const button = $("docker-refresh"); if (button && force) button.disabled = true;
|
||||
try { const data = await api(force ? "/api/docker/refresh" : "/api/docker", force ? { method: "POST", body: "{}" } : {}); observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || []; poolIndex = new Map(pools.map(pool => [pool.id, pool])); renderSettings(); if (force || canLiveRender()) renderDiscovery(); }
|
||||
catch (error) { if (force) toast(error.message, true); } finally { if (button) button.disabled = false; }
|
||||
let dockerLoading = null, dockerForcePending = false;
|
||||
async function refresh(force = false, context = null) {
|
||||
if (!authenticated) return;
|
||||
if (dockerLoading) {
|
||||
if (force) dockerForcePending = true;
|
||||
return dockerLoading;
|
||||
}
|
||||
dockerLoading = loadDocker(force, context);
|
||||
try { return await dockerLoading; }
|
||||
finally {
|
||||
dockerLoading = null;
|
||||
if (dockerForcePending && authenticated) { dockerForcePending = false; void refresh(true); }
|
||||
else dockerForcePending = false;
|
||||
}
|
||||
}
|
||||
async function loadDocker(force = false, context = null) {
|
||||
const button = $("docker-refresh"), epoch = authEpoch;
|
||||
if (button && force) button.disabled = true;
|
||||
const options = force ? { method: "POST", body: "{}" } : {};
|
||||
if (context?.signal) options.signal = context.signal;
|
||||
try {
|
||||
const data = await api(force ? "/api/docker/refresh" : "/api/docker", options);
|
||||
if (!authenticated || epoch !== authEpoch || (context?.isCurrent && !context.isCurrent())) return;
|
||||
observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || [];
|
||||
poolIndex = new Map(pools.map(pool => [pool.id, pool]));
|
||||
renderSettings(); if (force || canLiveRender()) renderDiscovery();
|
||||
} catch (error) {
|
||||
if (force && error?.name !== "AbortError" && authenticated && epoch === authEpoch) toast(error.message, true);
|
||||
} finally { if (button) button.disabled = false; }
|
||||
}
|
||||
|
||||
$("docker-refresh")?.addEventListener("click", () => void refresh(true));
|
||||
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); if (event.target.checked) void refresh(false); });
|
||||
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); rustProxyRefresh.sync(); });
|
||||
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
|
||||
$("docker-search")?.addEventListener("input", applyViewFilters);
|
||||
$("docker-vhost-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); applyViewFilters(); });
|
||||
@@ -379,6 +404,10 @@
|
||||
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
|
||||
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
|
||||
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
|
||||
setInterval(() => { if (authenticated && liveEnabled() && typeof currentPanel !== "undefined" && currentPanel === "docker-vhosts" && canLiveRender()) void refresh(false); }, 5000);
|
||||
rustProxyRefresh.register("docker", {
|
||||
interval: () => 5,
|
||||
active: () => currentPanel === "docker-vhosts" && liveEnabled() && canLiveRender(),
|
||||
run: async context => refresh(false, context)
|
||||
});
|
||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
})();
|
||||
@@ -61,6 +61,7 @@
|
||||
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);
|
||||
}
|
||||
window.rustProxyRefresh?.sync();
|
||||
}
|
||||
toggle.addEventListener("click", () => { collapsed = !collapsed; render(); });
|
||||
function register(type, api) { editors.set(type, api); render(); }
|
||||
@@ -92,7 +93,10 @@
|
||||
}
|
||||
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.rustProxyRefresh?.register("drafts", {
|
||||
interval: () => 60, active: () => drafts.length > 0 && !collapsed,
|
||||
run: async () => render()
|
||||
});
|
||||
|
||||
window.rustProxyDrafts = { register, dock: dockEditor, prepare, reset, refresh: render, captureControls, restoreControls, takeChildren, restoreChildren };
|
||||
})();
|
||||
@@ -28,6 +28,21 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Visible even when the dashboard JavaScript fails to initialize. -->
|
||||
<div class="app-startup" id="app-startup" role="status" aria-live="polite">
|
||||
<div class="app-startup-card">
|
||||
<div class="app-startup-brand" aria-hidden="true">r<span>/</span>p</div>
|
||||
<div class="app-startup-spinner" aria-hidden="true"></div>
|
||||
<h2 id="app-startup-title" data-i18n="startupLoading">Loading RustProxy…</h2>
|
||||
<p class="app-startup-message" data-i18n="startupPreparing">Connecting to the administration panel.</p>
|
||||
<div class="app-startup-recovery">
|
||||
<p data-i18n="startupSlow">The panel is taking longer than expected. Check your connection and try again.</p>
|
||||
<p id="app-startup-detail" class="app-startup-detail" hidden></p>
|
||||
<a class="outline" href="" data-i18n="startupRetry">Reload panel</a>
|
||||
</div>
|
||||
<noscript>JavaScript is required to use the administration panel.</noscript>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/" aria-label="RustProxy"><span
|
||||
@@ -111,10 +126,13 @@
|
||||
</section>
|
||||
<div hidden="" id="workspace">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<div class="page-intro-copy">
|
||||
<div class="eyebrow"><i class="signal" id="signal"></i><span id="connection-label">CONNECTED TO
|
||||
LOCAL CORE</span></div>
|
||||
<h1 id="page-title">Traffic, in focus.</h1>
|
||||
<div class="page-intro-title-row">
|
||||
<h1 id="page-title">Traffic, in focus.</h1>
|
||||
<button class="quiet overview-refresh" data-i18n="refreshNow" id="overview-refresh" type="button" hidden>Refresh now</button>
|
||||
</div>
|
||||
<p class="lead" id="page-description"></p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1221,7 +1239,7 @@
|
||||
role="status"></span>
|
||||
<label class="check cache-live-toggle"><input checked="" id="traffic-live"
|
||||
type="checkbox" />
|
||||
<span data-i18n="cacheLive">Live (3 s)</span></label>
|
||||
<span data-i18n="refreshViewLive">Auto in this view</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="Traffic analytics" class="settings-tabs sub-tabs traffic-tabs" id="traffic-tabs"
|
||||
@@ -1375,8 +1393,7 @@
|
||||
type="button">Active BANs</button>
|
||||
<div class="cache-toolbar-status"><span class="fine" id="cache-panel-state"
|
||||
role="status"></span><label class="check cache-live-toggle"><input checked=""
|
||||
id="cache-live" type="checkbox" /><span data-i18n="cacheLive">Live (3
|
||||
s)</span></label></div>
|
||||
id="cache-live" type="checkbox" /><span data-i18n="refreshViewLive">Auto in this view</span></label></div>
|
||||
<p class="operation-message cache-toolbar-message" id="cache-toolbar-operation-status"
|
||||
role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
@@ -1530,8 +1547,7 @@
|
||||
id="transform-panel-settings" type="button">Settings</button>
|
||||
<div class="cache-toolbar-status"><span class="fine" id="transform-panel-state"
|
||||
role="status"></span><label class="check cache-live-toggle"><input checked=""
|
||||
id="transform-live" type="checkbox" /><span data-i18n="cacheLive">Live (3
|
||||
s)</span></label></div>
|
||||
id="transform-live" type="checkbox" /><span data-i18n="refreshViewLive">Auto in this view</span></label></div>
|
||||
</div>
|
||||
<details class="card function-live-chart" id="transform-live-chart">
|
||||
<summary class="function-live-chart-summary">
|
||||
@@ -1912,6 +1928,33 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="appearance-section" id="refresh-preferences">
|
||||
<div class="appearance-section-heading"><div>
|
||||
<div class="small-label" data-i18n="refreshSection">REFRESH</div>
|
||||
<h3 data-i18n="refreshTitle">Data refresh</h3>
|
||||
</div><button class="quiet appearance-section-reset" id="refresh-reset" type="button" data-i18n="resetSection">Reset section</button></div>
|
||||
<p class="fine" data-i18n="refreshHelp">Per-account refresh intervals. Off pauses all automatic sources; manual refresh remains available.</p>
|
||||
<div class="appearance-settings-grid">
|
||||
<label class="check"><input type="checkbox" checked id="refresh-live-enabled" data-refresh-preference="live_enabled" />
|
||||
<span data-i18n="refreshLiveEnabled">Automatic refresh enabled</span></label>
|
||||
<label><span data-i18n="refreshOverview">Live overview / SSE interval</span>
|
||||
<select id="refresh-overview-seconds" data-refresh-preference="overview_seconds">
|
||||
<option value="1">1 s</option><option selected value="2">2 s</option><option value="5">5 s</option><option value="10">10 s</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="refreshTraffic">Traffic interval</span>
|
||||
<select id="refresh-traffic-seconds" data-refresh-preference="traffic_seconds">
|
||||
<option value="0" data-i18n="refreshManual">Manual</option><option value="3">3 s</option><option selected value="5">5 s</option><option value="10">10 s</option><option value="30">30 s</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="refreshCache">Cache interval</span>
|
||||
<select id="refresh-cache-seconds" data-refresh-preference="cache_seconds">
|
||||
<option value="0" data-i18n="refreshManual">Manual</option><option value="3">3 s</option><option selected value="5">5 s</option><option value="10">10 s</option><option value="30">30 s</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="refreshSystemInfo">System information interval</span>
|
||||
<select id="refresh-system-info-seconds" data-refresh-preference="system_info_seconds">
|
||||
<option value="0" data-i18n="refreshManual">Manual</option><option value="15">15 s</option><option value="30">30 s</option><option selected value="60">60 s</option><option value="300">300 s</option>
|
||||
</select></label>
|
||||
</div>
|
||||
</section>
|
||||
<div class="appearance-actions"><button class="outline" data-i18n="appearanceReset"
|
||||
id="appearance-reset" type="button">Reset appearance</button></div>
|
||||
</div>
|
||||
@@ -2290,6 +2333,22 @@
|
||||
</div>
|
||||
<p class="fine" data-i18n="cacheViaHelp">Defaults to Via: RustProxy. Changing this
|
||||
response-only header does not purge cached objects.</p>
|
||||
<div class="cache-maintenance-help">
|
||||
<h3 class="subheading" data-i18n="cacheHttpInvalidationTitle">HTTP BAN / PURGE IP allowlist</h3>
|
||||
<details class="metric-info cache-maintenance-info">
|
||||
<summary aria-label="curl examples for HTTP BAN and PURGE" data-i18n-aria="cacheHttpInvalidationCurlLabel">i</summary>
|
||||
<div class="metric-info-popover" role="note">
|
||||
<p class="fine" data-i18n="cacheHttpInvalidationCurlIntro">Run curl from an allowlisted IP against a public vhost:</p>
|
||||
<code>curl -i -X PURGE 'https://site.example/assets/app.js?v=1'</code>
|
||||
<code>curl -i -X BAN 'https://site.example/assets/'</code>
|
||||
<code>curl -i -X BAN 'https://site.example/' -H 'X-RustProxy-Confirm-Wide: 1'</code>
|
||||
<p class="fine" data-i18n="cacheHttpInvalidationCurlNote">PURGE removes an exact URL; BAN invalidates a path prefix for 1 hour. No admin cookie is needed, but your IP must be allowed. A reverse proxy must forward these methods and trusted client IP information.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<label><span data-i18n="cacheHttpInvalidationAddresses">Allowed client IP addresses or CIDRs (one per line)</span>
|
||||
<textarea id="cache-http-invalidation-cidrs" rows="4" spellcheck="false" placeholder="192.0.2.10 2001:db8:10::/64"></textarea></label>
|
||||
<p class="fine" data-i18n="cacheHttpInvalidationHelp">Empty = disabled. PURGE /path?query removes exact matching entries; BAN /path lazily blocks cached URLs starting with the prefix (TTL 1 hour, optional X-RustProxy-Ban-Ttl). BAN / requires X-RustProxy-Confirm-Wide: 1. Only the matched vhost and Host are affected. Trusted reverse proxies must sanitize forwarding headers.</p>
|
||||
<p class="fine" data-i18n="cacheAgeHelp">Age is sent on cache hits unless an
|
||||
explicit response-header rule removes or replaces it. Such Age rules are
|
||||
wire-only and do not change cache freshness.</p>
|
||||
@@ -2697,6 +2756,13 @@
|
||||
type="number" required="" /></label>
|
||||
<p class="fine" data-i18n="administrationSessionDaysHelp">Browser sessions use an
|
||||
absolute 7–30 day lifetime.</p>
|
||||
<h3 class="subheading" data-i18n="panelCompressionTitle">Panel content compression</h3>
|
||||
<div class="switch-grid">
|
||||
<label class="check"><input id="admin-compression-zstd" type="checkbox" checked /><span>Zstd</span></label>
|
||||
<label class="check"><input id="admin-compression-br" type="checkbox" checked /><span>Brotli (br)</span></label>
|
||||
<label class="check"><input id="admin-compression-gzip" type="checkbox" checked /><span>gzip</span></label>
|
||||
</div>
|
||||
<p class="fine" data-i18n="panelCompressionHelp">The browser negotiates the best supported encoding (equal quality: Zstd → Brotli → gzip). Disable all to send uncompressed panel assets. Applies immediately; a reverse proxy may forward or re-encode the response.</p>
|
||||
<p class="fine" id="admin-status" role="status"></p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply"
|
||||
type="submit">Validate, save and apply</button></div>
|
||||
@@ -2706,9 +2772,10 @@
|
||||
data-settings-page="system-info" hidden="" id="settings-page-system-info" role="tabpanel">
|
||||
<article class="card edit-form runtime-information-card">
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="systemInformation">System information</h2><span class="pill">API</span>
|
||||
<h2 data-i18n="systemInformation">System information</h2><div class="system-info-heading-actions"><span class="pill">API</span><button class="quiet" data-i18n="refreshNow" id="system-info-refresh" type="button">Refresh now</button></div>
|
||||
</div>
|
||||
<p class="fine" data-i18n="systemInformationHelp">Live, read-only information from the authenticated RustProxy status API. Unavailable metrics are not estimated.</p>
|
||||
<p class="fine" data-i18n="systemInformationHelp">Read-only information from the authenticated system-info API. Unavailable metrics are not estimated.</p>
|
||||
<p class="fine" id="system-info-refresh-note" role="status"></p>
|
||||
<h3 data-i18n="systemHostHeading">Operating system and hardware</h3>
|
||||
<div class="runtime-information-grid">
|
||||
<div><span class="small-label" data-i18n="runtimePlatform">Platform</span><code id="runtime-platform">-</code></div>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
const target = buttons.find(button => button.dataset.settingsTab === name); if (!target) return;
|
||||
if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
|
||||
active = name;
|
||||
if (typeof window !== "undefined") window.rustProxyRefresh?.setSettingsTab(name);
|
||||
for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); }
|
||||
document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name);
|
||||
if (persist) syncLocation();
|
||||
@@ -213,6 +214,7 @@ curl --fail --show-error --request POST \
|
||||
$("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current;
|
||||
$("admin-hosts").value = (admin.allowed_hosts || []).join("\n");
|
||||
$("admin-session-days").value = Number(admin.session_days || 30);
|
||||
for (const codec of ["zstd", "br", "gzip"]) $( `admin-compression-${codec}` ).checked = admin.panel_compression?.[codec] !== false;
|
||||
}
|
||||
const listenLocked = !!adminStatus?.listen_locked;
|
||||
$("admin-listen").disabled = listenLocked; $("admin-listen-lock-note").hidden = !listenLocked;
|
||||
@@ -298,7 +300,7 @@ curl --fail --show-error --request POST \
|
||||
}, "prometheus");
|
||||
bindSettingsForm("administration-form", next => {
|
||||
const locked = !!latestStatus?.administration?.listen_locked;
|
||||
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value) };
|
||||
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value), panel_compression: Object.fromEntries(["zstd", "br", "gzip"].map(codec => [codec, $( `admin-compression-${codec}` ).checked])) };
|
||||
}, "administration");
|
||||
bindSettingsForm("tls-policy-form", next => {
|
||||
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
|
||||
|
||||
+160
-2
@@ -165,8 +165,102 @@ html.app-loading {
|
||||
background: var(--bg)
|
||||
}
|
||||
|
||||
html.app-loading body {
|
||||
visibility: hidden
|
||||
/* The initial shell may be unavailable; never hide the boot screen itself. */
|
||||
html.app-loading .shell {
|
||||
visibility: hidden;
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
.app-startup {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg, #10151b);
|
||||
color: var(--text, #edf2f7);
|
||||
text-align: center
|
||||
}
|
||||
|
||||
html:not(.app-loading) .app-startup {
|
||||
display: none
|
||||
}
|
||||
|
||||
.app-startup-card {
|
||||
width: min(100%, 360px)
|
||||
}
|
||||
|
||||
.app-startup-brand {
|
||||
font: 700 29px var(--mono, monospace);
|
||||
letter-spacing: -2px;
|
||||
margin-bottom: 24px
|
||||
}
|
||||
|
||||
.app-startup-brand span {
|
||||
color: var(--accent, #65cfa2)
|
||||
}
|
||||
|
||||
.app-startup-spinner {
|
||||
box-sizing: border-box;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 auto 22px;
|
||||
border: 3px solid var(--line, #374352);
|
||||
border-top-color: var(--accent, #65cfa2);
|
||||
border-radius: 50%;
|
||||
animation: app-startup-spin .8s linear infinite
|
||||
}
|
||||
|
||||
.app-startup h2 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4
|
||||
}
|
||||
|
||||
.app-startup-message,
|
||||
.app-startup-recovery p {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted, #b5c0cc)
|
||||
}
|
||||
|
||||
.app-startup-recovery {
|
||||
visibility: hidden;
|
||||
animation: app-startup-reveal 0s linear 12s forwards
|
||||
}
|
||||
|
||||
.app-startup-recovery .outline {
|
||||
margin-top: 10px;
|
||||
display: inline-flex
|
||||
}
|
||||
|
||||
html.app-startup-failed .app-startup-spinner,
|
||||
html.app-startup-failed .app-startup-message {
|
||||
display: none
|
||||
}
|
||||
|
||||
html.app-startup-failed .app-startup-recovery {
|
||||
visibility: visible;
|
||||
animation: none
|
||||
}
|
||||
|
||||
.app-startup-detail {
|
||||
overflow-wrap: anywhere
|
||||
}
|
||||
|
||||
@keyframes app-startup-spin {
|
||||
to { transform: rotate(360deg) }
|
||||
}
|
||||
|
||||
@keyframes app-startup-reveal {
|
||||
to { visibility: visible }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-startup-spinner { animation: none }
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -9966,3 +10060,67 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
.php-origin-errors > label { max-width: 430px; }
|
||||
.php-origin-errors > .check { margin: 0 !important; }
|
||||
.php-origin-errors .fine { margin: 0; }
|
||||
|
||||
/* Keep the Overview refresh action on the same row as the page title. */
|
||||
.page-intro-copy,
|
||||
.page-intro-title-row {
|
||||
min-width: 0;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.page-intro-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px
|
||||
}
|
||||
|
||||
.page-intro-title-row h1 {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto
|
||||
}
|
||||
|
||||
.page-intro-title-row .overview-refresh {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-intro-title-row h1 {
|
||||
font-size: clamp(18px, 5vw, 24px);
|
||||
letter-spacing: -.6px
|
||||
}
|
||||
.page-intro-title-row .overview-refresh {
|
||||
padding: 7px 9px;
|
||||
font-size: 11px
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the system API indicator next to its action, aligned as one unit. */
|
||||
.system-info-heading-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }
|
||||
.system-info-heading-actions .pill { white-space: nowrap; }
|
||||
|
||||
/* Contextual, keyboard-accessible curl examples next to the cache allowlist. */
|
||||
.cache-maintenance-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
.cache-maintenance-help .subheading { margin: 0; }
|
||||
.cache-maintenance-info { position: static; flex-shrink: 0; }
|
||||
.cache-maintenance-info .metric-info-popover {
|
||||
max-height: min(60vh, 420px);
|
||||
overflow-y: auto;
|
||||
width: min(440px, calc(100vw - 56px));
|
||||
}
|
||||
.cache-maintenance-info .metric-info-popover code {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 7px 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.cache-maintenance-info .metric-info-popover .fine + code { margin-top: 6px; }
|
||||
.cache-maintenance-info .metric-info-popover code + .fine { margin-top: 8px; }
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
let loading = false, timer = null, lastData = null, activeTab = "overview";
|
||||
let loading = false, lastData = null, activeTab = "overview";
|
||||
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);
|
||||
@@ -101,12 +101,17 @@
|
||||
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${formatTime(new Date())}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!authenticated || loading) return; loading = true;
|
||||
try { render(await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`)); }
|
||||
catch (error) { $("traffic-panel-state").textContent = error.message; }
|
||||
finally { loading = false; }
|
||||
async function loadTraffic({ signal, isCurrent }) {
|
||||
if (!authenticated || currentPanel !== "traffic" || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const data = await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`, { signal });
|
||||
if (isCurrent()) render(data);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError" && isCurrent()) $("traffic-panel-state").textContent = error.message;
|
||||
} finally { loading = false; }
|
||||
}
|
||||
function refresh() { return rustProxyRefresh.manual("traffic"); }
|
||||
|
||||
function reset() {
|
||||
lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
||||
@@ -121,7 +126,12 @@
|
||||
document.querySelectorAll("[data-traffic-tab]").forEach(button => button.addEventListener("click", () => selectTab(button.dataset.trafficTab)));
|
||||
$("traffic-refresh")?.addEventListener("click", () => void refresh()); $("traffic-scope")?.addEventListener("change", () => void refresh());
|
||||
$("traffic-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("logging"); });
|
||||
timer = setInterval(() => { if (authenticated && $("traffic-live")?.checked && !$("panel-traffic")?.hidden) void refresh(); }, 3000);
|
||||
rustProxyRefresh.register("traffic", {
|
||||
interval: () => rustProxyRefresh.current().traffic_seconds,
|
||||
active: () => currentPanel === "traffic" && !!$("traffic-live")?.checked,
|
||||
run: loadTraffic
|
||||
});
|
||||
$("traffic-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
||||
selectTab(activeTab);
|
||||
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData), select: selectTab };
|
||||
})();
|
||||
@@ -144,26 +144,32 @@
|
||||
renderTypes(counters); renderReasons(counters); renderVhosts(data);
|
||||
$("transform-panel-state").textContent = `${t("cacheUpdated")}: ${formatTime(new Date())}`;
|
||||
}
|
||||
async function refresh() {
|
||||
async function loadTransform({ signal, isCurrent }) {
|
||||
if (!authenticated || currentPanel !== "transform" || loading) return;
|
||||
loading = true; const epoch = authEpoch, token = generation;
|
||||
loading = true;
|
||||
const epoch = authEpoch, token = generation;
|
||||
try {
|
||||
const data = await api("/api/transform");
|
||||
if (epoch !== authEpoch || token !== generation || !authenticated) return;
|
||||
render(data);
|
||||
const data = await api("/api/transform", { signal });
|
||||
if (isCurrent() && token === generation) render(data);
|
||||
} catch (error) {
|
||||
if (epoch === authEpoch) { $("transform-panel-state").textContent = error.message; if (error.status === 401) lock(); }
|
||||
} finally {
|
||||
loading = false;
|
||||
if (token !== generation && authenticated && currentPanel === "transform") void refresh();
|
||||
}
|
||||
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||
$("transform-panel-state").textContent = error.message;
|
||||
if (error.status === 401) lock();
|
||||
}
|
||||
} finally { loading = false; }
|
||||
}
|
||||
function refresh() { return rustProxyRefresh.manual("transform"); }
|
||||
initializeLiveChartLimit();
|
||||
$("transform-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
|
||||
$("transform-scope")?.addEventListener("change", () => { resetLiveChart(); if (lastData) render(lastData); });
|
||||
$("transform-refresh")?.addEventListener("click", () => { generation++; void refresh(); });
|
||||
$("transform-panel-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("transform"); $("transform-form")?.scrollIntoView({ block: "start" }); });
|
||||
setInterval(() => { if (!document.hidden && $("transform-live")?.checked) void refresh(); }, 3000);
|
||||
rustProxyRefresh.register("transform", {
|
||||
interval: () => 3,
|
||||
active: () => currentPanel === "transform" && !!$("transform-live")?.checked,
|
||||
run: loadTransform
|
||||
});
|
||||
$("transform-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
||||
window.rustProxyTransformPanel = {
|
||||
refresh,
|
||||
reset() {
|
||||
|
||||
Reference in new issue
Block a user