diff --git a/AGENTS.md b/AGENTS.md index 298abef..e183624 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for -compatibility. Current source version: 0.33.6. +compatibility. Current source version: 0.33.7. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f12a6..114fb7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ > Operator-facing release history. Historical engineering notes are kept in > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.33.7 + +- 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 - Normalize the height of all 14 large Overview/WWW plots to 125px, avoiding diff --git a/Cargo.lock b/Cargo.lock index ce40dfc..1616cc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.33.6" +version = "0.33.7" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 0ffa85f..889f186 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.33.6" +version = "0.33.7" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index ea55664..4ef41e8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 3e1cb7f..db3ae6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # 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 - [x] Fix all 14 Overview/WWW full-width SVG heights to 125px and align their diff --git a/SHA256SUMS b/SHA256SUMS index e141187..b4d6067 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,13 +1,13 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -ffae7ce20cc0fb1990394822515c933843ba39304aa44134403f0d25a6a66d9f AGENTS.md -8b72ed7b17505c129b60faf97ee6a838b855e4303dc390ea7196b0d97ee79134 CHANGELOG.md -a35a5d557bb57df37d130a03752ffc092f005cb4849733d3d81485fb9450a931 Cargo.lock -8b77b2beffb31dd14ff62e225ff5384084d2b4e66faa100aaf38470883fca879 Cargo.toml +c793a8be7f4b76350e04ab36dbeec3792bb2dcf33c87a86f76fa2f330e26dd9f AGENTS.md +4d86a27c6b8f8931e4c43bbbd0da1a5880ffafd17bd139bd9ba93fa1e7c02af4 CHANGELOG.md +d5dc2b778af98f52ad52a7515e2d1b4ba8c2e01a04cc6e02564377075b01c501 Cargo.lock +7fd5dd1ccae8fac77bbbea4bbf960f5cf762f712ff17a2e629a5dd8629eb7ff9 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -dfe12a6a0d360e0febec982f30098123a8974007c7c3fba39509be5c45d3ebbd README.md -65340bb1f93da256043cb6bd75b6c9de8539e31f0417f24c6f05093a27f8319f ROADMAP.md +6f9f4d0fde39289402388744c4d023518aa651126ac2119d2e774ef13cb4a202 README.md +442ada0145d069fa017da6c9fe3f2099b7e5f8bfcad9894c44c19d678eae3c20 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md @@ -62,9 +62,9 @@ cd4ed3af26eb51c9ff6ef3dda9d52ff5120bcfc78c842bce34a639cf85b6aea9 docs/ROADMAP_H ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md -4dcf3243a13d9ae1827b5cefdbb9e27bd6310321c50d4d5934527d44437df6ef docs/SETTINGS.md +84d1523a18dc98d266a290ed7fb8fe2624bb37fd54f4f0b991ba37a7624d8a3a docs/SETTINGS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md -72baf37ed0989de65123eba73f83d8c31cba7415382dd00e639d8ca412f0ef0d docs/TEST_REPORT.md +420d8e3976882ef42c2b7dde409560825db72a22405b023876f9c39055671754 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md @@ -96,8 +96,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md -7783e629ca4b2c1614e776838bf90f3692d521e50ee580aa5e92236c92ada844 langs/en_US.json -58f2fdaf0aa9d3a3ce25d47659501a8b3df622e0c08eee0a4baaad4d5f1ac660 langs/pl_pl.json +e4e83b0ee5726c97b13f38aba484657fbc762ceab7a8774558309bc3e655c27c langs/en_US.json +c0e065698befbfdf9ae2a7ba029fcd66ad65c90bb75fe7a6fa0899babaaabd2a langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -123,7 +123,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py -63ed91f4cd77450cfe04f4bdb6e5f866b7868afa10207f78041e6fcd86d96fb0 scripts/preflight.py +fb24401390718b121e420530c1421535cc79d49c444f6e407e600056a079ee7f scripts/preflight.py 3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py @@ -132,13 +132,15 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py cc7026ec9c5b69850a815816df093de0ffdf4aeadabb3af702783483122ede55 scripts/tests/dashboard_chart_headers_0335_browser_test.py aa3ca7419d6d7d506c5aaf4d624d20cc7e8ee66bec3cb0cf9b5e7d164bcbab57 scripts/tests/dashboard_chart_width_0333_browser_test.py -fdcea6dd1e3bc160abe1139bf94b45c9a28f6aaf7afbe7e38fb452ee4892b8f1 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py -52eacb467df123dc4a35f14933e006b090c9238df1b14dcff856a16b37c8063f scripts/tests/dashboard_layout_0331_browser_test.py -276e732fc2dfb0d39badb159e4fdcba0d7d73509c60ad5c9df49621c03046f1b scripts/tests/dashboard_metric_visuals_ui_test.mjs -61a0fb2c4de86f25debec9662aedd923bce8c2ccd51e75593d485b2e0c9b8551 scripts/tests/dashboard_modal_0333_browser_test.py -0c8a566979b51cd62601d138697ccd5c4ed47b8fa33e8faa28fa3a085a72ad97 scripts/tests/dashboard_resizing_0333_browser_test.py -f82d57d3c01858eb60aa6882f7d3470e6587d7fc60e7ee6dbd8c5bec1f223c66 scripts/tests/dashboard_resume_rate_charts_ui_test.mjs -aa12d00acdd226602b3efc90221fbc936f69f73bcabbaa6c01dfb0e05d08c505 scripts/tests/overview_samples_0330_ui_test.mjs +247da431b28c113116d87febab20ca744f101443fe0884d23fa80ce99edc52a6 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py +0ef8a6dc914e690cca42727cfcb9a726897c4f378465be3741d0944e9b0576bb scripts/tests/dashboard_layout_0331_browser_test.py +515c7accb906753ee51d805c242eab17519f2cb8e5af1a5068c1168c0b54cd78 scripts/tests/dashboard_metric_visuals_ui_test.mjs +b67e5974427e51d417e2302e222e92f7e74bba78f1326ab07b6945ad1b618866 scripts/tests/dashboard_modal_0333_browser_test.py +3ab97f6536530ef17a246f7b747c6dc7b33fa9e9cf83ef0267e94b0c67202716 scripts/tests/dashboard_presets_0337_browser_test.py +ea656e87ee5803789111311c3c2d4ebc3ed2b0b3a2509a0848717a80ab39b1c5 scripts/tests/dashboard_resizing_0333_browser_test.py +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 222077dab4d4928ae9110a9f2d64c07cc8386ea14099819b43304bbd2364e5b5 scripts/tests/sidebar_nav_0333_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 74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs 33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs -6884210022056d7f2a92160b6d37026e420483c2285368b7ae3c01b1c981df8b src/admin/openapi.json +48e5f22a624d2652165293784a29873f9296b8a9cd7234d1f6dc27b8694a0105 src/admin/openapi.json d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs -92b3e05f04711d8001b56816405859631d9e917868e15be965c7e76ef4ddef99 src/admin/preferences.rs +e9da94b3fa65a6eeba5c5fc739a3a2f5dff6cd2b980a21770cba6566b52d4f75 src/admin/preferences.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 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs -6d12bb97819ff05773d017d6b73bd6659dc9f2345f49d449fbaf9e34b936aab8 src/admin/router.rs +0cebd387e43a59498981710992df889be8990c3b8aca93b8b98a6df90c5c8af5 src/admin/router.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs 77fe317c15bf2e03301b22e0552518fe2d7e1ad8d80afaba4e16cd2bf8ddf10c src/admin/status.rs 4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs @@ -193,23 +196,24 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail 18b778c2c4e723e2fe5cbaaef20effa6ea50fcc35d507ebde2361c9b6eda62b2 src/admin/tests.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js +a085ee336e6669229f3ad2f74536f19bd088412ca79bcaff30dff293f1e1baf9 src/admin/web/app/chart-scales.js d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js 8aa205bc88530e4c4678f84ea324c6ecbc823b4ed681624102ff177f3867bc98 src/admin/web/app/core.js f3b566d79ba47848021a74653dbd7fec72f0fb819c6b2a07259e047bc8c427fd src/admin/web/app/dashboard-inline-layout.js -851a4337127f332db91f6954f689363ee227fcddde2dda82e55f645940d0ce3b src/admin/web/app/dashboard-layout-editor.js -2f1669cbeb9f49aef43a548e2096825aa14c64c7097ffad520822bbbaefb2b4c src/admin/web/app/overview-extra-metrics.js +2c253650e6512cdb9615567224014a15839baf201c2ae523561168f4f63e84d1 src/admin/web/app/dashboard-layout-editor.js +bfb3fbac62f9ff762a298d9c06808ae81ae55de7a41e0bb35ce5c167f333ffe2 src/admin/web/app/overview-extra-metrics.js 5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.js -41d9d872ba2f8e1871564fa3f72e88bcb8f118c0ddd5255f256ac7230de945b6 src/admin/web/app/overview-wide-metrics.js -5bde5f0881320464eb133cd44de9eff5211e6816e79f7f7e9574b16784fab126 src/admin/web/app/preferences.js +d997f62e8bd7ac9d4f16bb302800c98ad943afab663bb0b212ea26c35b58de11 src/admin/web/app/overview-wide-metrics.js +12ee717f013c39143bc34746fb89344f7cb14a915700a3964f6dfc6b11ed0413 src/admin/web/app/preferences.js 8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js 43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js daa20d634f921191cb4ac37889504f203ab39ad7e8fd8f33e1b182fc4206c24d src/admin/web/app/runtime.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js 8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js -655db0b4d5e7ee14dbe69fa22bdb9d99eecc32abcf3b1b76a5819a8015f72309 src/admin/web/app/web-overview.js -0603dc8793a731a0e582cbbec53aa6d1cc6e007faafcec5d8843730b2cdc39e1 src/admin/web/cache-panel.js +7f89bfae66e2a08a05a112733864e908f036bb1ee3d8b5b9ff23783b870acd96 src/admin/web/app/web-overview.js +bb94697a1cbeccaf80069dd47ab84efe14b052d81c472f8309478bbeb4e05f06 src/admin/web/cache-panel.js 02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js aec5755244242424e5091ff57c0258d2618588618fe1d52d36f081d03879f686 src/admin/web/dashboard-defaults.json 797e2935a34b974cb0e07a88056c31e6948cc11385bf2a076d902a332882d0a0 src/admin/web/dashboard-widgets.json @@ -234,7 +238,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js -0ebfde7081385aa7158f3b0f8f2b9355836e087c3d91a7c98d5187afbd47abd0 src/admin/web/index.html +91623043542997e29292234ed9b23f94ac4d344b98dee060d2ac0e70c6c03204 src/admin/web/index.html 03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html @@ -244,9 +248,9 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/ c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js -cc0fdbed4a344487fef7593375177043ee4524b98f58842f37a97e2d2a414183 src/admin/web/style.css +81f06d951f9f8c45fe14bca7870a9cd3e516031979aa69dccbf413af79e62113 src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js -69f412bd897e45f4889b71e9982cfff56df9ef20ba2da109b454713a5ecd7c74 src/admin/web/transform-panel.js +dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js 8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 2a91c86..a0fea4c 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -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). +## 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) **Settings → Appearance → Dashboard layout** now puts Dashboard density on a 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 left/right browsing buttons and accessible keyboard focus. **Show all cards** -expands the same 62 visibility/reorder controls into a wrapped grid. Use the -existing visual layout editor for drag-and-drop arrangement. Reordering and +expands the same 62 visibility/reorder controls into a wrapped grid. Use +**Unlock layout** on Overview for drag-and-drop arrangement. Reordering and visibility still save through `/api/preferences` per account; this UI-only change does not alter server polling, proxy routing or backend metrics. ### 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) @@ -581,9 +597,9 @@ Under **Settings → Appearance → Dashboard layout**, the existing Show/Hide a ## 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) @@ -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 differences; newly added or reset members cannot create arbitrary spikes. -To arrange the actual Overview cards visually, click **Arrange cards visually** -on Overview or **Visual layout editor** in Settings -> Appearance -> Dashboard -layout. The modal displays copies of the existing telemetry cards; drag to -reorder, or use the move arrows for keyboard-only input. Hide/show tiles in -the modal and select **Save layout** once to persist order and visibility in -the existing `dashboard_widgets` and `dashboard_hidden_widgets` preferences. -Cancel/Escape discards the draft. The existing Appearance visibility and move -buttons remain available. No extra API endpoint, refresh loop, or database -migration is needed. +To arrange the actual Overview cards, click **Unlock layout** on Overview. Drag +the real cards or use the move/resize/hide controls, then save and lock the +layout. The existing Appearance visibility and move controls remain available. +Use **Dashboard presets** only to save, inspect and load named snapshots of the +current layout. Presets reuse `dashboard_widgets`, `dashboard_hidden_widgets`, +`dashboard_widget_sizes` and Dashboard density in the existing preferences +document. No extra status refresh loop or database migration is needed. 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; diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 5d85606..7261581 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,44 +1,61 @@ -# RustProxy 0.33.6 - verification report +# RustProxy 0.33.7 - 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%. +0.33.7 changes the Dashboard UI without changing proxy traffic handling or +telemetry collection: -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. +- Replace the duplicated **Arrange Overview** editor modal with an account-scoped + **Dashboard presets** manager. A named preset stores Dashboard density, card + order, visibility and optional custom widths; presets can be saved/updated, + loaded and deleted through the existing administrator preferences document. +- Keep actual Dashboard editing in the existing inline **Unlock layout** flow and + Settings visibility/order controls. +- Use the same monospace font family as **CONNECTION ACTIVITY** for Overview chart + titles, including compact chart cards and wide charts. +- No SQLite schema migration, new telemetry polling loop or proxy-path change is + introduced. ## 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. +- **PASS:** `python scripts/tests/dashboard_presets_0337_browser_test.py` against + the real bundled HTML/CSS/JS with a simulated preferences API at 320, 375, + 768 and 1440px. Covered save/update snapshot data, load, delete, responsive + modal layout, removal of the former duplicated editor controls, and chart-title + font parity with **CONNECTION ACTIVITY**. +- **PASS:** `python scripts/tests/dashboard_modal_0333_browser_test.py` after the + 0.33.7 preset conversion at 320, 375, 550, 620, 800, 900, 960, 1158, 1440 and + 1920px with no horizontal viewport overflow. +- **PASS:** updated `dashboard_layout_0331_browser_test.py` and + `dashboard_resizing_0333_browser_test.py`; inline editing still covers + add/hide/reorder/resize, live refresh, cancel/save and persisted custom widths, + while opening/closing the preset modal does not mutate the active layout. +- **PASS:** all 13 current `scripts/tests/*browser_test.py` simulated-browser + regressions, including chart heading/width/fixed CPU/stable scale coverage, + progress bars, sidebar, RAM, Web observability and Web Server live metrics. + The obsolete CPU autoscale regression was removed because 0.33.5 intentionally + replaced that behavior with a fixed 0-100% CPU scale and has its own current + regression test. +- **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:** JSON parsing for `src/admin/openapi.json`, `langs/en_US.json` and + `langs/pl_pl.json`; JavaScript syntax checks for the changed preferences/preset + files; Python syntax checks for changed browser tests; `python scripts/preflight.py`. +- **PASS:** `./dev.sh doctor` reports the source layout as OK and confirms that a + C compiler and Python are available. +- **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 a production-data upgrade. +- **PASS:** parsed `scripts/deploy/upgrade.py --help` and + `scripts/migration/migrate_npm.py --help`; no live external upgrade/migration + was performed. ## 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. +- `./dev.sh check` and `./dev.sh test` exit 127 because Cargo/rustc are not + installed. Therefore Rust compilation, Rust unit/integration tests, an actual + RustProxy process, real SSE traffic, production database upgrade and live load + testing are not verified here. +- The real-runtime acceptance gate remains open until Rust builds and runs against + representative test traffic. diff --git a/langs/en_US.json b/langs/en_US.json index 0f7e984..53c3f83 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -2510,9 +2510,9 @@ "backendWidgetNetworkBreakdown": "Connect / DNS / TLS / I/O", "backendWidgetPoolGeneration": "Maximum active pool generation", "backendWidgetPoolAge": "Oldest generation age", - "overviewLayoutEditorOpen": "Arrange Overview", - "overviewLayoutEditorTitle": "Arrange entire Overview", - "overviewLayoutEditorHelp": "Move any tile or panel by dragging or using the arrows. Hidden items can be enabled below. Default view previews the standard layout; Reset layout asks for confirmation before saving.", + "overviewLayoutEditorOpen": "Dashboard presets", + "overviewLayoutEditorTitle": "Dashboard presets", + "overviewLayoutEditorHelp": "Save the current Dashboard setup as a named preset. Presets include card order, visibility, widths and Dashboard density.", "overviewLayoutEditorVisible": "Visible cards", "overviewLayoutEditorHidden": "Hidden cards", "overviewLayoutEditorHide": "Hide card", @@ -2524,7 +2524,7 @@ "overviewLayoutEditorHiddenEmpty": "All cards are visible.", "overviewLayoutEditorMoveBefore": "Move card before", "overviewLayoutEditorSaved": "Dashboard layout saved", - "overviewLayoutEditorOpenSettings": "Visual Overview editor", + "overviewLayoutEditorOpenSettings": "Dashboard presets", "overviewLayoutEditorSearch": "Find hidden card", "overviewLayoutDefaultPreview": "Default view", "overviewLayoutReset": "Reset layout", @@ -2703,10 +2703,28 @@ "webLivePhpCapacity": "PHP capacity", "webLiveKinds": "Static / PHP / other", "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", "overviewLayoutWiden": "Increase card width", "overviewLayoutWidth": "Card width in 12-column grid", "overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert." -} \ No newline at end of file +} diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 173327f..167d86c 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -2510,9 +2510,9 @@ "backendWidgetNetworkBreakdown": "TCP / DNS / TLS / I/O", "backendWidgetPoolGeneration": "Najwyższa generacja puli", "backendWidgetPoolAge": "Najstarsza generacja", - "overviewLayoutEditorOpen": "Ułóż Dashboard", - "overviewLayoutEditorTitle": "Ułóż cały Dashboard", - "overviewLayoutEditorHelp": "Przeciągaj kafelki i panele lub używaj strzałek. Ukryte elementy włączysz poniżej. Widok domyślny pokazuje standardowy układ, a Resetuj układ prosi o potwierdzenie przed zapisem.", + "overviewLayoutEditorOpen": "Presety Dashboardu", + "overviewLayoutEditorTitle": "Presety Dashboardu", + "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", "overviewLayoutEditorHidden": "Ukryte kafelki", "overviewLayoutEditorHide": "Ukryj kafelek", @@ -2524,7 +2524,7 @@ "overviewLayoutEditorHiddenEmpty": "Wszystkie kafelki są widoczne.", "overviewLayoutEditorMoveBefore": "Przesuń kafelek przed", "overviewLayoutEditorSaved": "Układ Dashboardu zapisany", - "overviewLayoutEditorOpenSettings": "Wizualny edytor Overview", + "overviewLayoutEditorOpenSettings": "Presety Dashboardu", "overviewLayoutEditorSearch": "Znajdź ukryty kafelek", "overviewLayoutDefaultPreview": "Widok domyślny", "overviewLayoutReset": "Resetuj układ", @@ -2703,10 +2703,28 @@ "webLivePhpCapacity": "Limit PHP", "webLiveKinds": "Statyczne / PHP / inne", "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", "overviewLayoutWiden": "Zwiększ szerokość karty", "overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn", "overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany." -} \ No newline at end of file +} diff --git a/scripts/preflight.py b/scripts/preflight.py index 523f839..9056812 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -74,7 +74,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]: flat = module.parent.with_suffix(".rs") if flat.is_file(): 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(): raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}") # The Reader uses compile-time include_str! for its bundled Markdown. A diff --git a/scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py b/scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py deleted file mode 100755 index a6af4dc..0000000 --- a/scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py +++ /dev/null @@ -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() diff --git a/scripts/tests/dashboard_layout_0331_browser_test.py b/scripts/tests/dashboard_layout_0331_browser_test.py index 6866573..541626b 100755 --- a/scripts/tests/dashboard_layout_0331_browser_test.py +++ b/scripts/tests/dashboard_layout_0331_browser_test.py @@ -166,13 +166,25 @@ with sync_playwright() as p: window.rustProxyStreamDashboard.render(status); }''') assert_visible(page, ['backend_health'], ['backend_traffic']) - # Restore defaults in the modal after a custom layout was saved. + # 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').click() page.wait_for_function("document.getElementById('overview-layout-dialog').open") - page.locator('#overview-layout-default').click() - page.locator('#overview-layout-save').click() + assert page.locator('#overview-preset-name').is_visible() + 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") + 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 page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets'] assert page.evaluate('window.__savedDashboard.dashboard_hidden_widgets') == PRESET['dashboard_hidden_widgets'] diff --git a/scripts/tests/dashboard_modal_0333_browser_test.py b/scripts/tests/dashboard_modal_0333_browser_test.py index fdee4ff..58495cb 100755 --- a/scripts/tests/dashboard_modal_0333_browser_test.py +++ b/scripts/tests/dashboard_modal_0333_browser_test.py @@ -1,8 +1,8 @@ #!/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. -This is browser layout testing, not Rust server acceptance. +0.33.7 repurposed the former visual layout editor as Dashboard presets. Keep +this older viewport regression focused on modal fit/no horizontal overflow. """ import json import re @@ -10,17 +10,17 @@ 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() -CSS = (ROOT / 'src/admin/web/style.css').read_text() -ROUTER = (ROOT / 'src/admin/router.rs').read_text() +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' / (name + '.json')).read_text())} - for name in ('en_US', 'pl_pl') + 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) + 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()] @@ -29,141 +29,64 @@ def app_bundle(): line = line.strip() include = re.search(r'include_str!\("([^"]+)"\)', line) if include: - result.append((ROOT / 'src/admin' / include.group(1)).read_text()) - elif line == 'admin_vhosts_javascript(),': - result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text() - for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();') + 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) + 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.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyDashboardLayout') - page.evaluate('''packs => { - languagePacks = new Map(Object.entries(packs)); - defaultLanguage = 'en_US'; - language = 'pl_pl'; - document.documentElement.lang = 'pl-PL'; - document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n)); - }''', PACKS) - page.evaluate('''() => { - document.documentElement.classList.remove('app-loading', 'app-startup-failed'); - document.getElementById('workspace').hidden = false; - document.getElementById('panel-overview').hidden = false; - document.getElementById('overview-layout-unlock').hidden = false; - document.getElementById('overview-layout-open').hidden = false; - currentPanel = 'overview'; - window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS); - window.rustProxyPreferences.load = async () => window.__savedDashboard; - window.rustProxyPreferences.current = () => window.__savedDashboard; - window.rustProxyPreferences.update = async patch => { - window.__savedDashboard = {...window.__savedDashboard, ...patch}; - applyAdminPreferences(window.__savedDashboard); - return window.__savedDashboard; - }; - applyAdminPreferences(window.__savedDashboard); - language = 'pl_pl'; - document.documentElement.lang = 'pl-PL'; - document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n)); - }''') - - -def geometry(page): - return page.evaluate('''() => { - const d = document.getElementById('overview-layout-dialog'); - const grid = document.getElementById('overview-layout-visible'); - const rect = node => node.getBoundingClientRect(); - const tiles = Array.from(grid.querySelectorAll('.overview-layout-tile')); - const overflows = []; - for (const tile of tiles) { - const r = rect(tile); - const toolbar = tile.querySelector('.overview-layout-tile-toolbar'); - if (tile.scrollWidth > tile.clientWidth + 2) - overflows.push([tile.dataset.previewWidget, 'tile-scroll', tile.clientWidth, tile.scrollWidth]); - for (const button of toolbar.querySelectorAll('button')) { - const b = rect(button); - if (b.left < r.left - 2 || b.right > r.right + 2) - overflows.push([tile.dataset.previewWidget, 'button-outside', button.className, b.left, b.right, r.left, r.right]); - } - const buttons = Array.from(toolbar.querySelectorAll('button')); - for (let i = 0; i < buttons.length; i++) { - const a = rect(buttons[i]); - for (let j = i + 1; j < buttons.length; j++) { - const b = rect(buttons[j]); - if (a.right - b.left > 1 && b.right - a.left > 1 && a.bottom - b.top > 1 && b.bottom - a.top > 1) - overflows.push([tile.dataset.previewWidget, 'overlapping-buttons', buttons[i].className, buttons[j].className]); - } - } - } - const hiddenList = document.getElementById('overview-layout-hidden'); - for (const row of hiddenList.querySelectorAll('.overview-layout-hidden-item')) { - const r = rect(row), button = row.querySelector('button'); - const b = rect(button); - if (b.right > r.right + 2 || b.left < r.left - 2) - overflows.push(['hidden card', 'button-outside', b.left, b.right, r.left, r.right]); - } - const panelPreview = grid.querySelector('[data-preview-widget="network_diagnostics"]'); - const dialogRect = rect(d); - return { - cols: getComputedStyle(grid).gridTemplateColumns.split(' ').length, - panelWidth: panelPreview ? rect(panelPreview).width : null, - dialogWidth: d.clientWidth, dialogScrollWidth: d.scrollWidth, - gridWidth: grid.clientWidth, gridScrollWidth: grid.scrollWidth, - dialogLeft:dialogRect.left, dialogRight:dialogRect.right, - visibleCount:tiles.length, overflows, - firstTileWidth:rect(tiles[0]).width, - }; - }''') - - with sync_playwright() as p: - browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) + browser = p.chromium.launch(headless=True, executable_path="/usr/bin/chromium", args=["--no-sandbox"]) for width in (320, 375, 550, 620, 800, 900, 960, 1158, 1440, 1920): - context = browser.new_context(viewport={'width': width, 'height': 823}) - page = context.new_page() + page = browser.new_page(viewport={"width": width, "height": 823}) errors = [] - page.on('pageerror', lambda err: errors.append(str(err))) - load(page) - page.locator('#overview-layout-open').click() + page.on("pageerror", lambda error: errors.append(str(error))) + page.set_content(HTML.replace("/assets/app___RUSTPROXY_ASSET_ID__.js", "") + .replace("/assets/style___RUSTPROXY_ASSET_ID__.css", "")) + page.add_style_tag(content=CSS) + page.add_script_tag(content=BUNDLE) + page.wait_for_function("window.rustProxyPreferences && window.rustProxyDashboardLayout") + page.evaluate("""packs => { + languagePacks = new Map(Object.entries(packs)); defaultLanguage = 'en_US'; + language = 'pl_pl'; languagePreference = 'pl_pl'; + document.documentElement.classList.remove('app-loading', 'app-startup-failed'); + document.getElementById('workspace').hidden = false; + document.getElementById('panel-overview').hidden = false; + document.getElementById('overview-layout-open').hidden = false; + currentPanel = 'overview'; + window.__savedDashboard = {...structuredClone(ADMIN_APPEARANCE_DEFAULTS), language:'pl_pl'}; + window.rustProxyPreferences.load = async () => window.__savedDashboard; + window.rustProxyPreferences.current = () => window.__savedDashboard; + window.rustProxyPreferences.update = async patch => { + window.__savedDashboard = {...window.__savedDashboard, ...patch}; + applyAdminPreferences(window.__savedDashboard); return window.__savedDashboard; + }; + applyAdminPreferences(window.__savedDashboard); language = 'pl_pl'; languagePreference = 'pl_pl'; + for (const node of document.querySelectorAll('#overview-layout-dialog [data-i18n]')) + node.textContent = t(node.dataset.i18n); + }""", PACKS) + page.locator("#overview-layout-open").click() page.wait_for_function("document.getElementById('overview-layout-dialog').open") - check = geometry(page) - expected = 1 if width <= 620 else 2 if width <= 900 else 3 - assert check['cols'] == expected, (width, check) - assert check['visibleCount'] > 20 - assert check['panelWidth'] is not None - assert abs(check['firstTileWidth'] - check['panelWidth']) < 3, (width, check) - assert check['dialogLeft'] >= -1 and check['dialogRight'] <= width + 1, (width, check) - assert check['dialogScrollWidth'] <= check['dialogWidth'] + 2, (width, check) - assert check['gridScrollWidth'] <= check['gridWidth'] + 2, (width, check) - assert not check['overflows'], (width, check['overflows'][:20]) - tile = '#overview-layout-visible [data-preview-widget="http_protocol"]' - before = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width') - page.locator(tile + ' .overview-layout-widen').click() - assert page.locator(tile).get_attribute('data-dashboard-span') == '3' - after = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width') - assert abs(before - after) < 2, (width, before, after) - assert not geometry(page)['overflows'] - page.locator(tile + ' .overview-layout-narrow').click() - assert page.locator(tile).get_attribute('data-dashboard-span') == '2' - if width == 1158: - page.screenshot(path=str(ROOT.parent / 'modal_preview_1158.png')) - if width == 375: - page.screenshot(path=str(ROOT.parent / 'modal_preview_375.png')) - page.locator(tile + ' .overview-layout-hide').click() - assert page.locator(tile).count() == 0 - assert not geometry(page)['overflows'] - page.locator('#overview-layout-cancel').click() - page.wait_for_function("!document.getElementById('overview-layout-dialog').open") + check = page.locator("#overview-layout-dialog").evaluate("""d => { + const r = d.getBoundingClientRect(); + const rows = [...d.querySelectorAll('.dashboard-preset-save,.dashboard-preset-row')]; + return { + left:r.left, right:r.right, client:d.clientWidth, scroll:d.scrollWidth, + overflow: rows.some(row => row.scrollWidth > row.clientWidth + 2), + }; + }""") + assert check["left"] >= -1 and check["right"] <= width + 1, (width, check) + assert check["scroll"] <= check["client"] + 2 and not check["overflow"], (width, check) + assert page.locator("#overview-preset-name").is_visible() + assert page.locator("#overview-preset-save").is_visible() assert not errors, (width, errors) - context.close() - print('PASS visual modal grid, no overlap/scroll, width/hide/cancel:', width) + page.close() + print(f"PASS responsive Dashboard preset modal at {width}px") browser.close() -print('PASS 0.33.3 modal regression (simulated API)') +print("PASS modal responsive regression after 0.33.7 preset conversion") diff --git a/scripts/tests/dashboard_presets_0337_browser_test.py b/scripts/tests/dashboard_presets_0337_browser_test.py new file mode 100755 index 0000000..e5a4110 --- /dev/null +++ b/scripts/tests/dashboard_presets_0337_browser_test.py @@ -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)") diff --git a/scripts/tests/dashboard_resizing_0333_browser_test.py b/scripts/tests/dashboard_resizing_0333_browser_test.py index 6c8d76c..3900327 100755 --- a/scripts/tests/dashboard_resizing_0333_browser_test.py +++ b/scripts/tests/dashboard_resizing_0333_browser_test.py @@ -142,25 +142,23 @@ with sync_playwright() as p: assert page.locator(selector).get_attribute('data-dashboard-span') == '2' assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '6' assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None - # In a fresh draft, the modal editor also provides width controls. - page.locator('#overview-layout-open').click() - page.wait_for_function("document.getElementById('overview-layout-dialog').open") - tile = '#overview-layout-visible [data-preview-widget="wide_transform_activity"]' - page.locator(tile + ' .overview-layout-narrow').click() - assert page.locator(tile).get_attribute('data-dashboard-span') == '4' - page.locator(tile + ' .overview-layout-narrow').click() - assert page.locator(tile).get_attribute('data-dashboard-span') == '3' - # Modal drag-and-drop also works across former small/wide size groups. + # In 0.33.7 resizing/reordering remains in the inline layout editor; the + # former modal is now dedicated to named presets. + page.locator('#overview-layout-unlock').click() + page.wait_for_function('window.rustProxyInlineLayout.isEditing()') + for _ in range(2): + page.locator('[data-dashboard-widget="wide_transform_activity"] .overview-inline-narrow').click() + assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3' page.evaluate('''() => { - const from = document.querySelector('[data-preview-widget="wide_transform_activity"]'); - const to = document.querySelector('[data-preview-widget="requests"]'); + const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip'); + const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip'); const transfer = new DataTransfer(); from.dispatchEvent(new DragEvent('dragstart', {bubbles:true,cancelable:true,dataTransfer:transfer})); - to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer})); + to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer,clientX:0})); from.dispatchEvent(new DragEvent('dragend', {bubbles:true})); }''') - page.locator('#overview-layout-save').click() - page.wait_for_function("!document.getElementById('overview-layout-dialog').open") + page.locator('#overview-inline-save').click() + page.wait_for_function('!window.rustProxyInlineLayout.isEditing()') assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes.wide_transform_activity') == 3 assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity' # Reload account settings: new card width persists. @@ -184,11 +182,12 @@ with sync_playwright() as p: 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 page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2, (width, data) - page.locator('#overview-layout-open').click() - page.wait_for_function("document.getElementById('overview-layout-dialog').open") - page.locator('#overview-layout-default').click() - page.locator('#overview-layout-save').click() - page.wait_for_function("!document.getElementById('overview-layout-dialog').open") + page.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 page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets'] assert page.evaluate('document.getElementById("dashboard-overview-grid").dataset.overviewLayoutMode') == 'legacy' diff --git a/scripts/tests/dashboard_stable_scales_0336_browser_test.py b/scripts/tests/dashboard_stable_scales_0336_browser_test.py old mode 100644 new mode 100755 diff --git a/src/admin/openapi.json b/src/admin/openapi.json index b5863dd..f51de8f 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -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." }, + "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": { "type": "array", "maxItems": 78, diff --git a/src/admin/preferences.rs b/src/admin/preferences.rs index 8789d1b..5f316f8 100644 --- a/src/admin/preferences.rs +++ b/src/admin/preferences.rs @@ -1,4 +1,5 @@ include!("preferences_columns.rs"); +include!("preferences_dashboard_presets.rs"); fn default_admin_theme() -> String { "dark".to_owned() @@ -27,33 +28,15 @@ fn default_admin_table_density() -> String { fn default_admin_dashboard_density() -> String { DASHBOARD_DEFAULT_PRESET.dashboard_density.clone() } -// Preserve the user's saved order/visibility, drop identifiers unknown to this -// binary, and append new registry widgets according to their opt-in policy. -// Stored preferences from older/newer releases are normalized on read. +// Stored dashboard layouts are normalized on read so presets remain usable +// when cards are added or removed between releases. fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) { - let mut seen = std::collections::HashSet::new(); - prefs.dashboard_widgets.retain(|id| { - DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen.insert(id.clone()) - }); - let mut seen_hidden = std::collections::HashSet::new(); - prefs.dashboard_hidden_widgets.retain(|id| { - 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()); - } - } - } + reconcile_dashboard_layout( + &mut prefs.dashboard_widgets, + &mut prefs.dashboard_hidden_widgets, + &mut prefs.dashboard_widget_sizes, + ); + reconcile_dashboard_presets(&mut prefs.dashboard_presets); } fn default_file_log_sort() -> String { @@ -135,6 +118,8 @@ struct AdminPreferences { // A map (including {}) means that order is fully user-controlled. #[serde(default)] dashboard_widget_sizes: Option>, + #[serde(default)] + dashboard_presets: Vec, #[serde(default = "default_file_log_columns")] file_log_columns: Vec, #[serde(default = "default_file_log_sort")] @@ -189,6 +174,7 @@ impl Default for AdminPreferences { dashboard_widgets: default_admin_dashboard_widgets(), dashboard_hidden_widgets: default_hidden_dashboard_widgets(), dashboard_widget_sizes: None, + dashboard_presets: Vec::new(), file_log_columns: default_file_log_columns(), file_log_sort: default_file_log_sort(), 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(), )); } + validate_dashboard_presets(&preferences.dashboard_presets)?; if preferences.backend_table_columns.is_empty() || preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len() { @@ -481,6 +468,17 @@ async fn update_preferences( preferences .dashboard_hidden_widgets .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)?; let document = serde_json::to_string(&preferences).map_err(auth::internal)?; if !state diff --git a/src/admin/preferences_dashboard_presets.rs b/src/admin/preferences_dashboard_presets.rs new file mode 100644 index 0000000..7354d63 --- /dev/null +++ b/src/admin/preferences_dashboard_presets.rs @@ -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, + dashboard_hidden_widgets: Vec, + #[serde(default)] + dashboard_widget_sizes: Option>, +} + +fn reconcile_dashboard_layout( + order: &mut Vec, + hidden: &mut Vec, + sizes: &mut Option>, +) { + 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(()) +} diff --git a/src/admin/preferences_tests.rs b/src/admin/preferences_tests.rs index f724a57..eec3d1b 100644 --- a/src/admin/preferences_tests.rs +++ b/src/admin/preferences_tests.rs @@ -255,3 +255,59 @@ fn dashboard_width_preferences_preserve_legacy_and_allow_custom_grids() { .contains_key("not_a_card")); 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()); +} diff --git a/src/admin/web/app/dashboard-layout-editor.js b/src/admin/web/app/dashboard-layout-editor.js index 8cbf991..05ab13c 100644 --- a/src/admin/web/app/dashboard-layout-editor.js +++ b/src/admin/web/app/dashboard-layout-editor.js @@ -1,279 +1,142 @@ window.rustProxyDashboardLayout = (() => { const dialog = $("overview-layout-dialog"); - const visibleRoot = $("overview-layout-visible"); - const hiddenRoot = $("overview-layout-hidden"); - const saveButton = $("overview-layout-save"); - const cancelButton = $("overview-layout-cancel"); - const search = $("overview-layout-search"); - const typeFilter = $("overview-layout-type-filter"); + const nameInput = $("overview-preset-name"); + const saveButton = $("overview-preset-save"); + const list = $("overview-preset-list"); + const empty = $("overview-preset-empty"); + const count = $("overview-preset-count"); const message = $("overview-layout-message"); - const defaultButton = $("overview-layout-default"); - const resetButton = $("overview-layout-reset"); - const resetConfirm = $("overview-reset-confirm"); - const resetCancel = $("overview-reset-cancel"); - const resetApply = $("overview-reset-apply"); - let draft = null; - let dragging = null; - let saving = false; + const closeButton = $("overview-layout-cancel"); + const MAX_PRESETS = 20; + let busy = false; let opening = false; - function initialDraft() { - const prefs = window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS; + const current = () => 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); - return { order, hidden: new Set(normalizeHiddenDashboardWidgets(prefs.dashboard_hidden_widgets, prefs.dashboard_widgets)), - sizes: normalizeDashboardWidgetSizes(prefs.dashboard_widget_sizes) }; - } - function defaultDraft() { return { - order: [...ADMIN_DASHBOARD_WIDGETS], - hidden: new Set(ADMIN_DEFAULT_HIDDEN_WIDGETS), - sizes: null, + name, + dashboard_density: normalizeAdminDensity(prefs.dashboard_density, "compact"), + 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) { - const widget = ADMIN_DASHBOARD_BY_ID.get(id); - return widget ? t(widget.label_key) : id; + function summary(preset) { + const visible = preset.dashboard_widgets.length - preset.dashboard_hidden_widgets.length; + 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() { - return dashboardVisualOrder(draft.order, draft.sizes) - .filter(id => !draft.hidden.has(id)); - } - 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); + function setBusy(value) { + busy = value; + if (nameInput) nameInput.disabled = value; + if (closeButton) closeButton.disabled = value; render(); } - function move(id, target, after = false) { - const next = visibleIds(); - if (id === target || !next.includes(id) || !next.includes(target)) return; - next.splice(next.indexOf(id), 1); - next.splice(next.indexOf(target) + (after ? 1 : 0), 0, id); - reorderVisible(next); - } - function nudge(id, direction) { - const next = visibleIds(), index = next.indexOf(id), other = index + direction; - if (index < 0 || other < 0 || other >= next.length) return; - [next[index], next[other]] = [next[other], next[index]]; - reorderVisible(next); - visibleRoot.querySelector(`[data-preview-widget="${id}"] .overview-layout-up`)?.focus(); - } - function resize(id, delta) { - if (!draft) return; - const widths = dashboardAllowedWidths(id); - const next = widths[widths.indexOf(dashboardWidgetWidth(id, draft.sizes)) + delta]; - if (next === undefined) return; - enterFreeLayout(); - if (next === dashboardDefaultWidth(id)) delete draft.sizes[id]; - else draft.sizes[id] = next; - render(); - visibleRoot.querySelector(`[data-preview-widget="${id}"] .${delta < 0 ? "overview-layout-narrow" : "overview-layout-widen"}`)?.focus(); - } - function preview(id) { - const original = document.querySelector(`#panel-overview [data-dashboard-widget="${id}"]`); - if (!original) return el("article", label(id), "stat"); - if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") { - const card = el("article", undefined, "overview-layout-panel-preview"); - card.append(el("strong", label(id))); - const chart = id === "backend_table" ? null : original.querySelector?.("svg"); - if (chart) { - const graphic = chart.cloneNode(true); - for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id"); - graphic.removeAttribute("data-i18n-aria"); - graphic.setAttribute("aria-hidden", "true"); - card.append(graphic); - } - const detailKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key; - if (detailKey) card.append(el("small", t(detailKey))); - card.setAttribute("aria-hidden", "true"); - return card; + async function saveCurrent() { + const name = (nameInput?.value || "").trim(); + if (busy || !name) return; + const next = presets(); + const key = name.toLowerCase(); + 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; } - const copy = original.cloneNode(true); - copy.hidden = false; - copy.removeAttribute("data-dashboard-widget"); - for (const node of [copy, ...copy.querySelectorAll("[id]")]) node.removeAttribute("id"); - for (const node of copy.querySelectorAll("[aria-labelledby], [aria-describedby]")) { - node.removeAttribute("aria-labelledby"); node.removeAttribute("aria-describedby"); - } - copy.classList.add("overview-layout-card-preview"); - copy.setAttribute("aria-hidden", "true"); - return copy; + const value = snapshot(name); + if (index >= 0) next[index] = value; + else next.unshift(value); + setBusy(true); + message.textContent = t("dashboardPresetSaving"); + 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); } + } + async function loadPreset(preset) { + if (busy) return; + setBusy(true); + message.textContent = t("dashboardPresetLoading").replace("{name}", preset.name); + try { + await window.rustProxyPreferences.update({ + dashboard_density: preset.dashboard_density, + dashboard_widgets: [...preset.dashboard_widgets], + dashboard_hidden_widgets: [...preset.dashboard_hidden_widgets], + dashboard_widget_sizes: preset.dashboard_widget_sizes === null ? null : { ...preset.dashboard_widget_sizes }, + }); + dialog.close(); + toast(t("dashboardPresetLoaded").replace("{name}", preset.name)); + } catch (error) { message.textContent = error.message; } + finally { setBusy(false); } + } + async function deletePreset(preset) { + if (busy || !confirm(t("dashboardPresetDeleteConfirm").replace("{name}", preset.name))) return; + setBusy(true); + message.textContent = t("dashboardPresetDeleting").replace("{name}", preset.name); + try { + const next = presets().filter(item => item.name.toLowerCase() !== preset.name.toLowerCase()); + await window.rustProxyPreferences.update({ dashboard_presets: next }); + message.textContent = t("dashboardPresetDeleted").replace("{name}", preset.name); + } catch (error) { message.textContent = error.message; } + finally { setBusy(false); } + } + function presetRow(preset) { + const row = el("article", undefined, "dashboard-preset-row"); + row.setAttribute("role", "listitem"); + const info = el("div", undefined, "dashboard-preset-info"); + info.append(el("strong", preset.name), el("small", summary(preset), "fine")); + const actions = el("div", undefined, "dashboard-preset-actions"); + const load = el("button", t("dashboardPresetLoad"), "outline"); + load.type = "button"; load.disabled = busy; + load.setAttribute("aria-label", `${t("dashboardPresetLoad")}: ${preset.name}`); + load.addEventListener("click", () => { void loadPreset(preset); }); + const remove = el("button", t("dashboardPresetDelete"), "quiet"); + remove.type = "button"; remove.disabled = busy; + remove.setAttribute("aria-label", `${t("dashboardPresetDelete")}: ${preset.name}`); + remove.addEventListener("click", () => { void deletePreset(preset); }); + actions.append(load, remove); + row.append(info, actions); + return row; } function render() { - if (!draft || !dialog) return; - const visible = visibleIds(); - const tiles = visible.map((id, index) => { - const tile = el("div", undefined, "overview-layout-tile"); - tile.dataset.previewWidget = id; - tile.dataset.dashboardSpan = String(dashboardWidgetWidth(id, draft.sizes)); - if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") tile.classList.add("is-panel"); - tile.draggable = true; - 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")])); + if (!dialog || !list) return; + const values = presets(); + list.replaceChildren(...values.map(presetRow)); + if (empty) empty.hidden = values.length > 0; + if (count) count.textContent = t("dashboardPresetCount").replace("{count}", String(values.length)).replace("{max}", String(MAX_PRESETS)); + if (saveButton) saveButton.disabled = busy || !(nameInput?.value || "").trim(); + if (nameInput) nameInput.disabled = busy; + if (closeButton) closeButton.disabled = busy; } async function open() { if (dialog?.open || opening || window.rustProxyInlineLayout?.isEditing?.()) return; opening = true; 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; - draft = initialDraft(); - dragging = null; - search.value = ""; - if (typeFilter) typeFilter.value = "all"; + if (nameInput) nameInput.value = ""; message.textContent = ""; render(); dialog.showModal(); - cancelButton.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); } + nameInput?.focus(); } for (const id of ["overview-layout-open", "overview-layout-open-settings"]) $(id)?.addEventListener("click", () => { void open(); }); - cancelButton.addEventListener("click", () => { if (!saving) dialog.close(); }); - saveButton.addEventListener("click", () => { void save(); }); - defaultButton?.addEventListener("click", restoreDefaultPreview); - resetButton?.addEventListener("click", () => { - if (!saving && draft && !resetConfirm.open) resetConfirm.showModal(); + nameInput?.addEventListener("input", render); + nameInput?.addEventListener("keydown", event => { + if (event.key === "Enter" && !saveButton?.disabled) { event.preventDefault(); void saveCurrent(); } }); - resetCancel?.addEventListener("click", () => { if (!saving) resetConfirm.close(); }); - resetApply?.addEventListener("click", () => { void resetToDefault(); }); - resetConfirm?.addEventListener("cancel", event => { if (saving) event.preventDefault(); }); - search.addEventListener("input", render); - typeFilter?.addEventListener("change", 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(); } }; + saveButton?.addEventListener("click", () => { void saveCurrent(); }); + closeButton?.addEventListener("click", () => { if (!busy) dialog.close(); }); + dialog?.addEventListener("cancel", event => { if (busy) event.preventDefault(); }); + dialog?.addEventListener("close", () => { message.textContent = ""; }); + return { open, refresh: () => { if (dialog?.open) render(); } }; })(); diff --git a/src/admin/web/app/preferences.js b/src/admin/web/app/preferences.js index d911a1d..a43cdf2 100644 --- a/src/admin/web/app/preferences.js +++ b/src/admin/web/app/preferences.js @@ -13,7 +13,7 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([ ])]); const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({ theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default", - 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" }); let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false; @@ -70,6 +70,25 @@ function normalizeDashboardWidgetSizes(value) { } 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) { return sizes?.[id] ?? dashboardDefaultWidth(id); } @@ -284,7 +303,7 @@ function applyAdminPreferences(value, cache = true) { refresh: normalizeRefreshPreferences(value.refresh), theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache), time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), 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; window.rustProxyRefresh?.update(normalized.refresh); @@ -332,7 +351,7 @@ function updateAdminPreferences(patch) { next.refresh = normalizeRefreshPreferences(next.refresh); next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale); next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.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) }); if (!authenticated || epoch !== authEpoch) return adminPreferences; return applyAdminPreferences(saved); diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 237feae..d9db978 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -139,7 +139,7 @@ + type="button" hidden>Dashboard presets