Compare commits
2
Commits
master
...
7e625c5876
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e625c5876 | ||
|
|
f462ca7194 |
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+23
-5
@@ -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,7 +2703,25 @@
|
|||||||
"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",
|
||||||
|
|||||||
+23
-5
@@ -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,7 +2703,25 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
def load(page):
|
browser = p.chromium.launch(headless=True, executable_path="/usr/bin/chromium", args=["--no-sandbox"])
|
||||||
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
|
for width in (320, 375, 550, 620, 800, 900, 960, 1158, 1440, 1920):
|
||||||
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
|
page = browser.new_page(viewport={"width": width, "height": 823})
|
||||||
|
errors = []
|
||||||
|
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||||
|
page.set_content(HTML.replace("/assets/app___RUSTPROXY_ASSET_ID__.js", "")
|
||||||
|
.replace("/assets/style___RUSTPROXY_ASSET_ID__.css", ""))
|
||||||
page.add_style_tag(content=CSS)
|
page.add_style_tag(content=CSS)
|
||||||
page.add_script_tag(content=BUNDLE)
|
page.add_script_tag(content=BUNDLE)
|
||||||
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyDashboardLayout')
|
page.wait_for_function("window.rustProxyPreferences && window.rustProxyDashboardLayout")
|
||||||
page.evaluate('''packs => {
|
page.evaluate("""packs => {
|
||||||
languagePacks = new Map(Object.entries(packs));
|
languagePacks = new Map(Object.entries(packs)); defaultLanguage = 'en_US';
|
||||||
defaultLanguage = 'en_US';
|
language = 'pl_pl'; languagePreference = 'pl_pl';
|
||||||
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.documentElement.classList.remove('app-loading', 'app-startup-failed');
|
||||||
document.getElementById('workspace').hidden = false;
|
document.getElementById('workspace').hidden = false;
|
||||||
document.getElementById('panel-overview').hidden = false;
|
document.getElementById('panel-overview').hidden = false;
|
||||||
document.getElementById('overview-layout-unlock').hidden = false;
|
|
||||||
document.getElementById('overview-layout-open').hidden = false;
|
document.getElementById('overview-layout-open').hidden = false;
|
||||||
currentPanel = 'overview';
|
currentPanel = 'overview';
|
||||||
window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
|
window.__savedDashboard = {...structuredClone(ADMIN_APPEARANCE_DEFAULTS), language:'pl_pl'};
|
||||||
window.rustProxyPreferences.load = async () => window.__savedDashboard;
|
window.rustProxyPreferences.load = async () => window.__savedDashboard;
|
||||||
window.rustProxyPreferences.current = () => window.__savedDashboard;
|
window.rustProxyPreferences.current = () => window.__savedDashboard;
|
||||||
window.rustProxyPreferences.update = async patch => {
|
window.rustProxyPreferences.update = async patch => {
|
||||||
window.__savedDashboard = {...window.__savedDashboard, ...patch};
|
window.__savedDashboard = {...window.__savedDashboard, ...patch};
|
||||||
applyAdminPreferences(window.__savedDashboard);
|
applyAdminPreferences(window.__savedDashboard); return window.__savedDashboard;
|
||||||
return window.__savedDashboard;
|
|
||||||
};
|
};
|
||||||
applyAdminPreferences(window.__savedDashboard);
|
applyAdminPreferences(window.__savedDashboard); language = 'pl_pl'; languagePreference = 'pl_pl';
|
||||||
language = 'pl_pl';
|
for (const node of document.querySelectorAll('#overview-layout-dialog [data-i18n]'))
|
||||||
document.documentElement.lang = 'pl-PL';
|
node.textContent = t(node.dataset.i18n);
|
||||||
document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
|
}""", PACKS)
|
||||||
}''')
|
page.locator("#overview-layout-open").click()
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
|
|
||||||
for width in (320, 375, 550, 620, 800, 900, 960, 1158, 1440, 1920):
|
|
||||||
context = browser.new_context(viewport={'width': width, 'height': 823})
|
|
||||||
page = context.new_page()
|
|
||||||
errors = []
|
|
||||||
page.on('pageerror', lambda err: errors.append(str(err)))
|
|
||||||
load(page)
|
|
||||||
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
@@ -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'
|
||||||
|
|||||||
Regular → Executable
File mode changed.
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
message.textContent = t("dashboardPresetLimitReached").replace("{max}", String(MAX_PRESETS));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function nudge(id, direction) {
|
const value = snapshot(name);
|
||||||
const next = visibleIds(), index = next.indexOf(id), other = index + direction;
|
if (index >= 0) next[index] = value;
|
||||||
if (index < 0 || other < 0 || other >= next.length) return;
|
else next.unshift(value);
|
||||||
[next[index], next[other]] = [next[other], next[index]];
|
setBusy(true);
|
||||||
reorderVisible(next);
|
message.textContent = t("dashboardPresetSaving");
|
||||||
visibleRoot.querySelector(`[data-preview-widget="${id}"] .overview-layout-up`)?.focus();
|
try {
|
||||||
|
await window.rustProxyPreferences.update({ dashboard_presets: next });
|
||||||
|
if (nameInput) nameInput.value = "";
|
||||||
|
message.textContent = t(index >= 0 ? "dashboardPresetUpdated" : "dashboardPresetSavedMessage").replace("{name}", name);
|
||||||
|
toast(message.textContent);
|
||||||
|
} catch (error) { message.textContent = error.message; }
|
||||||
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
function resize(id, delta) {
|
async function loadPreset(preset) {
|
||||||
if (!draft) return;
|
if (busy) return;
|
||||||
const widths = dashboardAllowedWidths(id);
|
setBusy(true);
|
||||||
const next = widths[widths.indexOf(dashboardWidgetWidth(id, draft.sizes)) + delta];
|
message.textContent = t("dashboardPresetLoading").replace("{name}", preset.name);
|
||||||
if (next === undefined) return;
|
try {
|
||||||
enterFreeLayout();
|
await window.rustProxyPreferences.update({
|
||||||
if (next === dashboardDefaultWidth(id)) delete draft.sizes[id];
|
dashboard_density: preset.dashboard_density,
|
||||||
else draft.sizes[id] = next;
|
dashboard_widgets: [...preset.dashboard_widgets],
|
||||||
render();
|
dashboard_hidden_widgets: [...preset.dashboard_hidden_widgets],
|
||||||
visibleRoot.querySelector(`[data-preview-widget="${id}"] .${delta < 0 ? "overview-layout-narrow" : "overview-layout-widen"}`)?.focus();
|
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); }
|
||||||
}
|
}
|
||||||
function preview(id) {
|
async function deletePreset(preset) {
|
||||||
const original = document.querySelector(`#panel-overview [data-dashboard-widget="${id}"]`);
|
if (busy || !confirm(t("dashboardPresetDeleteConfirm").replace("{name}", preset.name))) return;
|
||||||
if (!original) return el("article", label(id), "stat");
|
setBusy(true);
|
||||||
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") {
|
message.textContent = t("dashboardPresetDeleting").replace("{name}", preset.name);
|
||||||
const card = el("article", undefined, "overview-layout-panel-preview");
|
try {
|
||||||
card.append(el("strong", label(id)));
|
const next = presets().filter(item => item.name.toLowerCase() !== preset.name.toLowerCase());
|
||||||
const chart = id === "backend_table" ? null : original.querySelector?.("svg");
|
await window.rustProxyPreferences.update({ dashboard_presets: next });
|
||||||
if (chart) {
|
message.textContent = t("dashboardPresetDeleted").replace("{name}", preset.name);
|
||||||
const graphic = chart.cloneNode(true);
|
} catch (error) { message.textContent = error.message; }
|
||||||
for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id");
|
finally { setBusy(false); }
|
||||||
graphic.removeAttribute("data-i18n-aria");
|
|
||||||
graphic.setAttribute("aria-hidden", "true");
|
|
||||||
card.append(graphic);
|
|
||||||
}
|
}
|
||||||
const detailKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
|
function presetRow(preset) {
|
||||||
if (detailKey) card.append(el("small", t(detailKey)));
|
const row = el("article", undefined, "dashboard-preset-row");
|
||||||
card.setAttribute("aria-hidden", "true");
|
row.setAttribute("role", "listitem");
|
||||||
return card;
|
const info = el("div", undefined, "dashboard-preset-info");
|
||||||
}
|
info.append(el("strong", preset.name), el("small", summary(preset), "fine"));
|
||||||
const copy = original.cloneNode(true);
|
const actions = el("div", undefined, "dashboard-preset-actions");
|
||||||
copy.hidden = false;
|
const load = el("button", t("dashboardPresetLoad"), "outline");
|
||||||
copy.removeAttribute("data-dashboard-widget");
|
load.type = "button"; load.disabled = busy;
|
||||||
for (const node of [copy, ...copy.querySelectorAll("[id]")]) node.removeAttribute("id");
|
load.setAttribute("aria-label", `${t("dashboardPresetLoad")}: ${preset.name}`);
|
||||||
for (const node of copy.querySelectorAll("[aria-labelledby], [aria-describedby]")) {
|
load.addEventListener("click", () => { void loadPreset(preset); });
|
||||||
node.removeAttribute("aria-labelledby"); node.removeAttribute("aria-describedby");
|
const remove = el("button", t("dashboardPresetDelete"), "quiet");
|
||||||
}
|
remove.type = "button"; remove.disabled = busy;
|
||||||
copy.classList.add("overview-layout-card-preview");
|
remove.setAttribute("aria-label", `${t("dashboardPresetDelete")}: ${preset.name}`);
|
||||||
copy.setAttribute("aria-hidden", "true");
|
remove.addEventListener("click", () => { void deletePreset(preset); });
|
||||||
return copy;
|
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(); } };
|
|
||||||
})();
|
})();
|
||||||
@@ -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);
|
||||||
|
|||||||
+21
-51
@@ -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"
|
||||||
|
data-i18n-placeholder="dashboardPresetNamePlaceholder" placeholder="e.g. Operations" />
|
||||||
|
</div>
|
||||||
|
<button class="primary" id="overview-preset-save" type="button" data-i18n="dashboardPresetSaveCurrent">Save current Dashboard</button>
|
||||||
</section>
|
</section>
|
||||||
<section aria-labelledby="overview-layout-hidden-title">
|
<section aria-labelledby="dashboard-preset-list-title">
|
||||||
<div class="overview-layout-section-heading">
|
<div class="dashboard-preset-list-heading">
|
||||||
<h3 id="overview-layout-hidden-title" data-i18n="overviewLayoutEditorHidden">Hidden cards</h3>
|
<h3 id="dashboard-preset-list-title" data-i18n="dashboardPresetSaved">Saved presets</h3>
|
||||||
<div class="overview-layout-filters">
|
<span class="fine" id="overview-preset-count"></span>
|
||||||
<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="dashboard-preset-list" id="overview-preset-list" role="list"></div>
|
||||||
<div class="overview-layout-hidden-list" id="overview-layout-hidden" 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">
|
||||||
|
|||||||
@@ -11814,3 +11814,103 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
|||||||
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user