This commit is contained in:
Mateusz Gruszczyński committed 2026-11-09 11:22:09 +01:00
1 parent 0cbe0566f5
commit 992a1795af
34 files changed
+1409 -604

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# RustProxy source and documentation maintenance
Current source version: **0.36.8**. The Cargo package and binary are named `rust-proxy`.
Current source version: **0.36.9**. The Cargo package and binary are named `rust-proxy`.
Before editing, read [README.md](README.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
+8
View File
@@ -2,6 +2,14 @@
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in the latest release.
## 0.36.9 — memory breakdown card and row-based Overview layout
- **Memory breakdown (Overview card, hidden by default):** a new optional `memory_breakdown` dashboard card splits the process RSS into cache data (object bodies), cache bookkeeping (index, keys, headers), cache buffers (fills, COLD queue/IO), free memory held by the allocator (fragmentation, an upper bound), code and mapped files, and the rest of the process. Chips show RSS / cache ratio, cache overhead per object, HOT object count and heap fragmentation; an allocator section lists heap, in-use, free-held and large mmap blocks. An "Outside cache" chart tracks RSS minus accounted cache and, after 10 minutes of samples, labels the trend as stable, rising or falling (with the slope in MiB/h) to tell a leak apart from flat overhead. Enable it in the layout editor.
- **Status API:** `GET /api/status` (full and `view=live`) adds an additive `memory_allocator` object with glibc `mallinfo2` statistics (`heap_bytes`, `in_use_bytes`, `free_bytes`, `releasable_bytes`, `mmap_bytes`, `mmap_blocks`), sampled at most every 2 seconds; it is `null` on musl, non-Linux and the optional mimalloc build.
- **Overview layout with row heights:** in a custom (unlocked/saved) layout, card heights now snap to a shared row unit — half the height of the tallest compact stat card, so stat tiles always span two rows and line up — instead of every card in a row stretching to the tallest one. A tall panel (Process, RAM cache, Network…) can sit beside several rows of small cards or charts with aligned edges. Each card automatically gets the rows its content needs; in the layout editor, the new **↕ − / +** control next to the width makes a card taller (`A` marks automatic height, lowering it to the content height returns to automatic). Charts (also with chart scales enabled), chart stacks and tables grow into the extra height, and a card is stretched over the empty cells below it down to the next card or the end of the grid, so rows end on straight edges. **Fill gaps** packs blocks with their real heights. Small content changes (for example a growing table) cannot make the grid jump: a card grows at once but shrinks only after the smaller size is seen several times. Below a 700 px wide dashboard, cards stack by content height as before.
- **Preferences API:** additive optional `dashboard_widget_heights` (widget ID → 1–32 rows) in `/api/preferences` and dashboard presets. Older documents and presets without it keep automatic heights.
- **Upgrade notes:** no configuration or database migration is required; existing saved layouts receive the new card hidden at the end. Legacy (never customized) layouts keep their grouped rows until the layout is edited.
## 0.36.8 — editor forms, toasts and CSP fixes
- **Editor forms:** the `pattern` attribute of generated ID fields (vhost, location, member, listener, stream ID, error fallback target, affinity cookie name) escaped the hyphen incorrectly for browsers that compile patterns with the `v` flag, which logged "Pattern attribute value ... is not a valid regular expression". The hyphen is now escaped.
Generated
+1 -1
View File
@@ -1943,7 +1943,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.36.8"
version = "0.36.9"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.36.8"
version = "0.36.9"
edition = "2021"
rust-version = "1.99.1"
description = "RustProxy - independent reverse proxy with local administration"
+581 -579
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -76,6 +76,7 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se
## Monitoring and logs
- Overview dashboard with requests per second, bandwidth, status codes, error share, cache hit ratio, protocol mix, system load, backend health, requests rejected by limits and nearest certificate expiry.
- Optional **Memory breakdown** card: process RSS split into cache data, cache bookkeeping, cache buffers, allocator-held free memory, mapped files and the rest of the process, with glibc heap statistics and an "outside cache" trend for spotting leaks.
- Customizable dashboard layouts (drag-and-drop with a live drop placeholder, keyboard moves, per-block width, explicit gap filling) and saved presets.
- Per-Vhost, per-backend, per-Web-Service and per-Stream statistics.
- Live request log in the browser and in the terminal (`--livelogs`).
+4
View File
@@ -81,6 +81,10 @@ Otherwise keep the default. The system allocator returns memory from evicted cac
The mimalloc rows above were measured with 0.35.2 and a different object mix; re-measure on your own workload before choosing the optional build.
### Where the memory goes
Process memory is always larger than the configured HOT cache. The HOT size already includes RustProxy's estimate of per-object bookkeeping (index, keys, headers), but not allocator rounding and fragmentation, connection and TLS buffers, the runtime, logs or GeoIP data. Enable the optional **Memory breakdown** Overview card to see the split for your workload: if the "Outside cache" trend stays flat while the cache is full, the difference is overhead or fragmentation; a steady rise points to memory that is not released. For reference, Varnish documents about 1 KiB of per-object overhead outside its storage size and notes that total use can reach about twice the configured storage.
## Production tuning
| Setting | Recommendation |
+2 -2
View File
@@ -77,10 +77,10 @@ ENV_FILE=integrations/routeros-oci-build/.env \
bash integrations/routeros-oci-build/build-routeros-oci.sh push
```
For example, with RustProxy version `0.36.8` and the defaults this publishes:
For example, with RustProxy version `0.36.9` and the defaults this publishes:
```text
zot.example.net/network/rustproxy:0.36.8
zot.example.net/network/rustproxy:0.36.9
zot.example.net/network/rustproxy:latest
zot.example.net/network/rustproxy:routeros
```
+34
View File
@@ -2309,6 +2309,35 @@
"processOverviewTitle": "Process",
"processOverviewDescription": "Process CPU, file descriptors, memory split and CPU/RSS history (cache memory is on the RAM cache card).",
"processClassicTitle": "Process simple",
"memoryBreakdownTitle": "Memory breakdown",
"memoryBreakdownDescription": "Process RSS split into cache data, cache bookkeeping, allocator fragmentation and the rest of the process, with a leak trend.",
"memoryBreakdownInfo": "Memory breakdown information",
"memoryBreakdownHint": "Splits the process RSS into cached object data, the cache's own bookkeeping (index, keys, headers), short-lived cache buffers, memory the allocator keeps free for reuse (fragmentation; an upper bound, part of it may already be returned to the kernel), program code and mapped files, and everything else (connections, TLS, runtime, logs, GeoIP). For comparison, Varnish documents about 1 KiB of per-object overhead outside its storage size. A steadily rising \"outside cache\" trend while the cache is full suggests a leak; a flat one means overhead or fragmentation.",
"memoryRatio": "RSS / cache",
"memoryRatioHelp": "Process RSS divided by the accounted HOT cache size",
"memoryPerObject": "Overhead / object",
"memoryPerObjectHelp": "Cache bookkeeping per stored object (accounted size minus object data)",
"memoryObjects": "HOT objects",
"memoryFragmentation": "Fragmentation",
"memoryFragmentationHelp": "Free memory held by the allocator as a share of its heap",
"memoryCacheData": "Cache data (object bodies)",
"memoryCacheMeta": "Cache bookkeeping",
"memoryTransient": "Cache buffers (fills, queues)",
"memoryAllocatorFree": "Free, held by allocator",
"memoryFiles": "Code and mapped files",
"memoryRest": "Rest of process",
"memoryAllocatorTitle": "Allocator",
"memoryAllocatorHeap": "Heap (arenas)",
"memoryAllocatorInUse": "In use",
"memoryAllocatorFreeHeld": "Free, held",
"memoryAllocatorMmap": "Large blocks (mmap) · count",
"memoryAllocatorUnavailable": "Allocator statistics are available only with the default glibc allocator.",
"memoryOutsideCache": "Outside cache",
"memoryOutsideCacheHistory": "Memory outside cache history",
"memoryTrendCollecting": "Collecting",
"memoryTrendStable": "Stable",
"memoryTrendRising": "Rising",
"memoryTrendFalling": "Falling",
"processLoadTitle": "Load",
"processMemoryTitle": "Memory",
"processFdHistory": "File descriptors history",
@@ -3041,6 +3070,11 @@
"pathPermissionsOptions": "Options and help",
"overviewLayoutNarrow": "Decrease card width",
"overviewLayoutWiden": "Increase card width",
"overviewLayoutHeight": "Height in rows",
"overviewLayoutLower": "Lower",
"overviewLayoutTaller": "Taller",
"overviewLayoutHeightAuto": "Automatic height: fits the content (A). Use + to make the card taller.",
"overviewLayoutHeightFixed": "Fixed height in rows. Lower it to the content height to return to automatic sizing.",
"overviewLayoutWidth": "Card width in 12-column grid",
"dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again.",
"overviewLayoutInlineDragging": "Dragging: drop the block on the dashed frame. Esc restores its previous position.",
+34
View File
@@ -2309,6 +2309,35 @@
"processOverviewTitle": "Proces",
"processOverviewDescription": "Obciążenie procesu, deskryptory plików, podział pamięci oraz historia CPU i RSS (pamięć cache jest w karcie RAM cache).",
"processClassicTitle": "Proces prosty",
"memoryBreakdownTitle": "Rozkład pamięci",
"memoryBreakdownDescription": "RSS procesu podzielone na dane cache, metadane cache, fragmentację alokatora i resztę procesu, z trendem wycieku.",
"memoryBreakdownInfo": "Informacje o rozkładzie pamięci",
"memoryBreakdownHint": "Dzieli RSS procesu na dane obiektów w cache, metadane samego cache (indeks, klucze, nagłówki), krótkotrwałe bufory cache, pamięć trzymaną przez alokator jako wolną do ponownego użycia (fragmentacja; górne oszacowanie, część mogła już wrócić do jądra), kod programu i zmapowane pliki oraz całą resztę (połączenia, TLS, runtime, logi, GeoIP). Dla porównania: Varnish podaje około 1 KiB narzutu na obiekt poza rozmiarem storage. Stale rosnący trend „poza cache” przy pełnym cache wskazuje na wyciek; płaski oznacza narzut lub fragmentację.",
"memoryRatio": "RSS / cache",
"memoryRatioHelp": "RSS procesu podzielone przez rozliczony rozmiar cache HOT",
"memoryPerObject": "Narzut / obiekt",
"memoryPerObjectHelp": "Metadane cache na jeden obiekt (rozliczony rozmiar minus dane obiektu)",
"memoryObjects": "Obiekty HOT",
"memoryFragmentation": "Fragmentacja",
"memoryFragmentationHelp": "Wolna pamięć trzymana przez alokator jako udział w jego stercie",
"memoryCacheData": "Dane cache (treść obiektów)",
"memoryCacheMeta": "Metadane cache",
"memoryTransient": "Bufory cache (zapisy, kolejki)",
"memoryAllocatorFree": "Wolna, trzymana przez alokator",
"memoryFiles": "Kod i zmapowane pliki",
"memoryRest": "Reszta procesu",
"memoryAllocatorTitle": "Alokator",
"memoryAllocatorHeap": "Sterta (areny)",
"memoryAllocatorInUse": "W użyciu",
"memoryAllocatorFreeHeld": "Wolna, trzymana",
"memoryAllocatorMmap": "Duże bloki (mmap) · liczba",
"memoryAllocatorUnavailable": "Statystyki alokatora są dostępne tylko z domyślnym alokatorem glibc.",
"memoryOutsideCache": "Poza cache",
"memoryOutsideCacheHistory": "Historia pamięci poza cache",
"memoryTrendCollecting": "Zbieranie",
"memoryTrendStable": "Stabilnie",
"memoryTrendRising": "Rośnie",
"memoryTrendFalling": "Spada",
"processLoadTitle": "Obciążenie",
"processMemoryTitle": "Pamięć",
"processFdHistory": "Historia deskryptorów",
@@ -3041,6 +3070,11 @@
"pathPermissionsOptions": "Opcje i pomoc",
"overviewLayoutNarrow": "Zmniejsz szerokość karty",
"overviewLayoutWiden": "Zwiększ szerokość karty",
"overviewLayoutHeight": "Wysokość w wierszach",
"overviewLayoutLower": "Niższy",
"overviewLayoutTaller": "Wyższy",
"overviewLayoutHeightAuto": "Wysokość automatyczna: dopasowana do treści (A). Użyj +, aby powiększyć blok.",
"overviewLayoutHeightFixed": "Stała wysokość w wierszach. Zmniejsz do wysokości treści, aby wrócić do trybu automatycznego.",
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn",
"dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie.",
"overviewLayoutInlineDragging": "Przeciąganie: upuść blok w miejscu ramki. Esc przywraca poprzednią pozycję.",
+7
View File
@@ -6,6 +6,8 @@ struct DashboardPresetLayout {
dashboard_widgets: Vec<String>,
dashboard_hidden_widgets: Vec<String>,
dashboard_widget_sizes: Option<BTreeMap<String, u8>>,
#[serde(default)]
dashboard_widget_heights: Option<BTreeMap<String, u8>>,
dashboard_density: String,
overview_samples: u16,
}
@@ -43,6 +45,8 @@ fn validate_dashboard_preset_layout(layout: &DashboardPresetLayout) -> Result<()
.clone_from(&layout.dashboard_hidden_widgets);
pref.dashboard_widget_sizes
.clone_from(&layout.dashboard_widget_sizes);
pref.dashboard_widget_heights
.clone_from(&layout.dashboard_widget_heights);
pref.dashboard_density.clone_from(&layout.dashboard_density);
validate_admin_preferences(&pref)
}
@@ -66,12 +70,14 @@ async fn dashboard_presets(
dashboard_widgets: layout.dashboard_widgets,
dashboard_hidden_widgets: layout.dashboard_hidden_widgets,
dashboard_widget_sizes: layout.dashboard_widget_sizes,
dashboard_widget_heights: layout.dashboard_widget_heights,
..Default::default()
};
reconcile_dashboard_widgets(&mut prefs);
layout.dashboard_widgets = prefs.dashboard_widgets;
layout.dashboard_hidden_widgets = prefs.dashboard_hidden_widgets;
layout.dashboard_widget_sizes = prefs.dashboard_widget_sizes;
layout.dashboard_widget_heights = prefs.dashboard_widget_heights;
if validate_dashboard_preset_layout(&layout).is_ok() {
presets.push(NamedDashboardPreset { name, layout });
}
@@ -170,6 +176,7 @@ mod dashboard_preset_validation_tests {
dashboard_widgets: default_admin_dashboard_widgets_for_tests(),
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None,
dashboard_widget_heights: None,
dashboard_density: default_admin_dashboard_density(),
overview_samples: 600,
};
+60 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.36.8",
"version": "0.36.9",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
@@ -1560,6 +1560,41 @@
"properties": {},
"additionalProperties": true
},
"memory_allocator": {
"type": "object",
"nullable": true,
"description": "glibc heap statistics (mallinfo2), sampled at most every 2 s; null with other allocators. free_bytes is an upper bound of resident allocator-held free memory.",
"properties": {
"allocator": {
"type": "string"
},
"heap_bytes": {
"type": "integer",
"format": "int64"
},
"in_use_bytes": {
"type": "integer",
"format": "int64"
},
"free_bytes": {
"type": "integer",
"format": "int64"
},
"releasable_bytes": {
"type": "integer",
"format": "int64"
},
"mmap_bytes": {
"type": "integer",
"format": "int64"
},
"mmap_blocks": {
"type": "integer",
"format": "int64"
}
},
"additionalProperties": false
},
"system": {
"type": "object",
"properties": {},
@@ -3954,6 +3989,7 @@
"cache_diagnostics",
"process_diagnostics",
"process_simple",
"memory_breakdown",
"network_diagnostics",
"backend_table",
"backend_tiny",
@@ -4063,6 +4099,17 @@
},
"description": "Nullable account-scoped per-card widths (12-column grid). Null preserves the legacy grouped visual arrangement; an object, including an empty one, enables freely ordered cards. Key must be a registered Overview widget ID. Panels require width >= 4, charts >= 3 and backend table >= 6."
},
"dashboard_widget_heights": {
"type": "object",
"nullable": true,
"maxProperties": 96,
"additionalProperties": {
"type": "integer",
"minimum": 1,
"maximum": 32
},
"description": "Optional per-card heights in Overview row units (1-32; a compact stat card spans 2 rows). Cards without an entry size to their content; a height smaller than the content needs is raised automatically. Keys must be registered Overview widget IDs."
},
"dashboard_hidden_widgets": {
"type": "array",
"maxItems": 78,
@@ -4135,6 +4182,7 @@
"cache_diagnostics",
"process_diagnostics",
"process_simple",
"memory_breakdown",
"network_diagnostics",
"backend_table",
"backend_tiny",
@@ -7180,6 +7228,17 @@
},
"description": "Width overrides in a 12-column grid; null selects legacy groups"
},
"dashboard_widget_heights": {
"type": "object",
"nullable": true,
"maxProperties": 96,
"additionalProperties": {
"type": "integer",
"minimum": 1,
"maximum": 32
},
"description": "Optional per-card heights in Overview row units (1-32; a compact stat card spans 2 rows). Cards without an entry size to their content; a height smaller than the content needs is raised automatically. Keys must be registered Overview widget IDs."
},
"dashboard_density": {
"type": "string",
"enum": [
+15
View File
@@ -43,6 +43,11 @@ fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) {
.any(|widget| widget.id == *id && valid_dashboard_span(&widget.display_type, *span))
});
}
if let Some(heights) = prefs.dashboard_widget_heights.as_mut() {
heights.retain(|id, rows| {
valid_dashboard_rows(*rows) && DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id)
});
}
for widget in DASHBOARD_WIDGETS.iter() {
if !seen.contains(&widget.id) {
prefs.dashboard_widgets.push(widget.id.clone());
@@ -136,6 +141,9 @@ struct AdminPreferences {
dashboard_hidden_widgets: Vec<String>,
#[serde(default)]
dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>,
/// Optional per-card heights in Overview row units (1..=32, a compact stat card is 2); missing cards size to content.
#[serde(default)]
dashboard_widget_heights: Option<std::collections::BTreeMap<String, u8>>,
#[serde(default = "default_file_log_columns")]
file_log_columns: Vec<String>,
#[serde(default = "default_file_log_sort")]
@@ -193,6 +201,7 @@ impl Default for AdminPreferences {
dashboard_widgets: default_admin_dashboard_widgets(),
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None,
dashboard_widget_heights: None,
file_log_columns: default_file_log_columns(),
file_log_sort: default_file_log_sort(),
file_log_descending: default_file_log_descending(),
@@ -392,6 +401,12 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
"invalid dashboard card width preference".into(),
));
}
if !valid_dashboard_heights(&preferences.dashboard_widget_heights) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid dashboard card height preference".into(),
));
}
if preferences.backend_table_columns.is_empty()
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
{
+13
View File
@@ -162,6 +162,19 @@ fn valid_dashboard_sizes(sizes: &Option<std::collections::BTreeMap<String, u8>>)
})
}
fn valid_dashboard_rows(rows: u8) -> bool {
(1..=32).contains(&rows)
}
fn valid_dashboard_heights(heights: &Option<std::collections::BTreeMap<String, u8>>) -> bool {
heights.as_ref().is_none_or(|heights| {
heights.len() <= DASHBOARD_WIDGETS.len()
&& heights.iter().all(|(id, rows)| {
valid_dashboard_rows(*rows) && DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id)
})
})
}
fn default_hidden_dashboard_widgets() -> Vec<String> {
let mut hidden = DASHBOARD_DEFAULT_PRESET.dashboard_hidden_widgets.clone();
for widget in DASHBOARD_WIDGETS.iter() {
+44
View File
@@ -31,6 +31,7 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
"backend_health",
"backend_tiny",
"process_simple",
"memory_breakdown",
"extra_process_cpu",
"stream_traffic",
"wide_cache_activity",
@@ -328,3 +329,46 @@ fn customized_dashboard_preferences_fit_persistent_storage_limit() {
assert!(document.len() > 4096);
assert!(document.len() <= crate::db::ADMIN_PREFERENCES_MAX_BYTES);
}
#[test]
fn dashboard_height_preferences_are_optional_bounded_and_reconciled() {
let old: AdminPreferences = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap();
assert!(old.dashboard_widget_heights.is_none());
let mut prefs = AdminPreferences {
dashboard_widget_sizes: Some(std::collections::BTreeMap::new()),
dashboard_widget_heights: Some(std::collections::BTreeMap::from([
("process_diagnostics".to_owned(), 6),
("requests".to_owned(), 1),
("memory_breakdown".to_owned(), 32),
])),
..Default::default()
};
assert!(validate_admin_preferences(&prefs).is_ok());
let stored = serde_json::to_string(&prefs).unwrap();
let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap();
assert_eq!(recovered.dashboard_widget_heights, prefs.dashboard_widget_heights);
for rows in [0, 33] {
prefs
.dashboard_widget_heights
.as_mut()
.unwrap()
.insert("requests".to_owned(), rows);
assert!(validate_admin_preferences(&prefs).is_err(), "rows {rows}");
}
prefs
.dashboard_widget_heights
.as_mut()
.unwrap()
.insert("requests".to_owned(), 2);
prefs
.dashboard_widget_heights
.as_mut()
.unwrap()
.insert("undefined-widget".to_owned(), 3);
assert!(validate_admin_preferences(&prefs).is_err());
reconcile_dashboard_widgets(&mut prefs);
let heights = prefs.dashboard_widget_heights.as_ref().unwrap();
assert!(!heights.contains_key("undefined-widget"));
assert_eq!(heights.get("requests"), Some(&2));
assert!(validate_admin_preferences(&prefs).is_ok());
}
+2
View File
@@ -76,8 +76,10 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/app/overview-wide-metrics.js"),
include_str!("web/app/web-overview.js"),
include_str!("web/app/overview-masonry.js"),
include_str!("web/app/overview-rows.js"),
include_str!("web/app/config-history.js"),
include_str!("web/app/certificate-inspection.js"),
include_str!("web/app/memory-breakdown.js"),
include_str!("web/app/runtime.js"),
include_str!("web/app/settings.js"),
include_str!("web/management.js"),
+1
View File
@@ -161,6 +161,7 @@ fn status_payload(state: &App) -> Value {
let cache_view = state.cache.view();
let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform);
payload["cache"] = json!(&cache_view);
payload["memory_allocator"] = json!(crate::diagnostics::allocator_stats());
payload["transform_resources"] = json!(&transform_resources);
payload["geoip"] = json!(state.geoip.load().view());
let system_info = system_info_payload(state);
+1
View File
@@ -55,6 +55,7 @@ fn live_status_payload(state: &App) -> Value {
"web_server_metrics": web_server_metrics_payload(&snapshot),
"live_system": live_chart_system_payload(state),
"cache": state.cache.view(),
"memory_allocator": crate::diagnostics::allocator_stats(),
"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>(),
+1
View File
@@ -1978,6 +1978,7 @@ mod tests {
dashboard_widgets: AdminPreferences::default().dashboard_widgets,
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None,
dashboard_widget_heights: None,
dashboard_density: "compact".to_owned(),
overview_samples: 600,
};
+1
View File
@@ -52,6 +52,7 @@ function translate() {
document.querySelectorAll("[data-i18n]").forEach(e => e.textContent = t(e.dataset.i18n));
document.querySelectorAll("[data-i18n-placeholder]").forEach(e => e.placeholder = t(e.dataset.i18nPlaceholder));
document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria)));
document.querySelectorAll("[data-i18n-title]").forEach(e => { e.title = t(e.dataset.i18nTitle); });
window.rustProxyAclProfiles?.refreshTranslations?.();
$("language").value = languagePreference === "auto" ? "auto" : language; setPanel(currentPanel, false); syncAuthForm(); renderSession();
window.rustProxyDrafts?.refresh();
+47 -6
View File
@@ -28,7 +28,8 @@ window.rustProxyInlineLayout = (() => {
const label = id => dashboardWidgetLabel(id);
const dirty = () => draft && original && (draft.order.some((id, i) => id !== original.order[i])
|| [...draft.hidden].some(id => !original.hidden.has(id)) || [...original.hidden].some(id => !draft.hidden.has(id))
|| JSON.stringify(draft.sizes) !== JSON.stringify(original.sizes));
|| JSON.stringify(draft.sizes) !== JSON.stringify(original.sizes)
|| JSON.stringify(normalizeDashboardWidgetHeights(draft.heights)) !== JSON.stringify(normalizeDashboardWidgetHeights(original.heights)));
function renderHidden() {
if (!hiddenList || !draft) return;
@@ -52,7 +53,8 @@ window.rustProxyInlineLayout = (() => {
if (hiddenShelf) { hiddenShelf.open = true; hiddenShelf.querySelector("summary")?.focus(); }
}
function paint() {
applyDashboardLayout(draft.order, [...draft.hidden], draft.sizes);
applyDashboardLayout(draft.order, [...draft.hidden], draft.sizes, draft.heights);
window.rustProxyOverviewRows?.flush?.();
renderHidden();
const visible = visibleIds();
for (const node of nodes()) {
@@ -70,6 +72,21 @@ window.rustProxyInlineLayout = (() => {
const widen = node.querySelector(".overview-inline-widen");
if (narrow) narrow.disabled = at <= 0;
if (widen) widen.disabled = at === widths.length - 1;
const rows = window.rustProxyOverviewRows?.rowsFor?.(node.dataset.dashboardWidget);
const heightControls = node.querySelector(".overview-inline-height");
if (heightControls) heightControls.hidden = !rows;
if (rows) {
const explicit = draft.heights?.[node.dataset.dashboardWidget] !== undefined;
const heightValue = node.querySelector(".overview-inline-height-value");
if (heightValue) {
heightValue.textContent = `↕${rows.span}${explicit ? "" : " A"}`;
heightValue.title = explicit ? t("overviewLayoutHeightFixed") : t("overviewLayoutHeightAuto");
}
const lower = node.querySelector(".overview-inline-lower");
const taller = node.querySelector(".overview-inline-taller");
if (lower) lower.disabled = !explicit;
if (taller) taller.disabled = rows.span >= DASHBOARD_MAX_ROWS;
}
}
}
function clearDrag() { if (pointerDrag) settle(false); }
@@ -95,6 +112,19 @@ window.rustProxyInlineLayout = (() => {
paint();
nodeFor(id)?.querySelector(delta < 0 ? ".overview-inline-narrow" : ".overview-inline-widen")?.focus();
}
function resizeHeight(id, delta) {
if (!draft) return;
const rows = window.rustProxyOverviewRows?.rowsFor?.(id);
if (!rows) return;
enterFreeLayout();
draft.heights = { ...(draft.heights || {}) };
const next = rows.span + delta;
// At or below the content height the card returns to automatic sizing.
if (next <= rows.min) delete draft.heights[id];
else draft.heights[id] = Math.min(DASHBOARD_MAX_ROWS, next);
paint();
nodeFor(id)?.querySelector(delta < 0 ? ".overview-inline-lower" : ".overview-inline-taller")?.focus();
}
function nudge(id, delta) {
const visible = visibleIds();
const index = visible.indexOf(id), target = visible[index + delta];
@@ -124,7 +154,14 @@ window.rustProxyInlineLayout = (() => {
control("−", `${t("overviewLayoutNarrow")}: ${title}`, "overview-inline-narrow", () => resize(id, -1)),
widthValue,
control("+", `${t("overviewLayoutWiden")}: ${title}`, "overview-inline-widen", () => resize(id, 1)));
bar.append(grip, dashboardWidgetTypeBadge(id), sizeControls,
const heightControls = el("span", undefined, "overview-inline-width overview-inline-height");
const heightValue = el("span", "", "overview-inline-width-value overview-inline-height-value");
heightValue.setAttribute("aria-label", t("overviewLayoutHeight"));
heightControls.append(
control("−", `${t("overviewLayoutLower")}: ${title}`, "overview-inline-lower", () => resizeHeight(id, -1)),
heightValue,
control("+", `${t("overviewLayoutTaller")}: ${title}`, "overview-inline-taller", () => resizeHeight(id, 1)));
bar.append(grip, dashboardWidgetTypeBadge(id), sizeControls, heightControls,
control("←", `${t("moveUp")}: ${title}`, "overview-inline-up", () => nudge(id, -1)),
control("→", `${t("moveDown")}: ${title}`, "overview-inline-down", () => nudge(id, 1)),
control("×", `${t("overviewLayoutInlineHide")}: ${title}`, "overview-inline-hide", () => hide(id)));
@@ -158,7 +195,7 @@ window.rustProxyInlineLayout = (() => {
if (hiddenShelf) { hiddenShelf.open = false; hiddenShelf.hidden = true; }
if (hiddenList) hiddenList.replaceChildren();
if (modalButton) modalButton.disabled = false;
if (!preserveSaved && original) applyDashboardLayout(original.order, [...original.hidden], original.sizes);
if (!preserveSaved && original) applyDashboardLayout(original.order, [...original.hidden], original.sizes, original.heights);
draft = original = null;
}
async function begin() {
@@ -171,8 +208,9 @@ window.rustProxyInlineLayout = (() => {
const order = normalizeDashboardWidgets(saved.dashboard_widgets);
const hidden = new Set(normalizeHiddenDashboardWidgets(saved.dashboard_hidden_widgets, saved.dashboard_widgets));
const sizes = normalizeDashboardWidgetSizes(saved.dashboard_widget_sizes);
original = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes } };
draft = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes } };
const heights = normalizeDashboardWidgetHeights(saved.dashboard_widget_heights);
original = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes }, heights: heights ? { ...heights } : null };
draft = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes }, heights: heights ? { ...heights } : null };
showEditing();
} catch (error) { if (error?.status !== 401) toast(error.message, true); }
finally { opening = false; unlock.disabled = false; }
@@ -186,6 +224,7 @@ window.rustProxyInlineLayout = (() => {
await window.rustProxyPreferences.update({
dashboard_widgets: [...draft.order], dashboard_hidden_widgets: [...draft.hidden],
dashboard_widget_sizes: draft.sizes === null ? null : { ...draft.sizes },
dashboard_widget_heights: normalizeDashboardWidgetHeights(draft.heights),
});
finish(true);
toast(t("overviewLayoutEditorSaved"));
@@ -269,6 +308,7 @@ window.rustProxyInlineLayout = (() => {
const rect = drag.node.getBoundingClientRect();
const placeholder = el("div", undefined, "overview-drop-placeholder");
placeholder.dataset.dashboardSpan = drag.node.dataset.dashboardSpan || "2";
if (drag.node.dataset.dashboardRows) placeholder.style.setProperty("--overview-rows", drag.node.dataset.dashboardRows);
placeholder.style.minHeight = `${Math.round(rect.height)}px`;
placeholder.setAttribute("aria-hidden", "true");
drag.origin = drag.node.nextSibling;
@@ -380,6 +420,7 @@ window.rustProxyInlineLayout = (() => {
currentDraft: () => draft ? {
order: [...draft.order], hidden: [...draft.hidden],
sizes: draft.sizes === null ? null : { ...draft.sizes },
heights: draft.heights ? { ...draft.heights } : null,
} : null,
cancel,
};
+1 -1
View File
@@ -237,7 +237,7 @@ window.rustProxyDashboardLayout = (() => {
try {
await window.rustProxyPreferences.update({
dashboard_widgets: [...defaults.order], dashboard_hidden_widgets: [...defaults.hidden],
dashboard_widget_sizes: null,
dashboard_widget_sizes: null, dashboard_widget_heights: null,
});
resetConfirm.close();
dialog.close();
+3
View File
@@ -18,6 +18,7 @@ window.rustProxyDashboardLayout = (() => {
dashboard_widgets: [...normalizeDashboardWidgets(current.dashboard_widgets)],
dashboard_hidden_widgets: [...normalizeHiddenDashboardWidgets(current.dashboard_hidden_widgets)],
dashboard_widget_sizes: normalizeDashboardWidgetSizes(current.dashboard_widget_sizes),
dashboard_widget_heights: normalizeDashboardWidgetHeights(current.dashboard_widget_heights),
dashboard_density: normalizeAdminDensity(current.dashboard_density, "compact"),
overview_samples: window.rustProxyOverviewSamples.getLimit(),
};
@@ -97,6 +98,7 @@ window.rustProxyDashboardLayout = (() => {
dashboard_widgets: [...layout.dashboard_widgets],
dashboard_hidden_widgets: [...layout.dashboard_hidden_widgets],
dashboard_widget_sizes: layout.dashboard_widget_sizes === null ? null : { ...layout.dashboard_widget_sizes },
dashboard_widget_heights: normalizeDashboardWidgetHeights(layout.dashboard_widget_heights),
dashboard_density: layout.dashboard_density,
});
window.rustProxyOverviewSamples.setLimit(layout.overview_samples, true);
@@ -130,6 +132,7 @@ window.rustProxyDashboardLayout = (() => {
dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS],
dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS],
dashboard_widget_sizes: null,
dashboard_widget_heights: null,
dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density,
overview_samples: ADMIN_DASHBOARD_DEFAULTS.overview_samples,
});
+122
View File
@@ -0,0 +1,122 @@
// Memory breakdown card (memory_breakdown): splits process RSS into cache data,
// cache bookkeeping, cache buffers, allocator-held free memory, file-backed pages
// and the rest of the process, and tracks the "outside cache" share over time so
// a steady climb (a leak) is distinguishable from flat overhead.
window.rustProxyMemoryBreakdown = (() => {
const $ = id => document.getElementById(id);
const segments = ["cache-data", "cache-meta", "transient", "alloc-free", "files", "rest"];
const MIN_TREND_MS = 10 * 60 * 1000;
let samples = [];
const num = value => value == null || value === "" ? null : (Number.isFinite(Number(value)) ? Number(value) : null);
const limit = () => window.rustProxyOverviewSamples?.getLimit?.() ?? 600;
function compute(status) {
const live = status.live_system || {}, system = status.system || {}, cache = status.cache || {};
const rss = num(live.process_rss_bytes) ?? num(system.process_rss_bytes);
if (rss == null || rss <= 0) return null;
const allocated = num(cache.hot_allocated_bytes) ?? num(cache.resident_bytes) ?? 0;
const logical = Math.min(num(cache.hot_logical_bytes) ?? allocated, allocated);
const transient = num(cache.transient_bytes) ?? 0;
const fileBacked = (num(system.process_rss_file_bytes) ?? 0) + (num(system.process_rss_shmem_bytes) ?? 0);
const allocator = status.memory_allocator || null;
let free = allocator ? (num(allocator.free_bytes) ?? 0) : 0;
const values = { "cache-data": logical, "cache-meta": allocated - logical, transient, "alloc-free": free, files: fileBacked };
let rest = rss - Object.values(values).reduce((sum, value) => sum + value, 0);
// Allocator free memory may already be released to the kernel (malloc_trim),
// so it is the first share to shrink when the parts exceed the measured RSS.
if (rest < 0) { const cut = Math.min(free, -rest); free -= cut; values["alloc-free"] = free; rest += cut; }
values.rest = Math.max(0, rest);
return {
rss, values, allocator, allocated, logical,
entries: num(cache.entries),
outside: Math.max(0, rss - allocated - transient)
};
}
function slopePerHour(points) {
if (points.length < 3) return null;
const t0 = points[0].t, n = points.length;
let sx = 0, sy = 0, sxx = 0, sxy = 0;
for (const p of points) { const x = (p.t - t0) / 3600000; sx += x; sy += p.v; sxx += x * x; sxy += x * p.v; }
const den = n * sxx - sx * sx;
return den > 0 ? (n * sxy - sx * sy) / den : null;
}
function setText(id, text) { const node = $(id); if (node) node.textContent = text; }
function render(status, appendSample = true) {
const card = document.querySelector('[data-dashboard-widget="memory_breakdown"]');
if (!card || !$("mb-bar")) return;
const lang = typeof locale === "function" ? locale() : undefined;
const fmtBytes = value => value == null || !Number.isFinite(value) ? "-" : bytes(Math.max(0, value));
const percent = value => Number.isFinite(value) ? `${(value * 100).toLocaleString(lang, { maximumFractionDigits: 1 })}%` : "-";
const tr = key => (typeof t === "function" ? t(key) : key);
const data = compute(status);
if (!data) {
setText("mb-rss", "-");
return;
}
setText("mb-rss", `RSS ${fmtBytes(data.rss)}`);
const bar = $("mb-bar").children;
segments.forEach((key, index) => {
const value = data.values[key], share = value / data.rss;
bar[index].style.width = `${Math.max(0, share * 100)}%`;
bar[index].title = `${tr(rowKey(key))}: ${fmtBytes(value)} (${percent(share)})`;
setText(`mb-${key}-value`, fmtBytes(value));
setText(`mb-${key}-share`, percent(share));
const row = card.querySelector(`[data-mb-row="${key}"]`);
if (row) row.hidden = key === "alloc-free" && !data.allocator;
});
setText("mb-ratio", data.allocated > 0 ? `${(data.rss / data.allocated).toFixed(2)}×` : "-");
setText("mb-per-object", data.entries > 0 ? fmtBytes((data.allocated - data.logical) / data.entries) : "-");
setText("mb-objects", data.entries == null ? "-" : data.entries.toLocaleString(lang));
const allocator = data.allocator, heap = num(allocator?.heap_bytes);
$("mb-allocator").hidden = !allocator;
$("mb-allocator-unavailable").hidden = !!allocator;
setText("mb-fragmentation", allocator && heap > 0 ? percent(num(allocator.free_bytes) / heap) : "-");
if (allocator) {
setText("mb-heap", fmtBytes(heap));
setText("mb-in-use", fmtBytes(num(allocator.in_use_bytes)));
setText("mb-free", `${fmtBytes(num(allocator.free_bytes))}${heap > 0 ? ` · ${percent(num(allocator.free_bytes) / heap)}` : ""}`);
setText("mb-mmap", `${fmtBytes(num(allocator.mmap_bytes))} · ${num(allocator.mmap_blocks) ?? 0}`);
}
if (appendSample) {
samples.push({ t: Date.now(), v: data.outside });
samples = samples.slice(-limit());
}
const values = samples.map(sample => sample.v);
const peak = Math.max(1, ...values) * 1.1;
const width = 300, height = 60, pad = 5;
const points = values.length < 2 ? "0,55 300,55" : values.map((value, index) =>
`${index * width / (values.length - 1)},${height - pad - value / peak * (height - pad * 2)}`).join(" ");
$("mb-outside-sparkline").setAttribute("points", points);
window.rustProxyChartScale?.axes?.("mb-outside-sparkline", peak, "bytes", values.length, { top: 5 / 60, bottom: 55 / 60 });
setText("mb-outside-value", fmtBytes(data.outside));
const badge = $("mb-trend-badge");
const span = samples.length ? samples.at(-1).t - samples[0].t : 0;
const slope = span >= MIN_TREND_MS ? slopePerHour(samples) : null;
let state = "collecting";
if (slope != null) {
// Rising faster than 2% of the current outside-cache size per hour (and at least 4 MiB/h).
const threshold = Math.max(4 * 1048576, data.outside * 0.02);
state = slope > threshold ? "rising" : slope < -threshold ? "falling" : "stable";
}
badge.dataset.state = state;
badge.textContent = tr({ collecting: "memoryTrendCollecting", rising: "memoryTrendRising", falling: "memoryTrendFalling", stable: "memoryTrendStable" }[state]);
const minutes = Math.round(span / 60000);
setText("mb-trend-rate", slope == null
? `${minutes} / ${MIN_TREND_MS / 60000} min`
: `${slope >= 0 ? "+" : "−"}${fmtBytes(Math.abs(slope))}/h · ${minutes} min`);
}
function rowKey(key) {
return { "cache-data": "memoryCacheData", "cache-meta": "memoryCacheMeta", transient: "memoryTransient",
"alloc-free": "memoryAllocatorFree", files: "memoryFiles", rest: "memoryRest" }[key];
}
window.rustProxyOverviewSamples?.onChange?.(() => { samples = samples.slice(-limit()); });
return { render, compute };
})();
+179
View File
@@ -0,0 +1,179 @@
// Row units for the custom Overview layout. Widths stay on the 12-column grid;
// heights snap to a shared row unit (the tallest compact stat card), so a tall
// panel can sit next to several rows of small cards and every edge lines up.
// Each card gets at least the rows its content needs; an explicit height
// (dashboard_widget_heights) can only make it taller, and charts stretch into
// the extra space. Below the narrow breakpoint the grid falls back to content
// height (CSS sets --overview-row-units: off).
window.rustProxyOverviewRows = (() => {
const grid = document.getElementById("dashboard-overview-grid");
// A compact stat card spans ROW_DIVISIONS rows; finer rows keep taller cards
// close to their natural height instead of rounding up by a whole stat card.
const MAX_ROWS = 32, ROW_DIVISIONS = 2, FALLBACK_STAT = 84, MIN_INTERVAL_MS = 1500, SHRINK_CONFIRMATIONS = 3;
const noop = { schedule() { }, flush() { }, setHeights() { }, rowsFor() { return null; } };
if (!grid) return noop;
let heights = null, frame = 0, lastRun = 0, lastWidth = 0;
const state = new Map();
const shrinkVotes = new Map();
const cards = () => [...grid.children].filter(node => node.dataset.dashboardWidget);
function enabled() {
if (grid.dataset.overviewLayoutMode !== "custom") return false;
return getComputedStyle(grid).getPropertyValue("--overview-row-units").trim() !== "off";
}
function clear() {
grid.classList.remove("has-row-units");
grid.style.removeProperty("--overview-row-unit");
for (const node of cards()) { node.style.removeProperty("--overview-rows"); delete node.dataset.dashboardRows; }
state.clear(); shrinkVotes.clear();
}
function apply() {
frame = 0;
if (!grid.getClientRects().length || grid.classList.contains("is-overview-sorting")) return;
if (!enabled()) { if (grid.classList.contains("has-row-units")) clear(); return; }
lastRun = performance.now();
lastWidth = grid.clientWidth;
const items = cards().filter(node => !node.hidden && node.getClientRects().length);
if (!items.length) return;
grid.classList.add("is-measuring-rows");
// Bounding boxes include CSS zoom (Settings → Interface scale); grid tracks,
// gaps and the row unit are in CSS pixels, so divide measurements by it.
const zoom = zoomOf();
const natural = items.map(node => node.getBoundingClientRect().height / zoom);
grid.classList.remove("is-measuring-rows");
const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
const isStat = node => node.classList.contains("stat");
// The unit is half of the smallest stat card, so every card ends within a
// few pixels of its natural height instead of being rounded up by a whole tile.
const stats = natural.filter((_, index) => isStat(items[index]) && natural[index] > 0);
const statHeight = stats.length ? Math.min(...stats) : FALLBACK_STAT;
const unit = Math.max(16, (statHeight - gap * (ROW_DIVISIONS - 1)) / ROW_DIVISIONS);
grid.style.setProperty("--overview-row-unit", `${unit}px`);
const seen = new Set();
items.forEach((node, index) => {
const id = node.dataset.dashboardWidget;
seen.add(id);
// Small tolerance so sub-pixel rounding never adds a whole row.
let min = Math.max(1, Math.ceil((natural[index] + gap) / (unit + gap) - 0.04));
const previous = state.get(id);
// Grow at once; shrink only after the smaller size is seen repeatedly,
// so live data (table rows, wrapping labels) cannot make the grid jump.
if (previous && min < previous.min) {
const votes = (shrinkVotes.get(id) || 0) + 1;
if (votes < SHRINK_CONFIRMATIONS) { shrinkVotes.set(id, votes); min = previous.min; }
else shrinkVotes.delete(id);
} else shrinkVotes.delete(id);
const span = Math.max(min, Math.min(MAX_ROWS, heights?.[id] ?? 0));
setRows(node, span);
state.set(id, { span, min });
});
for (const id of [...state.keys()]) if (!seen.has(id)) state.delete(id);
grid.classList.add("has-row-units");
const geometry = gridGeometry(unit + gap, zoom);
if (!geometry) return;
equalizeStatRows(items.filter(isStat), geometry);
alignBottom(items, geometry);
}
function setRows(node, rows) {
if (node.dataset.dashboardRows === String(rows)) return;
node.style.setProperty("--overview-rows", String(rows));
node.dataset.dashboardRows = String(rows);
}
const rowsOf = node => Number(node.dataset.dashboardRows) || 1;
function zoomOf() {
const css = parseFloat(getComputedStyle(grid).width);
const visual = grid.getBoundingClientRect().width;
return css > 0 && visual > 0 ? visual / css : 1;
}
// Geometry in the grid's own CSS pixels (content box), independent of zoom.
function gridGeometry(step, zoom) {
const style = getComputedStyle(grid);
const tracks = style.gridTemplateColumns.split(" ").map(parseFloat).filter(Number.isFinite);
if (tracks.length < 2) return null;
const columnGap = parseFloat(style.columnGap) || 0;
const starts = [];
tracks.reduce((x, width) => { starts.push(x); return x + width + columnGap; }, 0);
return {
starts, step, zoom,
left: (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0),
top: (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.paddingTop) || 0),
};
}
function place(node, geometry) {
const box = grid.getBoundingClientRect(), rect = node.getBoundingClientRect();
const { starts, zoom } = geometry;
const left = (rect.left - box.left) / zoom - geometry.left;
const right = (rect.right - box.left) / zoom - geometry.left;
const top = (rect.top - box.top) / zoom - geometry.top;
const first = starts.reduce((best, start, index) => Math.abs(start - left) < Math.abs(starts[best] - left) ? index : best, 0);
let last = first;
while (last + 1 < starts.length && starts[last + 1] < right - 1) last++;
const row = Math.max(0, Math.round(top / geometry.step));
return { node, first, last, row, end: row + rowsOf(node) };
}
// Stat cards that start on the same grid row get the height of the tallest of
// them, like a classic row of tiles. Raising a row can move later cards, so the
// pass repeats until nothing changes. Large cards never stretch the tiles.
function equalizeStatRows(stats, geometry) {
for (let pass = 0; pass < 6; pass++) {
const groups = new Map();
for (const node of stats) {
const { row } = place(node, geometry);
if (!groups.has(row)) groups.set(row, []);
groups.get(row).push(node);
}
let changed = false;
for (const group of groups.values()) {
const target = Math.max(...group.map(rowsOf));
for (const node of group) if (rowsOf(node) !== target) { setRows(node, target); changed = true; }
}
if (!changed) return;
}
}
// A card is stretched down over the empty cells beneath it, up to the next card
// below (or the grid's last row), so rows end on straight edges instead of
// jagged ones. Only empty cells are taken, which never changes where any other
// card is placed. The stored / edited height (state.span) is unaffected.
// Only small steps are closed (up to two stat tiles or half the card's own
// height); a large hole is left visible rather than hidden by a stretched card.
function alignBottom(items, geometry) {
const cards = items.map(node => place(node, geometry));
const bottom = Math.max(...cards.map(card => card.end));
for (const card of cards) {
const next = cards.reduce((limit, other) => other !== card && other.first <= card.last
&& other.last >= card.first && other.row >= card.end ? Math.min(limit, other.row) : limit, bottom);
const rows = card.end - card.row, extra = next - card.end;
if (extra > 0 && extra <= Math.max(ROW_DIVISIONS * 2, Math.ceil(rows / 2))) setRows(card.node, rows + extra);
}
}
function schedule(force = false) {
if (frame) return;
const wait = force ? 0 : Math.max(0, MIN_INTERVAL_MS - (performance.now() - lastRun));
if (wait > 0) { frame = setTimeout(() => { frame = 0; schedule(true); }, wait); return; }
frame = requestAnimationFrame(apply);
}
function flush() {
if (frame) { cancelAnimationFrame(frame); clearTimeout(frame); frame = 0; }
shrinkVotes.clear();
state.forEach(entry => { entry.min = 1; });
apply();
}
function setHeights(value) {
heights = value && typeof value === "object" ? { ...value } : null;
schedule(true);
}
if (typeof ResizeObserver === "function") {
new ResizeObserver(() => {
if (grid.clientWidth && grid.clientWidth !== lastWidth) { shrinkVotes.clear(); schedule(true); }
}).observe(grid);
}
document.fonts?.ready?.then(() => schedule(true));
return {
schedule,
flush,
setHeights,
rowsFor(id) {
return grid.classList.contains("has-row-units") ? state.get(id) || null : null;
},
};
})();
+21 -9
View File
@@ -14,7 +14,7 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
chart_axes: false, interface_icons: false, logo_animation: false,
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null,
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, dashboard_widget_heights: null,
});
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool", sunset: "chartStyleSunset", aurora: "chartStyleAurora", ocean: "chartStyleOcean", forest: "chartStyleForest", pastel: "chartStylePastel", terminal: "chartStyleTerminal", blueprint: "chartStyleBlueprint", colorblind: "chartStyleColorblind" });
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
@@ -69,6 +69,15 @@ function normalizeDashboardWidgetSizes(value) {
}
return result;
}
const DASHBOARD_MAX_ROWS = 32;
function normalizeDashboardWidgetHeights(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const result = {};
for (const [id, rows] of Object.entries(value)) {
if (ADMIN_DASHBOARD_BY_ID.has(id) && Number.isInteger(rows) && rows >= 1 && rows <= DASHBOARD_MAX_ROWS) result[id] = rows;
}
return Object.keys(result).length ? result : null;
}
function dashboardWidgetWidth(id, sizes) {
return sizes?.[id] ?? dashboardDefaultWidth(id);
}
@@ -144,7 +153,7 @@ function applyAdminDashboardDensity(value) {
const density = normalizeAdminDensity(value, "compact"); document.documentElement.dataset.dashboardDensity = density;
const select = $("ui-dashboard-density"); if (select) select.value = density; return density;
}
function applyDashboardLayout(order, hidden, sizes = null) {
function applyDashboardLayout(order, hidden, sizes = null, heights = undefined) {
const normalizedOrder = normalizeDashboardWidgets(order), hiddenSet = new Set(normalizeHiddenDashboardWidgets(hidden));
const widths = normalizeDashboardWidgetSizes(sizes);
const grid = document.querySelector("#panel-overview .stats-grid");
@@ -161,6 +170,8 @@ function applyDashboardLayout(order, hidden, sizes = null) {
window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
window.rustProxyBackendDashboard?.syncVisibility?.([...hiddenSet]);
window.rustProxyMasonry?.refresh?.();
if (heights !== undefined) window.rustProxyOverviewRows?.setHeights?.(normalizeDashboardWidgetHeights(heights));
window.rustProxyOverviewRows?.schedule?.();
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview") { window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false); window.rustProxyBackendDashboard?.render?.(latestStatus, [...hiddenSet], false); window.rustProxyExtraOverview?.render?.(latestStatus, false); window.rustProxyWideOverview?.render?.(latestStatus, false); window.rustProxyWebOverview?.render?.(latestStatus, false); }
return { order: normalizedOrder, hidden: [...hiddenSet], sizes: widths };
}
@@ -283,14 +294,15 @@ function applyAdminPreferences(value, cache = true) {
const savedOrder = normalizeDashboardWidgets(value.dashboard_widgets);
const savedHidden = normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets);
const savedSizes = normalizeDashboardWidgetSizes(value.dashboard_widget_sizes);
if (editing) applyDashboardLayout(editing.order, editing.hidden, editing.sizes);
else applyDashboardLayout(savedOrder, savedHidden, savedSizes);
const savedHeights = normalizeDashboardWidgetHeights(value.dashboard_widget_heights);
if (editing) applyDashboardLayout(editing.order, editing.hidden, editing.sizes, editing.heights);
else applyDashboardLayout(savedOrder, savedHidden, savedSizes, savedHeights);
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), chart_axes: applyAdminChartAxes(value.chart_axes), interface_icons: applyAdminInterfaceIcons(value.interface_icons), logo_animation: applyAdminLogoAnimation(value.logo_animation), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes,
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes, dashboard_widget_heights: savedHeights,
};
adminPreferences = normalized;
window.rustProxyRefresh?.update(normalized.refresh);
@@ -338,7 +350,7 @@ function updateAdminPreferences(patch) {
next.refresh = normalizeRefreshPreferences(next.refresh);
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.interface_icons = next.interface_icons === true; next.logo_animation = next.logo_animation === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); next.dashboard_widget_heights = normalizeDashboardWidgetHeights(next.dashboard_widget_heights);
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
if (!authenticated || epoch !== authEpoch) return adminPreferences;
return applyAdminPreferences(saved);
@@ -461,7 +473,7 @@ $("ui-dashboard-density")?.addEventListener("change", () => {
async function resetAppearancePatch(patch, control, messageKey = "appearanceSectionResetDone") {
if (control) control.disabled = true; let previous = null;
try {
const current = await loadAdminPreferences(); previous = current ? { ...current, dashboard_widgets: [...current.dashboard_widgets], dashboard_hidden_widgets: [...current.dashboard_hidden_widgets], dashboard_widget_sizes: current.dashboard_widget_sizes === null ? null : { ...current.dashboard_widget_sizes } } : null;
const current = await loadAdminPreferences(); previous = current ? { ...current, dashboard_widgets: [...current.dashboard_widgets], dashboard_hidden_widgets: [...current.dashboard_hidden_widgets], dashboard_widget_sizes: current.dashboard_widget_sizes === null ? null : { ...current.dashboard_widget_sizes }, dashboard_widget_heights: current.dashboard_widget_heights ? { ...current.dashboard_widget_heights } : null } : null;
if (current) applyAdminPreferences({ ...current, ...patch }); await updateAdminPreferences(patch); toast(t(messageKey));
} catch (error) { if (previous) applyAdminPreferences(previous); if (error.status !== 401) toast(error.message, true); }
finally { if (control) control.disabled = false; }
@@ -469,9 +481,9 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
const patches = {
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", interface_icons: false, logo_animation: false, button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null },
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null, dashboard_widget_heights: null },
};
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
}));
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null }, $("appearance-reset"), "appearanceResetDone"));
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null, dashboard_widget_heights: null }, $("appearance-reset"), "appearanceResetDone"));
refreshAppearanceUi();
+2
View File
@@ -665,6 +665,8 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
$("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
renderProcessOverview(d, status, live, systemSample);
window.rustProxyMemoryBreakdown?.render(status, appendSample);
window.rustProxyOverviewRows?.schedule();
updateCacheMemorySizing();
const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null;
if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`);
+98
View File
@@ -1977,6 +1977,72 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
grid-column-start: auto;
}
/* Row units (custom layout): heights snap to the compact stat card height so tall
panels sit beside several rows of small cards with aligned edges.
overview-rows.js sets --overview-row-unit and each card's --overview-rows. */
#panel-overview .dashboard-overview-grid.is-aligned.has-row-units {
grid-auto-rows: var(--overview-row-unit, 84px);
}
#panel-overview .dashboard-overview-grid.is-aligned.has-row-units>:is([data-dashboard-widget], .overview-drop-placeholder) {
grid-row: auto/span var(--overview-rows, 1);
min-height: 0;
}
#panel-overview .dashboard-overview-grid.is-aligned.has-row-units>.overview-drop-placeholder {
min-height: 0 !important;
}
#panel-overview .dashboard-overview-grid.is-measuring-rows>[data-dashboard-widget] {
align-self: start !important;
}
/* A card taller than its content gives the extra height to its charts instead of
leaving an empty band at the bottom. */
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget]:not([hidden]) {
display: flex;
flex-direction: column;
}
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget]>svg:not(.ui-icon),
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget]>.mini-live-chart-stack,
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget]>.table-scroll {
flex: 1 0 auto;
}
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget] .mini-live-chart {
display: flex;
flex-direction: column;
}
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget] .mini-live-chart>svg:not(.ui-icon) {
flex: 1 0 auto;
}
/* With chart scales enabled the SVG sits in an axis host: grow the host and its SVG. */
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget] :is(.rp-chart-axis-host, .mini-live-chart>.rp-chart-axis-host) {
display: flex;
flex: 1 0 auto;
flex-direction: column;
}
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget] .rp-chart-axis-host>svg:not(.ui-icon) {
flex: 1 0 auto;
}
#panel-overview .dashboard-overview-grid.is-unlocked.has-row-units {
background-image: linear-gradient(to bottom, color-mix(in srgb, var(--accent) 7%, transparent) 0 var(--overview-row-unit, 84px), transparent var(--overview-row-unit, 84px));
background-size: 100% calc(var(--overview-row-unit, 84px) + var(--overview-gap, 8px));
}
#panel-overview .overview-inline-height-value {
min-width: 36px;
}
#panel-overview .overview-inline-height[hidden] {
display: none;
}
#panel-overview .overview-inline-width {
display: inline-flex;
align-items: center;
@@ -2462,6 +2528,38 @@ html[data-table-density=ultra] .pr-chip { padding: 1px 7px; }
.pr-cols, .pr-charts { grid-template-columns: 1fr; }
}
/* Memory breakdown card (memory_breakdown) */
.mb-card { display: flex; flex-direction: column; }
.mb-card .mb-chips { margin-bottom: 12px; }
.mb-bar { display: flex; height: 22px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); gap: 1px; }
.mb-bar i { display: block; width: 0; min-width: 0; transition: width .4s ease; }
.mb-cache-data { background: var(--chart-accent); }
.mb-cache-meta { background: color-mix(in srgb, var(--chart-accent) 50%, var(--panel)); }
.mb-transient { background: var(--chart-tertiary); }
.mb-alloc-free { background: repeating-linear-gradient(135deg, var(--chart-quinary) 0 4px, color-mix(in srgb, var(--chart-quinary) 35%, var(--panel)) 4px 8px); }
.mb-files { background: var(--chart-secondary); }
.mb-rest { background: color-mix(in srgb, var(--muted) 55%, var(--panel)); }
.mb-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 0 22px; margin: 10px 0 0; padding: 0; list-style: none; }
.mb-item { display: grid; grid-template-columns: 10px 1fr auto 46px; align-items: baseline; gap: 8px; padding: 6px 2px; border-top: 1px solid var(--row-line); }
.mb-item[hidden] { display: none; }
.mb-swatch { width: 10px; height: 10px; align-self: center; border-radius: var(--rp-progress-radius, 0); }
.mb-name { min-width: 0; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
.mb-item b { font: 500 12px var(--mono); font-variant-numeric: tabular-nums; text-align: end; }
.mb-item em { color: var(--text-subtle); font: normal 11px var(--mono); font-variant-numeric: tabular-nums; text-align: end; }
.mb-cols { flex: 1 0 auto; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); margin-top: 4px; }
.mb-trend { display: flex; flex-direction: column; margin-top: 10px; }
.mb-trend svg:not(.ui-icon) { flex: 1 1 0; height: 0; min-height: 56px; }
.diagnostic-line.mb-outside-line { stroke: var(--chart-quinary); }
.mb-trend-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; }
.mb-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); color: var(--muted); font: 9px var(--mono); letter-spacing: 1px; text-transform: uppercase; }
.mb-badge[data-state="stable"] { border-color: color-mix(in srgb, var(--green) 55%, transparent); color: var(--green); }
.mb-badge[data-state="falling"] { border-color: color-mix(in srgb, var(--blue) 55%, transparent); color: var(--blue); }
.mb-badge[data-state="rising"] { border-color: color-mix(in srgb, var(--red) 60%, transparent); color: var(--red); }
.mb-unavailable { margin: 6px 0 0; }
html[data-table-density=compact] .mb-item { padding-top: 5px; padding-bottom: 5px; }
html[data-table-density=ultra] .mb-item { padding-top: 3px; padding-bottom: 3px; }
html[data-table-density=ultra] .mb-bar { height: 16px; }
/* initial width of the cache outcome segments (set from JS via CSSOM; inline style attributes are blocked by the CSP) */
.cache-mix-hit, .cache-mix-miss, .cache-mix-bypass, .cache-mix-other { width: 0; }
+7 -1
View File
@@ -1597,8 +1597,10 @@
--overview-widget-placement: auto/span 3;
}
/* A third of the wide grid becomes half of the 6-column grid, so two panels or
charts sit side by side instead of leaving a two-column hole. */
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="4"] {
--overview-widget-placement: auto/span 4;
--overview-widget-placement: auto/span 3;
}
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="12"],
@@ -1608,6 +1610,10 @@
}
@container overview-canvas (max-width:700px) {
#panel-overview .dashboard-overview-grid {
--overview-row-units: off;
}
#panel-overview .dashboard-overview-grid.is-aligned {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+3 -1
View File
@@ -66,6 +66,7 @@
"extra_cold_queue_memory",
"process_diagnostics",
"process_simple",
"memory_breakdown",
"network_diagnostics",
"backend_table",
"backend_tiny",
@@ -144,7 +145,8 @@
"extra_rejected_rate",
"extra_cert_expiry",
"backend_tiny",
"process_simple"
"process_simple",
"memory_breakdown"
],
"dashboard_density": "compact",
"overview_samples": 600
+11
View File
@@ -725,6 +725,17 @@
"default_hidden": true,
"display_type": "panel"
},
{
"id": "memory_breakdown",
"label_key": "memoryBreakdownTitle",
"description_key": "memoryBreakdownDescription",
"element_selector": "[data-dashboard-widget=\"memory_breakdown\"]",
"data_source": "system_status",
"no_data": "neutral_dash",
"category": "panels",
"default_hidden": true,
"display_type": "panel"
},
{
"id": "network_diagnostics",
"label_key": "networkDiagnostics",
+51
View File
@@ -339,6 +339,57 @@
</div>
</div>
</article>
<article class="card mb-card" data-dashboard-widget="memory_breakdown">
<div class="mini-heading">
<h3 data-i18n="memoryBreakdownTitle">Memory breakdown</h3>
<div class="mini-heading-actions">
<details class="metric-info">
<summary data-i18n-aria="memoryBreakdownInfo" aria-label="Memory breakdown information">i</summary>
<div class="metric-info-popover">
<p class="fine" data-i18n="memoryBreakdownHint">Splits the process RSS into cached object data, the cache's own bookkeeping (index, keys, headers), short-lived cache buffers, memory the allocator keeps free for reuse (fragmentation; an upper bound, part of it may already be returned to the kernel), program code and mapped files, and everything else (connections, TLS, runtime, logs, GeoIP). For comparison, Varnish documents about 1 KiB of per-object overhead outside its storage size. A steadily rising "outside cache" trend while the cache is full suggests a leak; a flat one means overhead or fragmentation.</p>
</div>
</details>
<span class="meta" id="mb-rss">-</span>
</div>
</div>
<dl class="pr-chips mb-chips">
<div class="pr-chip" title="Process RSS divided by the accounted HOT cache size" data-i18n-title="memoryRatioHelp"><dt data-i18n="memoryRatio">RSS / cache</dt><dd id="mb-ratio">-</dd></div>
<div class="pr-chip" title="Cache bookkeeping per stored object (accounted size minus object data)" data-i18n-title="memoryPerObjectHelp"><dt data-i18n="memoryPerObject">Overhead / object</dt><dd id="mb-per-object">-</dd></div>
<div class="pr-chip"><dt data-i18n="memoryObjects">HOT objects</dt><dd id="mb-objects">-</dd></div>
<div class="pr-chip" title="Free memory held by the allocator as a share of its heap" data-i18n-title="memoryFragmentationHelp"><dt data-i18n="memoryFragmentation">Fragmentation</dt><dd id="mb-fragmentation">-</dd></div>
</dl>
<div class="mb-bar" id="mb-bar" role="img" aria-label="Process memory breakdown" data-i18n-aria="memoryBreakdownTitle">
<i class="mb-cache-data"></i><i class="mb-cache-meta"></i><i class="mb-transient"></i><i class="mb-alloc-free"></i><i class="mb-files"></i><i class="mb-rest"></i>
</div>
<ul class="mb-legend">
<li class="mb-item" data-mb-row="cache-data"><i class="mb-swatch mb-cache-data" aria-hidden="true"></i><span class="mb-name" data-i18n="memoryCacheData">Cache data (object bodies)</span><b id="mb-cache-data-value">-</b><em id="mb-cache-data-share">-</em></li>
<li class="mb-item" data-mb-row="cache-meta"><i class="mb-swatch mb-cache-meta" aria-hidden="true"></i><span class="mb-name" data-i18n="memoryCacheMeta">Cache bookkeeping</span><b id="mb-cache-meta-value">-</b><em id="mb-cache-meta-share">-</em></li>
<li class="mb-item" data-mb-row="transient"><i class="mb-swatch mb-transient" aria-hidden="true"></i><span class="mb-name" data-i18n="memoryTransient">Cache buffers (fills, queues)</span><b id="mb-transient-value">-</b><em id="mb-transient-share">-</em></li>
<li class="mb-item" data-mb-row="alloc-free"><i class="mb-swatch mb-alloc-free" aria-hidden="true"></i><span class="mb-name" data-i18n="memoryAllocatorFree">Free, held by allocator</span><b id="mb-alloc-free-value">-</b><em id="mb-alloc-free-share">-</em></li>
<li class="mb-item" data-mb-row="files"><i class="mb-swatch mb-files" aria-hidden="true"></i><span class="mb-name" data-i18n="memoryFiles">Code and mapped files</span><b id="mb-files-value">-</b><em id="mb-files-share">-</em></li>
<li class="mb-item" data-mb-row="rest"><i class="mb-swatch mb-rest" aria-hidden="true"></i><span class="mb-name" data-i18n="memoryRest">Rest of process</span><b id="mb-rest-value">-</b><em id="mb-rest-share">-</em></li>
</ul>
<div class="pr-cols mb-cols">
<section>
<h4 class="pr-title" data-i18n="memoryAllocatorTitle">Allocator</h4>
<dl class="pr-list pr-plain" id="mb-allocator">
<div class="pr-row"><dt data-i18n="memoryAllocatorHeap">Heap (arenas)</dt><dd id="mb-heap">-</dd></div>
<div class="pr-row"><dt data-i18n="memoryAllocatorInUse">In use</dt><dd id="mb-in-use">-</dd></div>
<div class="pr-row"><dt data-i18n="memoryAllocatorFreeHeld">Free, held</dt><dd id="mb-free">-</dd></div>
<div class="pr-row"><dt data-i18n="memoryAllocatorMmap">Large blocks (mmap) · count</dt><dd id="mb-mmap">-</dd></div>
</dl>
<p class="fine mb-unavailable" id="mb-allocator-unavailable" hidden data-i18n="memoryAllocatorUnavailable">Allocator statistics are available only with the default glibc allocator.</p>
</section>
<section class="mini-live-chart mb-trend">
<div class="mini-live-chart-head"><span data-i18n="memoryOutsideCache">Outside cache</span><b id="mb-outside-value">-</b></div>
<svg preserveAspectRatio="none" aria-label="Memory outside cache history" data-i18n-aria="memoryOutsideCacheHistory" role="img" viewbox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line mb-outside-line" id="mb-outside-sparkline" points="0,55 300,55"></polyline>
</svg>
<div class="mb-trend-foot"><span class="mb-badge" id="mb-trend-badge" data-state="collecting">-</span><span class="meta" id="mb-trend-rate">-</span></div>
</section>
</div>
</article>
<article class="card" data-dashboard-widget="process_simple">
<div class="mini-heading">
<h3 data-i18n="processClassicTitle">Process simple</h3>
+51
View File
@@ -148,6 +148,57 @@ impl Diagnostics {
}
}
/// Heap statistics reported by the glibc allocator (`mallinfo2`).
///
/// `free_bytes` is memory the allocator keeps for reuse (fragmentation and
/// not-yet-returned pages). Part of it may already be released to the kernel
/// by `malloc_trim`, so it is an upper bound of the resident share.
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct AllocatorStats {
pub allocator: &'static str,
pub heap_bytes: u64,
pub in_use_bytes: u64,
pub free_bytes: u64,
pub releasable_bytes: u64,
pub mmap_bytes: u64,
pub mmap_blocks: u64,
}
/// Returns glibc heap statistics, sampled at most every two seconds because
/// `mallinfo2` walks every arena under its lock. `None` on other allocators
/// (musl, non-Linux or the optional mimalloc build).
pub fn allocator_stats() -> Option<AllocatorStats> {
#[cfg(all(target_os = "linux", target_env = "gnu", not(feature = "mimalloc")))]
{
use std::sync::Mutex;
use std::time::{Duration, Instant};
static LAST: Mutex<Option<(Instant, AllocatorStats)>> = Mutex::new(None);
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if let Some((at, stats)) = *last {
if at.elapsed() < Duration::from_secs(2) {
return Some(stats);
}
}
// SAFETY: mallinfo2 has no preconditions and only reads allocator state.
let info = unsafe { libc::mallinfo2() };
let stats = AllocatorStats {
allocator: "glibc",
heap_bytes: info.arena as u64,
in_use_bytes: info.uordblks as u64,
free_bytes: info.fordblks as u64,
releasable_bytes: info.keepcost as u64,
mmap_bytes: info.hblkhd as u64,
mmap_blocks: info.hblks as u64,
};
*last = Some((Instant::now(), stats));
Some(stats)
}
#[cfg(not(all(target_os = "linux", target_env = "gnu", not(feature = "mimalloc"))))]
{
None
}
}
pub fn trim_allocator() -> bool {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
+1 -1
View File
@@ -51,5 +51,5 @@ const events = openapi.paths["/api/events"].get;
const logsFlag = (events.parameters || []).find(parameter => parameter.name === "logs");
assert.equal(logsFlag?.schema?.type, "boolean");
assert.equal(logsFlag?.schema?.default, true);
assert.equal(openapi.info.version, "0.36.8");
assert.equal(openapi.info.version, "0.36.9");
console.log("Panel refresh/API contract checks: OK");