Compare commits

...
2 Commits
Author SHA1 Message Date
Mateusz Gruszczyński 7e625c5876 v0.33.7_docker_hostnames 2026-10-22 19:00:26 +02:00
Mateusz Gruszczyński f462ca7194 v0.33.7 2026-10-15 23:29:15 +02:00
29 changed files with 1017 additions and 730 deletions

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. 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 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 ## Delivery
+18
View File
@@ -3,6 +3,24 @@
> Operator-facing release history. Historical engineering notes are kept in > Operator-facing release history. Historical engineering notes are kept in
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.33.7
- Docker agent: suggest container names rather than name-port for newly published vhosts. Append port only for conflicts; preserve stable service keys and existing vhost hostnames.
- Replace the redundant **Arrange Overview** visual editor modal with an
account-scoped Dashboard preset manager. Save, load/overwrite and delete up
to 20 named presets containing Dashboard density, card order/visibility and
custom card widths; existing Settings and inline layout controls remain the
place for manual card configuration. No database schema migration.
- Normalize saved presets when Dashboard cards change between releases and
validate preset names/layouts through the existing preferences API. Document
the new `dashboard_presets` field in OpenAPI and keep English/Polish UI packs
in sync.
- Use the same monospace font family as **CONNECTION ACTIVITY** for titles on
compact chart cards, wide Overview/WWW charts and the request-rate chart,
without changing values, chart geometry, sampling or scaling.
- Add simulated Chromium coverage for preset save/load/delete, responsive modal
layout and chart-title font consistency.
## 0.33.6 ## 0.33.6
- Normalize the height of all 14 large Overview/WWW plots to 125px, avoiding - Normalize the height of all 14 large Overview/WWW plots to 125px, avoiding
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]] [[package]]
name = "rust-proxy" name = "rust-proxy"
version = "0.33.6" version = "0.33.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rust-proxy" name = "rust-proxy"
version = "0.33.6" version = "0.33.7"
edition = "2021" edition = "2021"
rust-version = "1.99" rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration" description = "RustProxy - independent reverse proxy with local administration"
+15 -1
View File
@@ -1,4 +1,18 @@
# RustProxy 0.33.6 # RustProxy 0.33.7
## 0.33.7: Dashboard presets and chart title typography
- **Dashboard presets** replaces the redundant **Arrange Overview** visual-editor
modal. Save the current Dashboard density, card order, visibility and custom
widths under a name, then load or delete the preset later. Presets are scoped
to the administrator account and stored in the existing preferences document;
no database migration or extra telemetry request is introduced.
- Actual card editing remains in **Unlock layout** on Overview and in the existing
Dashboard layout controls under Settings. The preset modal reads the currently
saved Dashboard state and applies a selected snapshot in one preferences update.
- Overview chart titles now use the same monospace font family as
**CONNECTION ACTIVITY**, including compact chart cards, wide charts and
**Requests per second**. Chart data, scales and sampling are unchanged.
## 0.33.5: fixed CPU chart scale on Overview ## 0.33.5: fixed CPU chart scale on Overview
+11
View File
@@ -1,5 +1,16 @@
# RustProxy roadmap # RustProxy roadmap
## 0.33.7 - Dashboard presets and chart heading typography
- [x] Replace the duplicated Arrange Overview modal with named Dashboard
presets stored in account preferences.
- [x] Save/load card order, visibility, widths and Dashboard density while
preserving Settings and inline layout editing for manual configuration.
- [x] Validate and reconcile presets across Dashboard registry changes and
expose the preference shape in OpenAPI without a database migration.
- [x] Match all Overview chart-title font families to CONNECTION ACTIVITY.
- [x] Add responsive Chromium regression coverage for presets and typography.
## 0.33.6 - Stable dashboard chart ranges and plot heights ## 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 - [x] Fix all 14 Overview/WWW full-width SVG heights to 125px and align their
+38 -34
View File
@@ -1,26 +1,26 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
ffae7ce20cc0fb1990394822515c933843ba39304aa44134403f0d25a6a66d9f AGENTS.md c793a8be7f4b76350e04ab36dbeec3792bb2dcf33c87a86f76fa2f330e26dd9f AGENTS.md
8b72ed7b17505c129b60faf97ee6a838b855e4303dc390ea7196b0d97ee79134 CHANGELOG.md 538acee273827bd09041e25fe7ee804284eb66ade85537cf3dcd7b968ec33b43 CHANGELOG.md
a35a5d557bb57df37d130a03752ffc092f005cb4849733d3d81485fb9450a931 Cargo.lock d5dc2b778af98f52ad52a7515e2d1b4ba8c2e01a04cc6e02564377075b01c501 Cargo.lock
8b77b2beffb31dd14ff62e225ff5384084d2b4e66faa100aaf38470883fca879 Cargo.toml 7fd5dd1ccae8fac77bbbea4bbf960f5cf762f712ff17a2e629a5dd8629eb7ff9 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
dfe12a6a0d360e0febec982f30098123a8974007c7c3fba39509be5c45d3ebbd README.md 6f9f4d0fde39289402388744c4d023518aa651126ac2119d2e774ef13cb4a202 README.md
65340bb1f93da256043cb6bd75b6c9de8539e31f0417f24c6f05093a27f8319f ROADMAP.md 442ada0145d069fa017da6c9fe3f2099b7e5f8bfcad9894c44c19d678eae3c20 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 82fbf6deba6841f843ea5fb419efa03ee820c7fb2af00b1bf4d8f6cddad52b9e docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
bf687a851374d8f1fa59d050f3f76532f7c12f16a6daa7d3c36102c2d9a8ec9c docker-agent/docker.go dcdf8b060d9529ceb8c286d0a592fa4bd876bd7fdcd1a178e4295e13cd88e6c0 docker-agent/docker.go
9e9f3f17f38f9b0730f598f65988ce8ef562d2182ef6778fc7292b990e9cf22f docker-agent/go.mod 9e9f3f17f38f9b0730f598f65988ce8ef562d2182ef6778fc7292b990e9cf22f docker-agent/go.mod
9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh 9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh
b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go
3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go 34552a436bc88a49d1a82a60875d4bd1c0c0b808759b6a099a7f954f44701ab0 docker-agent/main_test.go
68ad007eac216f115809695d3c2c6651e2850ff3d7334685ae6e49bc21b01087 docker-agent/model.go 68ad007eac216f115809695d3c2c6651e2850ff3d7334685ae6e49bc21b01087 docker-agent/model.go
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
@@ -62,9 +62,9 @@ cd4ed3af26eb51c9ff6ef3dda9d52ff5120bcfc78c842bce34a639cf85b6aea9 docs/ROADMAP_H
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
4dcf3243a13d9ae1827b5cefdbb9e27bd6310321c50d4d5934527d44437df6ef docs/SETTINGS.md 84d1523a18dc98d266a290ed7fb8fe2624bb37fd54f4f0b991ba37a7624d8a3a docs/SETTINGS.md
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
72baf37ed0989de65123eba73f83d8c31cba7415382dd00e639d8ca412f0ef0d docs/TEST_REPORT.md 193704656501643c86470be699d2e1c05b5fbb678f68ffac1b3f5c51172bf433 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
@@ -96,8 +96,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
7783e629ca4b2c1614e776838bf90f3692d521e50ee580aa5e92236c92ada844 langs/en_US.json e4e83b0ee5726c97b13f38aba484657fbc762ceab7a8774558309bc3e655c27c langs/en_US.json
58f2fdaf0aa9d3a3ce25d47659501a8b3df622e0c08eee0a4baaad4d5f1ac660 langs/pl_pl.json c0e065698befbfdf9ae2a7ba029fcd66ad65c90bb75fe7a6fa0899babaaabd2a langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -123,7 +123,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
63ed91f4cd77450cfe04f4bdb6e5f866b7868afa10207f78041e6fcd86d96fb0 scripts/preflight.py fb24401390718b121e420530c1421535cc79d49c444f6e407e600056a079ee7f scripts/preflight.py
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh 3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
@@ -132,13 +132,15 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
cc7026ec9c5b69850a815816df093de0ffdf4aeadabb3af702783483122ede55 scripts/tests/dashboard_chart_headers_0335_browser_test.py cc7026ec9c5b69850a815816df093de0ffdf4aeadabb3af702783483122ede55 scripts/tests/dashboard_chart_headers_0335_browser_test.py
aa3ca7419d6d7d506c5aaf4d624d20cc7e8ee66bec3cb0cf9b5e7d164bcbab57 scripts/tests/dashboard_chart_width_0333_browser_test.py aa3ca7419d6d7d506c5aaf4d624d20cc7e8ee66bec3cb0cf9b5e7d164bcbab57 scripts/tests/dashboard_chart_width_0333_browser_test.py
fdcea6dd1e3bc160abe1139bf94b45c9a28f6aaf7afbe7e38fb452ee4892b8f1 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py 247da431b28c113116d87febab20ca744f101443fe0884d23fa80ce99edc52a6 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py
52eacb467df123dc4a35f14933e006b090c9238df1b14dcff856a16b37c8063f scripts/tests/dashboard_layout_0331_browser_test.py 0ef8a6dc914e690cca42727cfcb9a726897c4f378465be3741d0944e9b0576bb scripts/tests/dashboard_layout_0331_browser_test.py
276e732fc2dfb0d39badb159e4fdcba0d7d73509c60ad5c9df49621c03046f1b scripts/tests/dashboard_metric_visuals_ui_test.mjs 515c7accb906753ee51d805c242eab17519f2cb8e5af1a5068c1168c0b54cd78 scripts/tests/dashboard_metric_visuals_ui_test.mjs
61a0fb2c4de86f25debec9662aedd923bce8c2ccd51e75593d485b2e0c9b8551 scripts/tests/dashboard_modal_0333_browser_test.py b67e5974427e51d417e2302e222e92f7e74bba78f1326ab07b6945ad1b618866 scripts/tests/dashboard_modal_0333_browser_test.py
0c8a566979b51cd62601d138697ccd5c4ed47b8fa33e8faa28fa3a085a72ad97 scripts/tests/dashboard_resizing_0333_browser_test.py 3ab97f6536530ef17a246f7b747c6dc7b33fa9e9cf83ef0267e94b0c67202716 scripts/tests/dashboard_presets_0337_browser_test.py
f82d57d3c01858eb60aa6882f7d3470e6587d7fc60e7ee6dbd8c5bec1f223c66 scripts/tests/dashboard_resume_rate_charts_ui_test.mjs ea656e87ee5803789111311c3c2d4ebc3ed2b0b3a2509a0848717a80ab39b1c5 scripts/tests/dashboard_resizing_0333_browser_test.py
aa12d00acdd226602b3efc90221fbc936f69f73bcabbaa6c01dfb0e05d08c505 scripts/tests/overview_samples_0330_ui_test.mjs 582b3253414195f92f80ed11e95543aefd034019105720b053ae6ca139634f3d scripts/tests/dashboard_resume_rate_charts_ui_test.mjs
8d2b4a08974f859f52924c955e259120266dcde16c1c195feec9c0445c6e3a6d scripts/tests/dashboard_stable_scales_0336_browser_test.py
ec64e453a17562fa21d8f66b9c6f21c96a7fbdda5bacd017520a243941e44737 scripts/tests/overview_samples_0330_ui_test.mjs
0ec69b7811db3dcd51cba60387900ca8df50eb836b22cc2a3ecfcc22b40a2edd scripts/tests/progressbar_0332_browser_test.py 0ec69b7811db3dcd51cba60387900ca8df50eb836b22cc2a3ecfcc22b40a2edd scripts/tests/progressbar_0332_browser_test.py
222077dab4d4928ae9110a9f2d64c07cc8386ea14099819b43304bbd2364e5b5 scripts/tests/sidebar_nav_0333_browser_test.py 222077dab4d4928ae9110a9f2d64c07cc8386ea14099819b43304bbd2364e5b5 scripts/tests/sidebar_nav_0333_browser_test.py
d5f675b97a85220043a9475347412972db904e559d863ce5058aa18f649f9e16 scripts/tests/system_ram_meter_0332_browser_test.py d5f675b97a85220043a9475347412972db904e559d863ce5058aa18f649f9e16 scripts/tests/system_ram_meter_0332_browser_test.py
@@ -174,17 +176,18 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs 74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs 33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
6884210022056d7f2a92160b6d37026e420483c2285368b7ae3c01b1c981df8b src/admin/openapi.json 48e5f22a624d2652165293784a29873f9296b8a9cd7234d1f6dc27b8694a0105 src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
92b3e05f04711d8001b56816405859631d9e917868e15be965c7e76ef4ddef99 src/admin/preferences.rs e9da94b3fa65a6eeba5c5fc739a3a2f5dff6cd2b980a21770cba6566b52d4f75 src/admin/preferences.rs
4a5cb4c3a3aa8b9a519020854506ce4be5e688a6c4298260322ae641dded7fe9 src/admin/preferences_columns.rs 4a5cb4c3a3aa8b9a519020854506ce4be5e688a6c4298260322ae641dded7fe9 src/admin/preferences_columns.rs
9a3f13e2477769ae680c03d63336880c3b7b7d5aba4a91e44390a976608b9b1d src/admin/preferences_tests.rs 5c1213e455a8978c3e4639234feed488914eef451294a9deebbc4c86b733dbcb src/admin/preferences_dashboard_presets.rs
546dcfe7144b97a7210449d19e1467a81532ee91ca5bb3741963e0099b41bfc9 src/admin/preferences_tests.rs
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs 055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs 9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
6d12bb97819ff05773d017d6b73bd6659dc9f2345f49d449fbaf9e34b936aab8 src/admin/router.rs 0cebd387e43a59498981710992df889be8990c3b8aca93b8b98a6df90c5c8af5 src/admin/router.rs
a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
77fe317c15bf2e03301b22e0552518fe2d7e1ad8d80afaba4e16cd2bf8ddf10c src/admin/status.rs 77fe317c15bf2e03301b22e0552518fe2d7e1ad8d80afaba4e16cd2bf8ddf10c src/admin/status.rs
4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs 4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs
@@ -193,23 +196,24 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail
18b778c2c4e723e2fe5cbaaef20effa6ea50fcc35d507ebde2361c9b6eda62b2 src/admin/tests.rs 18b778c2c4e723e2fe5cbaaef20effa6ea50fcc35d507ebde2361c9b6eda62b2 src/admin/tests.rs
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.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 d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
8aa205bc88530e4c4678f84ea324c6ecbc823b4ed681624102ff177f3867bc98 src/admin/web/app/core.js 8aa205bc88530e4c4678f84ea324c6ecbc823b4ed681624102ff177f3867bc98 src/admin/web/app/core.js
f3b566d79ba47848021a74653dbd7fec72f0fb819c6b2a07259e047bc8c427fd src/admin/web/app/dashboard-inline-layout.js f3b566d79ba47848021a74653dbd7fec72f0fb819c6b2a07259e047bc8c427fd src/admin/web/app/dashboard-inline-layout.js
851a4337127f332db91f6954f689363ee227fcddde2dda82e55f645940d0ce3b src/admin/web/app/dashboard-layout-editor.js 2c253650e6512cdb9615567224014a15839baf201c2ae523561168f4f63e84d1 src/admin/web/app/dashboard-layout-editor.js
2f1669cbeb9f49aef43a548e2096825aa14c64c7097ffad520822bbbaefb2b4c src/admin/web/app/overview-extra-metrics.js bfb3fbac62f9ff762a298d9c06808ae81ae55de7a41e0bb35ce5c167f333ffe2 src/admin/web/app/overview-extra-metrics.js
5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js 5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js
b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.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 12ee717f013c39143bc34746fb89344f7cb14a915700a3964f6dfc6b11ed0413 src/admin/web/app/preferences.js
8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js 8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js
43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js 43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js
daa20d634f921191cb4ac37889504f203ab39ad7e8fd8f33e1b182fc4206c24d src/admin/web/app/runtime.js daa20d634f921191cb4ac37889504f203ab39ad7e8fd8f33e1b182fc4206c24d src/admin/web/app/runtime.js
31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js 8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js
655db0b4d5e7ee14dbe69fa22bdb9d99eecc32abcf3b1b76a5819a8015f72309 src/admin/web/app/web-overview.js 7f89bfae66e2a08a05a112733864e908f036bb1ee3d8b5b9ff23783b870acd96 src/admin/web/app/web-overview.js
0603dc8793a731a0e582cbbec53aa6d1cc6e007faafcec5d8843730b2cdc39e1 src/admin/web/cache-panel.js bb94697a1cbeccaf80069dd47ab84efe14b052d81c472f8309478bbeb4e05f06 src/admin/web/cache-panel.js
02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js 02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js
aec5755244242424e5091ff57c0258d2618588618fe1d52d36f081d03879f686 src/admin/web/dashboard-defaults.json aec5755244242424e5091ff57c0258d2618588618fe1d52d36f081d03879f686 src/admin/web/dashboard-defaults.json
797e2935a34b974cb0e07a88056c31e6948cc11385bf2a076d902a332882d0a0 src/admin/web/dashboard-widgets.json 797e2935a34b974cb0e07a88056c31e6948cc11385bf2a076d902a332882d0a0 src/admin/web/dashboard-widgets.json
@@ -234,7 +238,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
0ebfde7081385aa7158f3b0f8f2b9355836e087c3d91a7c98d5187afbd47abd0 src/admin/web/index.html 91623043542997e29292234ed9b23f94ac4d344b98dee060d2ac0e70c6c03204 src/admin/web/index.html
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js 03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
@@ -244,9 +248,9 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/
c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
cc0fdbed4a344487fef7593375177043ee4524b98f58842f37a97e2d2a414183 src/admin/web/style.css 81f06d951f9f8c45fe14bca7870a9cd3e516031979aa69dccbf413af79e62113 src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js 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 d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js 8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
+4
View File
@@ -44,3 +44,7 @@ go build -trimpath -ldflags='-s -w' -o rustproxy-docker-agent .
The inventory endpoint is `GET /v1/inventory` with `Authorization: Bearer <token>`. `/healthz` does not expose Docker inventory. Both are served over HTTPS by default. The inventory endpoint is `GET /v1/inventory` with `Authorization: Bearer <token>`. `/healthz` does not expose Docker inventory. Both are served over HTTPS by default.
`RUSTPROXY_DOCKER_ADVERTISE_IP` marks a preferred address but does not suppress automatic discovery of the host's other addresses. `RUSTPROXY_DOCKER_PUBLIC_IP_URL` adds the externally observed address when enabled. RustProxy can choose a concrete reported address independently for every Docker-backed vhost. `RUSTPROXY_DOCKER_ADVERTISE_IP` marks a preferred address but does not suppress automatic discovery of the host's other addresses. `RUSTPROXY_DOCKER_PUBLIC_IP_URL` adds the externally observed address when enabled. RustProxy can choose a concrete reported address independently for every Docker-backed vhost.
## Default vhost hostname suggestions
The agent reports a DNS-safe Docker container name as `technical_name` (or the Compose service name when the container name is unavailable). RustProxy pre-fills this name for newly published vhosts. For example, `/grafana` suggests `grafana`, not `grafana-3000`. If multiple published services have the same normalized container name, the agent appends the container's internal port; remaining collisions get a deterministic identifier. The stable `service_key` is unchanged. Already saved vhost hostnames are never rewritten by discovery.
+43 -6
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -92,20 +93,18 @@ func serviceIdentity(c dockerContainer, p dockerPort) (key, technical string) {
project := c.Labels["com.docker.compose.project"] project := c.Labels["com.docker.compose.project"]
composeService := c.Labels["com.docker.compose.service"] composeService := c.Labels["com.docker.compose.service"]
replica := c.Labels["com.docker.compose.container-number"] replica := c.Labels["com.docker.compose.container-number"]
suffix := strconv.Itoa(int(p.PrivatePort))
if project != "" && composeService != "" { if project != "" && composeService != "" {
if replica == "" { if replica == "" {
replica = name replica = name
} }
key = fmt.Sprintf("compose:%s/%s/%s:%d/%s", project, composeService, replica, p.PrivatePort, p.Type) key = fmt.Sprintf("compose:%s/%s/%s:%d/%s", project, composeService, replica, p.PrivatePort, p.Type)
technicalBase := project + "-" + composeService technical = dnsLabel(name)
if replica != "1" { if name == "container" {
technicalBase += "-" + replica technical = dnsLabel(composeService)
} }
technical = dnsLabel(technicalBase + "-" + suffix)
} else { } else {
key = fmt.Sprintf("container:%s:%d/%s", name, p.PrivatePort, p.Type) key = fmt.Sprintf("container:%s:%d/%s", name, p.PrivatePort, p.Type)
technical = dnsLabel(name + "-" + suffix) technical = dnsLabel(name)
} }
return key, technical return key, technical
} }
@@ -163,5 +162,43 @@ func servicesFrom(containers []dockerContainer) []service {
out = append(out, *item) out = append(out, *item)
} }
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
// Prefer the Docker container name. Add the internal port only when names
// collide; retain stable service keys and prevent any duplicate hostnames.
counts := make(map[string]int, len(out))
for _, item := range out {
counts[item.TechnicalName]++
}
for i := range out {
if counts[out[i].TechnicalName] > 1 {
out[i].TechnicalName = dnsLabel(out[i].TechnicalName + "-" + strconv.Itoa(int(out[i].PrivatePort)))
}
}
// Port suffixes can still collide after normalization/truncation.
// Resolve these deterministically using a short hash of the stable key.
counts = make(map[string]int, len(out))
for _, item := range out {
counts[item.TechnicalName]++
}
used := make(map[string]struct{}, len(out))
for i := range out {
name := out[i].TechnicalName
if counts[name] > 1 {
digest := sha256.Sum256([]byte(out[i].Key))
name = fmt.Sprintf("%s-%x", strings.TrimRight(name[:min(len(name), 48)], "-"), digest[:6])
}
// Extremely unlikely hash collisions are still resolved without
// changing the stable key or depending on Docker listing order.
for attempt := 0; ; attempt++ {
candidate := name
if attempt > 0 {
candidate = fmt.Sprintf("%s-%d", name[:min(len(name), 56)], attempt)
}
if _, exists := used[candidate]; !exists {
out[i].TechnicalName = candidate
used[candidate] = struct{}{}
break
}
}
}
return out return out
} }
+37 -1
View File
@@ -41,7 +41,7 @@ func TestServicesFromUsesStableComposeIdentity(t *testing.T) {
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("got %d services", len(got)) t.Fatalf("got %d services", len(got))
} }
if got[0].Key != "compose:lab/web/1:8080/tcp" || got[0].TechnicalName != "lab-web-8080" { if got[0].Key != "compose:lab/web/1:8080/tcp" || got[0].TechnicalName != "web-1" {
t.Fatalf("unexpected service %#v", got[0]) t.Fatalf("unexpected service %#v", got[0])
} }
if len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18080 { if len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18080 {
@@ -72,6 +72,42 @@ func TestServicesFromKeepsComposeReplicasAndAllBindings(t *testing.T) {
} }
} }
func TestServiceNamesPreferContainerAndOnlyAppendPortsForCollisions(t *testing.T) {
items := []dockerContainer{
{ID: "a", Names: []string{"/grafana"}, Labels: map[string]string{}, Ports: []dockerPort{{PrivatePort: 3000, PublicPort: 3000, Type: "tcp", IP: "0.0.0.0"}}},
{ID: "b", Names: []string{"/frontend"}, Labels: map[string]string{"com.docker.compose.project": "example", "com.docker.compose.service": "web", "com.docker.compose.container-number": "1"}, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 8080, Type: "tcp", IP: "0.0.0.0"}}},
{ID: "c", Names: []string{"/shared"}, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 8081, Type: "tcp", IP: "0.0.0.0"}, {PrivatePort: 443, PublicPort: 8443, Type: "tcp", IP: "0.0.0.0"}}},
}
got := servicesFrom(items)
names := map[string]string{}
for _, item := range got {
names[item.Key] = item.TechnicalName
}
if names["container:grafana:3000/tcp"] != "grafana" || names["compose:example/web/1:80/tcp"] != "frontend" {
t.Fatalf("incorrect short names: %#v", names)
}
if names["container:shared:80/tcp"] != "shared-80" || names["container:shared:443/tcp"] != "shared-443" {
t.Fatalf("incorrect collision names: %#v", names)
}
}
func TestServiceNamesRemainUniqueAfterDNSNormalization(t *testing.T) {
items := []dockerContainer{
{Names: []string{"/A_B"}, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 18080, Type: "tcp"}}},
{Names: []string{"/a-b"}, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 18081, Type: "tcp"}}},
}
got := servicesFrom(items)
if len(got) != 2 || got[0].TechnicalName == got[1].TechnicalName {
t.Fatalf("names not unique: %#v", got)
}
reversed := servicesFrom([]dockerContainer{items[1], items[0]})
for i := range got {
if got[i].TechnicalName != reversed[i].TechnicalName {
t.Fatalf("unstable names: %#v vs %#v", got, reversed)
}
}
}
func TestServicesFromSkipsLoopbackOnlyBindings(t *testing.T) { func TestServicesFromSkipsLoopbackOnlyBindings(t *testing.T) {
items := []dockerContainer{{ID: "abc", Names: []string{"/app"}, Labels: map[string]string{}, Ports: []dockerPort{ items := []dockerContainer{{ID: "abc", Names: []string{"/app"}, Labels: map[string]string{}, Ports: []dockerPort{
{IP: "127.0.0.1", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {IP: "127.0.0.1", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"},
+28 -14
View File
@@ -37,20 +37,36 @@ Open **Settings → Certificate API** (`/settings?tab=certificate-api`) to manag
The existing **Generate / rotate token** form still creates new Key IDs (and retains its previous ability to rotate a Key ID by supplying it). Use **Edit settings** to modify settings without rotating, and **Regenerate token** to rotate without changing any settings. See [API](API.md) and [access control](API_ACCESS.md). The existing **Generate / rotate token** form still creates new Key IDs (and retains its previous ability to rotate a Key ID by supplying it). Use **Edit settings** to modify settings without rotating, and **Regenerate token** to rotate without changing any settings. See [API](API.md) and [access control](API_ACCESS.md).
## Dashboard presets (0.33.7)
**Dashboard presets** on Overview and under **Settings → Appearance → Dashboard layout**
opens the same account-scoped preset manager. Enter a name to save the current
Dashboard density, card order, visibility and custom widths. A saved preset can be
loaded later in one preferences update or deleted. Saving an existing name updates
that preset. Up to 20 presets are retained per administrator.
The preset manager no longer duplicates card editing. Use **Unlock layout** on
Overview for drag-and-drop, hide/show and width changes, or use the existing
visibility/order controls in Settings. Presets use the existing `/api/preferences`
document; they do not add a database migration, status polling or proxy-path work.
Overview chart titles use the same monospace font family as **CONNECTION ACTIVITY**.
This is typography-only and does not change chart scales, samples or telemetry.
## Compact Overview card controls (0.32.6 development branch) ## Compact Overview card controls (0.32.6 development branch)
**Settings → Appearance → Dashboard layout** now puts Dashboard density on a **Settings → Appearance → Dashboard layout** now puts Dashboard density on a
compact row and gives the Overview card controls the full width of the section. compact row and gives the Overview card controls the full width of the section.
The card selector is a single horizontally scrollable strip by default, with The card selector is a single horizontally scrollable strip by default, with
left/right browsing buttons and accessible keyboard focus. **Show all cards** left/right browsing buttons and accessible keyboard focus. **Show all cards**
expands the same 62 visibility/reorder controls into a wrapped grid. Use the expands the same 62 visibility/reorder controls into a wrapped grid. Use
existing visual layout editor for drag-and-drop arrangement. Reordering and **Unlock layout** on Overview for drag-and-drop arrangement. Reordering and
visibility still save through `/api/preferences` per account; this UI-only visibility still save through `/api/preferences` per account; this UI-only
change does not alter server polling, proxy routing or backend metrics. change does not alter server polling, proxy routing or backend metrics.
### Overview controls and density (0.32.7 development follow-up) ### Overview controls and density (0.32.7 development follow-up)
**Refresh now**, **Arrange Overview**, **Unlock layout**, **Visual Overview editor** and **Show all cards** inherit the same Normal/Compact button size rules as other buttons. Responsive placement still adapts to narrow screens, but no independent font-size, padding or height override applies. Dashboard density more clearly adjusts numeric tile sizing, inline chart height and the padding/title size of non-table Overview panels; the common tile gutters are unchanged. Button style and dashboard density remain independent per-account preferences. **Refresh now**, **Dashboard presets**, **Unlock layout** and **Show all cards** inherit the same Normal/Compact button size rules as other buttons. Responsive placement still adapts to narrow screens, but no independent font-size, padding or height override applies. Dashboard density more clearly adjusts numeric tile sizing, inline chart height and the padding/title size of non-table Overview panels; the common tile gutters are unchanged. Button style and dashboard density remain independent per-account preferences.
### Overview header actions (0.32.8) ### Overview header actions (0.32.8)
@@ -581,9 +597,9 @@ Under **Settings → Appearance → Dashboard layout**, the existing Show/Hide a
## Full Overview layout and additional metrics (0.32.3) ## Full Overview layout and additional metrics (0.32.3)
Open **Overview -> Arrange cards visually** (or **Settings -> Appearance -> Dashboard layout -> Visual layout editor**) to change **all 49** dashboard blocks: 30 previous tiles, 12 new optional charts, three large live charts, three diagnostic panels and the backend table. Drag tiles/sections, use arrow buttons for keyboard access, or hide and re-enable individual blocks. Large sections retain their own original charts, controls and live data after being moved. The responsive six-column desktop view scales to narrower widths. Visibility and order are account-scoped; they never alter backend behavior. Use **Overview -> Unlock layout** to change the Dashboard blocks directly: drag cards/sections, use arrow buttons for keyboard access, resize supported cards, or hide and re-enable individual blocks. The same visibility/order controls remain available under **Settings -> Appearance -> Dashboard layout**. Large sections retain their original charts, controls and live data after being moved. The responsive desktop grid scales to narrower widths. Visibility, order and widths are account-scoped; they never alter backend behavior.
**Default view** loads the factory order and visibility into the editor **without saving**; **Save layout** applies it. **Reset layout** asks for confirmation, then persists the same default order and visibility in one preferences write. **Cancel** preserves the current layout. All 12 added charts are hidden until explicitly enabled and use a maximum of 30 status samples without additional HTTP polling. They cover process CPU/RSS/file descriptors, connections, WebSocket tunnels, TLS handshake failure and request-error rates, cache HIT share and HOT RAM usage, incoming HTTP/2 request share, mean response duration, and unhealthy backends. Unknown and first-delta measurements show `-`; response time is a mean, not a percentile, and HTTP/2 share is **frontend** traffic rather than negotiated upstream ALPN. From 0.33.7, **Dashboard presets** replaces the older duplicated visual-editor modal. The modal saves or loads named snapshots of the current density, order, visibility and widths; it does not provide a second card-editing surface. All optional charts continue to use bounded histories without additional HTTP polling. Unknown and first-delta measurements show `-`; response time is a mean, not a percentile, and HTTP/2 share is **frontend** traffic rather than negotiated upstream ALPN.
## HTTP backend Overview cards (0.32.3) ## HTTP backend Overview cards (0.32.3)
@@ -620,15 +636,13 @@ and capacity rejections, and Hyper client-generation rotations and age. Rotation
are **not TLS handshake counts**. Rates are based on per-member positive counter are **not TLS handshake counts**. Rates are based on per-member positive counter
differences; newly added or reset members cannot create arbitrary spikes. differences; newly added or reset members cannot create arbitrary spikes.
To arrange the actual Overview cards visually, click **Arrange cards visually** To arrange the actual Overview cards, click **Unlock layout** on Overview. Drag
on Overview or **Visual layout editor** in Settings -> Appearance -> Dashboard the real cards or use the move/resize/hide controls, then save and lock the
layout. The modal displays copies of the existing telemetry cards; drag to layout. The existing Appearance visibility and move controls remain available.
reorder, or use the move arrows for keyboard-only input. Hide/show tiles in Use **Dashboard presets** only to save, inspect and load named snapshots of the
the modal and select **Save layout** once to persist order and visibility in current layout. Presets reuse `dashboard_widgets`, `dashboard_hidden_widgets`,
the existing `dashboard_widgets` and `dashboard_hidden_widgets` preferences. `dashboard_widget_sizes` and Dashboard density in the existing preferences
Cancel/Escape discards the draft. The existing Appearance visibility and move document. No extra status refresh loop or database migration is needed.
buttons remain available. No extra API endpoint, refresh loop, or database
migration is needed.
TLS ALPN selects H2 or H1 only when opening a **new TLS connection**, not for TLS ALPN selects H2 or H1 only when opening a **new TLS connection**, not for
each request. Hyper already pools reusable connections per compiled backend; each request. Hyper already pools reusable connections per compiled backend;
-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.
+24 -6
View File
@@ -2510,9 +2510,9 @@
"backendWidgetNetworkBreakdown": "Connect / DNS / TLS / I/O", "backendWidgetNetworkBreakdown": "Connect / DNS / TLS / I/O",
"backendWidgetPoolGeneration": "Maximum active pool generation", "backendWidgetPoolGeneration": "Maximum active pool generation",
"backendWidgetPoolAge": "Oldest generation age", "backendWidgetPoolAge": "Oldest generation age",
"overviewLayoutEditorOpen": "Arrange Overview", "overviewLayoutEditorOpen": "Dashboard presets",
"overviewLayoutEditorTitle": "Arrange entire Overview", "overviewLayoutEditorTitle": "Dashboard presets",
"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.", "overviewLayoutEditorHelp": "Save the current Dashboard setup as a named preset. Presets include card order, visibility, widths and Dashboard density.",
"overviewLayoutEditorVisible": "Visible cards", "overviewLayoutEditorVisible": "Visible cards",
"overviewLayoutEditorHidden": "Hidden cards", "overviewLayoutEditorHidden": "Hidden cards",
"overviewLayoutEditorHide": "Hide card", "overviewLayoutEditorHide": "Hide card",
@@ -2524,7 +2524,7 @@
"overviewLayoutEditorHiddenEmpty": "All cards are visible.", "overviewLayoutEditorHiddenEmpty": "All cards are visible.",
"overviewLayoutEditorMoveBefore": "Move card before", "overviewLayoutEditorMoveBefore": "Move card before",
"overviewLayoutEditorSaved": "Dashboard layout saved", "overviewLayoutEditorSaved": "Dashboard layout saved",
"overviewLayoutEditorOpenSettings": "Visual Overview editor", "overviewLayoutEditorOpenSettings": "Dashboard presets",
"overviewLayoutEditorSearch": "Find hidden card", "overviewLayoutEditorSearch": "Find hidden card",
"overviewLayoutDefaultPreview": "Default view", "overviewLayoutDefaultPreview": "Default view",
"overviewLayoutReset": "Reset layout", "overviewLayoutReset": "Reset layout",
@@ -2703,10 +2703,28 @@
"webLivePhpCapacity": "PHP capacity", "webLivePhpCapacity": "PHP capacity",
"webLiveKinds": "Static / PHP / other", "webLiveKinds": "Static / PHP / other",
"webLiveBytes": "Body delivered", "webLiveBytes": "Body delivered",
"webLiveNoPhp": "PHP disabled" "webLiveNoPhp": "PHP disabled",
"dashboardPresetName": "Preset name",
"dashboardPresetNamePlaceholder": "e.g. Operations",
"dashboardPresetSaveCurrent": "Save current Dashboard",
"dashboardPresetSaved": "Saved presets",
"dashboardPresetEmpty": "No saved Dashboard presets.",
"dashboardPresetSummary": "{visible} visible cards · {density} · {widths} custom widths",
"dashboardPresetCount": "{count}/{max} presets",
"dashboardPresetSaving": "Saving preset…",
"dashboardPresetUpdated": "Preset “{name}” updated",
"dashboardPresetSavedMessage": "Preset “{name}” saved",
"dashboardPresetLoading": "Loading “{name}”…",
"dashboardPresetLoaded": "Preset “{name}” loaded",
"dashboardPresetLoad": "Load",
"dashboardPresetDelete": "Delete",
"dashboardPresetDeleteConfirm": "Delete preset “{name}”?",
"dashboardPresetDeleting": "Deleting “{name}”…",
"dashboardPresetDeleted": "Preset “{name}” deleted",
"dashboardPresetLimitReached": "Preset limit reached ({max}). Delete a preset before saving a new one."
}, },
"overviewLayoutNarrow": "Decrease card width", "overviewLayoutNarrow": "Decrease card width",
"overviewLayoutWiden": "Increase card width", "overviewLayoutWiden": "Increase card width",
"overviewLayoutWidth": "Card width in 12-column grid", "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."
} }
+24 -6
View File
@@ -2510,9 +2510,9 @@
"backendWidgetNetworkBreakdown": "TCP / DNS / TLS / I/O", "backendWidgetNetworkBreakdown": "TCP / DNS / TLS / I/O",
"backendWidgetPoolGeneration": "Najwyższa generacja puli", "backendWidgetPoolGeneration": "Najwyższa generacja puli",
"backendWidgetPoolAge": "Najstarsza generacja", "backendWidgetPoolAge": "Najstarsza generacja",
"overviewLayoutEditorOpen": "Ułóż Dashboard", "overviewLayoutEditorOpen": "Presety Dashboardu",
"overviewLayoutEditorTitle": "Ułóż cały Dashboard", "overviewLayoutEditorTitle": "Presety Dashboardu",
"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.", "overviewLayoutEditorHelp": "Zapisz bieżące ustawienia Dashboardu jako nazwany preset. Preset obejmuje kolejność i widoczność kart, ich szerokości oraz gęstość Dashboardu.",
"overviewLayoutEditorVisible": "Widoczne kafelki", "overviewLayoutEditorVisible": "Widoczne kafelki",
"overviewLayoutEditorHidden": "Ukryte kafelki", "overviewLayoutEditorHidden": "Ukryte kafelki",
"overviewLayoutEditorHide": "Ukryj kafelek", "overviewLayoutEditorHide": "Ukryj kafelek",
@@ -2524,7 +2524,7 @@
"overviewLayoutEditorHiddenEmpty": "Wszystkie kafelki są widoczne.", "overviewLayoutEditorHiddenEmpty": "Wszystkie kafelki są widoczne.",
"overviewLayoutEditorMoveBefore": "Przesuń kafelek przed", "overviewLayoutEditorMoveBefore": "Przesuń kafelek przed",
"overviewLayoutEditorSaved": "Układ Dashboardu zapisany", "overviewLayoutEditorSaved": "Układ Dashboardu zapisany",
"overviewLayoutEditorOpenSettings": "Wizualny edytor Overview", "overviewLayoutEditorOpenSettings": "Presety Dashboardu",
"overviewLayoutEditorSearch": "Znajdź ukryty kafelek", "overviewLayoutEditorSearch": "Znajdź ukryty kafelek",
"overviewLayoutDefaultPreview": "Widok domyślny", "overviewLayoutDefaultPreview": "Widok domyślny",
"overviewLayoutReset": "Resetuj układ", "overviewLayoutReset": "Resetuj układ",
@@ -2703,10 +2703,28 @@
"webLivePhpCapacity": "Limit PHP", "webLivePhpCapacity": "Limit PHP",
"webLiveKinds": "Statyczne / PHP / inne", "webLiveKinds": "Statyczne / PHP / inne",
"webLiveBytes": "Wysłana treść", "webLiveBytes": "Wysłana treść",
"webLiveNoPhp": "PHP wyłączone" "webLiveNoPhp": "PHP wyłączone",
"dashboardPresetName": "Nazwa presetu",
"dashboardPresetNamePlaceholder": "np. Operacje",
"dashboardPresetSaveCurrent": "Zapisz bieżący Dashboard",
"dashboardPresetSaved": "Zapisane presety",
"dashboardPresetEmpty": "Brak zapisanych presetów Dashboardu.",
"dashboardPresetSummary": "{visible} widocznych kart · {density} · niestandardowe szerokości: {widths}",
"dashboardPresetCount": "{count}/{max} presetów",
"dashboardPresetSaving": "Zapisywanie presetu…",
"dashboardPresetUpdated": "Zaktualizowano preset „{name}”",
"dashboardPresetSavedMessage": "Zapisano preset „{name}”",
"dashboardPresetLoading": "Wczytywanie „{name}”…",
"dashboardPresetLoaded": "Wczytano preset „{name}”",
"dashboardPresetLoad": "Wczytaj",
"dashboardPresetDelete": "Usuń",
"dashboardPresetDeleteConfirm": "Usunąć preset „{name}”?",
"dashboardPresetDeleting": "Usuwanie „{name}”…",
"dashboardPresetDeleted": "Usunięto preset „{name}”",
"dashboardPresetLimitReached": "Osiągnięto limit presetów ({max}). Usuń preset przed zapisaniem nowego."
}, },
"overviewLayoutNarrow": "Zmniejsz szerokość karty", "overviewLayoutNarrow": "Zmniejsz szerokość karty",
"overviewLayoutWiden": "Zwiększ szerokość karty", "overviewLayoutWiden": "Zwiększ szerokość karty",
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn", "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."
} }
+1 -1
View File
@@ -74,7 +74,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
flat = module.parent.with_suffix(".rs") flat = module.parent.with_suffix(".rs")
if flat.is_file(): if flat.is_file():
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]: for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/preferences_dashboard_presets.rs", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file(): if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}") raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
# The Reader uses compile-time include_str! for its bundled Markdown. A # The Reader uses compile-time include_str! for its bundled Markdown. A
@@ -1,124 +0,0 @@
#!/usr/bin/env python3
"""0.33.5 CPU plot autoscaling regression with production HTML/CSS/JS.
Uses Chromium with simulated authenticated DOM and status samples, not a live
RustProxy server, SSE stream or real CPU telemetry.
"""
from pathlib import Path
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')
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
def inspect(page):
return page.evaluate('''() => {
const get = id => document.querySelector(`#dashboard-overview-grid [data-dashboard-widget="${id}"]`);
const cpu = get('wide_cpu'), cpuSvg = cpu.querySelector('svg');
const protocolSvg = get('wide_protocol_rates').querySelector('svg');
const backendSvg = get('backend_traffic').querySelector('svg');
const legend = cpu.querySelector('.overview-wide-legend').getBoundingClientRect();
const bounds = cpu.getBoundingClientRect();
const matrix = cpuSvg.getScreenCTM();
return {
cpuHeight: cpuSvg.getBoundingClientRect().height,
protocolHeight: protocolSvg.getBoundingClientRect().height,
backendHeight: backendSvg.getBoundingClientRect().height,
svgWidth: cpuSvg.getBoundingClientRect().width,
drawnWidth: Math.hypot(matrix.a, matrix.b) * cpuSvg.viewBox.baseVal.width,
ratio: cpuSvg.getAttribute('preserveAspectRatio'),
cpuSeries: cpuSvg.querySelectorAll('.overview-wide-series').length,
lineEffect: getComputedStyle(cpuSvg.querySelector('.overview-wide-series')).vectorEffect,
gridEffect: getComputedStyle(cpuSvg.querySelector('.chart-grid')).vectorEffect,
legendInCard: legend.top >= bounds.top && legend.bottom <= bounds.bottom + 1,
overflow: document.documentElement.scrollWidth - innerWidth,
};
}''')
def render_cpu(page, revision, system, process):
return page.evaluate('''({revision,system,process}) => {
window.rustProxyWideOverview.render({revision,live_system:{
cpu_percent:system, process_cpu_percent:process
}});
const read = name => {
const prefix = `overview-wide-wide_cpu-${name}`;
return {
path: document.getElementById(`${prefix}-line`).getAttribute('d'),
label: document.getElementById(`${prefix}-value`).textContent,
};
};
return {system:read('system'),process:read('process')};
}''', {'revision': revision, 'system': system, 'process': process})
def ys(path):
return [int(v) for v in re.findall(r'[ML]\d+\s+(\d+)', path)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for viewport in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': viewport, 'height': 950})
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.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
const grid = document.getElementById('dashboard-overview-grid');
grid.classList.add('is-aligned');
for (const card of grid.querySelectorAll(':scope > [data-dashboard-widget]')) {
card.hidden = !['wide_cpu', 'wide_protocol_rates', 'backend_traffic'].includes(card.dataset.dashboardWidget);
}
window.currentPanel = 'overview';
window.$ = id => document.getElementById(id);
window.fmt = (value, digits = 0) => Number(value).toFixed(digits);
window.fmtBytes = value => `${value} B`;
window.rustProxyOverviewSamples = {getLimit: () => 60, onChange: () => {}};
}''')
page.add_script_tag(content=WIDE_JS)
for density in ('compact', 'normal', 'ultra'):
page.evaluate('(density) => document.documentElement.dataset.dashboardDensity = density', density)
result = inspect(page)
assert result['protocolHeight'] >= 124, (viewport, density, result)
assert abs(result['cpuHeight'] - result['protocolHeight']) <= 1, (
'CPU must use standard chart height', viewport, density, result)
assert result['backendHeight'] > 0, (viewport, density, result)
assert result['ratio'] == 'none' and result['cpuSeries'] == 2, result
assert result['lineEffect'] == result['gridEffect'] == 'non-scaling-stroke', result
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.
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)
# The peak follows observed history and also works above 100% (multi-core).
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)
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)
# Revision changes reset the history. Zero stays at the plot baseline.
idle = render_cpu(page, 2, 0, 0)
assert ys(idle['system']['path']) == [137] and ys(idle['process']['path']) == [137]
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)')
page.close()
browser.close()
@@ -166,13 +166,25 @@ with sync_playwright() as p:
window.rustProxyStreamDashboard.render(status); window.rustProxyStreamDashboard.render(status);
}''') }''')
assert_visible(page, ['backend_health'], ['backend_traffic']) assert_visible(page, ['backend_health'], ['backend_traffic'])
# Restore defaults in the modal after a custom layout was saved. # 0.33.7: the former layout modal is a preset manager and must not mutate
# the active custom layout merely by opening/closing it.
page.locator('#overview-layout-open').evaluate('(node) => node.hidden = false') page.locator('#overview-layout-open').evaluate('(node) => node.hidden = false')
page.locator('#overview-layout-open').click() page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open") page.wait_for_function("document.getElementById('overview-layout-dialog').open")
page.locator('#overview-layout-default').click() assert page.locator('#overview-preset-name').is_visible()
page.locator('#overview-layout-save').click() assert page.locator('#overview-layout-default').count() == 0
assert_visible(page, ['backend_health'], ['backend_traffic'])
page.locator('#overview-layout-cancel').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open") page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
assert_visible(page, ['backend_health'], ['backend_traffic'])
# Restore factory defaults through the same account preference API used
# by Settings/inline editing rather than the preset manager.
page.evaluate('''preset => window.rustProxyPreferences.update({
dashboard_density: preset.dashboard_density,
dashboard_widgets: [...preset.dashboard_widgets],
dashboard_hidden_widgets: [...preset.dashboard_hidden_widgets],
dashboard_widget_sizes: null,
})''', PRESET)
assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions', 'www_status']) 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_widgets') == PRESET['dashboard_widgets']
assert page.evaluate('window.__savedDashboard.dashboard_hidden_widgets') == PRESET['dashboard_hidden_widgets'] assert page.evaluate('window.__savedDashboard.dashboard_hidden_widgets') == PRESET['dashboard_hidden_widgets']
@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Regression: Overview visual layout editor fits without horizontal overflow. """Responsive regression for the Overview toolbar modal.
Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences. 0.33.7 repurposed the former visual layout editor as Dashboard presets. Keep
This is browser layout testing, not Rust server acceptance. this older viewport regression focused on modal fit/no horizontal overflow.
""" """
import json import json
import re import re
@@ -10,17 +10,17 @@ from pathlib import Path
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text() HTML = (ROOT / "src/admin/web/index.html").read_text(encoding="utf-8")
CSS = (ROOT / 'src/admin/web/style.css').read_text() CSS = (ROOT / "src/admin/web/style.css").read_text(encoding="utf-8")
ROUTER = (ROOT / 'src/admin/router.rs').read_text() ROUTER = (ROOT / "src/admin/router.rs").read_text(encoding="utf-8")
PACKS = { PACKS = {
name: {'id': name, **json.loads((ROOT / 'langs' / (name + '.json')).read_text())} name: {"id": name, **json.loads((ROOT / "langs" / f"{name}.json").read_text(encoding="utf-8"))}
for name in ('en_US', 'pl_pl') for name in ("en_US", "pl_pl")
} }
def app_bundle(): def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER) match = re.search(r"fn admin_app_javascript\([^)]*\)[^{]*\{", ROUTER)
assert match assert match
body = ROUTER[match.end():] body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()] body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
@@ -29,141 +29,64 @@ def app_bundle():
line = line.strip() line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line) include = re.search(r'include_str!\("([^"]+)"\)', line)
if include: if include:
result.append((ROOT / 'src/admin' / include.group(1)).read_text()) result.append((ROOT / "src/admin" / include.group(1)).read_text(encoding="utf-8"))
elif line == 'admin_vhosts_javascript(),': elif line == "admin_vhosts_javascript(),":
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text() result.append("(() => {\n" + "\n".join(
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();') (ROOT / "src/admin/web/vhosts" / f"{name}.js").read_text(encoding="utf-8")
for name in ("core", "protocol", "health", "rates", "editor", "render")) + "\n})();")
elif line.startswith('"') and line.endswith('",'): elif line.startswith('"') and line.endswith('",'):
result.append(json.loads(line[:-1])) result.append(json.loads(line[:-1]))
return '\n'.join(result) return "\n".join(result)
BUNDLE = app_bundle() BUNDLE = app_bundle()
def load(page):
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=BUNDLE)
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyDashboardLayout')
page.evaluate('''packs => {
languagePacks = new Map(Object.entries(packs));
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));
}''', PACKS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('overview-layout-unlock').hidden = false;
document.getElementById('overview-layout-open').hidden = false;
currentPanel = 'overview';
window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
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;
};
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));
}''')
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,
};
}''')
with sync_playwright() as p: with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) 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): for width in (320, 375, 550, 620, 800, 900, 960, 1158, 1440, 1920):
context = browser.new_context(viewport={'width': width, 'height': 823}) page = browser.new_page(viewport={"width": width, "height": 823})
page = context.new_page()
errors = [] errors = []
page.on('pageerror', lambda err: errors.append(str(err))) page.on("pageerror", lambda error: errors.append(str(error)))
load(page) page.set_content(HTML.replace("/assets/app___RUSTPROXY_ASSET_ID__.js", "")
page.locator('#overview-layout-open').click() .replace("/assets/style___RUSTPROXY_ASSET_ID__.css", ""))
page.add_style_tag(content=CSS)
page.add_script_tag(content=BUNDLE)
page.wait_for_function("window.rustProxyPreferences && window.rustProxyDashboardLayout")
page.evaluate("""packs => {
languagePacks = new Map(Object.entries(packs)); defaultLanguage = 'en_US';
language = 'pl_pl'; languagePreference = 'pl_pl';
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('overview-layout-open').hidden = false;
currentPanel = 'overview';
window.__savedDashboard = {...structuredClone(ADMIN_APPEARANCE_DEFAULTS), language:'pl_pl'};
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;
};
applyAdminPreferences(window.__savedDashboard); language = 'pl_pl'; languagePreference = 'pl_pl';
for (const node of document.querySelectorAll('#overview-layout-dialog [data-i18n]'))
node.textContent = t(node.dataset.i18n);
}""", PACKS)
page.locator("#overview-layout-open").click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open") page.wait_for_function("document.getElementById('overview-layout-dialog').open")
check = geometry(page) check = page.locator("#overview-layout-dialog").evaluate("""d => {
expected = 1 if width <= 620 else 2 if width <= 900 else 3 const r = d.getBoundingClientRect();
assert check['cols'] == expected, (width, check) const rows = [...d.querySelectorAll('.dashboard-preset-save,.dashboard-preset-row')];
assert check['visibleCount'] > 20 return {
assert check['panelWidth'] is not None left:r.left, right:r.right, client:d.clientWidth, scroll:d.scrollWidth,
assert abs(check['firstTileWidth'] - check['panelWidth']) < 3, (width, check) overflow: rows.some(row => row.scrollWidth > row.clientWidth + 2),
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 check["left"] >= -1 and check["right"] <= width + 1, (width, check)
assert not check['overflows'], (width, check['overflows'][:20]) assert check["scroll"] <= check["client"] + 2 and not check["overflow"], (width, check)
tile = '#overview-layout-visible [data-preview-widget="http_protocol"]' assert page.locator("#overview-preset-name").is_visible()
before = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width') assert page.locator("#overview-preset-save").is_visible()
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")
assert not errors, (width, errors) assert not errors, (width, errors)
context.close() page.close()
print('PASS visual modal grid, no overlap/scroll, width/hide/cancel:', width) print(f"PASS responsive Dashboard preset modal at {width}px")
browser.close() browser.close()
print('PASS 0.33.3 modal regression (simulated API)') print("PASS modal responsive regression after 0.33.7 preset conversion")
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""0.33.7 Dashboard preset manager and chart-title font regression.
Runs the bundled administration DOM/CSS/JavaScript with mocked account preferences.
This is simulated-browser UI coverage, not a live RustProxy server test.
"""
import json
import re
from pathlib import Path
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")
CSS = (ROOT / "src/admin/web/style.css").read_text(encoding="utf-8")
ROUTER = (ROOT / "src/admin/router.rs").read_text(encoding="utf-8")
PACKS = {
name: {"id": name, **json.loads((ROOT / "langs" / f"{name}.json").read_text(encoding="utf-8"))}
for name in ("en_US", "pl_pl")
}
def app_bundle():
match = re.search(r"fn admin_app_javascript\([^)]*\)[^{]*\{", ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
result = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
result.append((ROOT / "src/admin" / include.group(1)).read_text(encoding="utf-8"))
elif line == "admin_vhosts_javascript(),":
result.append("(() => {\n" + "\n".join(
(ROOT / "src/admin/web/vhosts" / f"{name}.js").read_text(encoding="utf-8")
for name in ("core", "protocol", "health", "rates", "editor", "render")) + "\n})();")
elif line.startswith('"') and line.endswith('",'):
result.append(json.loads(line[:-1]))
return "\n".join(result)
BUNDLE = app_bundle()
def load(page):
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=BUNDLE)
page.wait_for_function("window.rustProxyPreferences && window.rustProxyDashboardLayout")
page.evaluate("""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('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('overview-layout-open').hidden = false;
currentPanel = 'overview';
const visible = ['connection_chart', 'traffic_chart'];
const hidden = ADMIN_DASHBOARD_WIDGETS.filter(id => !visible.includes(id));
window.__savedDashboard = {
...structuredClone(ADMIN_APPEARANCE_DEFAULTS),
language: 'pl_pl',
dashboard_density: 'ultra',
dashboard_widgets: [...visible, ...hidden],
dashboard_hidden_widgets: hidden,
dashboard_widget_sizes: {connection_chart: 6, traffic_chart: 3},
dashboard_presets: [],
};
window.rustProxyPreferences.load = async () => window.__savedDashboard;
window.rustProxyPreferences.current = () => window.__savedDashboard;
window.rustProxyPreferences.update = async patch => {
window.__savedDashboard = {...window.__savedDashboard, ...structuredClone(patch)};
applyAdminPreferences(window.__savedDashboard);
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
language = 'pl_pl'; languagePreference = 'pl_pl';
for (const node of document.querySelectorAll('#overview-layout-dialog [data-i18n]'))
node.textContent = t(node.dataset.i18n);
for (const node of document.querySelectorAll('#overview-layout-dialog [data-i18n-placeholder]'))
node.placeholder = t(node.dataset.i18nPlaceholder);
}""", PACKS)
with sync_playwright() as p:
browser = p.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": 900})
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
load(page)
page.locator("#overview-layout-open").click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open")
assert page.locator("#overview-layout-dialog-title").inner_text() == "Presety Dashboardu"
assert page.locator("#overview-layout-visible").count() == 0
assert page.locator("#overview-layout-hidden").count() == 0
geom = page.locator("#overview-layout-dialog").evaluate("""node => ({
left: node.getBoundingClientRect().left,
right: node.getBoundingClientRect().right,
client: node.clientWidth,
scroll: node.scrollWidth,
})""")
assert geom["left"] >= -1 and geom["right"] <= width + 1, (width, geom)
assert geom["scroll"] <= geom["client"] + 2, (width, geom)
page.locator("#overview-preset-name").fill("NOC")
page.locator("#overview-preset-save").click()
page.wait_for_function("window.__savedDashboard.dashboard_presets.length === 1")
saved = page.evaluate("window.__savedDashboard.dashboard_presets[0]")
assert saved["name"] == "NOC"
assert saved["dashboard_density"] == "ultra"
assert saved["dashboard_widgets"][:2] == ["connection_chart", "traffic_chart"]
assert len(saved["dashboard_hidden_widgets"]) == len(saved["dashboard_widgets"]) - 2
assert saved["dashboard_widget_sizes"] == {"connection_chart": 6, "traffic_chart": 3}
assert page.locator("#overview-preset-list .dashboard-preset-row").count() == 1
assert "2 widocznych kart" in page.locator(".dashboard-preset-info small").inner_text()
page.locator("#overview-preset-name").fill("NOC")
page.locator("#overview-preset-save").click()
page.wait_for_function("document.getElementById('overview-layout-message').textContent.includes('Zaktualizowano')")
assert page.evaluate("window.__savedDashboard.dashboard_presets.length") == 1
page.locator("#overview-layout-cancel").click()
page.evaluate("""async () => {
await window.rustProxyPreferences.update({
dashboard_density: 'normal',
dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS],
dashboard_hidden_widgets: [],
dashboard_widget_sizes: null,
});
}""")
page.locator("#overview-layout-open").click()
page.locator("#overview-preset-list .outline").click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
restored = page.evaluate("""() => ({
density: window.__savedDashboard.dashboard_density,
hidden: window.__savedDashboard.dashboard_hidden_widgets.length,
sizes: window.__savedDashboard.dashboard_widget_sizes,
})""")
assert restored == {
"density": "ultra",
"hidden": len(saved["dashboard_hidden_widgets"]),
"sizes": {"connection_chart": 6, "traffic_chart": 3},
}, (width, restored)
fonts = page.evaluate("""() => {
const family = selector => getComputedStyle(document.querySelector(selector)).fontFamily;
return {
connection: family('[data-dashboard-widget="connection_chart"] .small-label'),
traffic: family('[data-dashboard-widget="traffic_chart"] .card-heading h2'),
wide: family('[data-dashboard-widget="wide_cpu"] .card-heading h2'),
compact: family('[data-dashboard-widget="http_protocol"] > span:first-child'),
};
}""")
assert len(set(fonts.values())) == 1, (width, fonts)
page.locator("#overview-layout-open").click()
page.once("dialog", lambda prompt: prompt.accept())
page.locator("#overview-preset-list .quiet").click()
page.wait_for_function("window.__savedDashboard.dashboard_presets.length === 0")
assert page.locator("#overview-preset-list .dashboard-preset-row").count() == 0
assert page.locator("#overview-preset-empty").is_visible()
# The browser enforces the same 20-preset limit as the preferences API.
page.evaluate("""() => {
const prefs = window.__savedDashboard;
prefs.dashboard_presets = Array.from({length: 20}, (_, index) => ({
name: `Preset ${index + 1}`,
dashboard_density: prefs.dashboard_density,
dashboard_widgets: [...prefs.dashboard_widgets],
dashboard_hidden_widgets: [...prefs.dashboard_hidden_widgets],
dashboard_widget_sizes: prefs.dashboard_widget_sizes === null ? null : {...prefs.dashboard_widget_sizes},
}));
applyAdminPreferences(prefs);
window.rustProxyDashboardLayout.refresh();
}""")
assert page.locator("#overview-preset-list .dashboard-preset-row").count() == 20
page.locator("#overview-preset-name").fill("Preset 21")
page.locator("#overview-preset-save").click()
assert page.evaluate("window.__savedDashboard.dashboard_presets.length") == 20
assert "Osiągnięto limit presetów" in page.locator("#overview-layout-message").inner_text()
assert not errors, (width, errors)
page.close()
print(f"PASS Dashboard presets + chart title font at {width}px")
browser.close()
print("PASS 0.33.7 Dashboard preset manager regression (simulated API)")
@@ -142,25 +142,23 @@ with sync_playwright() as p:
assert page.locator(selector).get_attribute('data-dashboard-span') == '2' 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.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '6'
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
# In a fresh draft, the modal editor also provides width controls. # In 0.33.7 resizing/reordering remains in the inline layout editor; the
page.locator('#overview-layout-open').click() # former modal is now dedicated to named presets.
page.wait_for_function("document.getElementById('overview-layout-dialog').open") page.locator('#overview-layout-unlock').click()
tile = '#overview-layout-visible [data-preview-widget="wide_transform_activity"]' page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
page.locator(tile + ' .overview-layout-narrow').click() for _ in range(2):
assert page.locator(tile).get_attribute('data-dashboard-span') == '4' page.locator('[data-dashboard-widget="wide_transform_activity"] .overview-inline-narrow').click()
page.locator(tile + ' .overview-layout-narrow').click() assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3'
assert page.locator(tile).get_attribute('data-dashboard-span') == '3'
# Modal drag-and-drop also works across former small/wide size groups.
page.evaluate('''() => { page.evaluate('''() => {
const from = document.querySelector('[data-preview-widget="wide_transform_activity"]'); const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip');
const to = document.querySelector('[data-preview-widget="requests"]'); const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip');
const transfer = new DataTransfer(); const transfer = new DataTransfer();
from.dispatchEvent(new DragEvent('dragstart', {bubbles:true,cancelable:true,dataTransfer:transfer})); 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})); from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
}''') }''')
page.locator('#overview-layout-save').click() page.locator('#overview-inline-save').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open") 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_widget_sizes.wide_transform_activity') == 3
assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity' assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity'
# Reload account settings: new card width persists. # Reload account settings: new card width persists.
@@ -184,11 +182,12 @@ with sync_playwright() as p:
assert len(set(b['y'] for b in data['boxes'])) == 1, (width, data) assert len(set(b['y'] for b in data['boxes'])) == 1, (width, data)
assert all(b['w'] > 150 for b in data['boxes']), (width,data) assert all(b['w'] > 150 for b in data['boxes']), (width,data)
assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2, (width, data) assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2, (width, data)
page.locator('#overview-layout-open').click() page.evaluate('''preset => window.rustProxyPreferences.update({
page.wait_for_function("document.getElementById('overview-layout-dialog').open") dashboard_density: preset.dashboard_density,
page.locator('#overview-layout-default').click() dashboard_widgets: [...preset.dashboard_widgets],
page.locator('#overview-layout-save').click() dashboard_hidden_widgets: [...preset.dashboard_hidden_widgets],
page.wait_for_function("!document.getElementById('overview-layout-dialog').open") dashboard_widget_sizes: null,
})''', PRESET)
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets'] assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
assert page.evaluate('document.getElementById("dashboard-overview-grid").dataset.overviewLayoutMode') == 'legacy' assert page.evaluate('document.getElementById("dashboard-overview-grid").dataset.overviewLayoutMode') == 'legacy'
View File
File mode changed.
+49
View File
@@ -3574,6 +3574,55 @@
}, },
"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." "description": "Nullable account-scoped per-card widths (12-column grid). Null preserves the legacy grouped visual arrangement; an object, including an empty one, enables freely ordered cards. Key must be a registered Overview widget ID. Panels require width >= 4, charts >= 3 and backend table >= 6."
}, },
"dashboard_presets": {
"type": "array",
"maxItems": 20,
"default": [],
"description": "Account-scoped named Dashboard presets. Each preset stores Dashboard density, card order/visibility and optional custom card widths.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"dashboard_density",
"dashboard_widgets",
"dashboard_hidden_widgets"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"dashboard_density": {
"type": "string",
"enum": ["normal", "compact", "ultra"]
},
"dashboard_widgets": {
"type": "array",
"minItems": 1,
"maxItems": 78,
"uniqueItems": true,
"items": {"type": "string"}
},
"dashboard_hidden_widgets": {
"type": "array",
"maxItems": 78,
"uniqueItems": true,
"items": {"type": "string"}
},
"dashboard_widget_sizes": {
"type": "object",
"nullable": true,
"maxProperties": 78,
"additionalProperties": {
"type": "integer",
"enum": [2, 3, 4, 6, 12]
}
}
}
}
},
"dashboard_hidden_widgets": { "dashboard_hidden_widgets": {
"type": "array", "type": "array",
"maxItems": 78, "maxItems": 78,
+24 -26
View File
@@ -1,4 +1,5 @@
include!("preferences_columns.rs"); include!("preferences_columns.rs");
include!("preferences_dashboard_presets.rs");
fn default_admin_theme() -> String { fn default_admin_theme() -> String {
"dark".to_owned() "dark".to_owned()
@@ -27,33 +28,15 @@ fn default_admin_table_density() -> String {
fn default_admin_dashboard_density() -> String { fn default_admin_dashboard_density() -> String {
DASHBOARD_DEFAULT_PRESET.dashboard_density.clone() DASHBOARD_DEFAULT_PRESET.dashboard_density.clone()
} }
// Preserve the user's saved order/visibility, drop identifiers unknown to this // Stored dashboard layouts are normalized on read so presets remain usable
// binary, and append new registry widgets according to their opt-in policy. // when cards are added or removed between releases.
// Stored preferences from older/newer releases are normalized on read.
fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) { fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) {
let mut seen = std::collections::HashSet::new(); reconcile_dashboard_layout(
prefs.dashboard_widgets.retain(|id| { &mut prefs.dashboard_widgets,
DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen.insert(id.clone()) &mut prefs.dashboard_hidden_widgets,
}); &mut prefs.dashboard_widget_sizes,
let mut seen_hidden = std::collections::HashSet::new(); );
prefs.dashboard_hidden_widgets.retain(|id| { reconcile_dashboard_presets(&mut prefs.dashboard_presets);
DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen_hidden.insert(id.clone())
});
if let Some(sizes) = prefs.dashboard_widget_sizes.as_mut() {
sizes.retain(|id, span| {
DASHBOARD_WIDGETS
.iter()
.any(|widget| widget.id == *id && valid_dashboard_span(&widget.display_type, *span))
});
}
for widget in DASHBOARD_WIDGETS.iter() {
if !seen.contains(&widget.id) {
prefs.dashboard_widgets.push(widget.id.clone());
if widget.default_hidden && seen_hidden.insert(widget.id.clone()) {
prefs.dashboard_hidden_widgets.push(widget.id.clone());
}
}
}
} }
fn default_file_log_sort() -> String { fn default_file_log_sort() -> String {
@@ -135,6 +118,8 @@ struct AdminPreferences {
// A map (including {}) means that order is fully user-controlled. // A map (including {}) means that order is fully user-controlled.
#[serde(default)] #[serde(default)]
dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>, dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>,
#[serde(default)]
dashboard_presets: Vec<DashboardPreset>,
#[serde(default = "default_file_log_columns")] #[serde(default = "default_file_log_columns")]
file_log_columns: Vec<String>, file_log_columns: Vec<String>,
#[serde(default = "default_file_log_sort")] #[serde(default = "default_file_log_sort")]
@@ -189,6 +174,7 @@ impl Default for AdminPreferences {
dashboard_widgets: default_admin_dashboard_widgets(), dashboard_widgets: default_admin_dashboard_widgets(),
dashboard_hidden_widgets: default_hidden_dashboard_widgets(), dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None, dashboard_widget_sizes: None,
dashboard_presets: Vec::new(),
file_log_columns: default_file_log_columns(), file_log_columns: default_file_log_columns(),
file_log_sort: default_file_log_sort(), file_log_sort: default_file_log_sort(),
file_log_descending: default_file_log_descending(), file_log_descending: default_file_log_descending(),
@@ -380,6 +366,7 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
"invalid dashboard card width preference".into(), "invalid dashboard card width preference".into(),
)); ));
} }
validate_dashboard_presets(&preferences.dashboard_presets)?;
if preferences.backend_table_columns.is_empty() if preferences.backend_table_columns.is_empty()
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len() || preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
{ {
@@ -481,6 +468,17 @@ async fn update_preferences(
preferences preferences
.dashboard_hidden_widgets .dashboard_hidden_widgets
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id)); .retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
for preset in &mut preferences.dashboard_presets {
preset
.dashboard_widgets
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
preset
.dashboard_hidden_widgets
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
if let Some(sizes) = preset.dashboard_widget_sizes.as_mut() {
sizes.retain(|id, _| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
}
}
validate_admin_preferences(&preferences)?; validate_admin_preferences(&preferences)?;
let document = serde_json::to_string(&preferences).map_err(auth::internal)?; let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
if !state if !state
+103
View File
@@ -0,0 +1,103 @@
const DASHBOARD_PRESET_MAX: usize = 20;
const DASHBOARD_PRESET_NAME_MAX: usize = 64;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct DashboardPreset {
name: String,
dashboard_density: String,
dashboard_widgets: Vec<String>,
dashboard_hidden_widgets: Vec<String>,
#[serde(default)]
dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>,
}
fn reconcile_dashboard_layout(
order: &mut Vec<String>,
hidden: &mut Vec<String>,
sizes: &mut Option<std::collections::BTreeMap<String, u8>>,
) {
let mut seen = std::collections::HashSet::new();
order.retain(|id| {
DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen.insert(id.clone())
});
let mut seen_hidden = std::collections::HashSet::new();
hidden.retain(|id| {
DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen_hidden.insert(id.clone())
});
if let Some(sizes) = sizes.as_mut() {
sizes.retain(|id, span| {
DASHBOARD_WIDGETS
.iter()
.any(|widget| widget.id == *id && valid_dashboard_span(&widget.display_type, *span))
});
}
for widget in DASHBOARD_WIDGETS.iter() {
if !seen.contains(&widget.id) {
order.push(widget.id.clone());
if widget.default_hidden && seen_hidden.insert(widget.id.clone()) {
hidden.push(widget.id.clone());
}
}
}
}
fn reconcile_dashboard_presets(presets: &mut [DashboardPreset]) {
for preset in presets {
reconcile_dashboard_layout(
&mut preset.dashboard_widgets,
&mut preset.dashboard_hidden_widgets,
&mut preset.dashboard_widget_sizes,
);
}
}
fn valid_dashboard_preset_name(name: &str) -> bool {
!name.is_empty()
&& name.chars().count() <= DASHBOARD_PRESET_NAME_MAX
&& name.trim() == name
&& !name.chars().any(char::is_control)
}
fn valid_dashboard_preset(preset: &DashboardPreset) -> bool {
if !valid_dashboard_preset_name(&preset.name)
|| !matches!(preset.dashboard_density.as_str(), "normal" | "compact" | "ultra")
|| preset.dashboard_widgets.is_empty()
|| preset.dashboard_widgets.len() > DASHBOARD_WIDGETS.len()
|| preset.dashboard_hidden_widgets.len() > DASHBOARD_WIDGETS.len()
|| !valid_dashboard_sizes(&preset.dashboard_widget_sizes)
{
return false;
}
let mut order = std::collections::HashSet::new();
if preset.dashboard_widgets.iter().any(|id| {
!DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) || !order.insert(id.as_str())
}) {
return false;
}
let mut hidden = std::collections::HashSet::new();
!preset.dashboard_hidden_widgets.iter().any(|id| {
!order.contains(id.as_str()) || !hidden.insert(id.as_str())
})
}
fn validate_dashboard_presets(presets: &[DashboardPreset]) -> Result<(), ApiError> {
if presets.len() > DASHBOARD_PRESET_MAX {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"too many dashboard presets".into(),
));
}
let mut names = std::collections::HashSet::new();
for preset in presets {
if !valid_dashboard_preset(preset)
|| !names.insert(preset.name.to_lowercase())
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid dashboard preset".into(),
));
}
}
Ok(())
}
+56
View File
@@ -255,3 +255,59 @@ fn dashboard_width_preferences_preserve_legacy_and_allow_custom_grids() {
.contains_key("not_a_card")); .contains_key("not_a_card"));
assert!(validate_admin_preferences(&prefs).is_ok()); assert!(validate_admin_preferences(&prefs).is_ok());
} }
#[test]
fn dashboard_presets_round_trip_reconcile_and_validate() {
let mut prefs = AdminPreferences::default();
let mut sizes = std::collections::BTreeMap::new();
sizes.insert("requests".to_owned(), 4);
prefs.dashboard_presets.push(DashboardPreset {
name: "Operations".to_owned(),
dashboard_density: "ultra".to_owned(),
dashboard_widgets: default_admin_dashboard_widgets(),
dashboard_hidden_widgets: vec!["bandwidth".to_owned()],
dashboard_widget_sizes: Some(sizes),
});
assert!(validate_admin_preferences(&prefs).is_ok());
let saved = serde_json::to_string(&prefs).unwrap();
let restored: AdminPreferences = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.dashboard_presets.len(), 1);
assert_eq!(restored.dashboard_presets[0].name, "Operations");
assert_eq!(restored.dashboard_presets[0].dashboard_density, "ultra");
assert_eq!(
restored.dashboard_presets[0]
.dashboard_widget_sizes
.as_ref()
.and_then(|sizes| sizes.get("requests")),
Some(&4)
);
prefs.dashboard_presets[0].dashboard_widgets = vec![
"requests".to_owned(),
"removed_in_future".to_owned(),
];
prefs.dashboard_presets[0].dashboard_hidden_widgets =
vec!["removed_in_future".to_owned()];
prefs.dashboard_presets[0]
.dashboard_widget_sizes
.as_mut()
.unwrap()
.insert("removed_in_future".to_owned(), 4);
reconcile_dashboard_widgets(&mut prefs);
assert!(!prefs.dashboard_presets[0]
.dashboard_widgets
.contains(&"removed_in_future".to_owned()));
assert_eq!(
prefs.dashboard_presets[0].dashboard_widgets.len(),
DASHBOARD_WIDGETS.len()
);
assert!(validate_admin_preferences(&prefs).is_ok());
let duplicate = prefs.dashboard_presets[0].clone();
prefs.dashboard_presets.push(DashboardPreset {
name: "operations".to_owned(),
..duplicate
});
assert!(validate_admin_preferences(&prefs).is_err());
}
+114 -251
View File
@@ -1,279 +1,142 @@
window.rustProxyDashboardLayout = (() => { window.rustProxyDashboardLayout = (() => {
const dialog = $("overview-layout-dialog"); const dialog = $("overview-layout-dialog");
const visibleRoot = $("overview-layout-visible"); const nameInput = $("overview-preset-name");
const hiddenRoot = $("overview-layout-hidden"); const saveButton = $("overview-preset-save");
const saveButton = $("overview-layout-save"); const list = $("overview-preset-list");
const cancelButton = $("overview-layout-cancel"); const empty = $("overview-preset-empty");
const search = $("overview-layout-search"); const count = $("overview-preset-count");
const typeFilter = $("overview-layout-type-filter");
const message = $("overview-layout-message"); const message = $("overview-layout-message");
const defaultButton = $("overview-layout-default"); const closeButton = $("overview-layout-cancel");
const resetButton = $("overview-layout-reset"); const MAX_PRESETS = 20;
const resetConfirm = $("overview-reset-confirm"); let busy = false;
const resetCancel = $("overview-reset-cancel");
const resetApply = $("overview-reset-apply");
let draft = null;
let dragging = null;
let saving = false;
let opening = false; let opening = false;
function initialDraft() { const current = () => window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS;
const prefs = window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS; const presets = () => normalizeDashboardPresets(current().dashboard_presets);
const densityLabel = value => t({ normal: "densityNormal", compact: "densityCompact", ultra: "densityUltra" }[value] || "densityCompact");
function snapshot(name) {
const prefs = current();
const order = normalizeDashboardWidgets(prefs.dashboard_widgets); const order = normalizeDashboardWidgets(prefs.dashboard_widgets);
return { order, hidden: new Set(normalizeHiddenDashboardWidgets(prefs.dashboard_hidden_widgets, prefs.dashboard_widgets)),
sizes: normalizeDashboardWidgetSizes(prefs.dashboard_widget_sizes) };
}
function defaultDraft() {
return { return {
order: [...ADMIN_DASHBOARD_WIDGETS], name,
hidden: new Set(ADMIN_DEFAULT_HIDDEN_WIDGETS), dashboard_density: normalizeAdminDensity(prefs.dashboard_density, "compact"),
sizes: null, dashboard_widgets: order,
dashboard_hidden_widgets: normalizeHiddenDashboardWidgets(prefs.dashboard_hidden_widgets, prefs.dashboard_widgets),
dashboard_widget_sizes: normalizeDashboardWidgetSizes(prefs.dashboard_widget_sizes),
}; };
} }
function label(id) { function summary(preset) {
const widget = ADMIN_DASHBOARD_BY_ID.get(id); const visible = preset.dashboard_widgets.length - preset.dashboard_hidden_widgets.length;
return widget ? t(widget.label_key) : id; const widths = Object.keys(preset.dashboard_widget_sizes || {}).length;
return t("dashboardPresetSummary")
.replace("{visible}", String(visible))
.replace("{density}", densityLabel(preset.dashboard_density))
.replace("{widths}", String(widths));
} }
function visibleIds() { function setBusy(value) {
return dashboardVisualOrder(draft.order, draft.sizes) busy = value;
.filter(id => !draft.hidden.has(id)); if (nameInput) nameInput.disabled = value;
} if (closeButton) closeButton.disabled = value;
function enterFreeLayout() {
if (draft.sizes === null) {
draft.order = dashboardVisualOrder(draft.order, null);
draft.sizes = {};
}
}
function reorderVisible(nextVisible) {
enterFreeLayout();
const iter = nextVisible.values();
draft.order = draft.order.map(id => draft.hidden.has(id) ? id : iter.next().value);
render(); render();
} }
function move(id, target, after = false) { async function saveCurrent() {
const next = visibleIds(); const name = (nameInput?.value || "").trim();
if (id === target || !next.includes(id) || !next.includes(target)) return; if (busy || !name) return;
next.splice(next.indexOf(id), 1); const next = presets();
next.splice(next.indexOf(target) + (after ? 1 : 0), 0, id); const key = name.toLowerCase();
reorderVisible(next); const index = next.findIndex(preset => preset.name.toLowerCase() === key);
} if (index < 0 && next.length >= MAX_PRESETS) {
function nudge(id, direction) { message.textContent = t("dashboardPresetLimitReached").replace("{max}", String(MAX_PRESETS));
const next = visibleIds(), index = next.indexOf(id), other = index + direction; return;
if (index < 0 || other < 0 || other >= next.length) return;
[next[index], next[other]] = [next[other], next[index]];
reorderVisible(next);
visibleRoot.querySelector(`[data-preview-widget="${id}"] .overview-layout-up`)?.focus();
}
function resize(id, delta) {
if (!draft) return;
const widths = dashboardAllowedWidths(id);
const next = widths[widths.indexOf(dashboardWidgetWidth(id, draft.sizes)) + delta];
if (next === undefined) return;
enterFreeLayout();
if (next === dashboardDefaultWidth(id)) delete draft.sizes[id];
else draft.sizes[id] = next;
render();
visibleRoot.querySelector(`[data-preview-widget="${id}"] .${delta < 0 ? "overview-layout-narrow" : "overview-layout-widen"}`)?.focus();
}
function preview(id) {
const original = document.querySelector(`#panel-overview [data-dashboard-widget="${id}"]`);
if (!original) return el("article", label(id), "stat");
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") {
const card = el("article", undefined, "overview-layout-panel-preview");
card.append(el("strong", label(id)));
const chart = id === "backend_table" ? null : original.querySelector?.("svg");
if (chart) {
const graphic = chart.cloneNode(true);
for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id");
graphic.removeAttribute("data-i18n-aria");
graphic.setAttribute("aria-hidden", "true");
card.append(graphic);
}
const detailKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
if (detailKey) card.append(el("small", t(detailKey)));
card.setAttribute("aria-hidden", "true");
return card;
} }
const copy = original.cloneNode(true); const value = snapshot(name);
copy.hidden = false; if (index >= 0) next[index] = value;
copy.removeAttribute("data-dashboard-widget"); else next.unshift(value);
for (const node of [copy, ...copy.querySelectorAll("[id]")]) node.removeAttribute("id"); setBusy(true);
for (const node of copy.querySelectorAll("[aria-labelledby], [aria-describedby]")) { message.textContent = t("dashboardPresetSaving");
node.removeAttribute("aria-labelledby"); node.removeAttribute("aria-describedby"); try {
} await window.rustProxyPreferences.update({ dashboard_presets: next });
copy.classList.add("overview-layout-card-preview"); if (nameInput) nameInput.value = "";
copy.setAttribute("aria-hidden", "true"); message.textContent = t(index >= 0 ? "dashboardPresetUpdated" : "dashboardPresetSavedMessage").replace("{name}", name);
return copy; toast(message.textContent);
} catch (error) { message.textContent = error.message; }
finally { setBusy(false); }
}
async function loadPreset(preset) {
if (busy) return;
setBusy(true);
message.textContent = t("dashboardPresetLoading").replace("{name}", preset.name);
try {
await window.rustProxyPreferences.update({
dashboard_density: preset.dashboard_density,
dashboard_widgets: [...preset.dashboard_widgets],
dashboard_hidden_widgets: [...preset.dashboard_hidden_widgets],
dashboard_widget_sizes: preset.dashboard_widget_sizes === null ? null : { ...preset.dashboard_widget_sizes },
});
dialog.close();
toast(t("dashboardPresetLoaded").replace("{name}", preset.name));
} catch (error) { message.textContent = error.message; }
finally { setBusy(false); }
}
async function deletePreset(preset) {
if (busy || !confirm(t("dashboardPresetDeleteConfirm").replace("{name}", preset.name))) return;
setBusy(true);
message.textContent = t("dashboardPresetDeleting").replace("{name}", preset.name);
try {
const next = presets().filter(item => item.name.toLowerCase() !== preset.name.toLowerCase());
await window.rustProxyPreferences.update({ dashboard_presets: next });
message.textContent = t("dashboardPresetDeleted").replace("{name}", preset.name);
} catch (error) { message.textContent = error.message; }
finally { setBusy(false); }
}
function presetRow(preset) {
const row = el("article", undefined, "dashboard-preset-row");
row.setAttribute("role", "listitem");
const info = el("div", undefined, "dashboard-preset-info");
info.append(el("strong", preset.name), el("small", summary(preset), "fine"));
const actions = el("div", undefined, "dashboard-preset-actions");
const load = el("button", t("dashboardPresetLoad"), "outline");
load.type = "button"; load.disabled = busy;
load.setAttribute("aria-label", `${t("dashboardPresetLoad")}: ${preset.name}`);
load.addEventListener("click", () => { void loadPreset(preset); });
const remove = el("button", t("dashboardPresetDelete"), "quiet");
remove.type = "button"; remove.disabled = busy;
remove.setAttribute("aria-label", `${t("dashboardPresetDelete")}: ${preset.name}`);
remove.addEventListener("click", () => { void deletePreset(preset); });
actions.append(load, remove);
row.append(info, actions);
return row;
} }
function render() { function render() {
if (!draft || !dialog) return; if (!dialog || !list) return;
const visible = visibleIds(); const values = presets();
const tiles = visible.map((id, index) => { list.replaceChildren(...values.map(presetRow));
const tile = el("div", undefined, "overview-layout-tile"); if (empty) empty.hidden = values.length > 0;
tile.dataset.previewWidget = id; if (count) count.textContent = t("dashboardPresetCount").replace("{count}", String(values.length)).replace("{max}", String(MAX_PRESETS));
tile.dataset.dashboardSpan = String(dashboardWidgetWidth(id, draft.sizes)); if (saveButton) saveButton.disabled = busy || !(nameInput?.value || "").trim();
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") tile.classList.add("is-panel"); if (nameInput) nameInput.disabled = busy;
tile.draggable = true; if (closeButton) closeButton.disabled = busy;
tile.setAttribute("role", "listitem");
tile.setAttribute("aria-label", label(id));
const toolbar = el("div", undefined, "overview-layout-tile-toolbar");
const grip = el("span", "⠿", "overview-layout-grip");
grip.setAttribute("aria-hidden", "true");
const up = el("button", "←", "quiet overview-layout-up");
up.type = "button"; up.disabled = index === 0;
up.setAttribute("aria-label", `${t("moveUp")}: ${label(id)}`);
up.addEventListener("click", () => nudge(id, -1));
const down = el("button", "→", "quiet overview-layout-down");
down.type = "button"; down.disabled = index === visible.length - 1;
down.setAttribute("aria-label", `${t("moveDown")}: ${label(id)}`);
down.addEventListener("click", () => nudge(id, 1));
const hide = el("button", t("overviewLayoutEditorHide"), "quiet overview-layout-hide");
hide.type = "button";
hide.setAttribute("aria-label", `${t("overviewLayoutEditorHide")}: ${label(id)}`);
hide.addEventListener("click", () => { draft.hidden.add(id); render(); });
const widths = dashboardAllowedWidths(id);
const width = dashboardWidgetWidth(id, draft.sizes);
const at = widths.indexOf(width);
const narrow = el("button", "−", "quiet overview-layout-narrow");
narrow.type = "button"; narrow.disabled = at <= 0;
narrow.setAttribute("aria-label", `${t("overviewLayoutNarrow")}: ${label(id)}`);
narrow.addEventListener("click", () => resize(id, -1));
const widen = el("button", "+", "quiet overview-layout-widen");
widen.type = "button"; widen.disabled = at === widths.length - 1;
widen.setAttribute("aria-label", `${t("overviewLayoutWiden")}: ${label(id)}`);
widen.addEventListener("click", () => resize(id, 1));
const widthValue = el("span", `${width}/12`, "overview-layout-width-value");
widthValue.title = t("overviewLayoutWidth");
// The visual editor intentionally keeps uniformly sized previews.
// Group sizing separately from ordering/hiding so both sets wrap
// inside a narrow dialog rather than spilling into adjacent tiles.
const sizeActions = el("span", undefined, "overview-layout-size-actions");
sizeActions.append(narrow, widthValue, widen);
const orderActions = el("span", undefined, "overview-layout-order-actions");
orderActions.append(up, down, hide);
const controls = el("span", undefined, "overview-layout-controls");
controls.append(sizeActions, orderActions);
toolbar.append(grip, dashboardWidgetTypeBadge(id), controls);
tile.append(toolbar, preview(id));
tile.addEventListener("dragstart", event => {
if (event.target !== tile) return;
dragging = id;
event.dataTransfer?.setData("text/plain", id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
tile.classList.add("is-dragging");
});
tile.addEventListener("dragover", event => {
if (!dragging || dragging === id) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
tile.classList.add("is-drop-target");
});
tile.addEventListener("dragleave", () => tile.classList.remove("is-drop-target"));
tile.addEventListener("drop", event => {
event.preventDefault();
tile.classList.remove("is-drop-target");
if (dragging && dragging !== id) {
const startIndex = visibleIds().indexOf(dragging);
const targetIndex = visibleIds().indexOf(id);
move(dragging, id, startIndex < targetIndex);
}
dragging = null;
});
tile.addEventListener("dragend", () => { dragging = null; tile.classList.remove("is-dragging", "is-drop-target"); });
return tile;
});
visibleRoot.replaceChildren(...(tiles.length ? tiles : [el("p", t("overviewLayoutEditorEmpty"), "fine")]));
const query = (search?.value || "").trim().toLocaleLowerCase();
const hidden = draft.order.filter(id => draft.hidden.has(id)
&& (typeFilter?.value === "all" || !typeFilter?.value || dashboardWidgetType(id) === typeFilter.value)
&& (`${label(id)} ${dashboardWidgetTypeLabel(id)}`).toLocaleLowerCase().includes(query));
const hiddenItems = hidden.map(id => {
const item = el("div", undefined, "overview-layout-hidden-item");
item.setAttribute("role", "listitem");
const name = el("span", label(id));
const meta = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
if (meta) name.title = t(meta);
const add = el("button", t("overviewLayoutEditorShow"), "outline");
add.type = "button";
add.setAttribute("aria-label", `${t("overviewLayoutEditorShow")}: ${label(id)}`);
add.addEventListener("click", () => { draft.hidden.delete(id); render(); });
item.append(name, dashboardWidgetTypeBadge(id), add);
return item;
});
hiddenRoot.replaceChildren(...(hiddenItems.length || query ? hiddenItems : [el("p", t("overviewLayoutEditorHiddenEmpty"), "fine")]));
} }
async function open() { async function open() {
if (dialog?.open || opening || window.rustProxyInlineLayout?.isEditing?.()) return; if (dialog?.open || opening || window.rustProxyInlineLayout?.isEditing?.()) return;
opening = true; opening = true;
try { await window.rustProxyPreferences?.load?.(); } try { await window.rustProxyPreferences?.load?.(); }
catch (error) { if (error?.status === 401) { opening = false; return; } } catch (error) { if (error?.status === 401) { opening = false; return; } message.textContent = error.message; }
opening = false; opening = false;
draft = initialDraft(); if (nameInput) nameInput.value = "";
dragging = null;
search.value = "";
if (typeFilter) typeFilter.value = "all";
message.textContent = ""; message.textContent = "";
render(); render();
dialog.showModal(); dialog.showModal();
cancelButton.focus(); nameInput?.focus();
}
function setSaving(value) {
saving = value;
for (const control of [saveButton, cancelButton, defaultButton, resetButton, resetCancel, resetApply])
if (control) control.disabled = value;
}
function restoreDefaultPreview() {
if (saving || !draft) return;
draft = defaultDraft();
dragging = null;
if (search) search.value = "";
if (typeFilter) typeFilter.value = "all";
message.textContent = t("overviewLayoutDefaultLoaded");
render();
}
async function resetToDefault() {
if (saving || !draft) return;
setSaving(true);
message.textContent = t("overviewLayoutEditorSaving");
const defaults = defaultDraft();
try {
await window.rustProxyPreferences.update({
dashboard_widgets: [...defaults.order], dashboard_hidden_widgets: [...defaults.hidden],
dashboard_widget_sizes: null,
});
resetConfirm.close();
dialog.close();
toast(t("overviewLayoutResetSaved"));
} catch (error) { message.textContent = error.message; }
finally { setSaving(false); }
}
async function save() {
if (saving || !draft) return;
setSaving(true);
message.textContent = t("overviewLayoutEditorSaving");
try {
await window.rustProxyPreferences.update({
dashboard_widgets: [...draft.order], dashboard_hidden_widgets: [...draft.hidden],
dashboard_widget_sizes: draft.sizes === null ? null : { ...draft.sizes },
});
dialog.close();
toast(t("overviewLayoutEditorSaved"));
} catch (error) { message.textContent = error.message; }
finally { setSaving(false); }
} }
for (const id of ["overview-layout-open", "overview-layout-open-settings"]) $(id)?.addEventListener("click", () => { void open(); }); for (const id of ["overview-layout-open", "overview-layout-open-settings"]) $(id)?.addEventListener("click", () => { void open(); });
cancelButton.addEventListener("click", () => { if (!saving) dialog.close(); }); nameInput?.addEventListener("input", render);
saveButton.addEventListener("click", () => { void save(); }); nameInput?.addEventListener("keydown", event => {
defaultButton?.addEventListener("click", restoreDefaultPreview); if (event.key === "Enter" && !saveButton?.disabled) { event.preventDefault(); void saveCurrent(); }
resetButton?.addEventListener("click", () => {
if (!saving && draft && !resetConfirm.open) resetConfirm.showModal();
}); });
resetCancel?.addEventListener("click", () => { if (!saving) resetConfirm.close(); }); saveButton?.addEventListener("click", () => { void saveCurrent(); });
resetApply?.addEventListener("click", () => { void resetToDefault(); }); closeButton?.addEventListener("click", () => { if (!busy) dialog.close(); });
resetConfirm?.addEventListener("cancel", event => { if (saving) event.preventDefault(); }); dialog?.addEventListener("cancel", event => { if (busy) event.preventDefault(); });
search.addEventListener("input", render); dialog?.addEventListener("close", () => { message.textContent = ""; });
typeFilter?.addEventListener("change", render); return { open, refresh: () => { if (dialog?.open) render(); } };
dialog.addEventListener("cancel", event => { if (saving || resetConfirm.open) event.preventDefault(); });
dialog.addEventListener("close", () => { draft = null; dragging = null; message.textContent = ""; });
return { open, refresh: () => { if (dialog.open) render(); } };
})(); })();
+22 -3
View File
@@ -13,7 +13,7 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([
])]); ])]);
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({ const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default", theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, dashboard_presets: [],
}); });
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" }); const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false; let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
@@ -70,6 +70,25 @@ function normalizeDashboardWidgetSizes(value) {
} }
return result; return result;
} }
function normalizeDashboardPresets(value) {
if (!Array.isArray(value)) return [];
const result = [], names = new Set();
for (const raw of value.slice(0, 20)) {
if (!raw || typeof raw !== "object") continue;
const name = typeof raw.name === "string" ? raw.name.trim() : "";
const key = name.toLowerCase();
if (!name || name.length > 64 || names.has(key)) continue;
names.add(key);
const order = normalizeDashboardWidgets(raw.dashboard_widgets);
result.push({
name, dashboard_density: normalizeAdminDensity(raw.dashboard_density, "compact"),
dashboard_widgets: order,
dashboard_hidden_widgets: normalizeHiddenDashboardWidgets(raw.dashboard_hidden_widgets, raw.dashboard_widgets),
dashboard_widget_sizes: normalizeDashboardWidgetSizes(raw.dashboard_widget_sizes),
});
}
return result;
}
function dashboardWidgetWidth(id, sizes) { function dashboardWidgetWidth(id, sizes) {
return sizes?.[id] ?? dashboardDefaultWidth(id); return sizes?.[id] ?? dashboardDefaultWidth(id);
} }
@@ -284,7 +303,7 @@ function applyAdminPreferences(value, cache = true) {
refresh: normalizeRefreshPreferences(value.refresh), refresh: normalizeRefreshPreferences(value.refresh),
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache), theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density), time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes, dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes, dashboard_presets: normalizeDashboardPresets(value.dashboard_presets),
}; };
adminPreferences = normalized; adminPreferences = normalized;
window.rustProxyRefresh?.update(normalized.refresh); window.rustProxyRefresh?.update(normalized.refresh);
@@ -332,7 +351,7 @@ function updateAdminPreferences(patch) {
next.refresh = normalizeRefreshPreferences(next.refresh); next.refresh = normalizeRefreshPreferences(next.refresh);
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale); next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density); next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); next.dashboard_presets = normalizeDashboardPresets(next.dashboard_presets);
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) }); const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
if (!authenticated || epoch !== authEpoch) return adminPreferences; if (!authenticated || epoch !== authEpoch) return adminPreferences;
return applyAdminPreferences(saved); return applyAdminPreferences(saved);
+22 -52
View File
@@ -139,7 +139,7 @@
<button class="quiet" data-i18n="refreshNow" id="overview-refresh" type="button" <button class="quiet" data-i18n="refreshNow" id="overview-refresh" type="button"
hidden>Refresh now</button> hidden>Refresh now</button>
<button class="quiet" data-i18n="overviewLayoutEditorOpen" id="overview-layout-open" <button class="quiet" data-i18n="overviewLayoutEditorOpen" id="overview-layout-open"
type="button" hidden>Arrange cards visually</button> type="button" hidden>Dashboard presets</button>
<button class="outline" id="overview-layout-unlock" type="button" hidden <button class="outline" id="overview-layout-unlock" type="button" hidden
data-i18n="overviewLayoutUnlock" aria-pressed="false">Unlock layout</button> data-i18n="overviewLayoutUnlock" aria-pressed="false">Unlock layout</button>
<div class="overview-inline-actions" id="overview-inline-actions" hidden> <div class="overview-inline-actions" id="overview-inline-actions" hidden>
@@ -2881,8 +2881,8 @@
<div class="dashboard-widget-settings-controls"> <div class="dashboard-widget-settings-controls">
<button class="outline" type="button" <button class="outline" type="button"
id="overview-layout-open-settings" id="overview-layout-open-settings"
data-i18n="overviewLayoutEditorOpenSettings">Visual layout data-i18n="overviewLayoutEditorOpenSettings">Dashboard
editor</button> presets</button>
<button class="quiet" id="dashboard-widget-expand" type="button" <button class="quiet" id="dashboard-widget-expand" type="button"
aria-expanded="false" aria-controls="dashboard-widget-list" aria-expanded="false" aria-controls="dashboard-widget-list"
data-i18n="dashboardWidgetsShowAll">Show all</button> data-i18n="dashboardWidgetsShowAll">Show all</button>
@@ -4228,65 +4228,35 @@
type="button">Cancel</button></div> type="button">Cancel</button></div>
</form> </form>
</dialog> </dialog>
<dialog aria-labelledby="overview-layout-dialog-title" aria-modal="true" class="audit-dialog overview-layout-dialog" <dialog aria-labelledby="overview-layout-dialog-title" aria-modal="true" class="audit-dialog overview-layout-dialog dashboard-presets-dialog"
id="overview-layout-dialog"> id="overview-layout-dialog">
<div class="window-heading"> <div class="window-heading">
<div><span class="small-label" data-i18n="dashboardWidgets">Overview cards</span> <div><span class="small-label" data-i18n="dashboardWidgets">Overview cards</span>
<h2 id="overview-layout-dialog-title" data-i18n="overviewLayoutEditorTitle">Arrange Overview cards</h2> <h2 id="overview-layout-dialog-title" data-i18n="overviewLayoutEditorTitle">Dashboard presets</h2>
</div> </div>
</div> </div>
<div class="overview-layout-body"> <div class="overview-layout-body dashboard-presets-body">
<p class="fine" data-i18n="overviewLayoutEditorHelp">Drag visible cards to change their order. Changes are <p class="fine" data-i18n="overviewLayoutEditorHelp">Save the current Dashboard layout as a named preset, then load it whenever you need the same view again.</p>
saved only when you select Save.</p> <section class="dashboard-preset-save" aria-labelledby="dashboard-preset-save-title">
<section aria-labelledby="overview-layout-visible-title"> <div class="dashboard-preset-name-field">
<h3 id="overview-layout-visible-title" data-i18n="overviewLayoutEditorVisible">Visible cards</h3> <label id="dashboard-preset-save-title" for="overview-preset-name" data-i18n="dashboardPresetName">Preset name</label>
<div class="overview-layout-grid" id="overview-layout-visible" role="list"></div> <input id="overview-preset-name" type="text" maxlength="64" autocomplete="off"
</section> data-i18n-placeholder="dashboardPresetNamePlaceholder" placeholder="e.g. Operations" />
<section aria-labelledby="overview-layout-hidden-title">
<div class="overview-layout-section-heading">
<h3 id="overview-layout-hidden-title" data-i18n="overviewLayoutEditorHidden">Hidden cards</h3>
<div class="overview-layout-filters">
<select id="overview-layout-type-filter" data-i18n-aria="overviewLayoutTypeFilter"
aria-label="Filter blocks by type">
<option value="all" data-i18n="overviewBlockTypeAll">All block types</option>
<option value="value" data-i18n="overviewBlockTypeValue">Number</option>
<option value="value_chart" data-i18n="overviewBlockTypeValueChart">Number + chart</option>
<option value="chart" data-i18n="overviewBlockTypeChart">Chart</option>
<option value="panel" data-i18n="overviewBlockTypePanel">Diagnostics panel</option>
<option value="table" data-i18n="overviewBlockTypeTable">Table</option>
</select>
<input id="overview-layout-search" type="search"
data-i18n-placeholder="overviewLayoutEditorSearch" aria-label="Find hidden card"
data-i18n-aria="overviewLayoutEditorSearch" autocomplete="off" />
</div>
</div> </div>
<div class="overview-layout-hidden-list" id="overview-layout-hidden" role="list"></div> <button class="primary" id="overview-preset-save" type="button" data-i18n="dashboardPresetSaveCurrent">Save current Dashboard</button>
</section>
<section aria-labelledby="dashboard-preset-list-title">
<div class="dashboard-preset-list-heading">
<h3 id="dashboard-preset-list-title" data-i18n="dashboardPresetSaved">Saved presets</h3>
<span class="fine" id="overview-preset-count"></span>
</div>
<div class="dashboard-preset-list" id="overview-preset-list" role="list"></div>
<p class="fine empty" id="overview-preset-empty" data-i18n="dashboardPresetEmpty">No saved Dashboard presets.</p>
</section> </section>
</div> </div>
<div class="overview-layout-footer"> <div class="overview-layout-footer">
<p id="overview-layout-message" role="alert" aria-live="polite"></p> <p id="overview-layout-message" role="alert" aria-live="polite"></p>
<button class="quiet" id="overview-layout-default" type="button" <button class="quiet" id="overview-layout-cancel" type="button" data-i18n="close">Close</button>
data-i18n="overviewLayoutDefaultPreview">Default view</button>
<button class="outline" id="overview-layout-reset" type="button" data-i18n="overviewLayoutReset">Reset
layout</button>
<button class="quiet" id="overview-layout-cancel" type="button"
data-i18n="overviewLayoutEditorCancel">Cancel</button>
<button class="primary" id="overview-layout-save" type="button" data-i18n="overviewLayoutEditorSave">Save
layout</button>
</div>
</dialog>
<dialog aria-labelledby="overview-reset-confirm-title" aria-modal="true" class="audit-dialog overview-reset-confirm"
id="overview-reset-confirm">
<div class="window-heading">
<h2 id="overview-reset-confirm-title" data-i18n="overviewLayoutResetTitle">Reset dashboard layout?</h2>
</div>
<p data-i18n="overviewLayoutResetConfirm">This restores the default order and visibility of all Overview blocks.
Other settings remain unchanged.</p>
<div class="overview-layout-footer">
<button class="quiet" id="overview-reset-cancel" type="button"
data-i18n="overviewLayoutEditorCancel">Cancel</button>
<button class="primary" id="overview-reset-apply" type="button" data-i18n="overviewLayoutResetApply">Reset
and save</button>
</div> </div>
</dialog> </dialog>
<dialog aria-labelledby="tunnels-dialog-title" class="audit-dialog" id="tunnels-dialog"> <dialog aria-labelledby="tunnels-dialog-title" class="audit-dialog" id="tunnels-dialog">
+101 -1
View File
@@ -11813,4 +11813,104 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] { #panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] {
padding-top: 135px; padding-top: 135px;
} }
} }
/* 0.33.7: the former visual layout modal is now an account-scoped preset manager. */
.dashboard-presets-dialog {
width: calc(min(760px, 100vw - 24px));
}
.dashboard-presets-body {
gap: 20px;
}
.dashboard-preset-save {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: end;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
background: var(--panel);
}
.dashboard-preset-name-field {
display: grid;
gap: 5px;
min-width: 0;
}
.dashboard-preset-name-field label {
font-size: 12px;
}
.dashboard-preset-name-field input {
width: 100%;
box-sizing: border-box;
}
.dashboard-preset-list-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.dashboard-preset-list {
display: grid;
gap: 7px;
}
.dashboard-preset-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 10px;
border: 1px solid var(--line);
background: var(--panel);
}
.dashboard-preset-info {
display: grid;
gap: 3px;
min-width: 0;
}
.dashboard-preset-info strong,
.dashboard-preset-info small {
overflow-wrap: anywhere;
}
.dashboard-preset-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
}
@media(max-width:550px) {
.dashboard-preset-save {
grid-template-columns: minmax(0, 1fr);
}
.dashboard-preset-save>button {
width: 100%;
}
.dashboard-preset-row {
align-items: stretch;
flex-direction: column;
}
.dashboard-preset-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
}
/* Chart headings use the same monospace family as CONNECTION ACTIVITY. */
#panel-overview .stat-chart>span:first-child,
#panel-overview .overview-wide-chart .card-heading h2,
#panel-overview .activity-card .card-heading h2 {
font-family: var(--mono);
}