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

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.33.6.
compatibility. Current source version: 0.33.7.
## Delivery
+47
View File
@@ -3,6 +3,53 @@
> Operator-facing release history. Historical engineering notes are kept in
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.33.7
- Apply the same theme-aware subdued `--muted` color used on small metric
tiles to every Overview heading and the chart/metric headings in Cache,
Transform, Traffic and Vhosts. Keep numerical readings, legends, font sizes
and established sentence/uppercase title rules unchanged.
- Make the shared Vhosts Backend/H2 popover (including rows and its close
control) follow the selected interface corner style: default/square,
subtle, rounded and extra-rounded, updating without dismissing the popup.
- Unify all 78 Overview card headings at 12px in sentence case, with the
same proportional font across metrics, Process, RAM cache, backend tiles
and wide charts. Use one monospaced font for compact descriptions and
numeric readings; preserve HTTP, H1/H2 and RAM acronym capitalization.
Keep the 9px chart legends and other panels' typography unchanged.
- Add 10px separation between the Cache "Domains and effectiveness" and
"Stored objects" cards, leaving other Cache section spacing unchanged.
- Replace the native Vhosts H1/H2 upstream badge tooltip with a click-to-open
panel sharing the backend quick-view's anchored popover, dismissal and
positioning. Keep the panel and its content nodes stable across status
refreshes; show current observations and H1/H2 response counters without
a blinking browser tooltip. Ensure clickable Backend count and Health tiles
keep the same square geometry and typography as adjacent Vhost detail and
live-metric tiles, regardless of the selected interface corner style.
- Align every chart legend and its value labels with the 9px monospace styling
used by **Requests per second**, including compact/ultra dashboard densities.
- Add a clickable Backend count / Health tile to Vhosts: a compact,
keyboard-dismissible backend list shows name, configured IP/hostname and
port, upstream scheme/HTTP mode, health and the last real HTTP response
code. The live status feed refreshes the open list without extra API calls.
- Replace the legacy Arrange Overview modal with authenticated SQLite-backed,
administrator-scoped named layout presets (16 maximum). Support save,
overwrite, load, rename, delete and current-view factory reset. Include
Overview history sample limits, density, card widths, order and visibility.
- Add SQLite migration `0010_dashboard_presets.sql` (schema 10), protected
preset endpoints and English/Polish UI translations. Existing preferences
and proxy configuration documents remain unchanged.
- Extract only Overview/WWW metric and chart elements into compile-time HTML
partials. Preserve card IDs, DOM order, dashboard controls and API polling.
- Harmonize chart headings at 12px, using the existing proportional heading
font (sentence case on Overview, uppercase in other panels). Use the existing
monospaced chart font for **all** legends and data labels, not only their
numeric values. Covers Overview, Compression activity, Web Server, Cache,
Transform, Traffic, Vhosts and Streams, including dynamically rendered charts
and compact/ultra dashboard densities.
## 0.33.6
- Normalize the height of all 14 large Overview/WWW plots to 125px, avoiding
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.33.6"
version = "0.33.7"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.33.6"
version = "0.33.7"
edition = "2021"
rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration"
+56 -1
View File
@@ -1,4 +1,59 @@
# RustProxy 0.33.6
# RustProxy 0.33.7
## 0.33.7: named dashboard presets, panel components and chart typography
Titles across metric tiles and charts now use the same understated text tone
(`--muted`) as the small Overview counters. The shade follows the active UI
color theme; numerical readings keep their original contrast and fonts.
The shared Vhosts Backend/H2 quick-view popover follows Corner rounding
settings (including square, subtle, rounded and extra-rounded) for its panel,
rows and close button, immediately and without losing the open popover.
Every Overview card title is now set at 12px in sentence case: counters,
Process, RAM cache, backend tiles, charts and diagnostics use one proportional
heading font. Secondary labels and small numeric readings use consistent
monospace, while established HTTP/RAM/H1/H2 abbreviations retain their case.
This Overview-only refinement does not change the uppercase styling of chart
headings elsewhere in the interface.
The Cache page separates **Domains and effectiveness** from **Stored objects**
with a consistent 10px gap. Vhosts upstream H1/H2 protocol badges now open
the same anchored click-to-open popover used by the backend quick-view. It
shows protocol observations and H1/H2 totals without a native hover tooltip;
live status updates change its contents in place, without closing it.
Chart legends use the same 9px monospaced size as **Requests per second**
throughout the interface, including numeric values and Overview density modes.
In the Vhosts list, click the **Backends** count or **Status** metric to show a
compact per-origin overview (ID, configured address and port, HTTP/HTTPS and
HTTP version policy, health and last HTTP status from a real proxied response).
Missing HTTP responses remain explicitly unknown; the configured hostname is
shown rather than inventing a DNS-resolved IP. No additional probe or request
is made to open the panel. Click outside or press Escape to dismiss.
The H1/H2 policy badges use that same click-to-open popover instead of a
transient hover tooltip. Its text and response counters update in place on
live status ticks. Clickable Backend and Health tiles retain the square shape
of adjacent Vhost metrics at every corner-rounding setting. Cache's two
adjacent domain/object tables have a 10px gap.
- **Dashboard Presets** replaces the former Arrange Overview modal. Save up to
16 named layouts per administrator, including widget order, visibility, card
widths, density and Overview history sample count. Load, overwrite, rename,
delete or restore factory defaults from one dialog. **Unlock layout** remains
the interactive editor; existing saved preferences are retained.
- Presets are persisted in a separate SQLite `dashboard_presets` table (schema
10), outside the 4096-byte administrator appearance document. They do not
change the active proxy configuration revision. A factory reset changes the
current dashboard, not existing named presets.
- Chart headings throughout Overview, Web Server, diagnostics, Cache,
Transform, Traffic, Vhosts and Streams use the established proportional font
at **12px** (Overview sentence case, other sections uppercase). Chart labels, values and
entire legends (including Compression activity series names) use the existing
monospaced font. Chart behavior is unchanged; two Overview titles now
use sentence-case English/Polish translations.
Proxy forwarding, trusted-client IP headers and WAF behavior were not
modified. See [API](docs/API.md) and [verification](docs/TEST_REPORT.md).
Rust compilation and live server acceptance remain outstanding.
## 0.33.5: fixed CPU chart scale on Overview
+29
View File
@@ -1,5 +1,34 @@
# RustProxy roadmap
## 0.33.7 - named dashboard presets, focused HTML extraction and chart typography
- [x] Apply uniform 12px sentence-case labels to all Overview widgets,
including Process, RAM cache and backend tiles. Normalize secondary
captions and small values to monospace without changing other panels.
- [x] Space the two Cache table cards by 10px without altering other sections.
- [x] Reuse the backend quick-view popover for clickable Vhost upstream H1/H2
protocol details; keep it stable during repeated live status refreshes.
- [x] Keep the clickable Backend and Health fields square, visually matching
adjacent Vhost tiles under every interface corner style.
- [x] Standardize the full legend text (including every value) at 9px monospace,
matching Requests per second across desktop, mobile and density modes.
- [x] Add a compact, nonmodal Vhost backend quick-view through both backend
count and health metric; use existing configuration and status fields, with
accurate unknown/last HTTP status handling and accessible dismissal.
- [x] Introduce account-scoped, bounded SQLite storage for 16 named Overview
layout snapshots without changing proxy configuration or existing preferences.
- [x] Replace Arrange Overview modal with save/load/overwrite/rename/delete,
and factory reset; keep Unlock layout editing intact.
- [x] Extract metrics and charts only into compile-time embedded HTML partials
without changing existing card DOM order, IDs or polling.
- [x] Standardize chart headings on 12px proportional type (Overview in
sentence case, all other panels uppercase); use mono for full legends,
captions and chart data across all chart families.
- [x] Validate SQL migration and run available simulated-browser/JS tests.
- [ ] Build/test Rust and run the new endpoint/UI against a real server with
representative persisted data. The production acceptance gate remains open.
## 0.33.6 - Stable dashboard chart ranges and plot heights
- [x] Fix all 14 Overview/WWW full-width SVG heights to 125px and align their
+59 -43
View File
@@ -1,13 +1,13 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
ffae7ce20cc0fb1990394822515c933843ba39304aa44134403f0d25a6a66d9f AGENTS.md
8b72ed7b17505c129b60faf97ee6a838b855e4303dc390ea7196b0d97ee79134 CHANGELOG.md
a35a5d557bb57df37d130a03752ffc092f005cb4849733d3d81485fb9450a931 Cargo.lock
8b77b2beffb31dd14ff62e225ff5384084d2b4e66faa100aaf38470883fca879 Cargo.toml
c793a8be7f4b76350e04ab36dbeec3792bb2dcf33c87a86f76fa2f330e26dd9f AGENTS.md
91edfded868ff72e97fbb34d08e1be2df5d5b8fa6582b534a8e4d109cd312acd CHANGELOG.md
d5dc2b778af98f52ad52a7515e2d1b4ba8c2e01a04cc6e02564377075b01c501 Cargo.lock
7fd5dd1ccae8fac77bbbea4bbf960f5cf762f712ff17a2e629a5dd8629eb7ff9 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
dfe12a6a0d360e0febec982f30098123a8974007c7c3fba39509be5c45d3ebbd README.md
65340bb1f93da256043cb6bd75b6c9de8539e31f0417f24c6f05093a27f8319f ROADMAP.md
30708454b96c0dd57c9c621050ace58b92dbdbd85591c7b8199ebb54937dc372 README.md
e796ab6e379b3d71cf5d77ca3eb30bc6a320080af0c9e35f07c82737e09cbc2e ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
@@ -27,7 +27,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
627f1d8727321ef8961b153aabab7448f239fa383177bb9ef115cfeef48452da docs/API.md
e21a5c3cea78386fcb3ebd6516a66eed2ecd1966481ae0d3b2f57fa01010542e docs/API.md
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
d30551a52ffd34c5963094d2826ba39795210a4e6a4e4d7333066dae7456fc50 docs/ARCHITECTURE.md
fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md
@@ -64,11 +64,11 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
4dcf3243a13d9ae1827b5cefdbb9e27bd6310321c50d4d5934527d44437df6ef docs/SETTINGS.md
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
72baf37ed0989de65123eba73f83d8c31cba7415382dd00e639d8ca412f0ef0d docs/TEST_REPORT.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
fd1c187ea8eef101764670d4ebb0c554173412514e37b909c81ea3f22ba8e7ee docs/VHOSTS.md
711c69c9e05761ba5cb96dadc3c368eb2961e2d2287ca039eb49c7fbca0dd63c docs/VHOSTS.md
09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md
4257729a4c1b1f3949538410af78a12067bd3417c70525a15bd01411155d25fd docs/WEB_SERVER.md
c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md
@@ -96,8 +96,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
7783e629ca4b2c1614e776838bf90f3692d521e50ee580aa5e92236c92ada844 langs/en_US.json
58f2fdaf0aa9d3a3ce25d47659501a8b3df622e0c08eee0a4baaad4d5f1ac660 langs/pl_pl.json
1a69ed38b730c3b379b8b14b5707d8ae923320a0c66d084c7ee478e6a01eb53c langs/en_US.json
6513435292eb947935f50eeadf9ef2ee0995f4b57e989699dfc9cda686f21685 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -107,6 +107,7 @@ faa7e50922bf180c363f7627fbcfe8079831e36282220d17481cf69bc64b57f1 migrations/000
f093aef9639a959a2522946995a02d2e67c464e794c3ef665261a9a5b348c968 migrations/0007_secret_store.sql
90f6dce865496e018c047133d3bc1721fc2a19b2f4ced22e2a3625d9e9ff18ac migrations/0008_config_history.sql
fc7585fdcbe99fe3496f0e62f4c4e2161f4a0173222ba4950331aec6c44e50f8 migrations/0009_certificate_api_grants.sql
a8b1e5ec3236a0c2681dfa1746c526eeef77e8bcb5ea8634d954ac398b4642bb migrations/0010_dashboard_presets.sql
478220552ffeea3de00e2866d427d9f87f4cfc4ae5232e5c3a774e62425b1e0c nagios/README.md
91233910aa4dab07ae917fef93d018a7a0beaab697f9a0bb889ac613e595cca4 nagios/check_rustproxy.py
a42e01b341725c9da0928edf40c8c3b9c814ef90592f208c52b1f2c56a2ad383 nagios/check_rustproxy_api
@@ -123,31 +124,40 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
63ed91f4cd77450cfe04f4bdb6e5f866b7868afa10207f78041e6fcd86d96fb0 scripts/preflight.py
b5b3135449f6be4e97088b76ef7cf2428e2de3c7944c9cbbcb1edd2016c0fd4a scripts/preflight.py
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
cc7026ec9c5b69850a815816df093de0ffdf4aeadabb3af702783483122ede55 scripts/tests/dashboard_chart_headers_0335_browser_test.py
aa3ca7419d6d7d506c5aaf4d624d20cc7e8ee66bec3cb0cf9b5e7d164bcbab57 scripts/tests/dashboard_chart_width_0333_browser_test.py
fdcea6dd1e3bc160abe1139bf94b45c9a28f6aaf7afbe7e38fb452ee4892b8f1 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py
52eacb467df123dc4a35f14933e006b090c9238df1b14dcff856a16b37c8063f scripts/tests/dashboard_layout_0331_browser_test.py
276e732fc2dfb0d39badb159e4fdcba0d7d73509c60ad5c9df49621c03046f1b scripts/tests/dashboard_metric_visuals_ui_test.mjs
61a0fb2c4de86f25debec9662aedd923bce8c2ccd51e75593d485b2e0c9b8551 scripts/tests/dashboard_modal_0333_browser_test.py
0c8a566979b51cd62601d138697ccd5c4ed47b8fa33e8faa28fa3a085a72ad97 scripts/tests/dashboard_resizing_0333_browser_test.py
f82d57d3c01858eb60aa6882f7d3470e6587d7fc60e7ee6dbd8c5bec1f223c66 scripts/tests/dashboard_resume_rate_charts_ui_test.mjs
aa12d00acdd226602b3efc90221fbc936f69f73bcabbaa6c01dfb0e05d08c505 scripts/tests/overview_samples_0330_ui_test.mjs
0ec69b7811db3dcd51cba60387900ca8df50eb836b22cc2a3ecfcc22b40a2edd scripts/tests/progressbar_0332_browser_test.py
222077dab4d4928ae9110a9f2d64c07cc8386ea14099819b43304bbd2364e5b5 scripts/tests/sidebar_nav_0333_browser_test.py
d5f675b97a85220043a9475347412972db904e559d863ce5058aa18f649f9e16 scripts/tests/system_ram_meter_0332_browser_test.py
efb7a1fde2c74f30cd4a1abb88ae07c1cac7378bb33685dd09d52bb30849daac scripts/tests/dashboard_chart_headers_0335_browser_test.py
eb2c9e5c968938b6fbe4675b547ba1f537519878b9095e1fea133f4d83f96fc8 scripts/tests/dashboard_chart_typography_0337_browser_test.py
6a908fd434d2a8a80adfd06a66d4b83c4174e448d453a8c10b1812475d036677 scripts/tests/dashboard_chart_width_0333_browser_test.py
252b401c29e2db50e105d7fb044bcae14752748261a67da1a4025d71bfa2608c scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py
41a2b3765006c138324ae1312f5c1edfed6ce48051230f85779e8d2aad34d2a3 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py
de583adde771e0d6f64ea0d5aed45b1c942a7c78b7193598eeeb77fff00c2803 scripts/tests/dashboard_layout_0331_browser_test.py
1c813c78dfe96ba4d2c9470c93dd24e81be06e6485ebdc39cbfa30f8d32aa0fe scripts/tests/dashboard_metric_visuals_ui_test.mjs
f14626c5ec006477062b496bdc0025caa3366c7037ee598e15cd55e29041d58f scripts/tests/dashboard_modal_0333_browser_test.py
f934f5ce66105a1394b93761a6081a51133837ac038f5faf67a236f676b15c1b scripts/tests/dashboard_overview_labels_0337_browser_test.py
df87420f16c418aa17a8ba8808e506095681446d32a7d76ae0244222ed37928a scripts/tests/dashboard_presets_sql_test.py
df1457797241aab114a5671ce3f0229815dd29d06ab6f6254c0a1048576af6e1 scripts/tests/dashboard_resizing_0333_browser_test.py
149910e6e8d569443a724fd21701e2fd8f6b5e360991d4f710f5d21364402bce scripts/tests/dashboard_resume_rate_charts_ui_test.mjs
3868dc7b241f1cf15bc297104e29449ec3e35f75cb66078439bc4b5c4ee11040 scripts/tests/dashboard_stable_scales_0336_browser_test.py
fa42ec0475e4398d647b3c094309b4332cd2b5649db223cae0b8e821d6b44a28 scripts/tests/dashboard_title_color_0337_browser_test.py
3c1b2bda6410d3348b0f5a05c70494cca9636339bed8217fc4abc0a30981faae scripts/tests/overview_samples_0330_ui_test.mjs
72185f59f31bf04a4e88d53fe2fca52509f3663cb5f40b1d3bc319a5f945ef23 scripts/tests/panel_html.mjs
b0136544af717b75df2cb107900ea65c3f945fdd0040369dbb93919008fb954c scripts/tests/panel_html.py
8d1903e2b17b3d59cd0a5bec3e298e468b3d88ed2dda8b724239cc45d8353c59 scripts/tests/progressbar_0332_browser_test.py
1bb99b148d0297235b707f16865ca077329246fc19ecbb20a143bdeb56a23f37 scripts/tests/sidebar_nav_0333_browser_test.py
12f2b18b0ed2171c49eb8cde2c2b88fc627054c2f574c04bd9847821b00cd4c1 scripts/tests/system_ram_meter_0332_browser_test.py
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/tests/test_stream_tcp.py
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/tests/test_stream_udp.py
2da8b8d56b6e53b061664c352df0e7a351602c4e460e5f35ce61b738467a5d23 scripts/tests/web_observability_0330_browser_test.py
fc982558e5c27281d71c2b3cbc926c415ab9134cf595e7ac12b7214f8a180447 scripts/tests/web_observability_0330_source_test.py
651330e4bd38d1f2c45d304e5f7461848f414e80de5089358775af4e5eba5b97 scripts/tests/web_server_0330_ui_test.py
17839b4048e055c55b15c46f95e6745d8d87bde17ea0d04e557a85411526e83e scripts/tests/web_server_0334_browser_test.py
f0a2e3daa0a49aac54723cb5a281360e49a97c076281372c5cca2a85e096c02c scripts/tests/vhost_backend_popup_0337_browser_test.py
23cb1f790cd5cee1b76ecd85f6a2a44ef2e16b0840bf06beaa2ae13048063b2f scripts/tests/web_observability_0330_browser_test.py
3babdd0fd6e2998755ae88615112f6ea056f5c808245abb6de60fbda555594d1 scripts/tests/web_observability_0330_source_test.py
f55efa99245bbdbc98fe3202e52952d224ac3200612561580d52f6e251810049 scripts/tests/web_server_0330_ui_test.py
0dd1cef06a1c3e1546e2fb9b085bf369dfbed02107b9ab01b4fb4f3b894099c0 scripts/tests/web_server_0334_browser_test.py
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
55485a513e8c4da2a58daa7abcb15b32ed408a5fc062cf23599fb83d16b90cfe src/acl.rs
63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs
@@ -165,6 +175,7 @@ e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests
b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs
b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs
019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
b5f6d49f1add583cfc8cd4aa36ffe4db56dac4de3b151fe8239fe42f507019cc src/admin/dashboard_presets.rs
5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs
958546c43c05bf4bed8121b24db3b3096f9243f3c56a71e1762592b755684013 src/admin/documentation/documents.rs
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
@@ -173,34 +184,36 @@ ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file
c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
6884210022056d7f2a92160b6d37026e420483c2285368b7ae3c01b1c981df8b src/admin/openapi.json
e552c4b565e3198cc2224d1efa3bef21db02a9872cb80cd96f446181035f19fb src/admin/mod.rs
28dd33ef19d7eab08cfa243b253785012d6c731bc4c56b4f69f28edd3d9e07a7 src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
92b3e05f04711d8001b56816405859631d9e917868e15be965c7e76ef4ddef99 src/admin/preferences.rs
4a5cb4c3a3aa8b9a519020854506ce4be5e688a6c4298260322ae641dded7fe9 src/admin/preferences_columns.rs
9a3f13e2477769ae680c03d63336880c3b7b7d5aba4a91e44390a976608b9b1d src/admin/preferences_tests.rs
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
6d12bb97819ff05773d017d6b73bd6659dc9f2345f49d449fbaf9e34b936aab8 src/admin/router.rs
daef5e663c1bc99a22b3b8fa4481ae5e4916b91db132c104bc050f51b1fdf77f src/admin/router.rs
a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
77fe317c15bf2e03301b22e0552518fe2d7e1ad8d80afaba4e16cd2bf8ddf10c src/admin/status.rs
4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
18b778c2c4e723e2fe5cbaaef20effa6ea50fcc35d507ebde2361c9b6eda62b2 src/admin/tests.rs
3d9dcf96d8f05f1f03ad41b6f53d2b743ce6f9fadd608745d04fced0efabb9af src/admin/tests.rs
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js
a085ee336e6669229f3ad2f74536f19bd088412ca79bcaff30dff293f1e1baf9 src/admin/web/app/chart-scales.js
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
8aa205bc88530e4c4678f84ea324c6ecbc823b4ed681624102ff177f3867bc98 src/admin/web/app/core.js
f3b566d79ba47848021a74653dbd7fec72f0fb819c6b2a07259e047bc8c427fd src/admin/web/app/dashboard-inline-layout.js
afe4e0707fdcdfbce9134f3998e21ef05066da7cb29fe22136a67d288d5a92d6 src/admin/web/app/dashboard-inline-layout.js
851a4337127f332db91f6954f689363ee227fcddde2dda82e55f645940d0ce3b src/admin/web/app/dashboard-layout-editor.js
2f1669cbeb9f49aef43a548e2096825aa14c64c7097ffad520822bbbaefb2b4c src/admin/web/app/overview-extra-metrics.js
a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/app/dashboard-presets.js
bfb3fbac62f9ff762a298d9c06808ae81ae55de7a41e0bb35ce5c167f333ffe2 src/admin/web/app/overview-extra-metrics.js
5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js
b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.js
41d9d872ba2f8e1871564fa3f72e88bcb8f118c0ddd5255f256ac7230de945b6 src/admin/web/app/overview-wide-metrics.js
d997f62e8bd7ac9d4f16bb302800c98ad943afab663bb0b212ea26c35b58de11 src/admin/web/app/overview-wide-metrics.js
5bde5f0881320464eb133cd44de9eff5211e6816e79f7f7e9574b16784fab126 src/admin/web/app/preferences.js
8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js
43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js
@@ -208,8 +221,8 @@ daa20d634f921191cb4ac37889504f203ab39ad7e8fd8f33e1b182fc4206c24d src/admin/web/
31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js
655db0b4d5e7ee14dbe69fa22bdb9d99eecc32abcf3b1b76a5819a8015f72309 src/admin/web/app/web-overview.js
0603dc8793a731a0e582cbbec53aa6d1cc6e007faafcec5d8843730b2cdc39e1 src/admin/web/cache-panel.js
7f89bfae66e2a08a05a112733864e908f036bb1ee3d8b5b9ff23783b870acd96 src/admin/web/app/web-overview.js
bb94697a1cbeccaf80069dd47ab84efe14b052d81c472f8309478bbeb4e05f06 src/admin/web/cache-panel.js
02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js
aec5755244242424e5091ff57c0258d2618588618fe1d52d36f081d03879f686 src/admin/web/dashboard-defaults.json
797e2935a34b974cb0e07a88056c31e6948cc11385bf2a076d902a332882d0a0 src/admin/web/dashboard-widgets.json
@@ -234,25 +247,27 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
0ebfde7081385aa7158f3b0f8f2b9355836e087c3d91a7c98d5187afbd47abd0 src/admin/web/index.html
6684e7de2540499f9129b78c790f9a820b0e6c5de33e3b60c318d9a1afc931a5 src/admin/web/index.html
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js
097fdb41aa1cd526c4d9bc65e502b05a62e664ef9fff8e69f706b93b0b124dc2 src/admin/web/management.js
4481a592fcce9a2aa1db5e4a25619e19fd459c39039b86c0549dfc427eec1b45 src/admin/web/partials/overview-charts.html
c1b831617bf3cd03dfa416b43791777c8e71f0711c78f9d467fd0f1930ca2a1e src/admin/web/partials/overview-metrics.html
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
cc0fdbed4a344487fef7593375177043ee4524b98f58842f37a97e2d2a414183 src/admin/web/style.css
0109870251482b02f5adc63a8359a790bd793e21c827951e9b39a4fcd5fc9451 src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
69f412bd897e45f4889b71e9982cfff56df9ef20ba2da109b454713a5ecd7c74 src/admin/web/transform-panel.js
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js
b991e81a98dd604a1d7c30158c0cd8cd825479bd2cd94b082b3550de1071bb58 src/admin/web/vhosts/render.js
bb93e2017f40e3a45ff7f4ca92e4188bb464f432704b6d2c39da9d9621b7984c src/admin/web/vhosts/render.js
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
0049e5c2a35e3ec25f3c1590879684a7b15165584e96eb1b022fb60d3c548f2c src/admin/web/web-server.js
53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs
@@ -323,8 +338,9 @@ a19fc675720c7c2dbf4bba706e1b1c30c83c6df1da3f29e3cbb3b82442e7246a src/config/mod
f66c2388f31b19142c12469df23d43451e0f7c48b158d4996920282c06d32076 src/config/routing.rs
818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs
76cbb4f1339de9f13d441561a3c4ee6bc5ff06e90ff0f019b7f0239c74f9542d src/config/tests.rs
c6db84438878accb8a0c96be13fa01a83da824af39c929dc3f6df498f393b7e0 src/db.rs
23541660151d9005555a07db6d491455d61b185f0cc70c3ec3a0bee2aef08dc3 src/db.rs
30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs
20ba52d3755c359d670968efa53b0604ff7cbed63a416f9f765b50a1f45074ac src/db/dashboard_presets.rs
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs
19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs
+32 -1
View File
@@ -76,6 +76,37 @@ rejection, 409 for already-completed setup, 422 for password policy and 429 for
throttling/busy workers. Error objects usually contain an `error` code/string;
framework extraction failures are not guaranteed to have that JSON shape.
## Administrator Overview layout presets (0.33.7)
The dashboard preset endpoints are private control-plane routes and use the
existing administrator session cookie. The same Origin, request-marker and
CSRF rules as other settings writes apply. They do **not** change the proxy
configuration revision and cannot be called from public vhosts.
| Method / path | Request or result |
|---|---|
| `GET /api/dashboard-presets` | Array of named layouts for the authenticated administrator |
| `PUT /api/dashboard-presets/{name}` | Create/overwrite a named layout; JSON body described below; returns 204 |
| `PATCH /api/dashboard-presets/{name}` | Rename using `{ "name": "new name" }`; returns 204 |
| `DELETE /api/dashboard-presets/{name}` | Delete the named layout; returns 204 |
A layout is `{ "dashboard_widgets": ["..."], "dashboard_hidden_widgets": ["..."],
"dashboard_widget_sizes": null, "dashboard_density": "compact",
"overview_samples": 600 }`, using registered widget IDs and the same
width/density validation as administrator view preferences. History accepts
60, 120, 300 or 600 samples. For sizes, `null` preserves legacy grouped
placement; an object maps widget IDs to valid 12-column spans. The account
may store at most 16 named layouts; names are 1-64 UTF-8 bytes with no
control characters, surrounding whitespace or slash. Invalid values receive
422; full capacity or a conflicting rename receives 409. Unknown delete
returns 404. All write endpoints require a session-bound CSRF token.
The data resides in SQLite schema 10 (`dashboard_presets`), separate from
`admin_preferences`; the factory preset is not stored as a mutable row.
The browser saves the chosen history sample count using its existing local
preference when loading a preset. The client does not use JSON configuration
files to store named presets.
## Configuration and observations
| Method / path | Result |
@@ -107,7 +138,7 @@ and rechecks session validity. It does not queue an unlimited per-viewer event l
or guarantee replay. The browser falls back to compact status/log polling only after SSE fails and stops polling after its recovery. Invalid SSE intervals are rejected with 422. An active dashboard renews
idle activity, not the absolute session deadline.
Configuration history, introduced in SQLite schema 8 and retained in current schema 9, keeps the newest 512 successful manual/configuration revisions. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121.
Configuration history, introduced in SQLite schema 8 and retained in current schema 10, keeps the newest 512 successful manual/configuration revisions. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121.
Configuration updates return 409 for a stale revision, 422 for validation failure
and 429 when the eight-operation update admission bound is reached. Persistence and
-44
View File
@@ -1,44 +0,0 @@
# RustProxy 0.33.6 - verification report
## Scope (2026-10-15)
Additional 0.33.6 chart/UI changes:
- All 14 full-width Overview and Web Server plots retain a fixed 125px SVG
height, regardless of viewport width and dashboard density.
- Grid lines are mapped to the actual plotting range rather than approximate
horizontal positions.
- Transform and Compression activity (plus other unbounded unit series) use
sane zero-based minimum ranges and monotonic, rounded range expansion, so
low values do not look like maximum utilization and spikes remain visible.
- Full Cache and Transform live charts keep an 180px plot height and the same
stable per-second range policy. Compact percent sparklines stay 0-100%.
Previous 0.33.5 regression scope:
Correct the Overview **System and process CPU** widget:
- Use a fixed 0-100% vertical range, not the measured-history peak.
- Map 25%, 50% and 75% to the horizontal grid lines.
- Clip plotted CPU values above 100% at the top without modifying their measured values in the legend or summary.
- Fix only this plot's CSS height at 125px, independent of viewport width, dashboard density and CPU samples.
- Leave other chart scales, plot dimensions, status sampling and dashboard preferences unchanged.
## Checks completed
- **PASS:** `python scripts/tests/dashboard_stable_scales_0336_browser_test.py`
with real bundled HTML/CSS/JS in simulated Chromium at 320, 375, 768,
1440 and 1920px and all three density modes. Covered 14 large plots,
125/180px plot heights, quarter-grid alignment, 0-100% CPU mapping,
steady low/high traffic plotting, minimum count/rate/latency/memory scales,
sticky expanded scales, reset and non-clipped live operation-rate labels.
- **PASS:** `python scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py` against the actual bundled HTML/CSS/JS in simulated Chromium, at 320, 375, 768, 1440 and 1920px, in normal/compact/ultra densities. Checked low and high CPU histories, missing samples, new status revisions, clipping above 100%, actual percentage labels, consistent grid positions, fixed plot height and no sample-dependent changes to the CPU/adjacent Connection Activity cards' heights.
- **PASS:** All 11 prior `scripts/tests/*browser_test.py` simulated-browser regressions (including the 0.33.5 CPU test) and the new 0.33.6 browser regression: chart headers, resized widths, dashboard grid/modal resizing, sidebar, progress bars, System RAM, Web observability and Web Server metrics. No live RustProxy server was used.
- **PASS:** `node scripts/tests/overview_samples_0330_ui_test.mjs`, `node scripts/tests/dashboard_resume_rate_charts_ui_test.mjs` and `node scripts/tests/dashboard_metric_visuals_ui_test.mjs`.
- **PASS:** `./dev.sh doctor` and `python scripts/preflight.py` (source layout).
- **PASS:** All eight `migrations/*.sql` applied successfully in two successive cycles to a disposable, in-memory SQLite database. This verifies SQL script execution/idempotency in this context, not an upgrade of production data.
- **PASS:** Parsed `scripts/deploy/upgrade.py --help` and `scripts/migration/migrate_npm.py --help`; did not run a live upgrade or migration against an external database or service.
## Not verified in this environment
- `./dev.sh check` and `./dev.sh test` both exit 127: Cargo/rustc are unavailable. Therefore no Rust compilation, unit/integration suite, actual proxy runtime, real SSE feed, production database upgrade or live load test has been verified.
- The real-runtime acceptance gate remains open until Rust builds and runs against representative test traffic.
+11
View File
@@ -6,6 +6,17 @@
This is the source implementation contract. Rust compilation and runtime acceptance
were not performed in the packaging environment; see TEST_REPORT.md.
## Backend quick-view in Vhosts
Click the **Backends** count or the **Status** tile of a Vhost to open its
backend list. Each member displays the configured ID, origin host/IP and port,
HTTP/HTTPS scheme and HTTP mode, current reported health and `last_status`.
`last_status` is the most recent HTTP code from a proxied request; zero/no
member means no known HTTP response, not a failed probe. DNS origins show the
configured hostname, as the status API does not report the resolved IP.
The panel updates from the existing status feed without initiating requests,
and supports Escape and click-outside dismissal.
## One vhost, one backend pool
A `vhosts` entry owns an `id`, `enabled`, `frontend`, `source`, `backends`, `load_balancing`,
+37 -7
View File
@@ -209,7 +209,7 @@
"settingsDescription": "Switch between function tabs. Unsaved changes stay in their forms.",
"cacheHitRatio": "Cache hit ratio",
"processMemory": "Process memory",
"systemLoad": "SYSTEM LOAD",
"systemLoad": "System load",
"systemChart": "Recent system CPU usage",
"systemMemory": "RAM used / total",
"systemSwap": "System swap",
@@ -323,7 +323,6 @@
"vhGenerationRequests": "Gen. requests",
"vhGenerationAge": "Gen. age",
"vhClientRotations": "Rotations",
"vhIntro": "Each vhost owns its bound IPs, paths and backends. Unlisted addresses cannot reach it. Listener changes require restart.",
"vhList": "Virtual hosts",
"vhSearch": "Search vhosts",
"vhSearchPlaceholder": "Search hostname, vhost or backend",
@@ -343,6 +342,14 @@
"vhAddress": "IP:port (IPv6: [address]:port)",
"vhTls": "TLS enabled",
"vhHttp2": "Prefer HTTP/2 over TLS; retain HTTP/1.1",
"vhBackendPopupOpen": "Show backend details",
"vhBackendPopupTitle": "Backends",
"vhBackendPopupAddress": "IP / host",
"vhBackendPopupProtocol": "Protocol",
"vhBackendPopupLastHttp": "Last HTTP response",
"vhBackendPopupNoResponse": "No HTTP response yet",
"vhBackendPopupEmpty": "No configured backends.",
"vhBackendPopupHint": "The last HTTP response comes from an actual proxied request, not a health probe. DNS names show the configured host, not a resolved IP.",
"vhBackendPool": "Backends",
"vhAddBackend": "Add backend",
"vhMemberId": "Backend ID",
@@ -1795,7 +1802,7 @@
"statisticsSource": "Statistics source",
"dataDirectory": "Data directory",
"databaseLocation": "Database",
"connectionActivity": "CONNECTION ACTIVITY",
"connectionActivity": "Connection activity",
"reloadCertificate": "Reload certificate",
"certificateApiComment": "Comment",
"certificateApiCertificateIds": "Allowed certificate IDs",
@@ -2510,7 +2517,7 @@
"backendWidgetNetworkBreakdown": "Connect / DNS / TLS / I/O",
"backendWidgetPoolGeneration": "Maximum active pool generation",
"backendWidgetPoolAge": "Oldest generation age",
"overviewLayoutEditorOpen": "Arrange Overview",
"overviewLayoutEditorOpen": "Dashboard presets",
"overviewLayoutEditorTitle": "Arrange entire Overview",
"overviewLayoutEditorHelp": "Move any tile or panel by dragging or using the arrows. Hidden items can be enabled below. Default view previews the standard layout; Reset layout asks for confirmation before saving.",
"overviewLayoutEditorVisible": "Visible cards",
@@ -2524,7 +2531,7 @@
"overviewLayoutEditorHiddenEmpty": "All cards are visible.",
"overviewLayoutEditorMoveBefore": "Move card before",
"overviewLayoutEditorSaved": "Dashboard layout saved",
"overviewLayoutEditorOpenSettings": "Visual Overview editor",
"overviewLayoutEditorOpenSettings": "Dashboard presets",
"overviewLayoutEditorSearch": "Find hidden card",
"overviewLayoutDefaultPreview": "Default view",
"overviewLayoutReset": "Reset layout",
@@ -2703,10 +2710,33 @@
"webLivePhpCapacity": "PHP capacity",
"webLiveKinds": "Static / PHP / other",
"webLiveBytes": "Body delivered",
"webLiveNoPhp": "PHP disabled"
"webLiveNoPhp": "PHP disabled",
"dashboardPresetsTitle": "Dashboard presets",
"dashboardPresetsHelp": "Save named arrangements and restore them from any browser. Use Unlock layout to rearrange cards.",
"dashboardPresetsName": "Preset name",
"dashboardPresetsNamePlaceholder": "e.g. Monitoring",
"dashboardPresetsSave": "Save current view",
"dashboardPresetsEmpty": "No presets saved yet.",
"dashboardPresetsRenameHint": "To rename a preset, enter a new name above and click Rename beside it.",
"dashboardPresetsLoad": "Load",
"dashboardPresetsOverwrite": "Overwrite",
"dashboardPresetsRename": "Rename",
"dashboardPresetsDelete": "Delete",
"dashboardPresetsFactory": "Restore factory layout",
"dashboardPresetsResetConfirm": "Restore factory order, visibility, widths, density and history length? Your saved presets will not be deleted.",
"dashboardPresetsDeleteConfirm": "Delete saved preset \"{name}\"?",
"dashboardPresetsOverwriteConfirm": "Overwrite saved preset \"{name}\" with the current view?",
"dashboardPresetsSaved": "Preset saved.",
"dashboardPresetsLoaded": "Preset loaded.",
"dashboardPresetsRenamed": "Preset renamed.",
"dashboardPresetsDeleted": "Preset deleted.",
"dashboardPresetsRestored": "Factory dashboard layout restored.",
"dashboardPresetsNameRequired": "Enter a preset name (up to 64 characters).",
"dashboardPresetsLimit": "You can save up to 16 presets."
},
"overviewLayoutNarrow": "Decrease card width",
"overviewLayoutWiden": "Increase card width",
"overviewLayoutWidth": "Card width in 12-column grid",
"overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert."
"overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert.",
"dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again."
}
+37 -7
View File
@@ -209,7 +209,7 @@
"settingsDescription": "Przelaczaj karty funkcji. Niezapisane zmiany pozostaja w formularzach.",
"cacheHitRatio": "Trafienia cache",
"processMemory": "Pamięć procesu",
"systemLoad": "OBCIĄŻENIE SYSTEMU",
"systemLoad": "Obciążenie systemu",
"systemChart": "Ostatnie próbki obciążenia CPU",
"systemMemory": "RAM użyta / łącznie",
"systemSwap": "Swap systemu",
@@ -323,7 +323,6 @@
"vhGenerationRequests": "Żądania gen.",
"vhGenerationAge": "Wiek gen.",
"vhClientRotations": "Rotacje",
"vhIntro": "Każdy vhost ma własne adresy IP, ścieżki i backendy. Inne adresy go nie obsługują. Zmiana nasłuchu wymaga restartu.",
"vhList": "Hosty wirtualne",
"vhSearch": "Szukaj vhostów",
"vhSearchPlaceholder": "Szukaj domeny, vhosta lub backendu",
@@ -343,6 +342,14 @@
"vhAddress": "IP:port (IPv6: [adres]:port)",
"vhTls": "TLS włączony",
"vhHttp2": "Preferuj HTTP/2 przez TLS; zachowaj HTTP/1.1",
"vhBackendPopupOpen": "Pokaż szczegóły backendów",
"vhBackendPopupTitle": "Backendy",
"vhBackendPopupAddress": "IP / host",
"vhBackendPopupProtocol": "Protokół",
"vhBackendPopupLastHttp": "Ostatnia odpowiedź HTTP",
"vhBackendPopupNoResponse": "Brak odpowiedzi HTTP",
"vhBackendPopupEmpty": "Brak skonfigurowanych backendów.",
"vhBackendPopupHint": "Ostatni kod HTTP pochodzi z rzeczywistego żądania proxy, a nie sondy. Dla DNS wyświetlany jest skonfigurowany host, nie rozwiązany adres IP.",
"vhBackendPool": "Backendy",
"vhAddBackend": "Dodaj backend",
"vhMemberId": "Identyfikator backendu",
@@ -1795,7 +1802,7 @@
"statisticsSource": "Źródło statystyk",
"dataDirectory": "Katalog danych",
"databaseLocation": "Baza danych",
"connectionActivity": "AKTYWNOŚĆ POŁĄCZEŃ",
"connectionActivity": "Aktywność połączeń",
"reloadCertificate": "Przeładuj certyfikat",
"certificateApiComment": "Komentarz",
"certificateApiCertificateIds": "Dozwolone ID certyfikatów",
@@ -2510,7 +2517,7 @@
"backendWidgetNetworkBreakdown": "TCP / DNS / TLS / I/O",
"backendWidgetPoolGeneration": "Najwyższa generacja puli",
"backendWidgetPoolAge": "Najstarsza generacja",
"overviewLayoutEditorOpen": "Ułóż Dashboard",
"overviewLayoutEditorOpen": "Presety dashboardu",
"overviewLayoutEditorTitle": "Ułóż cały Dashboard",
"overviewLayoutEditorHelp": "Przeciągaj kafelki i panele lub używaj strzałek. Ukryte elementy włączysz poniżej. Widok domyślny pokazuje standardowy układ, a Resetuj układ prosi o potwierdzenie przed zapisem.",
"overviewLayoutEditorVisible": "Widoczne kafelki",
@@ -2524,7 +2531,7 @@
"overviewLayoutEditorHiddenEmpty": "Wszystkie kafelki są widoczne.",
"overviewLayoutEditorMoveBefore": "Przesuń kafelek przed",
"overviewLayoutEditorSaved": "Układ Dashboardu zapisany",
"overviewLayoutEditorOpenSettings": "Wizualny edytor Overview",
"overviewLayoutEditorOpenSettings": "Presety dashboardu",
"overviewLayoutEditorSearch": "Znajdź ukryty kafelek",
"overviewLayoutDefaultPreview": "Widok domyślny",
"overviewLayoutReset": "Resetuj układ",
@@ -2703,10 +2710,33 @@
"webLivePhpCapacity": "Limit PHP",
"webLiveKinds": "Statyczne / PHP / inne",
"webLiveBytes": "Wysłana treść",
"webLiveNoPhp": "PHP wyłączone"
"webLiveNoPhp": "PHP wyłączone",
"dashboardPresetsTitle": "Presety dashboardu",
"dashboardPresetsHelp": "Zapisuj układy pod nazwą i wczytuj je w dowolnej przeglądarce. Karty możesz zmieniać przez Odblokuj układ.",
"dashboardPresetsName": "Nazwa presetu",
"dashboardPresetsNamePlaceholder": "np. Monitoring",
"dashboardPresetsSave": "Zapisz bieżący widok",
"dashboardPresetsEmpty": "Brak zapisanych presetów.",
"dashboardPresetsRenameHint": "Aby zmienić nazwę presetu, wpisz nową nazwę powyżej i kliknij Zmień nazwę przy wybranym presecie.",
"dashboardPresetsLoad": "Wczytaj",
"dashboardPresetsOverwrite": "Nadpisz",
"dashboardPresetsRename": "Zmień nazwę",
"dashboardPresetsDelete": "Usuń",
"dashboardPresetsFactory": "Przywróć układ fabryczny",
"dashboardPresetsResetConfirm": "Przywrócić fabryczną kolejność, widoczność, szerokości, gęstość i liczbę próbek? Zapisane presety pozostaną.",
"dashboardPresetsDeleteConfirm": "Usunąć zapisany preset „{name}”?",
"dashboardPresetsOverwriteConfirm": "Nadpisać preset „{name}” bieżącym widokiem?",
"dashboardPresetsSaved": "Preset zapisany.",
"dashboardPresetsLoaded": "Preset wczytany.",
"dashboardPresetsRenamed": "Zmieniono nazwę presetu.",
"dashboardPresetsDeleted": "Preset usunięty.",
"dashboardPresetsRestored": "Przywrócono fabryczny układ dashboardu.",
"dashboardPresetsNameRequired": "Wpisz nazwę presetu (maks. 64 znaki).",
"dashboardPresetsLimit": "Możesz zapisać maksymalnie 16 presetów."
},
"overviewLayoutNarrow": "Zmniejsz szerokość karty",
"overviewLayoutWiden": "Zwiększ szerokość karty",
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn",
"overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany."
"overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany.",
"dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie."
}
+10
View File
@@ -0,0 +1,10 @@
-- Named, administrator-scoped Overview presets. Keep this separate from the
-- existing 4096-byte appearance preferences document and proxy configuration.
CREATE TABLE IF NOT EXISTS dashboard_presets (
administrator_id INTEGER NOT NULL CHECK (administrator_id = 1),
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64),
document TEXT NOT NULL CHECK (length(document) BETWEEN 2 AND 8192),
updated_at INTEGER NOT NULL,
PRIMARY KEY (administrator_id, name),
FOREIGN KEY (administrator_id) REFERENCES administrator(id) ON DELETE CASCADE
);
+10
View File
@@ -89,6 +89,16 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
for doc_path in sorted(embedded_docs):
if not (root / doc_path).is_file():
raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration")
# An included Rust source fragment cannot begin with an inner doc comment.
# Catch E0753 here so a source-only release does not ship an unbuildable
# admin module when a compiler is unavailable during packaging.
admin_mod = (root / "src/admin/mod.rs").read_text(encoding="utf-8")
if 'include!("dashboard_presets.rs");' in admin_mod:
fragment = root / "src/admin/dashboard_presets.rs"
if not fragment.is_file():
raise ValueError(f"Missing included module: {fragment.relative_to(root)}")
if fragment.read_text(encoding="utf-8").lstrip().startswith(("//!", "/*!")):
raise ValueError("src/admin/dashboard_presets.rs starts with an inner doc comment; use /// instead (Rust E0753)")
text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.31.9 entry point; extract the complete package, not individual files.")
@@ -4,12 +4,13 @@
Runs a simulated authenticated DOM rather than a live RustProxy service.
"""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
ROUTER = (ROOT / 'src/admin/router.rs').read_text(encoding='utf-8')
@@ -76,7 +77,7 @@ with sync_playwright() as p:
noTrafficEyebrow: !traffic.querySelector('.small-label'),
noTrafficLiveBadge: !traffic.querySelector('.pill.live'),
noConnectionLiveBadge: !connection.querySelector('.pill.live'),
connectionTitleIntact: !!connection.querySelector('.card-heading .small-label[data-i18n="connectionActivity"]'),
connectionTitleIntact: !!connection.querySelector('.card-heading h3.overview-chart-heading[data-i18n="connectionActivity"]'),
peakInsideCard: peakRect.left >= cardRect.left - 1 && peakRect.right <= cardRect.right + 1,
peakInsideHeading: peakRect.top >= headingRect.top - 1 && peakRect.bottom <= headingRect.bottom + 1,
browserOverflow: document.documentElement.scrollWidth - innerWidth,
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""All chart titles and series labels in simulated Chromium (no live Rust server).
Covers Overview, diagnostics, Web Server, Traffic, Cache/Transform, Vhosts,
Streams, and DOM-created chart/legend variants. Preserve localized DOM text.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
CHECK = r'''(settings) => {
const { density, compactBody, theme } = settings;
document.documentElement.dataset.dashboardDensity = density;
document.documentElement.dataset.theme = theme;
document.body.dataset.density = compactBody ? 'compact' : 'comfortable';
const titleSelectors = [
'#panel-overview .overview-wide-chart .card-heading h2',
'#panel-overview .activity-card .card-heading h2',
'#panel-overview :is(.system-card, .connection-card) .overview-chart-heading',
'#panel-overview :is(.stat-chart, .stat-donut) > span:first-child',
'.mini-live-chart > .mini-live-chart-head > span:first-child',
'.traffic-chart-card > h3.subheading',
'.function-live-chart-heading > strong',
'.vhost-chart-head > span:first-child',
];
const monoSelectors = [
'.overview-wide-summary', '.overview-wide-legend',
'.overview-donut-legend', '.www-method-legend', '.stat-chart-legend',
'.mini-chart-legend', '.connection-legend',
'.traffic-protocol-legend', '.traffic-chart-legend',
'.function-live-chart-legend', '.stream-chart-legend',
'.chart-footer', '.vhost-chart-head > :not(:first-child)',
'.mini-live-chart-head > b', '.mini-live-chart-head > .mini-live-chart-actions',
'.network-interface-rate',
'.function-live-chart-summary > b', '.function-live-chart-heading small',
'.function-live-chart-samples',
'#panel-overview .stat-chart > small',
'#panel-overview .overview-wide-chart > .fine',
'#panel-overview .www-top-row', '.traffic-bar-row',
'#panel-overview .system-card .card-heading h2',
'#panel-overview .system-card .card-heading .meta',
'#panel-overview .system-card .memory-line',
'#panel-overview .connection-card .card-heading h2',
];
const legendSelectors = [
'.overview-wide-legend', '.overview-donut-legend', '.www-ring-legend',
'.www-method-legend', '.stat-chart-legend', '.mini-chart-legend',
'.connection-legend', '.traffic-protocol-legend', '.traffic-chart-legend',
'.function-live-chart-legend', '.stream-chart-legend', '.stream-traffic-legend'
];
const legendNodes = [...document.querySelectorAll(legendSelectors.join(','))];
const requestLegend = document.querySelector('[data-dashboard-widget="traffic_chart"] .traffic-protocol-legend');
const referenceLegendSize = getComputedStyle(requestLegend).fontSize;
const titleNodes = Array.from(document.querySelectorAll(titleSelectors.join(',')));
const monoNodes = Array.from(document.querySelectorAll(monoSelectors.join(',')));
const base = document.createElement('div');
base.style.fontFamily = 'var(--mono)';
document.body.append(base);
const mono = getComputedStyle(base).fontFamily;
base.style.fontFamily = 'var(--sans)';
const sans = getComputedStyle(base).fontFamily;
base.remove();
const titleErrors = titleNodes.flatMap(node => {
const css = getComputedStyle(node);
const inOverview = !!node.closest('#panel-overview');
const isSmallCaption = inOverview && !!node.closest('.mini-live-chart-head');
const wantedFont = isSmallCaption ? mono : sans;
const wantedSize = isSmallCaption ? '10px' : '12px';
const wantedCase = inOverview ? 'none' : 'uppercase';
const problems = [];
if (css.fontFamily !== wantedFont) problems.push(`font=${css.fontFamily}`);
if (css.fontSize !== wantedSize) problems.push(`size=${css.fontSize}`);
if (css.textTransform !== wantedCase) problems.push(`case=${css.textTransform}`);
return problems.map(problem => `${node.outerHTML.slice(0, 100)}: ${problem}`);
});
const monoErrors = monoNodes.flatMap(node => {
const all = [node, ...node.querySelectorAll('*')].filter(el => el.tagName.toLowerCase() !== 'svg');
return all.filter(el => getComputedStyle(el).fontFamily !== mono)
.map(el => `${el.outerHTML.slice(0, 100)}: ${getComputedStyle(el).fontFamily}`);
});
const legendErrors = legendNodes.flatMap(root => {
const children = [root, ...root.querySelectorAll('span, b, strong, small, li, em')];
return children.flatMap(node => {
const style = getComputedStyle(node);
const issues = [];
if (style.fontSize !== referenceLegendSize) issues.push(`size=${style.fontSize}`);
if (style.fontFamily !== mono) issues.push(`font=${style.fontFamily}`);
return issues.map(issue => `${node.outerHTML.slice(0, 95)} ${issue}`);
});
});
// Keep localized Overview titles in sentence case; other sections retain
// their independent uppercase chart-heading convention.
const compression = document.querySelector('[data-dashboard-widget="wide_compression_activity"]');
const compressionHeading = compression.querySelector('.card-heading h2');
const compressionLegend = compression.querySelector('.overview-wide-legend');
const originalTranslation = compressionHeading.textContent.trim() === 'Aktywność kompresji';
const legendFonts = [...compressionLegend.querySelectorAll('span, b')]
.map(el => getComputedStyle(el).fontFamily);
const unrelatedHeading = document.querySelector('#panel-traffic .card-heading h2');
const numericReading = document.querySelector('#system-cpu');
return {
counts: {titles: titleNodes.length, mono: monoNodes.length, legends: legendNodes.length},
titleErrors, monoErrors, legendErrors, referenceLegendSize, mono, sans,
originalTranslation, compressionTitleSize: getComputedStyle(compressionHeading).fontSize,
compressionTitleCase: getComputedStyle(compressionHeading).textTransform,
compressionLegendIsMono: legendFonts.every(font => font === mono),
unrelatedTitleSize: getComputedStyle(unrelatedHeading).fontSize,
numericReadingIsMono: getComputedStyle(numericReading).fontFamily === mono,
};
}'''
with sync_playwright() as api:
browser = api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': width, 'height': 960})
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.evaluate('''() => {
// Representative dynamically rendered Vhost/Web Service/Stream charts.
for (const panel of ['panel-vhosts','panel-web-server','panel-streams']) {
document.getElementById(panel).insertAdjacentHTML('beforeend', `
<div class="vhost-chart"><div class="vhost-chart-head">
<span class="small-label">Connection history</span>
<span class="fine">42 req/s</span></div>
<svg viewBox="0 0 300 54"><path d=""></path></svg>
<div class="stream-chart-legend"><span>RX 2 MB/s</span><span>TX 1 MB/s</span></div>
</div>`);
}
const comp = document.querySelector('[data-dashboard-widget="wide_compression_activity"] .card-heading h2');
comp.textContent = 'Aktywność kompresji';
// The traffic renderer inserts these series dynamically.
document.querySelectorAll('.traffic-chart-legend').forEach(root => {
root.innerHTML = `<li><span class="traffic-chart-label">GET requests</span><b>12</b></li>`;
});
const sample = document.createElement('div');
sample.innerHTML = `<div class="www-top-row"><span>php-worker</span><b>11/s</b></div>
<div class="traffic-bar-row"><span class="traffic-bar-label">GET</span><b class="traffic-bar-value">22</b></div>
<div class="network-interface-rate">100 Mb/s</div>`;
document.getElementById('panel-overview').append(sample);
}''')
for density in ('normal','compact','ultra'):
for compact_body in (False, True):
for theme in ('dark', 'light'):
result = page.evaluate(CHECK, {'density': density, 'compactBody': compact_body, 'theme': theme})
description = (width, density, compact_body, theme)
assert result['counts']['titles'] >= 43, (description, result['counts'])
assert result['counts']['mono'] >= 90, (description, result['counts'])
assert not result['titleErrors'], (description, result['titleErrors'][:10])
assert not result['monoErrors'], (description, result['monoErrors'][:10])
assert result['counts']['legends'] >= 39, (description, result['counts'])
assert result['referenceLegendSize'] == '9px', description
assert not result['legendErrors'], (description, result['legendErrors'][:10])
assert result['originalTranslation'], description
assert result['compressionTitleSize'] == '12px', description
assert result['compressionTitleCase'] == 'none', description
assert result['compressionLegendIsMono'], description
assert result['numericReadingIsMono'], description
assert result['unrelatedTitleSize'] != '12px', (description, result['unrelatedTitleSize'])
print(f'PASS all chart families at {width}px (3 dashboard densities, 2 interface densities, 2 themes)')
page.close()
browser.close()
@@ -7,10 +7,11 @@ fake network-interface samples. This does not exercise Rust HTTP/SSE.
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
@@ -1,16 +1,19 @@
#!/usr/bin/env python3
"""0.33.5 CPU plot autoscaling regression with production HTML/CSS/JS.
"""Legacy CPU plot regression: low and high real values on a fixed 0-100% axis.
Uses Chromium with simulated authenticated DOM and status samples, not a live
RustProxy server, SSE stream or real CPU telemetry.
Autoscaling was intentionally replaced by 0.33.5 fixed scaling. This test
retains the original history/reset and label checks using the current scale.
Chromium is simulated, not a live RustProxy server.
"""
from pathlib import Path
from panel_html import render_admin_index
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
@@ -83,6 +86,7 @@ with sync_playwright() as p:
window.fmtBytes = value => `${value} B`;
window.rustProxyOverviewSamples = {getLimit: () => 60, onChange: () => {}};
}''')
page.add_script_tag(content=SCALE_JS)
page.add_script_tag(content=WIDE_JS)
for density in ('compact', 'normal', 'ultra'):
@@ -97,21 +101,21 @@ with sync_playwright() as p:
assert result['svgWidth'] > 100 and abs(result['svgWidth'] - result['drawnWidth']) < 2, result
assert result['legendInCard'] and result['overflow'] <= 2, (viewport, density, result)
# Measured 0.5-0.8% must be visible, not flattened by a 100% ceiling.
# Measured 0.5-0.8% stay numerically visible without axis auto-expansion.
first = render_cpu(page, 1, 0.5, 0.2)
second = render_cpu(page, 1, 0.8, 0.6)
assert first['system']['label'] == '0.5%' and second['process']['label'] == '0.6%'
assert ys(second['system']['path']) == [77, 41], (viewport, second)
assert ys(second['process']['path']) == [113, 65], (viewport, second)
assert ys(second['system']['path']) == [136, 136], (viewport, second)
assert ys(second['process']['path']) == [137, 136], (viewport, second)
# The peak follows observed history and also works above 100% (multi-core).
# Multi-core values display accurately but clip at the fixed 100% ceiling.
third = render_cpu(page, 1, 3.4, 2.2)
assert ys(third['system']['path'])[-1] == 17, (viewport, third)
assert ys(third['process']['path'])[-1] == 59, (viewport, third)
assert ys(third['system']['path'])[-1] == 133, (viewport, third)
assert ys(third['process']['path'])[-1] == 134, (viewport, third)
high = render_cpu(page, 1, 250, 123)
assert high['system']['label'] == '250.0%'
assert ys(high['system']['path'])[-1] == 17, (viewport, high)
assert 17 <= ys(high['process']['path'])[-1] < 137, (viewport, high)
assert ys(high['process']['path'])[-1] == 17, (viewport, high)
# Revision changes reset the history. Zero stays at the plot baseline.
idle = render_cpu(page, 2, 0, 0)
@@ -119,6 +123,6 @@ with sync_playwright() as p:
missing = render_cpu(page, 3, None, None)
assert missing['system']['path'] == '' and missing['process']['label'] == '-'
assert not errors, (viewport, errors)
print(f'PASS CPU auto Y-scale and standard plot height at {viewport}px (all densities)')
print(f'PASS legacy CPU labels/history on fixed Y-scale at {viewport}px (all densities)')
page.close()
browser.close()
@@ -5,11 +5,12 @@ Uses Chromium with simulated authenticated DOM and status samples, not a live
RustProxy server, SSE stream or real CPU telemetry.
"""
from pathlib import Path
from panel_html import render_admin_index
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
@@ -7,6 +7,7 @@ not a Rust server integration test.
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
@@ -41,7 +42,7 @@ assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_wi
assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600
assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12
HTML = (ROOT / 'src/admin/web/index.html').read_text()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
BUNDLE = app_bundle()
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
@@ -94,6 +95,8 @@ with sync_playwright() as p:
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); };
appConfirm = async () => true;
return {
order: ADMIN_DASHBOARD_WIDGETS,
hidden: ADMIN_DEFAULT_HIDDEN_WIDGETS,
@@ -166,13 +169,12 @@ with sync_playwright() as p:
window.rustProxyStreamDashboard.render(status);
}''')
assert_visible(page, ['backend_health'], ['backend_traffic'])
# Restore defaults in the modal after a custom layout was saved.
# Restore factory layout through the new named-presets modal.
page.locator('#overview-layout-open').evaluate('(node) => node.hidden = false')
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open")
page.locator('#overview-layout-default').click()
page.locator('#overview-layout-save').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions', 'www_status'])
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
assert page.evaluate('window.__savedDashboard.dashboard_hidden_widgets') == PRESET['dashboard_hidden_widgets']
@@ -1,3 +1,4 @@
import { renderAdminIndex } from './panel_html.mjs';
// Deterministic Overview widget regression; simulated DOM/status, no running API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -6,7 +7,7 @@ import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const html = fs.readFileSync(path.join(root, 'src/admin/web/index.html'), 'utf8');
const html = renderAdminIndex(root);
const registry = JSON.parse(fs.readFileSync(path.join(root, 'src/admin/web/dashboard-widgets.json'), 'utf8'));
const lang = JSON.parse(fs.readFileSync(path.join(root, 'langs/en_US.json'), 'utf8')).translations;
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
@@ -1,16 +1,17 @@
#!/usr/bin/env python3
"""Regression: Overview visual layout editor fits without horizontal overflow.
"""Regression: named Overview presets, responsive modal and browser actions.
Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences.
Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences/API.
This is browser layout testing, not Rust server acceptance.
"""
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
PACKS = {
@@ -52,7 +53,7 @@ def load(page):
defaultLanguage = 'en_US';
language = 'pl_pl';
document.documentElement.lang = 'pl-PL';
document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
}''', PACKS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
@@ -72,98 +73,104 @@ def load(page):
applyAdminPreferences(window.__savedDashboard);
language = 'pl_pl';
document.documentElement.lang = 'pl-PL';
document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
}''')
def geometry(page):
return page.evaluate('''() => {
const d = document.getElementById('overview-layout-dialog');
const grid = document.getElementById('overview-layout-visible');
const rect = node => node.getBoundingClientRect();
const tiles = Array.from(grid.querySelectorAll('.overview-layout-tile'));
const overflows = [];
for (const tile of tiles) {
const r = rect(tile);
const toolbar = tile.querySelector('.overview-layout-tile-toolbar');
if (tile.scrollWidth > tile.clientWidth + 2)
overflows.push([tile.dataset.previewWidget, 'tile-scroll', tile.clientWidth, tile.scrollWidth]);
for (const button of toolbar.querySelectorAll('button')) {
const b = rect(button);
if (b.left < r.left - 2 || b.right > r.right + 2)
overflows.push([tile.dataset.previewWidget, 'button-outside', button.className, b.left, b.right, r.left, r.right]);
}
const buttons = Array.from(toolbar.querySelectorAll('button'));
for (let i = 0; i < buttons.length; i++) {
const a = rect(buttons[i]);
for (let j = i + 1; j < buttons.length; j++) {
const b = rect(buttons[j]);
if (a.right - b.left > 1 && b.right - a.left > 1 && a.bottom - b.top > 1 && b.bottom - a.top > 1)
overflows.push([tile.dataset.previewWidget, 'overlapping-buttons', buttons[i].className, buttons[j].className]);
}
}
}
const hiddenList = document.getElementById('overview-layout-hidden');
for (const row of hiddenList.querySelectorAll('.overview-layout-hidden-item')) {
const r = rect(row), button = row.querySelector('button');
const b = rect(button);
if (b.right > r.right + 2 || b.left < r.left - 2)
overflows.push(['hidden card', 'button-outside', b.left, b.right, r.left, r.right]);
}
const panelPreview = grid.querySelector('[data-preview-widget="network_diagnostics"]');
const dialogRect = rect(d);
return {
cols: getComputedStyle(grid).gridTemplateColumns.split(' ').length,
panelWidth: panelPreview ? rect(panelPreview).width : null,
dialogWidth: d.clientWidth, dialogScrollWidth: d.scrollWidth,
gridWidth: grid.clientWidth, gridScrollWidth: grid.scrollWidth,
dialogLeft:dialogRect.left, dialogRight:dialogRect.right,
visibleCount:tiles.length, overflows,
firstTileWidth:rect(tiles[0]).width,
};
}''')
# Regression for the replacement named-presets modal, with a simulated SQLite API.
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 550, 620, 800, 900, 960, 1158, 1440, 1920):
context = browser.new_context(viewport={'width': width, 'height': 823})
for width in (320, 375, 800, 1440):
context = browser.new_context(viewport={'width': width, 'height': 880})
page = context.new_page()
errors = []
page.on('pageerror', lambda err: errors.append(str(err)))
page.on('pageerror', lambda error: errors.append(str(error)))
load(page)
page.evaluate('''() => {
const saved = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
window.__savedDashboard = saved;
window.__presets = new Map();
window.rustProxyPreferences.load = async () => window.__savedDashboard;
window.rustProxyPreferences.current = () => window.__savedDashboard;
window.rustProxyPreferences.update = async patch => {
window.__savedDashboard = {...window.__savedDashboard, ...patch};
applyAdminPreferences(window.__savedDashboard);
return window.__savedDashboard;
};
api = async (path, options={}) => {
const method = options.method || 'GET';
if (path === '/api/dashboard-presets' && method === 'GET')
return [...window.__presets.entries()].map(([name, data]) => ({ name, ...structuredClone(data) }));
if (path.startsWith('/api/dashboard-presets/')) {
const name = decodeURIComponent(path.split('/').slice(3).join('/'));
if (method === 'PUT') { window.__presets.set(name, JSON.parse(options.body)); return {}; }
if (method === 'DELETE') { window.__presets.delete(name); return {}; }
if (method === 'PATCH') {
const changed = JSON.parse(options.body).name;
if (window.__presets.has(changed)) throw new Error('already exists');
window.__presets.set(changed, window.__presets.get(name));
window.__presets.delete(name);
return {};
}
}
throw new Error('unexpected API: ' + method + ' ' + path);
};
appConfirm = async () => true;
applyAdminPreferences(window.__savedDashboard);
document.getElementById('overview-layout-open').hidden = false;
}''')
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open")
check = geometry(page)
expected = 1 if width <= 620 else 2 if width <= 900 else 3
assert check['cols'] == expected, (width, check)
assert check['visibleCount'] > 20
assert check['panelWidth'] is not None
assert abs(check['firstTileWidth'] - check['panelWidth']) < 3, (width, check)
assert check['dialogLeft'] >= -1 and check['dialogRight'] <= width + 1, (width, check)
assert check['dialogScrollWidth'] <= check['dialogWidth'] + 2, (width, check)
assert check['gridScrollWidth'] <= check['gridWidth'] + 2, (width, check)
assert not check['overflows'], (width, check['overflows'][:20])
tile = '#overview-layout-visible [data-preview-widget="http_protocol"]'
before = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width')
page.locator(tile + ' .overview-layout-widen').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '3'
after = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width')
assert abs(before - after) < 2, (width, before, after)
assert not geometry(page)['overflows']
page.locator(tile + ' .overview-layout-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '2'
if width == 1158:
page.screenshot(path=str(ROOT.parent / 'modal_preview_1158.png'))
if width == 375:
page.screenshot(path=str(ROOT.parent / 'modal_preview_375.png'))
page.locator(tile + ' .overview-layout-hide').click()
assert page.locator(tile).count() == 0
assert not geometry(page)['overflows']
page.locator('#overview-layout-cancel').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
assert page.locator('#overview-presets-list .overview-presets-row').count() == 0
widths = page.evaluate('''() => {
const d = document.getElementById('overview-presets-dialog');
const r = d.getBoundingClientRect();
return {left:r.left,right:r.right,overflow:d.scrollWidth - d.clientWidth};
}''')
assert widths['left'] >= -1 and widths['right'] <= width+1 and widths['overflow'] <= 2, (width, widths)
# Persist the factory arrangement as a user preset.
page.locator('#overview-presets-name').fill('Daily')
page.locator('#overview-presets-save').click()
page.wait_for_function("window.__presets.has('Daily')")
assert page.locator('#overview-presets-list .overview-presets-row').count() == 1
assert page.evaluate("window.__presets.get('Daily').overview_samples") == 600
# Create another view, with customized widths, density and history.
page.evaluate('''async () => {
await window.rustProxyPreferences.update({
dashboard_hidden_widgets:['requests'], dashboard_widget_sizes:{requests:3},
dashboard_density:'ultra',
});
window.rustProxyOverviewSamples.setLimit(120, true);
}''')
page.locator('#overview-presets-name').fill('Detailed')
page.locator('#overview-presets-save').click()
page.wait_for_function("window.__presets.has('Detailed')")
assert page.evaluate('''() => {
const p=window.__presets.get('Detailed');
return [p.dashboard_widget_sizes.requests,p.dashboard_density,p.overview_samples];
}''') == [3,'ultra',120]
# Named load must restore full layout and history.
page.locator('.overview-presets-row').filter(has_text='Daily').locator('[data-preset-action="dashboardPresetsLoad"]').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert page.evaluate('''() => [window.__savedDashboard.dashboard_density,
window.__savedDashboard.dashboard_widget_sizes,
window.rustProxyOverviewSamples.getLimit()]''') == ['compact',None,600]
# Rename and delete remain administrator-scoped operations.
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-name').fill('Nightly')
page.locator('.overview-presets-row').filter(has_text='Detailed').locator('[data-preset-action="dashboardPresetsRename"]').click()
page.wait_for_function("window.__presets.has('Nightly')")
assert not page.evaluate("window.__presets.has('Detailed')")
page.locator('.overview-presets-row').filter(has_text='Nightly').locator('[data-preset-action="dashboardPresetsDelete"]').click()
page.wait_for_function("!window.__presets.has('Nightly')")
assert page.evaluate("window.__presets.has('Daily')")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert page.evaluate("window.__savedDashboard.dashboard_widgets") == json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())['dashboard_widgets']
assert not errors, (width, errors)
print('PASS named Dashboard presets save/load/rename/delete/factory and responsive modal:', width)
context.close()
print('PASS visual modal grid, no overlap/scroll, width/hide/cancel:', width)
browser.close()
print('PASS 0.33.3 modal regression (simulated API)')
print('PASS Overview preset modal (simulated SQLite API; no running Rust server)')
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""All Overview widget labels and small values in simulated Chromium.
This is a DOM/CSS regression test, not a live RustProxy integration test.
"""
from pathlib import Path
import json
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
PACKS = {name: json.loads((ROOT / f'langs/{name}.json').read_text(encoding='utf-8'))['translations']
for name in ('en_US', 'pl_pl')}
CHECK = r'''({density, lang, translations}) => {
document.documentElement.dataset.dashboardDensity = density;
document.documentElement.lang = lang === 'pl_pl' ? 'pl-PL' : 'en-US';
const grid = document.getElementById('dashboard-overview-grid');
grid.querySelectorAll('[data-i18n]').forEach(node => {
if (Object.hasOwn(translations, node.dataset.i18n)) node.textContent = translations[node.dataset.i18n];
});
const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget'));
const font = value => getComputedStyle(value).fontFamily;
const probe = document.createElement('div');
grid.append(probe);
probe.style.fontFamily = 'var(--sans)'; const sans = font(probe);
probe.style.fontFamily = 'var(--mono)'; const mono = font(probe);
probe.remove();
const titleErrors = [];
const titles = [];
const labelErrors = [];
const numericErrors = [];
for (const card of cards) {
let title;
if (card.classList.contains('stat')) title = card.querySelector(':scope > span:first-child');
else if (card.classList.contains('system-card') || card.classList.contains('connection-card'))
title = card.querySelector('.overview-chart-heading');
else if (card.querySelector('.mini-heading > h3')) title = card.querySelector('.mini-heading > h3');
else title = card.querySelector('.card-heading h2');
if (!title) { titleErrors.push(`${card.dataset.dashboardWidget}: missing title`); continue; }
const style = getComputedStyle(title);
titles.push(title);
if (style.fontFamily !== sans || style.fontSize !== '12px' || style.textTransform !== 'none')
titleErrors.push(`${card.dataset.dashboardWidget}: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`);
if (title.textContent.trim() === title.textContent.trim().toLocaleUpperCase('pl') &&
/[a-ząćęłńóśżź]{4}/i.test(title.textContent) &&
!/^(?:HIT|MISS|HTTP|CPU|RAM|WWW|RX|TX)/.test(title.textContent.trim()))
titleErrors.push(`${card.dataset.dashboardWidget}: all-uppercase title ${title.textContent.trim()}`);
for (const node of card.querySelectorAll(':scope > small, .mini-metrics dt, .mini-metrics dd, .mini-live-chart-head, .memory-line, .chart-footer, .meta, .small-label')) {
if (font(node) !== mono) labelErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`);
}
const readings = card.classList.contains('stat')
? [...card.querySelectorAll(':scope > strong'), ...card.querySelectorAll(':scope > small')]
: [...card.querySelectorAll('.mini-metrics dd')];
for (const node of readings) if (font(node) !== mono)
numericErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`);
}
const nested = [...grid.querySelectorAll('.mini-live-chart > .mini-live-chart-head > span:first-child')];
for (const node of nested) {
const style = getComputedStyle(node);
if (style.fontFamily !== mono || style.fontSize !== '10px' || style.textTransform !== 'none')
labelErrors.push(`mini-live-chart: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`);
}
const systemNumeric = getComputedStyle(document.querySelector('#system-cpu'));
return {
cardCount: cards.length, titleCount: titles.length, secondaryCount: nested.length,
titleErrors, labelErrors, numericErrors,
systemNumericMono: systemNumeric.fontFamily === mono,
cpuLabel: document.querySelector('[data-dashboard-widget="system_chart"] .overview-chart-heading').textContent,
connectionsLabel: document.querySelector('[data-dashboard-widget="connection_chart"] .overview-chart-heading').textContent,
memoryLabel: document.querySelector('[data-dashboard-widget="memory"] > span:first-child').textContent,
backendLabel: document.querySelector('[data-dashboard-widget="backend_health"] > span:first-child').textContent,
diagnosticsTitle: document.querySelector('[data-dashboard-widget="process_diagnostics"] .mini-heading h3').textContent,
};
}'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': width, 'height': 900})
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
for density in ('normal', 'compact', 'ultra'):
for lang in ('en_US', 'pl_pl'):
result = page.evaluate(CHECK, {'density': density, 'lang': lang, 'translations': PACKS[lang]})
descriptor = (width, density, lang)
assert result['cardCount'] == result['titleCount'] >= 75, (descriptor, result)
assert result['secondaryCount'] >= 6, descriptor
assert not result['titleErrors'], (descriptor, result['titleErrors'][:10])
assert not result['labelErrors'], (descriptor, result['labelErrors'][:10])
assert not result['numericErrors'], (descriptor, result['numericErrors'][:10])
assert result['systemNumericMono'], descriptor
expected = ('System load', 'Connection activity') if lang == 'en_US' else ('Obciążenie systemu', 'Aktywność połączeń')
assert (result['cpuLabel'], result['connectionsLabel']) == expected, (descriptor, result)
assert result['memoryLabel'] and result['backendLabel'] and result['diagnosticsTitle'], descriptor
print(f'PASS all Overview titles and small readings at {width}px (3 densities, 2 languages)')
page.close()
browser.close()
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Offline SQLite regression for additive admin preset migration and 16-slot upsert."""
from pathlib import Path
import sqlite3
ROOT = Path(__file__).resolve().parents[2]
all_scripts = sorted((ROOT / 'migrations').glob('*.sql'))
assert [p.name for p in all_scripts][-1] == '0010_dashboard_presets.sql'
con = sqlite3.connect(':memory:')
con.execute('PRAGMA foreign_keys = ON')
for _ in range(2):
for script in all_scripts:
con.executescript(script.read_text())
con.execute("INSERT INTO administrator(id,username,password_hash,generation,created_at) VALUES(1,'owner','hash',1,0)")
con.execute("INSERT INTO admin_preferences(administrator_id,document) VALUES(1,'{}')")
upsert = '''
INSERT INTO dashboard_presets(administrator_id, name, document, updated_at)
SELECT a.id, ?, ?, ? FROM administrator a WHERE a.id = 1 AND a.username = ?
AND ((SELECT count(*) FROM dashboard_presets WHERE administrator_id = a.id) < 16
OR EXISTS(SELECT 1 FROM dashboard_presets WHERE administrator_id = a.id AND name = ?))
ON CONFLICT(administrator_id, name) DO UPDATE SET document = excluded.document, updated_at = excluded.updated_at
'''
for i in range(16):
name = f'Preset {i}'
assert con.execute(upsert, (name,'{}',i,'owner',name)).rowcount == 1
assert con.execute(upsert, ('Overflow','{}',16,'owner','Overflow')).rowcount == 0
assert con.execute(upsert, ('Preset 0','{"changed":true}',17,'owner','Preset 0')).rowcount == 1
assert con.execute(upsert, ('Other account','{}',18,'intruder','Other account')).rowcount == 0
assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 16
assert con.execute('SELECT document FROM dashboard_presets WHERE name="Preset 0"').fetchone()[0] == '{"changed":true}'
assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Preset 1','Preset 0')).rowcount == 0
assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Renamed','Preset 0')).rowcount == 1
con.execute('DELETE FROM administrator WHERE id=1')
assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 0
assert con.execute('SELECT count(*) FROM admin_preferences').fetchone()[0] == 0
print('PASS SQLite migrations repeated, named-preset CRUD, cap, account ownership and cascade')
@@ -7,6 +7,7 @@ not a Rust server integration test.
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
@@ -41,7 +42,7 @@ assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_wi
assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600
assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12
HTML = (ROOT / 'src/admin/web/index.html').read_text()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
BUNDLE = app_bundle()
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
@@ -96,6 +97,8 @@ with sync_playwright() as p:
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); };
appConfirm = async () => true;
}''')
# Unchanged previous accounts preserve historic grouped rows.
assert page.evaluate('''() => ({
@@ -142,25 +145,24 @@ with sync_playwright() as p:
assert page.locator(selector).get_attribute('data-dashboard-span') == '2'
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '6'
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
# In a fresh draft, the modal editor also provides width controls.
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open")
tile = '#overview-layout-visible [data-preview-widget="wide_transform_activity"]'
page.locator(tile + ' .overview-layout-narrow').click()
# The actual unlocked dashboard remains the only layout editor.
page.locator('#overview-layout-unlock').click()
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
tile = '[data-dashboard-widget="wide_transform_activity"]'
page.locator(tile + ' .overview-inline-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '4'
page.locator(tile + ' .overview-layout-narrow').click()
page.locator(tile + ' .overview-inline-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '3'
# Modal drag-and-drop also works across former small/wide size groups.
page.evaluate('''() => {
const from = document.querySelector('[data-preview-widget="wide_transform_activity"]');
const to = document.querySelector('[data-preview-widget="requests"]');
const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip');
const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip');
const transfer = new DataTransfer();
from.dispatchEvent(new DragEvent('dragstart', {bubbles:true,cancelable:true,dataTransfer:transfer}));
to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer}));
to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer,clientX:0}));
from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
}''')
page.locator('#overview-layout-save').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
page.locator('#overview-inline-save').click()
page.wait_for_function('!window.rustProxyInlineLayout.isEditing()')
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes.wide_transform_activity') == 3
assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity'
# Reload account settings: new card width persists.
@@ -185,10 +187,9 @@ with sync_playwright() as p:
assert all(b['w'] > 150 for b in data['boxes']), (width,data)
assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2, (width, data)
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open")
page.locator('#overview-layout-default').click()
page.locator('#overview-layout-save').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
assert page.evaluate('document.getElementById("dashboard-overview-grid").dataset.overviewLayoutMode') == 'legacy'
@@ -1,3 +1,4 @@
import { renderAdminIndex } from './panel_html.mjs';
// Simulated DOM/status regression: no holes or artificial zeros after tab resume.
// This intentionally does not exercise the running Rust server or real browser.
import assert from 'node:assert/strict';
@@ -8,7 +9,7 @@ import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
const html = code('index.html');
const html = renderAdminIndex(root);
class Element {
constructor() { this.attributes = new Map(); this.style = {}; this.textContent = ''; this.children = []; this.hidden = false; }
+2 -1
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
"""0.33.6 plot size/scale checks in simulated Chromium, not a running RustProxy."""
from pathlib import Path
from panel_html import render_admin_index
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALES = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
RATES = (ROOT / 'src/admin/web/app/rate-sampling.js').read_text(encoding='utf-8')
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Simulated Chromium regression for unified subdued metric/chart titles.
Tests computed CSS in the assembled panel DOM; not a live Rust server.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
CHECK = r'''({theme, density}) => {
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.dashboardDensity = density;
const grid = document.getElementById('dashboard-overview-grid');
const probe = document.createElement('span');
probe.style.color = 'var(--muted)';
document.body.append(probe);
const expected = getComputedStyle(probe).color;
probe.style.color = 'var(--text)';
const primary = getComputedStyle(probe).color;
probe.remove();
const color = node => getComputedStyle(node).color;
const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget'));
const titles = cards.map(card => {
let node;
if (card.classList.contains('stat')) node = card.querySelector(':scope > span:first-child');
else if (card.matches('.system-card,.connection-card')) node = card.querySelector('.overview-chart-heading');
else node = card.querySelector('.mini-heading > h3') || card.querySelector('.card-heading h2');
return [card.dataset.dashboardWidget, node];
});
const otherSelectors = [
'#panel-cache .stats-grid .stat > span:first-child',
'#panel-transform .stats-grid .stat > span:first-child',
'#panel-traffic .stat > span:first-child',
'#panel-cache > .card .card-heading h2',
'#panel-transform > .card .card-heading h2',
'.traffic-chart-card > h3.subheading',
'.function-live-chart-heading > strong',
'.mini-live-chart-head > span:first-child',
'.vhost-chart-head > span:first-child',
'.vhost-live-metric > span',
];
// Include dynamically rendered Vhost/Streams chart and Vhost metric titles.
const container = document.getElementById('panel-vhosts');
const fixture = document.createElement('div');
fixture.innerHTML = `<div class="vhost-chart"><div class="vhost-chart-head">
<span class="small-label">Connections history</span><span class="fine">42/s</span>
</div></div><div class="vhost-live-metric"><span>Requests</span><strong>42</strong></div>`;
container.append(fixture);
const otherTitles = otherSelectors.flatMap(selector => [...document.querySelectorAll(selector)]
.map(node => [selector, node]));
const errors = [...titles, ...otherTitles].flatMap(([name, node]) => {
if (!node) return [`${name} missing title`];
if (color(node) !== expected) return [`${name}: ${color(node)} != ${expected} (${node.textContent.trim().slice(0, 35)})`];
return [];
});
const numeric = [
document.querySelector('#system-cpu'),
document.querySelector('#panel-overview .system-card .card-heading h2'),
document.querySelector('#panel-overview .connection-card .card-heading h2'),
document.querySelector('[data-dashboard-widget="requests"] > strong'),
fixture.querySelector('.vhost-live-metric strong'),
].map(node => ({node: node && node.outerHTML.slice(0, 80), color: node && color(node)}));
fixture.remove();
return {expected, primary, count: titles.length, otherCount: otherTitles.length,
errors, numeric};
}'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 768, 1440):
page = browser.new_page(viewport={'width': width, 'height': 900})
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
for theme in ('dark', 'light', 'ultra_dark'):
for density in ('normal', 'compact', 'ultra'):
result = page.evaluate(CHECK, {'theme': theme, 'density': density})
descriptor = width, theme, density
assert result['count'] == 78, (descriptor, result['count'])
assert result['otherCount'] >= 35, (descriptor, result['otherCount'])
assert result['primary'] != result['expected'], (descriptor, result)
assert not result['errors'], (descriptor, result['errors'][:25])
assert all(item['color'] == result['primary'] for item in result['numeric']), (descriptor, result['numeric'])
print(f'PASS muted titles, primary numeric readings at {width}px (3 themes, 3 densities)')
page.close()
browser.close()
print('PASS subdued metric/chart title CSS (simulated Chromium, no Rust runtime)')
@@ -1,3 +1,4 @@
import { renderAdminIndex } from './panel_html.mjs';
// Simulated browser/status contract: this does not exercise the Rust server.
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const html = read('src/admin/web/index.html');
const html = renderAdminIndex(root);
const controllerSource = read('src/admin/web/app/overview-samples.js');
const store = new Map();
+22
View File
@@ -0,0 +1,22 @@
import fs from 'node:fs';
import path from 'node:path';
// Same four compile-time substitutions as src/admin/public.rs.
export function renderAdminIndex(root) {
const web = path.join(root, 'src/admin/web');
let html = fs.readFileSync(path.join(web, 'index.html'), 'utf8');
const metrics = fs.readFileSync(path.join(web, 'partials/overview-metrics.html'), 'utf8')
.split('<!-- RP_METRICS_SPLIT -->');
const charts = fs.readFileSync(path.join(web, 'partials/overview-charts.html'), 'utf8')
.split('<!-- RP_CHARTS_SPLIT -->');
if (metrics.length !== 2 || charts.length !== 2) throw new Error('Invalid Overview partials');
for (const [marker, text] of [
['METRICS_MAIN', metrics[0]], ['CHARTS_MAIN', charts[0]],
['METRICS_WEB', metrics[1]], ['CHARTS_WEB', charts[1]],
]) {
const token = `<!-- RP_${marker} -->`;
if (!html.includes(token)) throw new Error(`Missing ${token}`);
html = html.replace(token, text);
}
return html;
}
+18
View File
@@ -0,0 +1,18 @@
"""Return the actual assembled embedded Overview HTML to simulated-browser tests."""
from pathlib import Path
def render_admin_index(root: Path) -> str:
web = root / 'src/admin/web'
html = (web / 'index.html').read_text(encoding='utf-8')
metrics = (web / 'partials/overview-metrics.html').read_text(encoding='utf-8').split('<!-- RP_METRICS_SPLIT -->')
charts = (web / 'partials/overview-charts.html').read_text(encoding='utf-8').split('<!-- RP_CHARTS_SPLIT -->')
assert len(metrics) == len(charts) == 2
for key, fragment in (
('METRICS_MAIN', metrics[0]), ('CHARTS_MAIN', charts[0]),
('METRICS_WEB', metrics[1]), ('CHARTS_WEB', charts[1]),
):
marker = '<!-- RP_' + key + ' -->'
assert html.count(marker) == 1
html = html.replace(marker, fragment)
return html
@@ -7,6 +7,7 @@ from __future__ import annotations
from io import BytesIO
from pathlib import Path
from panel_html import render_admin_index
import re
from PIL import Image
@@ -14,7 +15,7 @@ from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
APP_HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
APP_HTML = render_admin_index(ROOT)
assert re.search(r'<div class="overview-meter system-memory-meter" id="memory-meter"', APP_HTML)
assert 'id="memory-meter-fill"' in APP_HTML
CORNERS = {'default': '3px', 'square': '0px', 'subtle': '2px', 'rounded': '4px', 'extra': '999px'}
@@ -5,10 +5,11 @@ Uses actual production HTML/CSS in headless Chromium, not a live RustProxy
server or authenticated preference/session roundtrip.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
THEMES = ('dark', 'ultra_dark', 'light', 'cream')
PALETTES = ('default', 'soft', 'technical', 'high_contrast', 'neon', 'monochrome', 'warm', 'cool')
@@ -4,6 +4,7 @@
This is a simulated authenticated DOM/status test, not a Rust runtime integration test.
"""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
from playwright.sync_api import sync_playwright
@@ -29,7 +30,7 @@ def bundle_for(section):
result.append(json.loads(line[:-1]))
return '\n'.join(result)
html = (ROOT / 'src/admin/web/index.html').read_text()
html = render_admin_index(ROOT)
css = (ROOT / 'src/admin/web/style.css').read_text()
js = bundle_for('admin_app_javascript')
with sync_playwright() as playwright:
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Simulated Chromium Vhost backend quick-view, without a Rust server.
Verify both backend triggers, the shared click-to-open upstream H2 protocol
popover without native title flicker, and the two Cache card margins.
Exercise live updates, safe text, keyboard/outside dismissal and mobile sizing.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
import json
import re
import time
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
PACKS = {lang: {'id': lang, **json.loads((ROOT / 'langs' / f'{lang}.json').read_text())}
for lang in ('en_US', 'pl_pl')}
def app_bundle():
found = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert found
code = ROUTER[found.end():]
code = code[:re.search(r'\]\s*\.join\("\\n"\)', code).start()]
fragments = []
for line in code.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
fragments.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
fragments.append('(() => {\n' + '\n'.join(
(ROOT / 'src/admin/web/vhosts' / f'{name}.js').read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
fragments.append(json.loads(line[:-1]))
return '\n'.join(fragments)
CONFIG = {
'vhosts': [{
'id': 'api-one', 'enabled': True, 'source': {'type': 'reverse_proxy'},
'frontend': {'hosts': ['api.example.test'], 'listener_ids': []},
'backends': [
{'id': 'origin-http', 'url': 'http://10.12.0.5:8080/api', 'protocol': 'http2', 'enabled': True},
{'id': '<origin-dns>', 'url': 'https://hiddenuser:secret@backend.example.test:8443/path', 'protocol': 'auto', 'enabled': True},
]
}, {'id': 'static-only', 'enabled': True,
'source': {'type': 'web_server', 'web_service_id': 'static-test'},
'frontend': {'hosts': ['static.example.test'], 'listener_ids': []}, 'backends': []}],
'listeners': {'endpoints': []}, 'web_services': [], 'acl_profiles': [],
}
def snapshot(first=200):
return {'backend_pools': [{
'id': 'api-one', 'health_mode': 'on',
'members': [
{'id': 'origin-http', 'status': 'up', 'last_status': first,
'requests': 12, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0},
{'id': '<origin-dns>', 'status': 'down', 'last_status': 0,
'requests': 0, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0},
]
}], 'vhost_metrics': {}}
def protocol_snapshot(h2_count):
status = snapshot()
status['backend_pools'][0]['members'][0].update({
'upstream_protocol_observed': 'h2',
'upstream_protocol_last_seen_ms': int(time.time() * 1000),
'upstream_protocol_http1_total': 3,
'upstream_protocol_http2_total': h2_count,
})
return status
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440):
page = browser.new_page(viewport={'width': width, 'height': 820})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=app_bundle())
page.evaluate('''([config, status, packs]) => {
languagePacks = new Map(Object.entries(packs));
defaultLanguage = 'en_US'; language = 'pl_pl';
document.documentElement.lang = 'pl-PL';
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('login').hidden = true;
document.getElementById('workspace').hidden = false;
document.getElementById('panel-vhosts').hidden = false;
currentPanel = 'vhosts';
envelope = {revision: 12, config};
latestStatus = status;
window.rustProxyVhosts.render();
}''', [CONFIG, snapshot(), PACKS])
vhost = page.locator('[data-vhost-card="api-one"]')
backend = vhost.locator('.vhost-detail.vhost-backend-trigger')
health = vhost.locator('.vhost-live-metric.vhost-backend-trigger')
assert backend.count() == health.count() == 1, width
assert backend.get_attribute('aria-expanded') == 'false'
assert backend.get_attribute('aria-haspopup') == 'dialog'
# Clickable backend/health fields must match their non-button peers,
# including for user-selected interface corner styles.
shapes = page.evaluate('''() => {
const root = document.querySelector('[data-vhost-card="api-one"]');
const peers = [
[root.querySelector('.vhost-detail.vhost-backend-trigger'), root.querySelector('.vhost-detail:not(.vhost-backend-trigger)')],
[root.querySelector('.vhost-live-metric.vhost-backend-trigger'), root.querySelector('.vhost-live-metric:not(.vhost-backend-trigger)')]
];
const checks = ['square', 'subtle', 'rounded', 'extra'].map(corner => {
document.documentElement.dataset.cornerStyle = corner;
return {corner, tiles: peers.map(([trigger, peer]) => {
const left = getComputedStyle(trigger), right = getComputedStyle(peer);
return {radius: left.borderRadius, peerRadius: right.borderRadius,
padding: left.padding, peerPadding: right.padding,
font: left.fontFamily, peerFont: right.fontFamily};
})};
});
document.documentElement.removeAttribute('data-corner-style');
return checks;
}''')
for shape in shapes:
for tile in shape['tiles']:
assert tile['radius'] == tile['peerRadius'] == '0px', (width, shape)
assert tile['padding'] == tile['peerPadding'], (width, shape)
assert tile['font'] == tile['peerFont'], (width, shape)
backend.click()
popup = page.locator('#vhost-backend-popup')
assert popup.count() == 1, width
assert backend.get_attribute('aria-expanded') == 'true'
assert popup.get_attribute('role') == 'dialog'
# The anchored popup is a surface, not a permanently rounded tooltip.
# It must follow live interface corner preference changes without
# dismissing/recreating the open backend (or shared H2) popup.
corners = page.evaluate('''() => {
const node = document.getElementById('vhost-backend-popup');
const saved = document.documentElement.getAttribute('data-corner-style');
const out = {};
for (const style of ['default', 'square', 'subtle', 'rounded', 'extra']) {
document.documentElement.dataset.cornerStyle = style;
out[style] = {
popup: getComputedStyle(node).borderRadius,
row: getComputedStyle(node.querySelector('.vhost-backend-popup-row')).borderRadius,
close: getComputedStyle(node.querySelector('.vhost-backend-popup-close')).borderRadius,
reference: getComputedStyle(document.querySelector('[data-vhost-card="api-one"]')).borderRadius,
sameNode: node === document.getElementById('vhost-backend-popup'),
};
}
if (saved === null) document.documentElement.removeAttribute('data-corner-style');
else document.documentElement.dataset.cornerStyle = saved;
return out;
}''')
expected_radii = {'default': ('0px', '0px'), 'square': ('0px', '0px'),
'subtle': ('5px', '4px'), 'rounded': ('12px', '8px'),
'extra': ('18px', '12px')}
for style, (surface, control) in expected_radii.items():
actual = corners[style]
assert actual == {'popup': surface, 'row': surface, 'close': control,
'reference': surface, 'sameNode': True}, (width, style, actual)
values = popup.evaluate('''node => ({
cards: node.querySelectorAll('.vhost-backend-popup-row').length,
text: node.textContent, html: node.innerHTML,
box: (() => { const r = node.getBoundingClientRect();
return {left: r.left, right: r.right, top:r.top, bottom:r.bottom}; })(),
})''')
assert values['cards'] == 2, (width, values)
assert '10.12.0.5:8080' in values['text'] and 'HTTP 200' in values['text'], (width, values)
assert 'backend.example.test:8443' in values['text'], width
assert 'secret' not in values['text'], width
assert '<origin-dns>' in values['text'] and '&lt;origin-dns&gt;' in values['html'], width
assert 'Brak odpowiedzi HTTP' in values['text'], width
assert 'HTTPS · AUTO' in values['text'], width
assert values['box']['left'] >= -1 and values['box']['right'] <= width + 1, (width, values['box'])
assert values['box']['top'] >= -1 and values['box']['bottom'] <= 821, (width, values['box'])
assert page.evaluate('document.documentElement.scrollWidth - innerWidth') <= 2, width
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', snapshot(503))
assert 'HTTP 503' in popup.inner_text(), width
assert popup.locator('.has-error').count() == 1, width
health.click()
assert popup.count() == 1 and health.get_attribute('aria-expanded') == 'true', width
assert backend.get_attribute('aria-expanded') == 'false', width
page.keyboard.press('Escape')
assert popup.count() == 0 and health.get_attribute('aria-expanded') == 'false', width
assert health.evaluate('(node) => document.activeElement === node'), width
backend.click()
page.locator('#vhost-search').click(force=True)
assert popup.count() == 0, width
# A static-only vhost presents a translated empty state, not stale pool values.
page.locator('[data-vhost-card="static-only"] .vhost-detail.vhost-backend-trigger').click()
assert 'Brak skonfigurowanych backendów.' in popup.inner_text(), width
popup.locator('.vhost-backend-popup-close').click()
assert popup.count() == 0, width
backend.click()
assert popup.count() == 1, width
# The existing backend popover is also used by the H2 policy badge.
protocol = vhost.locator('[data-upstream-protocol-backend="origin-http"]')
assert protocol.count() == 1 and protocol.evaluate('n => n.tagName') == 'BUTTON', width
assert protocol.get_attribute('title') is None, width
protocol.click()
assert backend.get_attribute('aria-expanded') == 'false', width
assert protocol.get_attribute('aria-expanded') == 'true', width
assert 'Brak odpowiedzi backendu' in popup.inner_text(), width
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(8))
assert 'origin-http · H2 compatible → H2' in protocol.inner_text(), width
assert 'Ostatnia wersja HTTP' in popup.inner_text(), width
assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '8'], width
page.evaluate('''() => {
window.__popupReference = document.getElementById('vhost-backend-popup');
window.__headingReference = window.__popupReference.querySelector('h4');
window.__popupRemoved = 0;
window.__popupObserver = new MutationObserver(records => {
for (const record of records) for (const item of record.removedNodes) {
if (item === window.__popupReference) window.__popupRemoved++;
}
});
window.__popupObserver.observe(document.body, {childList: true});
}''')
for count in range(9, 19):
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(count))
unchanged = page.evaluate('''() => ({
samePopup: document.getElementById('vhost-backend-popup') === window.__popupReference,
sameHeading: window.__popupReference.querySelector('h4') === window.__headingReference,
removed: window.__popupRemoved,
expanded: document.querySelector('[data-upstream-protocol-backend="origin-http"]').getAttribute('aria-expanded'),
})''')
assert unchanged == {'samePopup': True, 'sameHeading': True, 'removed': 0, 'expanded': 'true'}, (width, unchanged)
assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '18'], width
page.evaluate('window.__popupObserver.disconnect()')
assert protocol.get_attribute('title') is None, width
# A stale observation should change the displayed information in
# place, not dismiss/recreate the click popover.
stale = protocol_snapshot(18)
stale['backend_pools'][0]['members'][0]['upstream_protocol_last_seen_ms'] -= 600000
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', stale)
assert 'H2 compatible → ?' in protocol.inner_text(), width
assert popup.locator('.vhost-backend-popup-list').is_hidden(), width
assert page.evaluate('document.getElementById("vhost-backend-popup") === window.__popupReference'), width
page.keyboard.press('Escape')
assert popup.count() == 0 and protocol.get_attribute('aria-expanded') == 'false', width
assert protocol.evaluate('node => document.activeElement === node'), width
# Cache Domains and Stored objects are the only directly adjacent
# table cards and need a visible, small gap at any viewport width.
cache_gap = page.evaluate('''() => {
const panel = document.getElementById('panel-cache');
panel.hidden = false;
const [domains, stored] = panel.querySelectorAll(':scope > article.card');
const gap = stored.getBoundingClientRect().top - domains.getBoundingClientRect().bottom;
panel.hidden = true;
return gap;
}''')
assert 9.5 <= cache_gap <= 10.5, (width, cache_gap)
page.evaluate('window.rustProxyVhosts.reset()')
assert popup.count() == 0, width
assert not errors, (width, errors)
print(f'PASS Vhosts shared backend/H2 popover and Cache gap at {width}px')
page.close()
browser.close()
print('PASS Vhost quick-view with simulated runtime status (not live Rust)')
@@ -1,4 +1,5 @@
from pathlib import Path
from panel_html import render_admin_index
import re, json
from playwright.sync_api import sync_playwright
root=Path(__file__).resolve().parents[2]
@@ -26,7 +27,7 @@ bundle=concatenate('admin_app_javascript')
assert 'window.rustProxyOverviewSamples =' in bundle
assert bundle.index('window.rustProxyOverviewSamples =') < bundle.index('window.rustProxyOverviewSamples.onChange(')
css=(root/'src/admin/web/style.css').read_text()
html=(root/'src/admin/web/index.html').read_text()
html=render_admin_index(root)
with sync_playwright() as p:
browser=p.chromium.launch(headless=True, executable_path='/usr/bin/chromium',args=['--no-sandbox','--allow-file-access-from-files'])
for width in [320,375,800,1440]:
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Static contracts for 0.33.0 WWW observability (not Rust compilation)."""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
import tomllib
@@ -18,7 +19,7 @@ widgets = [w for w in registry if w['id'].startswith('www_')]
assert len(widgets) == 12
assert all(w['default_hidden'] is True for w in widgets)
assert len({w['id'] for w in registry}) == len(registry)
html = read('src/admin/web/index.html')
html = render_admin_index(ROOT)
english = json.loads(read('langs/en_US.json'))['translations']
polish = json.loads(read('langs/pl_pl.json'))['translations']
for widget in widgets:
+2 -1
View File
@@ -7,6 +7,7 @@ Run: python3 scripts/tests/web_server_0330_ui_test.py
from __future__ import annotations
import json
from pathlib import Path
from panel_html import render_admin_index
import re
from playwright.sync_api import sync_playwright
@@ -58,7 +59,7 @@ with sync_playwright() as p:
page = browser.new_page(viewport={'width': width, 'height': 900})
errors = []
page.on('pageerror', lambda err: errors.append(str(err)))
page.set_content((ROOT / 'src/admin/web/index.html').read_text())
page.set_content(render_admin_index(ROOT))
page.add_style_tag(content=(ROOT / 'src/admin/web/style.css').read_text())
page.add_script_tag(content=BUNDLE)
page.evaluate("""() => {
@@ -5,12 +5,13 @@ Chromium exercises the real assembled admin JS/CSS and responsive layouts.
It does not establish Rust, SSE or PHP-FPM runtime acceptance.
"""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
+187
View File
@@ -0,0 +1,187 @@
/// Account-scoped snapshots of Overview order, visibility, widths and display.
use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct DashboardPresetLayout {
dashboard_widgets: Vec<String>,
dashboard_hidden_widgets: Vec<String>,
dashboard_widget_sizes: Option<BTreeMap<String, u8>>,
dashboard_density: String,
overview_samples: u16,
}
#[derive(serde::Serialize)]
struct NamedDashboardPreset {
name: String,
#[serde(flatten)]
layout: DashboardPresetLayout,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct DashboardPresetRename {
name: String,
}
fn valid_dashboard_preset_name(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value.trim() == value
&& !value.chars().any(|ch| ch.is_control() || ch == '/')
}
fn validate_dashboard_preset_layout(layout: &DashboardPresetLayout) -> Result<(), ApiError> {
if !matches!(layout.overview_samples, 60 | 120 | 300 | 600) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid Overview history samples".into(),
));
}
let mut pref = AdminPreferences::default();
pref.dashboard_widgets.clone_from(&layout.dashboard_widgets);
pref.dashboard_hidden_widgets
.clone_from(&layout.dashboard_hidden_widgets);
pref.dashboard_widget_sizes
.clone_from(&layout.dashboard_widget_sizes);
pref.dashboard_density.clone_from(&layout.dashboard_density);
validate_admin_preferences(&pref)
}
async fn dashboard_presets(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<Vec<NamedDashboardPreset>>, ApiError> {
let entries = state
.store
.list_admin_dashboard_presets(&session.username)
.await
.map_err(auth::internal)?;
let mut presets = Vec::with_capacity(entries.len());
for (name, document) in entries {
let Ok(mut layout) = serde_json::from_str::<DashboardPresetLayout>(&document) else {
tracing::warn!("skipped invalid saved Overview preset");
continue;
};
// Compatibility with future/retired widgets: use the same registry
// reconciliation logic as ordinary saved administrator preferences.
let mut prefs = AdminPreferences::default();
prefs.dashboard_widgets = layout.dashboard_widgets;
prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets;
prefs.dashboard_widget_sizes = layout.dashboard_widget_sizes;
reconcile_dashboard_widgets(&mut prefs);
layout.dashboard_widgets = prefs.dashboard_widgets;
layout.dashboard_hidden_widgets = prefs.dashboard_hidden_widgets;
layout.dashboard_widget_sizes = prefs.dashboard_widget_sizes;
if validate_dashboard_preset_layout(&layout).is_ok() {
presets.push(NamedDashboardPreset { name, layout });
}
}
Ok(Json(presets))
}
async fn save_dashboard_preset(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(name): Path<String>,
Json(layout): Json<DashboardPresetLayout>,
) -> Result<StatusCode, ApiError> {
if !valid_dashboard_preset_name(&name) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid dashboard preset name".into(),
));
}
validate_dashboard_preset_layout(&layout)?;
let document = serde_json::to_string(&layout).map_err(auth::internal)?;
if !state
.store
.save_admin_dashboard_preset(&session.username, &name, &document)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::CONFLICT,
"dashboard preset limit (16) reached".into(),
));
}
Ok(StatusCode::NO_CONTENT)
}
async fn delete_dashboard_preset(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(name): Path<String>,
) -> Result<StatusCode, ApiError> {
if !state
.store
.delete_admin_dashboard_preset(&session.username, &name)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::NOT_FOUND,
"dashboard preset not found".into(),
));
}
Ok(StatusCode::NO_CONTENT)
}
async fn rename_dashboard_preset(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(name): Path<String>,
Json(rename): Json<DashboardPresetRename>,
) -> Result<StatusCode, ApiError> {
if !valid_dashboard_preset_name(&rename.name) || !valid_dashboard_preset_name(&name) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid dashboard preset name".into(),
));
}
if !state
.store
.rename_admin_dashboard_preset(&session.username, &name, &rename.name)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::CONFLICT,
"dashboard preset missing or name in use".into(),
));
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod dashboard_preset_validation_tests {
use super::*;
#[test]
fn names_are_bounded_and_paths_are_not_accepted() {
assert!(valid_dashboard_preset_name("Work layout"));
assert!(valid_dashboard_preset_name("Układ na telefon"));
for invalid in ["", " name", "name ", "bad/name", "bad\nname"] {
assert!(!valid_dashboard_preset_name(invalid));
}
assert!(!valid_dashboard_preset_name(&"X".repeat(65)));
}
#[test]
fn layouts_reject_invalid_history_and_widgets() {
let mut layout = DashboardPresetLayout {
dashboard_widgets: default_admin_dashboard_widgets_for_tests(),
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None,
dashboard_density: default_admin_dashboard_density(),
overview_samples: 600,
};
assert!(validate_dashboard_preset_layout(&layout).is_ok());
layout.overview_samples = 601;
assert!(validate_dashboard_preset_layout(&layout).is_err());
layout.overview_samples = 600;
layout.dashboard_widgets.push("undefined-widget".into());
assert!(validate_dashboard_preset_layout(&layout).is_err());
}
fn default_admin_dashboard_widgets_for_tests() -> Vec<String> {
AdminPreferences::default().dashboard_widgets
}
}
+1
View File
@@ -4,6 +4,7 @@ include!("router.rs");
include!("access.rs");
include!("session.rs");
include!("preferences.rs");
include!("dashboard_presets.rs");
include!("status.rs");
include!("logging_api.rs");
include!("file_logs.rs");
+389 -9
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.33.4",
"version": "0.33.7",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -1241,15 +1241,37 @@
"type": "object",
"description": "Cached OS/process metrics for fast SSE/live charts only; full System details remain on /api/system-info",
"properties": {
"sampled_at_ms": { "type": "integer", "format": "int64" },
"cpu_percent": { "type": "number", "nullable": true },
"process_cpu_percent": { "type": "number", "nullable": true },
"process_rss_bytes": { "type": "integer", "format": "int64", "nullable": true },
"network_rx_bytes_per_second": { "type": "number", "nullable": true },
"network_tx_bytes_per_second": { "type": "number", "nullable": true },
"sampled_at_ms": {
"type": "integer",
"format": "int64"
},
"cpu_percent": {
"type": "number",
"nullable": true
},
"process_cpu_percent": {
"type": "number",
"nullable": true
},
"process_rss_bytes": {
"type": "integer",
"format": "int64",
"nullable": true
},
"network_rx_bytes_per_second": {
"type": "number",
"nullable": true
},
"network_tx_bytes_per_second": {
"type": "number",
"nullable": true
},
"network_interfaces": {
"type": "array",
"items": { "type": "object", "additionalProperties": true }
"items": {
"type": "object",
"additionalProperties": true
}
}
},
"additionalProperties": false
@@ -3570,7 +3592,13 @@
"maxProperties": 78,
"additionalProperties": {
"type": "integer",
"enum": [2, 3, 4, 6, 12]
"enum": [
2,
3,
4,
6,
12
]
},
"description": "Nullable account-scoped per-card widths (12-column grid). Null preserves the legacy grouped visual arrangement; an object, including an empty one, enables freely ordered cards. Key must be a registered Overview widget ID. Panels require width >= 4, charts >= 3 and backend table >= 6."
},
@@ -6631,6 +6659,82 @@
}
}
}
},
"DashboardPresetLayout": {
"type": "object",
"additionalProperties": false,
"description": "Validated Overview snapshot. Factory layout is separate from stored user presets.",
"properties": {
"dashboard_widgets": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"description": "Ordered registry widget identifiers"
},
"dashboard_hidden_widgets": {
"type": "array",
"items": {
"type": "string"
},
"description": "Invisible registry widget IDs"
},
"dashboard_widget_sizes": {
"nullable": true,
"type": "object",
"additionalProperties": {
"type": "integer",
"minimum": 2,
"maximum": 12
},
"description": "Width overrides in a 12-column grid; null selects legacy groups"
},
"dashboard_density": {
"type": "string",
"enum": [
"normal",
"compact",
"ultra"
]
},
"overview_samples": {
"type": "integer",
"enum": [
60,
120,
300,
600
]
}
},
"required": [
"dashboard_widgets",
"dashboard_hidden_widgets",
"dashboard_widget_sizes",
"dashboard_density",
"overview_samples"
]
},
"NamedDashboardPreset": {
"allOf": [
{
"$ref": "#/components/schemas/DashboardPresetLayout"
},
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
"required": [
"name"
]
}
]
}
}
},
@@ -11931,6 +12035,282 @@
}
}
}
},
"/api/dashboard-presets": {
"get": {
"tags": [
"Administration"
],
"summary": "List administrator dashboard presets",
"operationId": "get_dashboard_presets",
"responses": {
"200": {
"description": "Named layouts owned by the authenticated administrator",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NamedDashboardPreset"
}
}
}
}
},
"401": {
"description": "Administrator session required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Invalid Host, Origin, request marker or CSRF token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Storage unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/dashboard-presets/{name}": {
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "URL-encoded user-defined preset name (1-64 UTF-8 bytes)",
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
],
"put": {
"tags": [
"Administration"
],
"summary": "Save or overwrite an Overview dashboard preset",
"operationId": "put_dashboard_preset",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardPresetLayout"
}
}
}
},
"responses": {
"204": {
"description": "Preset saved"
},
"409": {
"description": "Account already has 16 named presets",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"422": {
"description": "Invalid preset name or layout",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Administrator session required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Invalid Host, Origin, request marker or CSRF token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Storage unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"patch": {
"tags": [
"Administration"
],
"summary": "Rename an Overview dashboard preset",
"operationId": "patch_dashboard_preset",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
}
}
}
},
"responses": {
"204": {
"description": "Preset renamed"
},
"409": {
"description": "Source name not found or target name already exists",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"422": {
"description": "Invalid preset name",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Administrator session required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Invalid Host, Origin, request marker or CSRF token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Storage unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"delete": {
"tags": [
"Administration"
],
"summary": "Delete an Overview dashboard preset",
"operationId": "delete_dashboard_preset",
"responses": {
"204": {
"description": "Preset deleted"
},
"404": {
"description": "Preset not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Administrator session required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Invalid Host, Origin, request marker or CSRF token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Storage unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}
+26 -3
View File
@@ -1,6 +1,14 @@
//! Deliberately small anonymous surface. Operational templates/assets are private.
use crate::{auth::Session, runtime::{ApiError, App}, settings::PanelCompression};
use axum::{extract::{rejection::JsonRejection, State}, response::{Html, IntoResponse, Response}, Extension};
use crate::{
auth::Session,
runtime::{ApiError, App},
settings::PanelCompression,
};
use axum::{
extract::{rejection::JsonRejection, State},
response::{Html, IntoResponse, Response},
Extension,
};
use http::{HeaderMap, StatusCode};
use std::sync::{Arc, OnceLock};
@@ -10,7 +18,19 @@ static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
let content = ADMIN_INDEX.get_or_init(|| {
// Compile-time embedded HTML fragments are assembled once at startup.
// No additional browser requests, changed DOM nodes or script ordering.
let (metrics, web_metrics) = include_str!("web/partials/overview-metrics.html")
.split_once("<!-- RP_METRICS_SPLIT -->")
.expect("invalid Overview metrics partial");
let (charts, web_charts) = include_str!("web/partials/overview-charts.html")
.split_once("<!-- RP_CHARTS_SPLIT -->")
.expect("invalid Overview charts partial");
let html = include_str!("web/index.html")
.replace("<!-- RP_METRICS_MAIN -->", metrics)
.replace("<!-- RP_CHARTS_MAIN -->", charts)
.replace("<!-- RP_METRICS_WEB -->", web_metrics)
.replace("<!-- RP_CHARTS_WEB -->", web_charts)
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"));
// Exactly one static panel document shared between all administrator accounts.
@@ -30,7 +50,10 @@ pub(super) async fn index(
return Html(html).into_response();
}
let snapshot = state.config.load();
panel_response(&headers, &snapshot.envelope.config.administration.panel_compression)
panel_response(
&headers,
&snapshot.envelope.config.administration.panel_compression,
)
}
pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
+9 -1
View File
@@ -69,7 +69,7 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/dashboard-widgets.json"),
");",
include_str!("web/app/preferences.js"),
include_str!("web/app/dashboard-layout-editor.js"),
include_str!("web/app/dashboard-presets.js"),
include_str!("web/app/dashboard-inline-layout.js"),
include_str!("web/app/stream-dashboard.js"),
include_str!("web/app/backend-dashboard.js"),
@@ -249,6 +249,14 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/file-logs", get(file_logs))
.route("/api/file-logs/query", get(file_logs_query))
.route("/api/preferences", get(preferences).put(update_preferences))
.route("/api/dashboard-presets", get(dashboard_presets))
.route(
"/api/dashboard-presets/{name}",
put(save_dashboard_preset)
.patch(rename_dashboard_preset)
.delete(delete_dashboard_preset)
.layer(DefaultBodyLimit::max(10240)),
)
.route("/api/audit", get(audit))
.route("/api/events", get(events))
.route("/metrics", get(metrics))
+85
View File
@@ -1639,6 +1639,87 @@ mod tests {
store.close().await;
}
#[tokio::test]
async fn named_dashboard_presets_are_private_validated_and_persistent() {
let (router, store, _dir, secret, csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let preset = DashboardPresetLayout {
dashboard_widgets: AdminPreferences::default().dashboard_widgets,
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None,
dashboard_density: "compact".to_owned(),
overview_samples: 600,
};
let body = serde_json::to_string(&preset).unwrap();
let anon = router.clone().oneshot(
request("/api/dashboard-presets").body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(anon.status(), StatusCode::UNAUTHORIZED);
let forbidden = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("content-type", "application/json")
.body(Body::from(body.clone())).unwrap()
).await.unwrap();
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
let saved = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.clone())).unwrap()
).await.unwrap();
assert_eq!(saved.status(), StatusCode::NO_CONTENT);
let listing = router.clone().oneshot(
request("/api/dashboard-presets")
.header("cookie", &cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(listing.status(), StatusCode::OK);
let entries = json_body(listing).await;
assert_eq!(entries[0]["name"], "Daily");
assert_eq!(entries[0]["overview_samples"], 600);
assert_eq!(entries[0]["dashboard_widget_sizes"], Value::Null);
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap().len(), 1);
assert!(store.list_admin_dashboard_presets("someone-else").await.unwrap().is_empty());
let rejected = router.clone().oneshot(
request("/api/dashboard-presets/Invalid").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.replace("\"overview_samples\":600", "\"overview_samples\":999"))).unwrap()
).await.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let renamed = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PATCH")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"Nightly"}"#)).unwrap()
).await.unwrap();
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap()[0].0, "Nightly");
let deleted = router.oneshot(
request("/api/dashboard-presets/Nightly").method("DELETE")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
assert!(store.list_admin_dashboard_presets("owner").await.unwrap().is_empty());
store.close().await;
}
#[tokio::test]
async fn admin_preferences_are_account_scoped_and_csrf_guarded() {
let (router, store, _dir, secret, csrf) = fixture().await;
@@ -3010,6 +3091,7 @@ mod tests {
"/api/system-info",
"/api/config",
"/api/preferences",
"/api/dashboard-presets",
"/api/cache",
"/api/cache/bans",
"/api/audit",
@@ -3044,6 +3126,9 @@ mod tests {
("DELETE", "/api/cache/bans/1"),
("PUT", "/api/config/cache"),
("PUT", "/api/preferences"),
("PUT", "/api/dashboard-presets/example"),
("DELETE", "/api/dashboard-presets/example"),
("PATCH", "/api/dashboard-presets/example"),
("POST", "/api/auth/logout-all"),
] {
let no_session = request(path).method(method).body(Body::empty()).unwrap();
+1 -1
View File
@@ -162,7 +162,7 @@ window.rustProxyInlineLayout = (() => {
draft = original = null;
}
async function begin() {
if (draft || opening || saving || $("overview-layout-dialog")?.open) return;
if (draft || opening || saving || $("overview-presets-dialog")?.open) return;
opening = true;
unlock.disabled = true;
try {
+144
View File
@@ -0,0 +1,144 @@
window.rustProxyDashboardLayout = (() => {
const dialog = $("overview-presets-dialog");
const list = $("overview-presets-list");
const input = $("overview-presets-name");
const message = $("overview-presets-message");
const save = $("overview-presets-save");
const factory = $("overview-presets-factory");
const close = $("overview-presets-close");
let presets = [];
let busy = false;
const presetPath = name => `/api/dashboard-presets/${encodeURIComponent(name)}`;
const nameValid = name => name.length > 0 && new TextEncoder().encode(name).length <= 64
&& !/[\x00-\x1f\x7f/]/.test(name);
const snapshot = () => {
const current = window.rustProxyPreferences.current() || ADMIN_APPEARANCE_DEFAULTS;
return {
dashboard_widgets: [...normalizeDashboardWidgets(current.dashboard_widgets)],
dashboard_hidden_widgets: [...normalizeHiddenDashboardWidgets(current.dashboard_hidden_widgets)],
dashboard_widget_sizes: normalizeDashboardWidgetSizes(current.dashboard_widget_sizes),
dashboard_density: normalizeAdminDensity(current.dashboard_density, "compact"),
overview_samples: window.rustProxyOverviewSamples.getLimit(),
};
};
const translateName = (key, name) => t(key).replace("{name}", name);
function notify(key, isError = false) {
message.textContent = isError ? key : t(key);
message.classList.toggle("error", isError);
}
function lock(value) {
busy = value;
for (const control of [save, factory, close, input, ...list.querySelectorAll("button")])
if (control) control.disabled = value;
}
async function run(fn) {
if (busy) return;
lock(true);
try { await fn(); }
catch (error) {
const message = error.status === 409
? (String(error.code || "").includes("limit (16)")
? t("dashboardPresetsLimit") : t("dashboardPresetsConflict"))
: (error.message || String(error));
notify(message, true);
}
finally { lock(false); }
}
async function reload() {
presets = await api("/api/dashboard-presets");
render();
}
function entryButton(key, className, fn, presetName) {
const button = el("button", t(key), className);
button.type = "button";
button.setAttribute("aria-label", `${t(key)}: ${presetName}`);
button.dataset.presetAction = key;
button.addEventListener("click", () => void run(fn));
return button;
}
function render() {
const elements = presets.map(preset => {
const row = el("div", undefined, "overview-presets-row");
row.setAttribute("role", "listitem");
const actions = el("div", undefined, "overview-presets-actions");
actions.append(
entryButton("dashboardPresetsLoad", "primary", async () => {
await apply(preset);
dialog.close();
toast(t("dashboardPresetsLoaded"));
}, preset.name),
entryButton("dashboardPresetsOverwrite", "outline", async () => {
if (!(await appConfirm(translateName("dashboardPresetsOverwriteConfirm", preset.name)))) return;
await api(presetPath(preset.name), { method: "PUT", body: JSON.stringify(snapshot()) });
await reload(); notify("dashboardPresetsSaved");
}, preset.name),
entryButton("dashboardPresetsRename", "quiet", async () => {
const name = input.value.trim();
if (!nameValid(name)) { notify("dashboardPresetsNameRequired"); return; }
if (name === preset.name) return;
await api(presetPath(preset.name), { method: "PATCH", body: JSON.stringify({ name }) });
input.value = "";
await reload(); notify("dashboardPresetsRenamed");
}, preset.name),
entryButton("dashboardPresetsDelete", "quiet", async () => {
if (!(await appConfirm(translateName("dashboardPresetsDeleteConfirm", preset.name), { danger: true }))) return;
await api(presetPath(preset.name), { method: "DELETE" });
await reload(); notify("dashboardPresetsDeleted");
}, preset.name),
);
row.append(el("strong", preset.name), actions);
return row;
});
list.replaceChildren(...(elements.length ? elements : [el("p", t("dashboardPresetsEmpty"), "fine")]));
}
async function apply(layout) {
await window.rustProxyPreferences.update({
dashboard_widgets: [...layout.dashboard_widgets],
dashboard_hidden_widgets: [...layout.dashboard_hidden_widgets],
dashboard_widget_sizes: layout.dashboard_widget_sizes === null ? null : { ...layout.dashboard_widget_sizes },
dashboard_density: layout.dashboard_density,
});
window.rustProxyOverviewSamples.setLimit(layout.overview_samples, true);
}
async function open() {
if (dialog?.open || busy || window.rustProxyInlineLayout?.isEditing?.()) return;
await run(async () => {
await window.rustProxyPreferences.load();
await reload();
input.value = "";
notify("");
dialog.showModal();
input.focus();
});
}
save?.addEventListener("click", () => void run(async () => {
const name = input.value.trim();
if (!nameValid(name)) { notify("dashboardPresetsNameRequired"); return; }
if (presets.length >= 16 && !presets.some(preset => preset.name === name)) {
notify("dashboardPresetsLimit"); return;
}
if (presets.some(preset => preset.name === name)
&& !(await appConfirm(translateName("dashboardPresetsOverwriteConfirm", name)))) return;
await api(presetPath(name), { method: "PUT", body: JSON.stringify(snapshot()) });
input.value = "";
await reload(); notify("dashboardPresetsSaved");
}));
factory?.addEventListener("click", () => void run(async () => {
if (!(await appConfirm(t("dashboardPresetsResetConfirm")))) return;
await apply({
dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS],
dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS],
dashboard_widget_sizes: null,
dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density,
overview_samples: ADMIN_DASHBOARD_DEFAULTS.overview_samples,
});
dialog.close();
toast(t("dashboardPresetsRestored"));
}));
close?.addEventListener("click", () => { if (!busy) dialog.close(); });
dialog?.addEventListener("cancel", event => { if (busy) event.preventDefault(); });
for (const id of ["overview-layout-open", "overview-layout-open-settings"])
$(id)?.addEventListener("click", () => { void open(); });
return { open, refresh: () => { if (dialog?.open) render(); } };
})();
+25 -1055
View File
File diff suppressed because it is too large. Load diff
+373
View File
@@ -0,0 +1,373 @@
<article class="card overview-wide-chart" data-dashboard-widget="wide_protocol_rates" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideProtocolRates">HTTP/1.1 · HTTP/2 · HTTP/3 traffic</h2>
<span class="overview-wide-summary" id="overview-wide-wide_protocol_rates-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="HTTP/1.1 · HTTP/2 · HTTP/3 traffic">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_protocol_rates-h1-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_protocol_rates-h2-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-2" id="overview-wide-wide_protocol_rates-h3-line" d="">
</path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span>H1</span><b
id="overview-wide-wide_protocol_rates-h1-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span>H2</span><b
id="overview-wide-wide_protocol_rates-h2-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span>H3</span><b
id="overview-wide-wide_protocol_rates-h3-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_cpu" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideCpu">System and process CPU</h2>
<span class="overview-wide-summary" id="overview-wide-wide_cpu-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="System and process CPU">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_cpu-system-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_cpu-process-line" d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideSystem">System</span><b
id="overview-wide-wide_cpu-system-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideProcess">Process</span><b
id="overview-wide-wide_cpu-process-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_memory" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideMemory">Process RAM and cache memory</h2>
<span class="overview-wide-summary" id="overview-wide-wide_memory-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Process RAM and cache memory">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_memory-process-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_memory-cache-line" d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideProcess">Process</span><b
id="overview-wide-wide_memory-process-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideCache">Cache</span><b
id="overview-wide-wide_memory-cache-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_cache_activity" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideCacheActivity">Cache hits / misses per second</h2>
<span class="overview-wide-summary" id="overview-wide-wide_cache_activity-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Cache hits / misses per second">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_cache_activity-hits-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_cache_activity-misses-line"
d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideHits">HIT</span><b
id="overview-wide-wide_cache_activity-hits-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideMisses">MISS</span><b
id="overview-wide-wide_cache_activity-misses-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_connections" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideConnections">Connections and WebSocket tunnels</h2>
<span class="overview-wide-summary" id="overview-wide-wide_connections-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Connections and WebSocket tunnels">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_connections-open-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_connections-websockets-line"
d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideOpen">Connections</span><b
id="overview-wide-wide_connections-open-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideWebsockets">WebSockets</span><b
id="overview-wide-wide_connections-websockets-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_backend_health" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideBackendHealth">Backend availability over time</h2>
<span class="overview-wide-summary" id="overview-wide-wide_backend_health-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Backend availability over time">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_backend_health-up-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_backend_health-down-line" d="">
</path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideHealthy">Healthy</span><b
id="overview-wide-wide_backend_health-up-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideUnhealthy">Unhealthy</span><b
id="overview-wide-wide_backend_health-down-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_latency" hidden>
<div class="card-heading">
<h2 data-i18n="overviewWideLatency">Response and backend probe latency</h2>
<span class="overview-wide-summary" id="overview-wide-wide_latency-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Response and backend probe latency">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_latency-response-line" d="">
</path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_latency-probe-line" d="">
</path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideResponse">HTTP</span><b
id="overview-wide-wide_latency-response-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewWideProbe">Probes</span><b
id="overview-wide-wide_latency-probe-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_transform_activity">
<div class="card-heading">
<h2 data-i18n="overviewWideTransformActivity">Transformation activity</h2>
<span class="overview-wide-summary" id="overview-wide-wide_transform_activity-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
data-i18n-aria="overviewWideTransformActivity" aria-label="Transformation activity">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0"
id="overview-wide-wide_transform_activity-applied-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1"
id="overview-wide-wide_transform_activity-cache_hits-line" d=""></path>
<path class="overview-wide-series overview-wide-series-2"
id="overview-wide-wide_transform_activity-skipped-line" d=""></path>
<path class="overview-wide-series overview-wide-series-3" id="overview-wide-wide_transform_activity-errors-line"
d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewTransformApplied">Applied</span><b
id="overview-wide-wide_transform_activity-applied-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="overviewTransformCacheHits">Cache HIT</span><b
id="overview-wide-wide_transform_activity-cache_hits-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span data-i18n="overviewTransformSkipped">Skipped</span><b
id="overview-wide-wide_transform_activity-skipped-value">-</b></span>
<span><i class="overview-wide-key-3"></i><span data-i18n="overviewTransformErrors">Errors</span><b
id="overview-wide-wide_transform_activity-errors-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="wide_compression_activity">
<div class="card-heading">
<h2 data-i18n="overviewWideCompressionActivity">Compression activity</h2>
<span class="overview-wide-summary" id="overview-wide-wide_compression_activity-summary">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
data-i18n-aria="overviewWideCompressionActivity" aria-label="Compression activity">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="overview-wide-wide_compression_activity-gzip-line"
d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="overview-wide-wide_compression_activity-br-line"
d=""></path>
<path class="overview-wide-series overview-wide-series-2" id="overview-wide-wide_compression_activity-zstd-line"
d=""></path>
<path class="overview-wide-series overview-wide-series-3"
id="overview-wide-wide_compression_activity-deflate-line" d=""></path>
</svg>
<div class="overview-wide-legend">
<span><i class="overview-wide-key-0"></i><span>gzip</span><b
id="overview-wide-wide_compression_activity-gzip-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span>Brotli</span><b
id="overview-wide-wide_compression_activity-br-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span>Zstd</span><b
id="overview-wide-wide_compression_activity-zstd-value">-</b></span>
<span><i class="overview-wide-key-3"></i><span>deflate</span><b
id="overview-wide-wide_compression_activity-deflate-value">-</b></span>
</div>
</article>
<!-- RP_CHARTS_SPLIT -->
<article class="card overview-wide-chart" data-dashboard-widget="www_requests" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricRequests">webMetricRequests</h2><span class="overview-wide-summary"
id="www_requests-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Web Server request rate" data-i18n-aria="webMetricRequests">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_requests-all-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_requests-php-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesAll">all</span><b id="www_requests-all-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesPhp">php</span><b
id="www_requests-php-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_latency" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricLatency">webMetricLatency</h2><span class="overview-wide-summary"
id="www_latency-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Web Server header latency" data-i18n-aria="webMetricLatency">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_latency-web-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_latency-php-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesWeb">web</span><b id="www_latency-web-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesPhp">php</span><b
id="www_latency-php-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_failures" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricFailures">webMetricFailures</h2><span class="overview-wide-summary"
id="www_failures-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="PHP FastCGI failures" data-i18n-aria="webMetricFailures">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_failures-timeout-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_failures-connection-line" d=""></path>
<path class="overview-wide-series overview-wide-series-2" id="www_failures-protocol-line" d=""></path>
<path class="overview-wide-series overview-wide-series-3" id="www_failures-capacity-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesTimeout">timeout</span><b id="www_failures-timeout-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesConnection">connection</span><b
id="www_failures-connection-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span data-i18n="webMetricSeriesProtocol">protocol</span><b
id="www_failures-protocol-value">-</b></span>
<span><i class="overview-wide-key-3"></i><span data-i18n="webMetricSeriesCapacity">capacity</span><b
id="www_failures-capacity-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_traffic" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricTraffic">webMetricTraffic</h2><span class="overview-wide-summary"
id="www_traffic-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Web Server body byte rate" data-i18n-aria="webMetricTraffic">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_traffic-nonphp-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_traffic-php-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesNonphp">nonphp</span><b id="www_traffic-nonphp-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesPhp">php</span><b
id="www_traffic-php-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_status_trend" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricStatusTrend">webMetricStatusTrend</h2><span class="overview-wide-summary"
id="www_status_trend-value">-</span>
</div>
<svg preserveAspectRatio="none" class="overview-wide-svg" viewBox="0 0 600 150" role="img"
aria-label="Web Server HTTP statuses" data-i18n-aria="webMetricStatusTrend">
<path class="chart-grid" d="M0 47H600 M0 77H600 M0 107H600"></path>
<path class="overview-wide-series overview-wide-series-0" id="www_status_trend-2xx-line" d=""></path>
<path class="overview-wide-series overview-wide-series-1" id="www_status_trend-3xx-line" d=""></path>
<path class="overview-wide-series overview-wide-series-2" id="www_status_trend-4xx-line" d=""></path>
<path class="overview-wide-series overview-wide-series-3" id="www_status_trend-5xx-line" d=""></path>
</svg>
<div class="overview-wide-legend"><span><i class="overview-wide-key-0"></i><span
data-i18n="webMetricSeriesTwoxx">2xx</span><b id="www_status_trend-2xx-value">-</b></span>
<span><i class="overview-wide-key-1"></i><span data-i18n="webMetricSeriesThreexx">3xx</span><b
id="www_status_trend-3xx-value">-</b></span>
<span><i class="overview-wide-key-2"></i><span data-i18n="webMetricSeriesFourxx">4xx</span><b
id="www_status_trend-4xx-value">-</b></span>
<span><i class="overview-wide-key-3"></i><span data-i18n="webMetricSeriesFivexx">5xx</span><b
id="www_status_trend-5xx-value">-</b></span>
</div>
</article>
<article class="card overview-wide-chart" data-dashboard-widget="www_top_services" hidden>
<div class="card-heading">
<h2 data-i18n="webMetricTopServices">Top Web Services</h2>
<span class="overview-wide-summary" id="www_top_services-value">-</span>
</div>
<div id="www_top_services-bars" class="www-top-bars"></div>
<small class="fine" data-i18n="webMetricTopServicesHelp">Requests per second by Web
Service.</small>
</article>
<article class="card activity-card" data-dashboard-widget="traffic_chart">
<div class="card-heading">
<h2 data-i18n="requestsPerSecond">Requests per second</h2>
<span id="chart-peak">max 0.0 req/s</span>
</div><svg preserveAspectRatio="none" aria-label="Recent request rate" id="traffic-chart" role="img"
viewbox="0 0 600 130">
<path class="chart-grid" d="M0 25H600 M0 65H600 M0 105H600"></path>
<polyline class="traffic-line traffic-line-total" id="sparkline" points="0,120 600,120">
</polyline>
<polyline class="traffic-line traffic-line-h1" id="sparkline-h1" points="0,120 600,120">
</polyline>
<polyline class="traffic-line traffic-line-h2" id="sparkline-h2" points="0,120 600,120">
</polyline>
<polyline class="traffic-line traffic-line-h3" id="sparkline-h3" points="0,120 600,120">
</polyline>
<polyline class="traffic-line traffic-line-transform" id="sparkline-transform" points="0,120 600,120">
</polyline>
</svg>
<div class="traffic-protocol-legend" aria-label="Request rate by HTTP protocol and transformed response rate">
<span class="legend-total"><i></i>Total <b id="rate-total">0.0/s</b></span>
<span class="legend-h1"><i></i>HTTP/1.1 <b id="rate-h1">0.0/s</b></span>
<span class="legend-h2"><i></i>HTTP/2 <b id="rate-h2">0.0/s</b></span>
<span class="legend-h3"><i></i>HTTP/3 <b id="rate-h3">0.0/s</b></span>
<span class="legend-transform"><i></i><span data-i18n="liveTransformed">Transformed</span> <b
id="rate-transform">0.0/s</b></span>
</div>
</article>
<article class="card system-card" data-dashboard-widget="system_chart">
<div class="card-heading">
<div>
<h3 class="overview-chart-heading" data-i18n="systemLoad">System load</h3>
<h2><span id="system-cpu">-</span> <small>CPU</small></h2>
</div><span class="meta" id="system-source">-</span>
</div><svg preserveAspectRatio="none" aria-label="System CPU history" data-i18n-aria="systemChart" id="system-chart"
role="img" viewbox="0 0 300 100">
<path class="chart-grid" d="M0 25H300 M0 50H300 M0 75H300"></path>
<polyline id="system-sparkline" points="0,95 300,95"></polyline>
</svg>
<div class="memory-line"><span data-i18n="systemMemory">System RAM</span><b id="system-memory">-</b></div>
<div class="overview-meter system-memory-meter" id="memory-meter" role="progressbar" aria-label="System RAM"
data-i18n-aria="systemMemory" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="memory-meter-fill"></span>
</div>
<div class="memory-line"><span data-i18n="systemSwap">System swap</span><b id="system-swap">-</b></div>
<div class="chart-footer"><span data-i18n="loadAverage">Load 1 / 5 / 15 min</span><span id="system-load">-</span>
</div>
<div class="chart-footer"><span data-i18n="systemUptime">System uptime</span><span id="system-uptime">-</span></div>
</article>
<article class="card connection-card" data-dashboard-widget="connection_chart">
<div class="card-heading">
<div>
<h3 class="overview-chart-heading" data-i18n="connectionActivity">Connection activity</h3>
<h2><span id="connection-current">0</span> <small data-i18n="connections">Open
connections</small></h2>
</div>
</div>
<svg preserveAspectRatio="none" aria-label="Connection activity history" data-i18n-aria="connectionActivity"
id="connection-chart" role="img" viewbox="0 0 300 100">
<path class="chart-grid" d="M0 25H300 M0 50H300 M0 75H300"></path>
<polyline class="connection-open-line" id="connection-open-sparkline" points="0,95 300,95"></polyline>
<polyline class="connection-exchange-line" id="connection-exchange-sparkline" points="0,95 300,95"></polyline>
</svg>
<div class="connection-legend"><span class="connection-open-key"><i></i><span data-i18n="connections">Open
connections</span> <b id="connection-open-value">0</b></span><span
class="connection-exchange-key"><i></i><span data-i18n="activeExchanges">Active
exchanges</span> <b id="connection-exchange-value">0</b></span></div>
<div class="chart-footer"><span><span data-i18n="backends">Backends</span>: <b id="connection-backends">0</b></span>
</div>
</article>
@@ -0,0 +1,471 @@
<article class="stat" data-dashboard-widget="requests"><span data-i18n="requests">Requests</span><strong
id="metric-requests">0</strong><small id="request-rate">0 req/s</small></article>
<article class="stat" data-dashboard-widget="active"><span data-i18n="activeExchanges">Active
exchanges</span><strong id="metric-active">0</strong><small data-i18n="bodyLifetime">Full body lifetime</small>
</article>
<article class="stat" data-dashboard-widget="bandwidth"><span data-i18n="bandwidth">Payload
transferred</span><strong id="metric-bytes">0 B</strong><small class="payload-split"
id="metric-bytes-split"><span>↓ <b id="metric-bytes-down">0 B</b></span><span>↑ <b id="metric-bytes-up">0
B</b></span></small>
</article>
<article class="stat" data-dashboard-widget="errors"><span data-i18n="errors">Errors</span><strong
id="metric-errors">0</strong><small data-i18n="errorDescription">5xx / body /
cancelled</small></article>
<article class="stat stat-donut" data-dashboard-widget="cache"><span data-i18n="cacheHitRatio">Cache hit
ratio</span>
<div class="overview-donut-row">
<svg class="overview-donut-svg" id="cache-ratio-chart" role="img" aria-label="Cache HIT and MISS distribution"
viewBox="0 0 64 64">
<circle class="overview-donut-track" cx="32" cy="32" r="24"></circle>
<circle class="overview-donut-hit" id="cache-ratio-hit-arc" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100"></circle>
<circle class="overview-donut-miss" id="cache-ratio-miss-arc" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100"></circle>
</svg>
<div class="overview-donut-info">
<strong id="metric-cache-ratio">-</strong>
<small class="overview-donut-legend" id="cache-summary">
<span class="overview-donut-hit-key"><i></i>HIT <b id="cache-ratio-hit-count">0</b></span>
<span class="overview-donut-miss-key"><i></i>MISS <b id="cache-ratio-miss-count">0</b></span>
</small>
</div>
</div>
</article>
<article class="stat" data-dashboard-widget="memory"><span data-i18n="processMemory">Process
memory</span><strong id="metric-rss">-</strong><small id="process-summary">-</small>
</article>
<article class="stat stat-with-action" data-dashboard-widget="websocket"><span data-i18n="wsActive">WebSocket
tunnels</span><button class="quiet stat-corner-action" data-i18n="tunnelsOpen" id="tunnels-open"
type="button">Active tunnels</button><strong id="metric-ws">0</strong><small
aria-label="Active WebSocket tunnels by frontend protocol"><span data-i18n="wsH1">H1
Upgrade</span> <span id="metric-ws-h1">0</span> · <span data-i18n="wsH2Rfc8441">H2
RFC 8441</span> <span id="metric-ws-h2">0</span> · <span data-i18n="wsH3Rfc9220">H3
RFC 9220</span> <span id="metric-ws-h3">0</span><br><span data-i18n="wsEstablished">Established</span>:
<span id="metric-ws-total">0</span></small></article>
<article class="stat" data-dashboard-widget="http2"><span data-i18n="h2Requests">HTTP/2
requests</span><strong id="metric-h2">0</strong><small><span data-i18n="h2ActiveConnections">Active H2
connections</span>: <span id="metric-h2-active">0</span></small></article>
<article class="stat" data-dashboard-widget="http3"><span data-i18n="h3Requests">HTTP/3
requests</span><strong id="metric-h3">0</strong></article>
<article class="stat" data-dashboard-widget="tls"><span data-i18n="tlsFailures">TLS handshake
errors</span><strong id="metric-tls-errors">0</strong></article>
<article class="stat stat-chart" data-dashboard-widget="http_protocol"><span data-i18n="httpProtocolMix">HTTP protocol
mix</span><strong id="metric-http-proto-total">0.0/s</strong><svg preserveAspectRatio="none"
aria-label="HTTP protocol activity" data-i18n-aria="httpProtocolMix" id="http-protocol-chart" role="img"
viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h1-line" id="http-protocol-h1" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line proto-h2-line" id="http-protocol-h2" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line proto-h3-line" id="http-protocol-h3" points="0,55 300,55"></polyline>
</svg><small class="stat-chart-legend"><span class="legend-h1"><i></i>H1 <b
id="metric-http-proto-h1">0.0/s</b></span><span class="legend-h2"><i></i>H2 <b
id="metric-http-proto-h2">0.0/s</b></span><span class="legend-h3"><i></i>H3 <b
id="metric-http-proto-h3">0.0/s</b></span></small></article>
<article class="stat stat-chart" data-dashboard-widget="websocket_protocol"><span data-i18n="wsProtocolMix">WebSocket
protocol
mix</span><strong id="metric-ws-proto-total">0</strong><svg preserveAspectRatio="none"
aria-label="WebSocket protocol activity" data-i18n-aria="wsProtocolMix" id="ws-protocol-chart" role="img"
viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h1-line" id="ws-protocol-h1" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line proto-h2-line" id="ws-protocol-h2" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line proto-h3-line" id="ws-protocol-h3" points="0,55 300,55"></polyline>
</svg><small class="stat-chart-legend"><span class="legend-h1"><i></i>H1 <b
id="metric-ws-proto-h1">0</b></span><span class="legend-h2"><i></i>H2 <b
id="metric-ws-proto-h2">0</b></span><span class="legend-h3"><i></i>H3 <b
id="metric-ws-proto-h3">0</b></span></small></article>
<article class="stat" data-dashboard-widget="stream_overview" hidden>
<span data-i18n="streamWidgetOverview">Stream overview</span><strong id="stream-dashboard-overview">-</strong>
<small id="stream-dashboard-overview-detail" data-i18n="streamWidgetUnavailable">No stream
data</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="stream_traffic" hidden>
<span data-i18n="streamWidgetTraffic">Stream traffic</span><strong id="stream-dashboard-traffic">-</strong>
<svg preserveAspectRatio="none" aria-label="Stream traffic RX/TX" data-i18n-aria="streamWidgetTraffic" role="img"
viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line stream-chart-rx" id="stream-dashboard-rx" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line stream-chart-tx" id="stream-dashboard-tx" points="0,55 300,55"></polyline>
</svg><small class="stat-chart-legend stream-traffic-legend" id="stream-dashboard-traffic-detail">
<span class="legend-rx"><i></i>RX <b id="stream-dashboard-rx-rate">-</b></span>
<span class="legend-tx"><i></i>TX <b id="stream-dashboard-tx-rate">-</b></span>
</small>
</article>
<article class="stat" data-dashboard-widget="stream_sessions" hidden>
<span data-i18n="streamWidgetSessions">Stream sessions</span><strong id="stream-dashboard-sessions">-</strong>
<small id="stream-dashboard-sessions-detail" data-i18n="streamWidgetUnavailable">No stream
data</small>
</article>
<article class="stat" data-dashboard-widget="stream_backend_health" hidden>
<span data-i18n="streamWidgetHealth">Stream backend health</span><strong id="stream-dashboard-health">-</strong>
<small id="stream-dashboard-health-detail" data-i18n="streamWidgetUnavailable">No stream
data</small>
</article>
<article class="stat" data-dashboard-widget="backend_health" hidden>
<span data-i18n="backendWidgetHealth">Backend health</span><strong id="backend-dashboard-health">-</strong>
<small id="backend-dashboard-health-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_activity" hidden>
<span data-i18n="backendWidgetActivity">Active backend requests</span><strong
id="backend-dashboard-activity">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetActivity" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h1-line" id="backend-dashboard-active-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-activity-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_errors" hidden>
<span data-i18n="backendWidgetErrors">Backend errors</span><strong id="backend-dashboard-errors">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetErrors" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h3-line" id="backend-dashboard-error-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-errors-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_latency" hidden>
<span data-i18n="backendWidgetLatency">Health probe RTT</span><strong id="backend-dashboard-latency">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetLatency" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-latency-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-latency-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_traffic" hidden>
<span data-i18n="backendWidgetTraffic">Backend traffic</span><strong id="backend-dashboard-traffic">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetTraffic" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line stream-chart-rx" id="backend-dashboard-in-line" points="0,55 300,55">
</polyline>
<polyline class="diagnostic-line stream-chart-tx" id="backend-dashboard-out-line" points="0,55 300,55">
</polyline>
</svg><small class="stat-chart-legend stream-traffic-legend">
<span class="legend-rx"><i></i>IN <b id="backend-dashboard-in-rate">-</b></span>
<span class="legend-tx"><i></i>OUT <b id="backend-dashboard-out-rate">-</b></span>
</small>
</article>
<article class="stat" data-dashboard-widget="backend_protocols" hidden>
<span data-i18n="backendWidgetProtocols">Configured backend modes</span><strong
id="backend-dashboard-protocols">-</strong>
<div class="backend-mode-bar" role="img" data-i18n-aria="backendWidgetProtocols">
<span class="backend-mode-h1" id="backend-mode-h1"></span>
<span class="backend-mode-auto" id="backend-mode-auto"></span>
<span class="backend-mode-compat" id="backend-mode-compat"></span>
<span class="backend-mode-h2" id="backend-mode-h2"></span>
</div><small id="backend-dashboard-protocols-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_requests" hidden>
<span data-i18n="backendWidgetRequests">Backend request rate</span><strong
id="backend-dashboard-requests">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetRequests" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h1-line" id="backend-dashboard-request-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-requests-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="backend_capacity" hidden>
<span data-i18n="backendWidgetCapacity">Backend capacity</span><strong id="backend-dashboard-capacity">-</strong>
<div class="overview-meter" id="backend-dashboard-capacity-meter" role="progressbar"
data-i18n-aria="backendWidgetCapacity" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="backend-dashboard-capacity-meter-fill"></span>
</div><small id="backend-dashboard-capacity-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_http5xx" hidden>
<span data-i18n="backendWidgetHttp5xx">backendWidgetHttp5xx</span><strong id="backend-dashboard-http5xx">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetHttp5xx" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-5xx-line" points="0,55 300,55"></polyline>
</svg><small id="backend-dashboard-http5xx-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_timeouts" hidden>
<span data-i18n="backendWidgetTimeouts">backendWidgetTimeouts</span><strong
id="backend-dashboard-timeouts">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetTimeouts" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-timeout-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-timeouts-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_network" hidden>
<span data-i18n="backendWidgetNetwork">backendWidgetNetwork</span><strong id="backend-dashboard-network">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetNetwork" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-network-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-network-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_retries" hidden>
<span data-i18n="backendWidgetRetries">backendWidgetRetries</span><strong id="backend-dashboard-retries">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetRetries" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-retry-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-retries-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="backend_queue" hidden>
<span data-i18n="backendWidgetQueue">backendWidgetQueue</span><strong id="backend-dashboard-queue">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetQueue" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-queue-line" points="0,55 300,55">
</polyline>
</svg><small id="backend-dashboard-queue-detail">-</small>
</article>
<article class="stat" data-dashboard-widget="backend_rotations" hidden>
<span data-i18n="backendWidgetRotations">Backend client pool rotations</span><strong
id="backend-dashboard-rotations">-</strong>
<small id="backend-dashboard-rotations-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_process_cpu" hidden>
<span data-i18n="overviewExtraProcessCpu">overviewExtraProcessCpu</span><strong
id="overview-extra-extra_process_cpu">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraProcessCpu" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_process_cpu-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_process_cpu-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_process_rss" hidden>
<span data-i18n="overviewExtraProcessRss">overviewExtraProcessRss</span><strong
id="overview-extra-extra_process_rss">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraProcessRss" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_process_rss-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_process_rss-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_process_fds" hidden>
<span data-i18n="overviewExtraProcessFds">overviewExtraProcessFds</span><strong
id="overview-extra-extra_process_fds">-</strong>
<div class="overview-meter" id="overview-extra-extra_process_fds-meter" role="progressbar"
data-i18n-aria="overviewExtraProcessFds" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_process_fds-meter-fill"></span>
</div><small id="overview-extra-extra_process_fds-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_open_connections" hidden>
<span data-i18n="overviewExtraOpenConnections">overviewExtraOpenConnections</span><strong
id="overview-extra-extra_open_connections">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraOpenConnections" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_open_connections-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_open_connections-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_websocket_active" hidden>
<span data-i18n="overviewExtraWebsocketActive">overviewExtraWebsocketActive</span><strong
id="overview-extra-extra_websocket_active">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraWebsocketActive" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_websocket_active-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_websocket_active-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_tls_error_rate" hidden>
<span data-i18n="overviewExtraTlsErrorRate">overviewExtraTlsErrorRate</span><strong
id="overview-extra-extra_tls_error_rate">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraTlsErrorRate" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_tls_error_rate-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_tls_error_rate-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_request_error_rate" hidden>
<span data-i18n="overviewExtraRequestErrorRate">overviewExtraRequestErrorRate</span><strong
id="overview-extra-extra_request_error_rate">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraRequestErrorRate" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_request_error_rate-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_request_error_rate-detail">-</small>
</article>
<article class="stat stat-donut" data-dashboard-widget="extra_cache_hit_rate" hidden>
<span data-i18n="overviewExtraCacheHitRate">Cache hits share (recent)</span>
<div class="overview-donut-row">
<svg class="overview-donut-svg" id="extra-cache-ratio-chart" role="img"
aria-label="Recent cache HIT and MISS distribution" viewBox="0 0 64 64">
<circle class="overview-donut-track" cx="32" cy="32" r="24"></circle>
<circle class="overview-donut-hit" id="extra-cache-ratio-hit-arc" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100"></circle>
<circle class="overview-donut-miss" id="extra-cache-ratio-miss-arc" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100"></circle>
</svg>
<div class="overview-donut-info">
<strong id="overview-extra-extra_cache_hit_rate">-</strong>
<small class="overview-donut-legend" id="overview-extra-extra_cache_hit_rate-detail">
<span class="overview-donut-hit-key"><i></i>HIT <b id="extra-cache-ratio-hit-count">0</b></span>
<span class="overview-donut-miss-key"><i></i>MISS <b id="extra-cache-ratio-miss-count">0</b></span>
</small>
</div>
</div>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_cache_memory">
<span data-i18n="overviewExtraCacheMemory">overviewExtraCacheMemory</span><strong
id="overview-extra-extra_cache_memory">-</strong>
<div class="overview-meter" id="overview-extra-extra_cache_memory-meter" role="progressbar"
data-i18n-aria="overviewExtraCacheMemory" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_cache_memory-meter-fill"></span>
</div><small id="overview-extra-extra_cache_memory-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_cold_queue_memory">
<span data-i18n="overviewExtraColdQueueMemory">COLD queue RAM utilization</span>
<strong id="overview-extra-extra_cold_queue_memory">-</strong>
<div class="overview-meter" id="overview-extra-extra_cold_queue_memory-meter" role="progressbar"
data-i18n-aria="overviewExtraColdQueueMemory" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_cold_queue_memory-meter-fill"></span>
</div>
<small id="overview-extra-extra_cold_queue_memory-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_cold_storage">
<span data-i18n="overviewExtraColdStorage">COLD disk utilization</span>
<strong id="overview-extra-extra_cold_storage">-</strong>
<div class="overview-meter" id="overview-extra-extra_cold_storage-meter" role="progressbar"
data-i18n-aria="overviewExtraColdStorage" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_cold_storage-meter-fill"></span>
</div>
<small id="overview-extra-extra_cold_storage-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_h2_share" hidden>
<span data-i18n="overviewExtraH2Share">overviewExtraH2Share</span><strong
id="overview-extra-extra_h2_share">-</strong>
<div class="overview-meter" id="overview-extra-extra_h2_share-meter" role="progressbar"
data-i18n-aria="overviewExtraH2Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_h2_share-meter-fill"></span>
</div><small id="overview-extra-extra_h2_share-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_response_latency" hidden>
<span data-i18n="overviewExtraResponseLatency">overviewExtraResponseLatency</span><strong
id="overview-extra-extra_response_latency">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraResponseLatency" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_response_latency-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_response_latency-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_backend_unhealthy" hidden>
<span data-i18n="overviewExtraBackendUnhealthy">overviewExtraBackendUnhealthy</span><strong
id="overview-extra-extra_backend_unhealthy">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraBackendUnhealthy" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_backend_unhealthy-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_backend_unhealthy-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_total_rps" hidden>
<span data-i18n="overviewExtraTotalRps">HTTP requests / s</span><strong
id="overview-extra-extra_total_rps">-</strong>
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="HTTP requests / s">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_total_rps-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_total_rps-detail" data-i18n="overviewExtraRecentSamples">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_active_exchanges" hidden>
<span data-i18n="overviewExtraActiveExchanges">Active HTTP exchanges</span><strong
id="overview-extra-extra_active_exchanges">-</strong>
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="Active HTTP exchanges">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_active_exchanges-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_active_exchanges-detail" data-i18n="overviewExtraRecentSamples">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_h1_share" hidden>
<span data-i18n="overviewExtraH1Share">HTTP/1.1 share</span><strong id="overview-extra-extra_h1_share">-</strong>
<div class="overview-meter" id="overview-extra-extra_h1_share-meter" role="progressbar"
data-i18n-aria="overviewExtraH1Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_h1_share-meter-fill"></span>
</div><small id="overview-extra-extra_h1_share-detail" data-i18n="overviewExtraRecentSamples">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_h3_share" hidden>
<span data-i18n="overviewExtraH3Share">HTTP/3 share</span><strong id="overview-extra-extra_h3_share">-</strong>
<div class="overview-meter" id="overview-extra-extra_h3_share-meter" role="progressbar"
data-i18n-aria="overviewExtraH3Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_h3_share-meter-fill"></span>
</div><small id="overview-extra-extra_h3_share-detail" data-i18n="overviewExtraRecentSamples">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_cache_miss_rate" hidden>
<span data-i18n="overviewExtraCacheMissRate">Cache misses / s</span><strong
id="overview-extra-extra_cache_miss_rate">-</strong>
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="Cache misses / s">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_cache_miss_rate-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_cache_miss_rate-detail" data-i18n="overviewExtraRecentSamples">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_backend_active" hidden>
<span data-i18n="overviewExtraBackendActive">In-flight backend requests</span><strong
id="overview-extra-extra_backend_active">-</strong>
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="In-flight backend requests">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_backend_active-line" points="0,55 300,55"></polyline>
</svg><small id="overview-extra-extra_backend_active-detail" data-i18n="overviewExtraRecentSamples">-</small>
</article>
<!-- RP_METRICS_SPLIT -->
<article class="stat stat-donut" data-dashboard-widget="www_status" hidden>
<span data-i18n="webMetricStatus">webMetricStatus</span>
<div class="overview-donut-row">
<svg class="overview-donut-svg" id="www_status-chart" role="img" viewBox="0 0 64 64" aria-label="WWW status"
data-i18n-aria="webMetricStatus">
<circle class="overview-donut-track" cx="32" cy="32" r="24"></circle>
<circle class="www-ring-arc www-ring-0" id="www_status-arc-0" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-1" id="www_status-arc-1" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-2" id="www_status-arc-2" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-3" id="www_status-arc-3" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
</svg>
<div class="overview-donut-info"><strong id="www_status-value">-</strong><small
class="overview-donut-legend www-ring-legend"><span><i class="www-dot-0"></i>2xx
<b id="www_status-count-0">0</b></span>
<span><i class="www-dot-1"></i>3xx <b id="www_status-count-1">0</b></span>
<span><i class="www-dot-2"></i>4xx <b id="www_status-count-2">0</b></span>
<span><i class="www-dot-3"></i>5xx <b id="www_status-count-3">0</b></span></small></div>
</div>
</article>
<article class="stat stat-donut" data-dashboard-widget="www_kind" hidden>
<span data-i18n="webMetricKind">webMetricKind</span>
<div class="overview-donut-row">
<svg class="overview-donut-svg" id="www_kind-chart" role="img" viewBox="0 0 64 64" aria-label="Static / PHP"
data-i18n-aria="webMetricKind">
<circle class="overview-donut-track" cx="32" cy="32" r="24"></circle>
<circle class="www-ring-arc www-ring-0" id="www_kind-arc-0" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-1" id="www_kind-arc-1" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
<circle class="www-ring-arc www-ring-2" id="www_kind-arc-2" cx="32" cy="32" r="24" pathLength="100"
stroke-dasharray="0 100" />
</svg>
<div class="overview-donut-info"><strong id="www_kind-value">-</strong><small
class="overview-donut-legend www-ring-legend"><span><i class="www-dot-0"></i>Static <b
id="www_kind-count-0">0</b></span>
<span><i class="www-dot-1"></i>PHP <b id="www_kind-count-1">0</b></span>
<span><i class="www-dot-2"></i>Other <b id="www_kind-count-2">0</b></span></small></div>
</div>
</article>
<article class="stat stat-meter" data-dashboard-widget="www_capacity" hidden>
<span data-i18n="webMetricCapacity">webMetricCapacity</span><strong id="www_capacity-value">-</strong>
<div class="overview-meter" id="www_capacity-meter" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuetext="-">
<span class="overview-meter-fill" id="www_capacity-meter-fill"></span>
</div><small id="www_capacity-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="www_error_rate" hidden>
<span data-i18n="webMetricErrorRate">webMetricErrorRate</span><strong id="www_error_rate-value">-</strong>
<div class="overview-meter" id="www_error_rate-meter" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuetext="-">
<span class="overview-meter-fill" id="www_error_rate-meter-fill"></span>
</div><small id="www_error_rate-detail">-</small>
</article>
<article class="stat" data-dashboard-widget="www_methods" hidden>
<span data-i18n="webMetricMethods">HTTP Methods</span><strong id="www_methods-value">-</strong>
<div class="www-segmented" id="www_methods-bar" role="img" aria-label="HTTP method share"
data-i18n-aria="webMetricMethods"><span class="www-segment-0" id="www_methods-segment-0"></span><span
class="www-segment-1" id="www_methods-segment-1"></span><span class="www-segment-2"
id="www_methods-segment-2"></span><span class="www-segment-3" id="www_methods-segment-3"></span><span
class="www-segment-4" id="www_methods-segment-4"></span></div>
<small class="www-method-legend" id="www_methods-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="www_php_requests" hidden>
<span data-i18n="webMetricPhpRequests">PHP requests/s</span><strong id="www_php_requests-value">-</strong>
<svg preserveAspectRatio="none" role="img" viewBox="0 0 300 60" aria-label="PHP requests per second"
data-i18n-aria="webMetricPhpRequests">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line proto-h2-line" id="www_php_requests-line" points="0,55 300,55"></polyline>
</svg>
<small data-i18n="webMetricPerSecond">Requests per second</small>
</article>
+347 -2
View File
@@ -3508,6 +3508,10 @@ body[data-density=dense] .stat strong {
margin: 6px 0
}
#panel-cache>article.card+article.card {
margin-top: 10px
}
.cache-table {
font-size: 12px
}
@@ -11210,7 +11214,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
border-radius: var(--rp-radius-surface) !important;
}
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(button:not(.nav):not(.file-log-tab), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-hidden-toggle, .overview-inline-restore) {
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(button:not(.nav):not(.file-log-tab):not(.vhost-backend-trigger), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-hidden-toggle, .overview-inline-restore) {
border-radius: var(--rp-radius-control) !important;
}
@@ -11237,6 +11241,16 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
padding: 3px 7px;
font: 10px var(--mono);
overflow-wrap: anywhere;
text-align: left;
}
.vhost-backend-protocol-badge:is(:hover, [aria-expanded=true]) {
background: var(--bg);
}
.vhost-backend-protocol-badge:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vhost-backend-protocol-badge[data-observed=true] {
@@ -11596,7 +11610,6 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
color: var(--text);
}
/* Web Services reuse the same full-width live tiles and chart as Vhosts. */
#panel-web-server .www-service-panel {
min-width: 0;
margin-top: 10px;
@@ -11814,3 +11827,335 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
padding-top: 135px;
}
}
#panel-overview :is(.system-card, .connection-card) :is(.card-heading h2, .card-heading .meta, .memory-line, .chart-footer, .connection-legend) {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
#panel-overview :is(.system-card, .connection-card) .card-heading h2 {
letter-spacing: normal;
}
.overview-presets-dialog {
width: calc(min(730px, 100vw - 24px));
max-height: calc(100dvh - 24px);
overflow-y: auto;
}
.overview-presets-content {
display: grid;
gap: 14px;
padding: 14px;
}
.overview-presets-content>p {
margin: 0;
}
.overview-presets-create {
display: grid;
gap: 7px;
}
.overview-presets-create-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.overview-presets-create-controls input {
min-width: 150px;
flex: 1 1 240px;
}
.overview-presets-list {
display: grid;
gap: 7px;
}
.overview-presets-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius, 8px);
background: var(--panel);
}
.overview-presets-row strong {
font-size: 13px;
overflow-wrap: anywhere;
}
.overview-presets-actions {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.overview-presets-dialog .overview-layout-footer {
flex-wrap: wrap;
}
@media(max-width:480px) {
.overview-presets-create-controls>button {
flex: 1 1 auto;
}
.overview-presets-row {
align-items: flex-start;
flex-direction: column;
}
.overview-presets-actions {
width: 100%;
}
}
#panel-overview .activity-card .card-heading h2,
#panel-overview .overview-wide-chart .card-heading h2,
#panel-overview :is(.stat-chart, .stat-donut)>span:first-child,
#panel-overview :is(.system-card, .connection-card) .overview-chart-heading,
.function-live-chart-heading>strong,
.mini-live-chart>.mini-live-chart-head>span:first-child,
.traffic-chart-card>h3.subheading,
.vhost-chart-head>span:first-child {
font-family: var(--sans);
font-size: 12px;
font-weight: 650;
line-height: 1.35;
letter-spacing: normal;
text-transform: uppercase;
}
html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.activity-card .card-heading h2,
html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.overview-wide-chart .card-heading h2,
html[data-dashboard-density] #panel-overview .stat:is(.stat-chart, .stat-donut)>span:first-child {
font-size: 12px;
}
#panel-overview :is(.system-card, .connection-card) .overview-chart-heading {
color: var(--text);
margin: 0 0 5px;
}
:is(.overview-wide-summary, .overview-wide-legend, .overview-donut-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .traffic-chart-legend, .function-live-chart-legend, .stream-chart-legend, .chart-footer, .vhost-chart-head, .network-interface-rate) {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
#panel-overview .overview-wide-chart>.fine,
#panel-overview .stat-chart>small,
#panel-overview .www-top-row,
.traffic-bar-row {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
#panel-overview :is(.overview-wide-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .stream-traffic-legend),
:is(.overview-wide-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .traffic-chart-legend, .function-live-chart-legend, .stream-chart-legend, .stream-traffic-legend) {
font-family: var(--mono);
font-size: 9px;
font-variant-numeric: tabular-nums;
}
#panel-overview :is(.overview-wide-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .stream-traffic-legend) :is(span, b, strong, small, li, em),
:is(.overview-wide-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stat-chart-legend, .mini-chart-legend, .connection-legend, .traffic-protocol-legend, .traffic-chart-legend, .function-live-chart-legend, .stream-chart-legend, .stream-traffic-legend) :is(span, b, strong, small, li, em) {
font-size: inherit;
font-family: inherit;
}
html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stream-traffic-legend),
html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .overview-donut-legend, .www-ring-legend, .www-method-legend, .stream-traffic-legend) :is(span, b, small, strong, li) {
font-size: 9px;
font-family: var(--mono);
}
.vhost-detail.vhost-backend-trigger,
.vhost-live-metric.vhost-backend-trigger {
appearance: none;
display: block;
width: 100%;
min-width: 0;
margin: 0;
border-radius: 0;
color: var(--text);
text-align: left;
font: inherit;
cursor: pointer;
}
.vhost-detail.vhost-backend-trigger {
border: 0;
background: var(--bg);
}
.vhost-live-metric.vhost-backend-trigger {
border: 1px solid var(--line);
background: transparent;
}
.vhost-backend-trigger:is(:hover, [aria-expanded=true]) {
background: var(--panel2);
}
.vhost-backend-trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vhost-backend-popup {
position: fixed;
z-index: 1600;
max-height: calc(min(360px, 100dvh - 24px));
overflow: auto;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--rp-radius-surface, 0);
box-shadow: 0 12px 35px var(--shadow-strong);
color: var(--text);
font-size: 11px;
}
.vhost-backend-popup-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin: 0 0 10px;
}
.vhost-backend-popup h4 {
margin: 0;
font-size: 12px;
font-weight: 650;
overflow-wrap: anywhere;
}
.vhost-backend-popup-close {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 25px;
height: 25px;
padding: 0;
border: 1px solid var(--line);
background: var(--bg);
color: var(--text);
cursor: pointer;
font: 18px/1 var(--sans);
border-radius: var(--rp-radius-control, 0);
}
.vhost-backend-popup-close:hover {
background: var(--panel2);
}
.vhost-backend-popup-list {
display: grid;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
}
.vhost-backend-popup-row {
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--rp-radius-surface, 0);
background: var(--bg);
min-width: 0;
}
.vhost-backend-popup-name {
display: block;
margin-bottom: 6px;
overflow-wrap: anywhere;
font: 650 11px var(--mono);
}
.vhost-backend-popup-columns {
display: grid;
grid-template-columns: minmax(0, 1.75fr) minmax(0, 1fr);
gap: 6px 10px;
}
.vhost-backend-popup-field {
min-width: 0;
}
.vhost-backend-popup-field>span {
display: block;
}
.vhost-backend-popup-field>span:first-child {
font-size: 9px;
color: var(--muted);
}
.vhost-backend-popup-field>span:last-child {
font: 10px/1.35 var(--mono);
overflow-wrap: anywhere;
}
.vhost-backend-popup-field.has-success>span:last-child {
color: var(--green);
}
.vhost-backend-popup-field.has-error>span:last-child {
color: var(--red);
}
.vhost-backend-popup-hint {
margin: 10px 0 0;
line-height: 1.4;
}
#panel-overview .dashboard-overview-grid>.card .mini-heading>h3,
#panel-overview .dashboard-overview-grid>.card .overview-chart-heading,
#panel-overview .dashboard-overview-grid>.card:is(.overview-wide-chart, .activity-card, .pool-card) .card-heading h2,
#panel-overview .dashboard-overview-grid>.stat>span:first-child {
font-family: var(--sans);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
letter-spacing: normal;
text-transform: none;
}
#panel-overview .dashboard-overview-grid>[data-dashboard-widget] :is(small, .mini-metrics dt, .mini-metrics dd, .mini-live-chart-head, .memory-line, .chart-footer, .small-label, .meta, .fine) {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
text-transform: none;
}
#panel-overview .dashboard-overview-grid .mini-live-chart>.mini-live-chart-head>span:first-child {
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
line-height: 1.35;
letter-spacing: normal;
text-transform: none;
}
html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool-card .card-heading h2 {
font-size: 12px;
}
#panel-cache>.card .card-heading h2,
#panel-overview .dashboard-overview-grid .mini-live-chart-head>span:first-child,
#panel-overview .dashboard-overview-grid>.card .mini-heading>h3,
#panel-overview .dashboard-overview-grid>.card .overview-chart-heading,
#panel-overview .dashboard-overview-grid>.card:not(.system-card):not(.connection-card) .card-heading h2,
#panel-overview .dashboard-overview-grid>.stat>span:first-child,
#panel-transform>.card .card-heading h2,
.function-live-chart-heading>strong,
.traffic-chart-card>h3.subheading,
.vhost-chart-head>span:first-child {
color: var(--muted);
}
+177 -9
View File
@@ -1,3 +1,158 @@
// Shared, anchored Vhost popover for backend quick-view and upstream HTTP
// protocol observations. Both consume existing live status without extra probes.
let activeVhostBackendPopup = null;
function closeVhostBackendPopup(returnFocus = false) {
const current = activeVhostBackendPopup;
if (!current) return;
activeVhostBackendPopup = null;
current.trigger.setAttribute("aria-expanded", "false");
current.node.remove();
if (returnFocus && current.trigger.isConnected) current.trigger.focus();
}
function positionVhostBackendPopup() {
const current = activeVhostBackendPopup;
if (!current) return;
const rect = current.trigger.getBoundingClientRect(), edge = 12, gap = 6;
const width = Math.min(430, Math.max(0, window.innerWidth - edge * 2));
const height = Math.min(current.node.scrollHeight, 360, Math.max(100, window.innerHeight - edge * 2));
const below = window.innerHeight - rect.bottom - edge - gap;
const above = rect.top - edge - gap;
const placeAbove = below < height && above > below;
const desiredTop = placeAbove ? rect.top - height - gap : rect.bottom + gap;
current.node.style.width = `${width}px`;
current.node.style.left = `${Math.max(edge, Math.min(rect.left, window.innerWidth - width - edge))}px`;
current.node.style.top = `${Math.max(edge, Math.min(desiredTop, window.innerHeight - height - edge))}px`;
}
function vhostPopupHeading(label) {
const heading = el("h4", label);
heading.id = "vhost-backend-popup-heading";
const headingRow = el("div", undefined, "vhost-backend-popup-heading");
const closeButton = el("button", "×", "vhost-backend-popup-close");
closeButton.type = "button";
closeButton.setAttribute("aria-label", t("close"));
closeButton.addEventListener("click", () => closeVhostBackendPopup(true));
headingRow.append(heading, closeButton);
return { heading, headingRow };
}
function refreshVhostProtocolPopup(current, vhost, members) {
const backend = (vhost.backends || []).find(item => item.id === current.backendId);
if (!backend) { closeVhostBackendPopup(); return; }
const member = members.get(backend.id);
const observed = upstreamProtocolBadgeText(backend, member);
if (!current.protocolView) {
const { heading, headingRow } = vhostPopupHeading(observed.label);
const info = el("p", "", "fine vhost-backend-popup-hint");
const list = el("ul", undefined, "vhost-backend-popup-list");
const row = el("li", undefined, "vhost-backend-popup-row");
const columns = el("div", undefined, "vhost-backend-popup-columns");
const counters = [];
for (const protocol of ["H1", "H2"]) {
const field = el("div", undefined, "vhost-backend-popup-field");
const value = el("span", "0");
field.append(el("span", protocol), value);
columns.append(field); counters.push(value);
}
row.append(columns); list.append(row);
current.node.append(headingRow, info, list);
current.node.setAttribute("aria-labelledby", heading.id);
current.protocolView = { heading, info, list, counters };
}
const view = current.protocolView;
const explanation = t(observed.recent ? "vhBackendProtocolRecent" : "vhBackendProtocolUnobserved");
if (view.heading.textContent !== observed.label) view.heading.textContent = observed.label;
if (view.info.textContent !== explanation) view.info.textContent = explanation;
view.list.hidden = !observed.recent;
if (observed.recent) {
const totals = [member.upstream_protocol_http1_total || 0, member.upstream_protocol_http2_total || 0];
totals.forEach((total, index) => {
const label = String(total);
if (view.counters[index].textContent !== label) view.counters[index].textContent = label;
});
}
positionVhostBackendPopup();
}
function refreshVhostBackendPopup(status = latestStatus) {
const current = activeVhostBackendPopup;
if (!current) return;
const vhost = (envelope?.config.vhosts || []).find(item => item.id === current.id);
if (!vhost || !current.trigger.isConnected) { closeVhostBackendPopup(); return; }
const pool = (status?.backend_pools || []).find(item => item.id === vhost.id);
const members = new Map((pool?.members || []).map(member => [member.id, member]));
if (current.backendId !== null) {
refreshVhostProtocolPopup(current, vhost, members);
return;
}
const { heading, headingRow } = vhostPopupHeading(`${t("vhBackendPopupTitle")} · ${vhost.id}`);
const list = el("ul", undefined, "vhost-backend-popup-list");
for (const backend of vhost.backends || []) {
const member = members.get(backend.id);
const row = el("li", undefined, "vhost-backend-popup-row");
row.append(el("strong", backend.id, "vhost-backend-popup-name"));
let address = "-", scheme = "-";
try {
const url = new URL(backend.url);
scheme = url.protocol.replace(":", "").toUpperCase();
const port = url.port || (scheme === "HTTPS" ? "443" : scheme === "HTTP" ? "80" : "");
address = `${url.hostname}${port ? `:${port}` : ""}`;
} catch { /* Configuration may have changed while status was refreshing. */ }
const upstream = upstreamProtocolBadgeText(backend, member);
const protocol = `${scheme} · ${upstream.mode}${upstream.observed ? ` → ${upstream.observed}` : ""}`;
const statusCode = Number(member?.last_status);
const hasStatus = member && Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599;
const httpResponse = hasStatus ? `HTTP ${statusCode}` : t("vhBackendPopupNoResponse");
const statusLabel = !member ? t("vhHealthUnknown") : t(({
up: "vhUp", down: "vhDown", disabled: "vhDisabled", checking: "vhChecking", draining: "vhDraining"
})[member.status] || "vhHealthUnknown");
const columns = el("div", undefined, "vhost-backend-popup-columns");
for (const [key, value] of [
["vhBackendPopupAddress", address], ["vhBackendPopupProtocol", protocol],
["vhBackendPopupLastHttp", httpResponse], ["status", statusLabel]
]) {
const field = el("div", undefined, "vhost-backend-popup-field");
field.append(text("span", key), el("span", value));
if (key === "vhBackendPopupLastHttp" && hasStatus) field.classList.add(statusCode >= 500 ? "has-error" : statusCode < 400 ? "has-success" : "");
columns.append(field);
}
row.append(columns); list.append(row);
}
if (!list.children.length) list.append(el("li", t("vhBackendPopupEmpty"), "fine"));
const foot = el("p", t("vhBackendPopupHint"), "fine vhost-backend-popup-hint");
current.node.replaceChildren(headingRow, list, foot);
current.node.setAttribute("aria-labelledby", heading.id);
positionVhostBackendPopup();
}
function toggleVhostBackendPopup(id, trigger, backendId = null) {
if (activeVhostBackendPopup?.trigger === trigger) { closeVhostBackendPopup(); return; }
closeVhostBackendPopup();
const node = el("section", undefined, "vhost-backend-popup");
node.id = "vhost-backend-popup";
node.setAttribute("role", "dialog");
node.setAttribute("aria-modal", "false");
trigger.setAttribute("aria-expanded", "true");
activeVhostBackendPopup = { id, trigger, node, backendId, protocolView: null };
document.body.append(node);
refreshVhostBackendPopup();
}
function enableVhostBackendPopup(trigger, vhost, backendId = null) {
trigger.dataset.vhostBackendTrigger = vhost.id;
trigger.setAttribute("aria-haspopup", "dialog");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-controls", "vhost-backend-popup");
if (backendId === null) trigger.setAttribute("aria-label", `${t("vhBackendPopupOpen")}: ${vhost.id}`);
trigger.addEventListener("click", () => toggleVhostBackendPopup(vhost.id, trigger, backendId));
}
document.addEventListener("pointerdown", event => {
const current = activeVhostBackendPopup;
if (current && !current.node.contains(event.target) && !current.trigger.contains(event.target)) closeVhostBackendPopup();
});
document.addEventListener("keydown", event => {
if (event.key === "Escape" && activeVhostBackendPopup) { event.preventDefault(); event.stopPropagation(); closeVhostBackendPopup(true); }
});
window.addEventListener("resize", positionVhostBackendPopup);
document.addEventListener("scroll", event => {
if (activeVhostBackendPopup && !activeVhostBackendPopup.node.contains(event.target)) positionVhostBackendPopup();
}, true);
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
@@ -107,7 +262,8 @@ function upstreamProtocolBadgeText(backend, member, now = Date.now()) {
const seen = Number(member?.upstream_protocol_last_seen_ms);
const recent = member && (member.upstream_protocol_observed === "h1" || member.upstream_protocol_observed === "h2") &&
Number.isFinite(seen) && seen > 0 && now >= seen && now - seen <= VHOST_PROTOCOL_FRESH_MS;
return { label: `${backend.id} · ${mode} → ${recent ? member.upstream_protocol_observed.toUpperCase() : "?"}`, recent };
const observed = recent ? member.upstream_protocol_observed.toUpperCase() : null;
return { label: `${backend.id} · ${mode} → ${observed || "?"}`, recent, mode, observed };
}
function renderUpstreamProtocolBadges(status) {
const members = new Map((status.backend_pools || []).flatMap(pool => (pool.members || []).map(member =>
@@ -119,13 +275,12 @@ function renderUpstreamProtocolBadges(status) {
if (!backend) return;
const member = members.get(`${vhost}\u0000${id}`);
const observed = upstreamProtocolBadgeText(backend, member, now);
node.textContent = observed.label;
if (node.textContent !== observed.label) node.textContent = observed.label;
node.dataset.observed = String(!!observed.recent);
const counts = member && observed.recent ? ` H1: ${member.upstream_protocol_http1_total || 0}; H2: ${member.upstream_protocol_http2_total || 0}.` : "";
node.title = `${t(observed.recent ? "vhBackendProtocolRecent" : "vhBackendProtocolUnobserved")}${counts}`;
});
}
function render() {
closeVhostBackendPopup();
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
const config = envelope.config;
@@ -209,10 +364,21 @@ function render() {
: t("webServer"))
: `${(v.backends || []).length}`],
];
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
for (const [key, value] of detailItems) {
const isBackend = key === "vhBackendPool";
const item = el(isBackend ? "button" : "div", undefined, `vhost-detail${isBackend ? " vhost-backend-trigger" : ""}`);
if (isBackend) { item.type = "button"; enableVhostBackendPopup(item, v); }
item.append(text("span", key, "small-label"), el("b", value)); details.append(item);
}
box.append(details);
const live = el("div", undefined, "vhost-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
const metric = (key, suffix) => {
const isHealth = key === "status";
const item = el(isHealth ? "button" : "div", undefined, `vhost-live-metric${isHealth ? " vhost-backend-trigger" : ""}`);
if (isHealth) { item.type = "button"; enableVhostBackendPopup(item, v); }
item.append(text("span", key), el("strong", "-"));
item.querySelector("strong").dataset[suffix] = v.id; live.append(item);
};
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
box.append(live);
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
@@ -243,11 +409,12 @@ function render() {
// Retain their own badge class so observed H1/H2 keeps its green styling.
if (hasProxySource) {
for (const backend of v.backends || []) {
const badge = el("span", undefined, "vhost-backend-protocol-badge");
const badge = el("button", undefined, "vhost-backend-protocol-badge");
badge.type = "button";
badge.dataset.upstreamProtocolVhost = v.id;
badge.dataset.upstreamProtocolBackend = backend.id;
badge.textContent = upstreamProtocolBadgeText(backend, null).label;
badge.title = t("vhBackendProtocolUnobserved");
enableVhostBackendPopup(badge, v, backend.id);
policy.append(badge);
}
}
@@ -282,6 +449,7 @@ function vhostTrafficRate(id, metrics, snapshot, now, seen) {
}
function renderStatus(status) {
renderUpstreamProtocolBadges(status);
refreshVhostBackendPopup(status);
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
@@ -415,5 +583,5 @@ $("pool-open").addEventListener("click", () => setPanel("vhosts"));
initPoolTableTools();
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { closeVhostBackendPopup(); dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
+6 -2
View File
@@ -2,6 +2,7 @@
//! No database call is made from the public HTTP forwarding path.
use crate::config::{Config, Envelope};
mod certificate_api;
mod dashboard_presets;
mod history;
use anyhow::{ensure, Context, Result};
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
@@ -12,7 +13,7 @@ use sqlx::{
};
use std::{path::Path, time::Duration};
pub const SCHEMA_VERSION: i64 = 9;
pub const SCHEMA_VERSION: i64 = 10;
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
pub const AUDIT_CAPACITY: i64 = 10_000;
@@ -123,6 +124,9 @@ impl Store {
))
.execute(&mut *tx)
.await?;
sqlx::raw_sql(include_str!("../migrations/0010_dashboard_presets.sql"))
.execute(&mut *tx)
.await?;
sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
.bind(serde_json::to_string(initial)?)
.execute(&mut *tx)
@@ -133,7 +137,7 @@ impl Store {
.bind(crate::auth::now())
.execute(&mut *tx)
.await?;
sqlx::query("PRAGMA user_version = 9")
sqlx::query("PRAGMA user_version = 10")
.execute(&mut *tx)
.await?;
tx.commit().await?;
+83
View File
@@ -0,0 +1,83 @@
//! SQLite persistence for user-named Overview snapshots (control plane only).
use super::Store;
use anyhow::{ensure, Result};
use sqlx::Row;
impl Store {
pub async fn list_admin_dashboard_presets(
&self,
username: &str,
) -> Result<Vec<(String, String)>> {
let rows = sqlx::query(
"SELECT p.name, p.document FROM dashboard_presets p \
JOIN administrator a ON a.id = p.administrator_id \
WHERE a.id = 1 AND a.username = ? ORDER BY p.name COLLATE NOCASE",
)
.bind(username)
.fetch_all(&self.pool)
.await?;
rows.iter()
.map(|row| Ok((row.try_get("name")?, row.try_get("document")?)))
.collect()
}
pub async fn save_admin_dashboard_preset(
&self,
username: &str,
name: &str,
document: &str,
) -> Result<bool> {
ensure!(
document.len() >= 2 && document.len() <= 8192,
"invalid dashboard preset size"
);
// Limit to 16 user-named presets. The cap and upsert are checked in one
// SQLite statement, including under concurrent requests.
let result = sqlx::query(
"INSERT INTO dashboard_presets(administrator_id, name, document, updated_at) \
SELECT a.id, ?, ?, ? FROM administrator a WHERE a.id = 1 AND a.username = ? \
AND ((SELECT count(*) FROM dashboard_presets WHERE administrator_id = a.id) < 16 \
OR EXISTS(SELECT 1 FROM dashboard_presets WHERE administrator_id = a.id AND name = ?)) \
ON CONFLICT(administrator_id, name) DO UPDATE SET \
document = excluded.document, updated_at = excluded.updated_at",
)
.bind(name).bind(document).bind(crate::auth::now())
.bind(username).bind(name)
.execute(&self.pool).await?;
Ok(result.rows_affected() == 1)
}
pub async fn delete_admin_dashboard_preset(&self, username: &str, name: &str) -> Result<bool> {
let result = sqlx::query(
"DELETE FROM dashboard_presets WHERE administrator_id = \
(SELECT id FROM administrator WHERE id = 1 AND username = ?) AND name = ?",
)
.bind(username)
.bind(name)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
pub async fn rename_admin_dashboard_preset(
&self,
username: &str,
old: &str,
new: &str,
) -> Result<bool> {
// UPDATE OR IGNORE ensures an existing target name is never overwritten.
let result = sqlx::query(
"UPDATE OR IGNORE dashboard_presets SET name = ?, updated_at = ? \
WHERE administrator_id = (SELECT id FROM administrator WHERE id = 1 AND username = ?) \
AND name = ? AND name <> ?",
)
.bind(new)
.bind(crate::auth::now())
.bind(username)
.bind(old)
.bind(new)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
}