From b757b34a31b308840d136ba5067b680d48f048f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 15 Oct 2026 10:37:23 +0200 Subject: [PATCH] v0.33.1 --- AGENTS.md | 2 +- CHANGELOG.md | 15 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 46 +++- ROADMAP.md | 9 + SHA256SUMS | 47 +++-- docs/DASHBOARD_PRESET.md | 47 +++++ grafana/rustproxy-overview.json | 2 +- langs/pl_pl.json | 2 +- .../dashboard_layout_0331_browser_test.py | 197 ++++++++++++++++++ .../web_observability_0330_source_test.py | 4 +- src/admin/openapi.json | 2 +- src/admin/preferences.rs | 29 ++- src/admin/preferences_columns.rs | 58 ++++++ src/admin/preferences_tests.rs | 106 +++++----- src/admin/router.rs | 3 + src/admin/web/app/backend-dashboard.js | 7 +- src/admin/web/app/dashboard-inline-layout.js | 8 +- src/admin/web/app/overview-extra-metrics.js | 25 ++- src/admin/web/app/overview-samples.js | 3 +- src/admin/web/app/overview-wide-metrics.js | 3 +- src/admin/web/app/preferences.js | 22 +- src/admin/web/app/stream-dashboard.js | 3 +- src/admin/web/app/web-overview.js | 6 +- src/admin/web/dashboard-defaults.json | 137 ++++++++++++ 26 files changed, 671 insertions(+), 116 deletions(-) create mode 100644 docs/DASHBOARD_PRESET.md create mode 100755 scripts/tests/dashboard_layout_0331_browser_test.py create mode 100644 src/admin/web/dashboard-defaults.json diff --git a/AGENTS.md b/AGENTS.md index b767a67..fe122bc 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.0. +compatibility. Current source version: 0.33.1. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index 60cc41e..ed5f023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ > Operator-facing release history. Historical engineering notes are kept in > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.33.1 + +- Fix intermittent inline Overview hide/show reversions: backend and Stream + telemetry renders now honor the unlocked layout draft until Save/Cancel. + Unrelated preference refreshes no longer overwrite the draft. +- Add a single bundled `dashboard-defaults.json` defining the factory ordering, + visibility and compact density provided by the operator, with 600 initial + Overview history samples. Apply only to new accounts and explicit resets; + preserve saved user layouts and browser-stored sample preferences. +- Add live UI regressions for draft add/hide, backend/Stream status updates, + cancel/save and default preservation. +- Preserve counter baselines and chart history while Overview widgets are hidden, avoiding discontinuities when shown again. +- Clarify the distinction between lifetime Cache HIT ratio and recent interval HIT ratio. +- Document safe dashboard preference export in the browser Console without `copy()`. + ## 0.33.0 - Move the Overview history sample selector to the connection-status header row diff --git a/Cargo.lock b/Cargo.lock index 7477e8b..270b36c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.33.0" +version = "0.33.1" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index f65ff46..4710322 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.33.0" +version = "0.33.1" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index 3c26655..80bc861 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,18 @@ -# RustProxy 0.33.0 +# RustProxy 0.33.1 + +## 0.33.1: factory Overview preset and live layout preview + +- `src/admin/web/dashboard-defaults.json` defines the factory Overview card order, + visibility, density and initial 600-sample history. Both the Rust preference + defaults and authenticated JavaScript use this same file. New accounts without + stored preferences use the full preset; **Reset to default** restores card + order/visibility, not density or browser-local sample history. Existing saved + layouts and browser-persisted sample choices are preserved. Future registry additions remain opt-in. +- An unlocked inline layout is now authoritative during live backend/Stream + updates and unrelated preference refreshes. Showing/hiding those cards takes + effect immediately and stays stable until **Save and lock** or **Cancel**. + Cancel restores the saved layout. No extra polling, storage migrations or + production request-path changes are introduced. **0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md). @@ -832,3 +846,33 @@ it is a loopback-only development peer, never an authentication product. ### Docker vhosts operational controls Docker vhosts now publish/unpublish immediately per service. Each Docker agent has a configurable inventory polling interval, while the optional Live view refreshes cached runtime state only. Docker-backed applications can use ACL profiles/inline ACLs and per-vhost proxy connect/header/read/send timeouts, matching the ordinary vhost proxy controls. + + +## Dashboard layout export (0.33.1) + +To share a preferred Overview arrangement for a future factory preset, open the authenticated +Overview and run this in the browser developer console (no privileged database access): + +```javascript +(async () => { + const response = await fetch('/api/preferences', { credentials: 'same-origin' }); + if (!response.ok) throw new Error(`Preferences HTTP ${response.status}`); + const prefs = await response.json(); + const exportData = { + dashboard_widgets: prefs.dashboard_widgets, + dashboard_hidden_widgets: prefs.dashboard_hidden_widgets, + dashboard_density: prefs.dashboard_density, + overview_samples: localStorage.getItem('rustproxy.overview.liveChartSamples') + }; + console.log(JSON.stringify(exportData, null, 2)); +})(); +``` + +Copy the JSON shown in Console using the browser's selection or context menu. +Do not export the full preferences response or session cookies. This is an export +for review, not an automatic factory-default change; existing account layouts +remain unchanged. + +The original Cache HIT donut shows accumulated cache counters; the optional recent +Cache HIT chart displays interval-delta counters. They intentionally measure +different windows and can produce different percentages. diff --git a/ROADMAP.md b/ROADMAP.md index 93f4d3f..aad1260 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # RustProxy roadmap +## 0.33.1 - Factory dashboard preset and inline live preview + +- [x] Ship the operator-supplied default ordering, hidden cards and compact density + in one shared Rust/UI JSON preset; use 600 as the first-use history sample default. +- [x] Keep all existing explicitly saved preferences and restore the factory preset + only for new accounts or an explicit dashboard reset. +- [x] Honor unsaved inline hide/show changes while backend and Stream status refresh; + preserve the draft across unrelated preference updates. + ## 0.33.0 - Web Server observability (implementation complete; live acceptance pending) - [x] Add one Overview 60/120/300/600 history selector on the connection-status header row, with temporary changes and explicit browser-persistent choice. Use the same source stream and leave the Cache and Transform selectors independent. diff --git a/SHA256SUMS b/SHA256SUMS index 09e44a5..1eedf95 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,13 +1,13 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -12bc9d0f2444dc556ab922b534bd5b7c1602d804325cc0fb3d4d0d28a76cd4ce AGENTS.md -09472318ab840fdffc9f1517f4a7a5e4baa46abfba1f96b7d921e69e8a9d25e9 CHANGELOG.md -f6df6e080138068fecfe4e6ad7f271c450d158633315da8dd5f2e7377c3674b3 Cargo.lock -bcdfe0d135824ad7b570b2070074b29783b449b4b7ff2cc001c29b36ac451168 Cargo.toml +72d08ff6d852ab20c16d9a4396053dfb49c4acfd817af683838783ac5285ceea AGENTS.md +1769a5bfa31abc9158b9f39e7f86279a54f196d2934f93fdfb4e7d248a86fada CHANGELOG.md +8f4e05b7681384329a9a126d48b9eb9a0a8d5bc0597997d67d2f7551a47dac2e Cargo.lock +ac9205ad566ed4c2fe8e862b2241eb4d34bcfaaff1242e7a0ec5f49731d7059a Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -acca7f883bb48101b4385625b940e852d8a888ac3955160d978e84e59aa350c1 README.md -5104ae23146a728c7561ff5d6aeaa787ee755b379b1ac1d4ed4bbf2e897c46b3 ROADMAP.md +13ad656e45773ad5581c1fdb54635dc78a48e5f99c93ec589469882e85d2177e README.md +92a11de221459b5678659b1d79118e5318d404f91eda9ac7fadd69764c8ced45 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md @@ -35,6 +35,7 @@ fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md 3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md 833ddb12f23dcac796223521388b19c6ec3ea04c5550cf1c5f4ea4eb7c58f3a0 docs/CUSTOM_LOCATIONS.md +3f2376d8a54229cad88706d4edda5a07db33f5ae81c5aa70d3a9f2b38389906c docs/DASHBOARD_PRESET.md 4d70797bda8d227787fcff897951e702e2458fc6c6f5352aa0b5577595bd834b docs/DOCKER_DISCOVERY.md 46811a09a03d83f4af686c33e30e3f94d0508afe2c75147a26885c7247c49cbd docs/ERROR_LOGGING.md f749f7bd2eb4ae9c484f77a3cd8cd5ff21445319a3938e0384a3414134fe713a docs/FILE_LOGS.md @@ -82,7 +83,7 @@ b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock- d242de9614fbb52933b4accd32f4715ca94107abe4a3b1d7e088362b07a6ba60 examples/vhosts-balanced.json af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhosts-private.json 1b8974af79a5026e0648ceab58a32f233e1760e21e7465a0d46e9c41fb09964f grafana/README.md -1b9120b1d64eb07060bcb84df404c28ed375da9e0214d39cf01e167dd46e17f9 grafana/rustproxy-overview.json +abd03fbc4c24317871978b88930e175cc2ab1c20c69ce754a20ac8f8a6e9088a grafana/rustproxy-overview.json a05cbbf101b288bf9cf83fedfc5017925a8b360a78f313d31c1a70f15dc1dd22 install-debian13.sh 909e9ab94617ace74a425f15084bf85f3086cc52ba5b28f2b441ff800abc6ef9 install.sh 42f9f1d18c021fc7734b5bd020844bf678725a0e539e2fcbe05ec676f6239eae integrations/README.md @@ -96,7 +97,7 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md b5922257df0009d3e0778d2a08c926952fed6db6c013049e04ca8b63eaa6df6d langs/en_US.json -f195133531f91a3c139979df7f14814e87e008899e5ccd123bfbeadec447cee7 langs/pl_pl.json +52a3d1dcc5e2517f4906fb71b1c6d9c915036a0d69e9ceec143bb2b27582611b langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -129,12 +130,13 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim 174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py +52eacb467df123dc4a35f14933e006b090c9238df1b14dcff856a16b37c8063f scripts/tests/dashboard_layout_0331_browser_test.py 276e732fc2dfb0d39badb159e4fdcba0d7d73509c60ad5c9df49621c03046f1b scripts/tests/dashboard_metric_visuals_ui_test.mjs aa12d00acdd226602b3efc90221fbc936f69f73bcabbaa6c01dfb0e05d08c505 scripts/tests/overview_samples_0330_ui_test.mjs 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/tests/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/tests/test_stream_udp.py 91bf803f05b97b4654a6e607de40fccdd0268882039d6d1c5d677edce9459006 scripts/tests/web_observability_0330_browser_test.py -38ac7c7cc8c789f407cb2543a8a7dedcc8cbfce13eac89f5ec6537f8944d83f9 scripts/tests/web_observability_0330_source_test.py +fc982558e5c27281d71c2b3cbc926c415ab9134cf595e7ac12b7214f8a180447 scripts/tests/web_observability_0330_source_test.py 8b600edbe0aff817f871f2bf3821a35ca8d2d29870c0f6b6bf3c0cef2139e221 scripts/tests/web_server_0330_ui_test.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py 55485a513e8c4da2a58daa7abcb15b32ed408a5fc062cf23599fb83d16b90cfe src/acl.rs @@ -162,17 +164,17 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs 74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs 33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs -5b75274164d97b2c7a3acf5d82f494c6de8a41e357fa9e5b07df60bf0fcebe8f src/admin/openapi.json +38351a070d610e96105bc10441e1f50608542a7e51fb18eeecef7819f48f9ed5 src/admin/openapi.json d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs -c10f0d277ca5f1fd84eea3705360a81bb0923e10b7e5f5d25378b55ee080de40 src/admin/preferences.rs -cd0fda2b5a7b86a6619d6fa7cf5cef5a2598f033a8e9304475c4a05a169c958c src/admin/preferences_columns.rs -d94640019bc1679c5ac39110ba90399d8228fe8261bafac456be46ec2c8781ab src/admin/preferences_tests.rs +421c8284e0f45e9bb938e41feda4d97b7a17028d51ff3f27ce51ce063058257d src/admin/preferences.rs +fd19a594502c678f0f72efa29dc678ee477b956d47b6784c857b95f6265accf1 src/admin/preferences_columns.rs +1abda2a65cbee89248c8c252bc75fb6c4ffae1ac753b6b147866a51581f54f9f src/admin/preferences_tests.rs 055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs -ef82ece387c6a8987aafbc08ca04ccadf1043b9f9a49c02efb28b9df456da429 src/admin/router.rs +6d12bb97819ff05773d017d6b73bd6659dc9f2345f49d449fbaf9e34b936aab8 src/admin/router.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs 77fe317c15bf2e03301b22e0552518fe2d7e1ad8d80afaba4e16cd2bf8ddf10c src/admin/status.rs 4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs @@ -180,25 +182,26 @@ c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/stat cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs 18b778c2c4e723e2fe5cbaaef20effa6ea50fcc35d507ebde2361c9b6eda62b2 src/admin/tests.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js -4722d47fce7bdb45f0205cc364cb81e94c06abbdfd026d566907eb969ff7e2a2 src/admin/web/app/backend-dashboard.js +fc89214fef20a4f5ad7958a7c98df62bd43e8d8e3f895a96ed632c9a3c204d48 src/admin/web/app/backend-dashboard.js d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js 8aa205bc88530e4c4678f84ea324c6ecbc823b4ed681624102ff177f3867bc98 src/admin/web/app/core.js -dec0b875086e3567486191231056bfc6c1edef006f7aad7c88f19ef37c034570 src/admin/web/app/dashboard-inline-layout.js +e71f77d459d7894f6a0920e9adbe6393c85ed3113e40582fde0c814561da5fd3 src/admin/web/app/dashboard-inline-layout.js dc097b9c6ec8ebd4fa53ed8b0a641c9446fe44184c74246b521cfe49cf5a32b3 src/admin/web/app/dashboard-layout-editor.js -2f17473efa4147b04bcf8b0ad91e936a35ccf7c44771de95dec115d770f1ee42 src/admin/web/app/overview-extra-metrics.js +1a3782c81e1848df738eab0ff0e38f7b35b839fe553612f3387c517ca2461bbb src/admin/web/app/overview-extra-metrics.js fb0ba435beaa91e518537606f5fe55b0eea63c70efd290798c8d9754fa6771b2 src/admin/web/app/overview-masonry.js -5cf84000b52815177792fd1e0d64b0cac93ecb1ae72ec8a9fa391fbe11fd9794 src/admin/web/app/overview-samples.js -fbac70623c8b16b577bcc79d499c739458d08b0ae7346d39c5bc0c9db38d3d39 src/admin/web/app/overview-wide-metrics.js -4ead1e93be94e411aefc4b944355414f034d1b0edfb31b402328fc80cdaba6dd src/admin/web/app/preferences.js +b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.js +646d939eb9275d2aee369053d8da4b2e6502da61d072919e05dc530465b4f330 src/admin/web/app/overview-wide-metrics.js +fb6c48b2367f9e1bfeced2cbb4362d1d85fe1ca0c1627c4660b4d4475ea70977 src/admin/web/app/preferences.js 8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js 43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js b97095ae0c94184693bcd3929724c456edf3d66119b32db4fcdad5ae5b7a84bc src/admin/web/app/runtime.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js -db25aeb84827b6c921519a1f17be607319c50add4b0f9ea57436a387829cf4bf src/admin/web/app/stream-dashboard.js -0029667b44b904bd4f81be9885248ddc717ca7be9a12a0923b019706b571c5ba src/admin/web/app/web-overview.js +972a499ef7cd7956308c114a9a37fa567347ac7c554e979b3e648ec59bf34d76 src/admin/web/app/stream-dashboard.js +20ff05cfddc7f48931b0e9348b63188f82e8eaec38a7fe3e1829fd2e87301568 src/admin/web/app/web-overview.js 0603dc8793a731a0e582cbbec53aa6d1cc6e007faafcec5d8843730b2cdc39e1 src/admin/web/cache-panel.js 02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js +62c84ba46b231c9c62d00e910d182ad42ddb832bb04d3cbd158c296fdc78effd src/admin/web/dashboard-defaults.json 797e2935a34b974cb0e07a88056c31e6948cc11385bf2a076d902a332882d0a0 src/admin/web/dashboard-widgets.json 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html d01a2e48f344fe074c1e63ee0a9bbf7151dfe26140690418535197e13a2a16d7 src/admin/web/docker.js diff --git a/docs/DASHBOARD_PRESET.md b/docs/DASHBOARD_PRESET.md new file mode 100644 index 0000000..43dc0ed --- /dev/null +++ b/docs/DASHBOARD_PRESET.md @@ -0,0 +1,47 @@ +# Factory Overview dashboard preset (0.33.1) + +`src/admin/web/dashboard-defaults.json` is the single factory configuration +for the Rust admin preference API and the authenticated JavaScript panel. + +The preset contains: + +- `dashboard_widgets`: complete card identifier order, including hidden cards; +- `dashboard_hidden_widgets`: identifiers hidden in the factory Overview; +- `dashboard_density`: `normal`, `compact` or `ultra`; +- `overview_samples`: `60`, `120`, `300` or `600` samples for first use in a browser. + +Card IDs and labels are defined in `src/admin/web/dashboard-widgets.json`. +The preset may omit newly registered card IDs: the server/client will append +those cards and apply the registry's `default_hidden` flag. The preset must +not contain unknown or duplicate IDs, or hidden IDs absent from its order. + +The bundled 0.33.1 preset reproduces the supplied operator layout: 78 ordered +cards, 51 hidden, compact density and 600 initial samples. The packaged +Web Server cards remain hidden by default. + +## Deploying an edited preset + +1. Edit `src/admin/web/dashboard-defaults.json` in the source tree. +2. Validate the JSON and identifiers +3. Rebuild RustProxy (`./dev.sh check` / the normal release build) and deploy + the newly built binary. The preset is **embedded**; editing the installed + source JSON alone does not update a running binary. + +## Existing installation and reset behavior + +- Admin accounts with stored preferences in SQLite retain their chosen + ordering, card visibility and density on upgrade. The preset never rewrites + these saved records. Accounts without saved preferences receive the new + factory defaults, even on upgraded installations. +- In Overview, **Arrange cards visually → Restore defaults** restores the + factory card order/visibility after the user confirms; it does not reset + unrelated appearance or sample-history settings. +- The history selector uses browser-local storage key + `rustproxy.overview.liveChartSamples`. An explicitly saved choice (including + 60) wins over the factory sample count. A browser without a saved choice + starts with 600. The sample limit does not change polling frequency. +- While layout is unlocked, hide/show/reorder changes are temporary and stay + effective during backend/Stream status refresh. **Cancel changes** restores + the saved layout; **Save and lock** persists the current draft. + +No SQLite schema migration or additional HTTP endpoint is required. diff --git a/grafana/rustproxy-overview.json b/grafana/rustproxy-overview.json index baf76b2..0c8de8f 100644 --- a/grafana/rustproxy-overview.json +++ b/grafana/rustproxy-overview.json @@ -5640,7 +5640,7 @@ }, "timepicker": {}, "timezone": "browser", - "title": "RustProxy 0.33.0 - Operations", + "title": "RustProxy 0.33.1 - Operations", "uid": "rustproxy-0170", "version": 6, "weekStart": "", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index e627759..d3a7197 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -2557,7 +2557,7 @@ "overviewExtraTlsErrorRateDescription": "Tempo wyliczone z przyrostu licznika błędów handshake TLS.", "overviewExtraRequestErrorRate": "Błędy żądań/s", "overviewExtraRequestErrorRateDescription": "Tempo wyliczone z przyrostu licznika błędów żądań.", - "overviewExtraCacheHitRate": "Udział trafień cache", + "overviewExtraCacheHitRate": "Udział trafień cache (ostatnie próbki)", "overviewExtraCacheHitRateDescription": "Udział HIT w nowych trafieniach i chybieniach; bez BYPASS.", "overviewExtraCacheMemory": "Wykorzystanie pamięci HOT cache", "overviewExtraCacheMemoryDescription": "Pamięć rezydentna cache względem limitu HOT RAM, gdy jest dostępny.", diff --git a/scripts/tests/dashboard_layout_0331_browser_test.py b/scripts/tests/dashboard_layout_0331_browser_test.py new file mode 100755 index 0000000..6866573 --- /dev/null +++ b/scripts/tests/dashboard_layout_0331_browser_test.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""0.33.1 factory/default + unsaved layout regression against the full bundled JS. + +The authenticated app bundle runs against simulated status/preferences; this is +not a Rust server integration test. +""" +import json +import re +from pathlib import Path +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] +PRESET = json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text()) +REGISTRY = json.loads((ROOT / 'src/admin/web/dashboard-widgets.json').read_text()) +ROUTER = (ROOT / 'src/admin/router.rs').read_text() + + +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()) + 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})();') + elif line.startswith('"') and line.endswith('",'): + result.append(json.loads(line[:-1])) + return '\n'.join(result) + + +assert len(PRESET['dashboard_widgets']) == len(REGISTRY) +assert set(PRESET['dashboard_widgets']) == {w['id'] for w in REGISTRY} +assert len(set(PRESET['dashboard_hidden_widgets'])) == len(PRESET['dashboard_hidden_widgets']) +assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_widgets'])) +assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600 +assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12 + +HTML = (ROOT / 'src/admin/web/index.html').read_text() +CSS = (ROOT / 'src/admin/web/style.css').read_text() +BUNDLE = app_bundle() +assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =') + + +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.rustProxyBackendDashboard') + + +def inspect(page, ids): + return page.evaluate('''ids => Object.fromEntries(ids.map(id => { + const node = document.querySelector(`[data-dashboard-widget="${id}"]`); + return [id, {hidden:node.hidden, display:getComputedStyle(node).display}]; + }))''', ids) + + +def assert_visible(page, visible, hidden): + result = inspect(page, visible + hidden) + for widget in visible: + assert result[widget]['hidden'] is False and result[widget]['display'] != 'none', (widget, result) + for widget in hidden: + assert result[widget]['hidden'] is True and result[widget]['display'] == 'none', (widget, result) + + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) + for width in [320, 375, 800, 1440]: + context = browser.new_context(viewport={'width': width, 'height': 950}) + page = context.new_page() + errors = [] + page.on('pageerror', lambda e: errors.append(str(e))) + load(page) + result = page.evaluate('''() => { + const prefs = ADMIN_APPEARANCE_DEFAULTS; + 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; + currentPanel = 'overview'; + window.__savedDashboard = JSON.parse(JSON.stringify(prefs)); + 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); + return { + order: ADMIN_DASHBOARD_WIDGETS, + hidden: ADMIN_DEFAULT_HIDDEN_WIDGETS, + density: document.documentElement.dataset.dashboardDensity, + samples:window.rustProxyOverviewSamples.getLimit(), + serviceCount:document.querySelectorAll('#dashboard-overview-grid [data-dashboard-widget]').length, + }; + }''') + assert result['order'] == PRESET['dashboard_widgets'], (width, result['order']) + assert result['hidden'] == PRESET['dashboard_hidden_widgets'], (width, result['hidden']) + assert result['samples'] == 600 and result['density'] == 'compact' and result['serviceCount'] == 78, result + assert_visible(page, ['backend_traffic', 'extra_response_latency', 'wide_transform_activity'], + ['backend_health', 'stream_sessions', 'www_status', 'bandwidth']) + + # Reproduce the previously flaky operation: opt in hidden backend + + # Stream cards and opt out a visible backend card, WITHOUT saving yet. + page.locator('#overview-layout-unlock').click() + page.wait_for_function('window.rustProxyInlineLayout.isEditing()') + outcome = page.evaluate('''() => { + const add = id => { + const label = dashboardWidgetLabel(id); + const item = [...document.querySelectorAll('#overview-inline-hidden-list .overview-inline-hidden-item')] + .find(node => node.firstElementChild.textContent === label); + if (!item) throw new Error('no hidden card to restore: ' + id); + item.querySelector('button').click(); + }; + add('backend_health'); + add('stream_sessions'); + const card = document.querySelector('[data-dashboard-widget="backend_traffic"]'); + card.querySelector('.overview-inline-hide').click(); + return window.rustProxyInlineLayout.currentDraft(); + }''') + assert 'backend_traffic' in outcome['hidden'] and 'backend_health' not in outcome['hidden'] + assert 'stream_sessions' not in outcome['hidden'] + assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic']) + for _ in range(5): + page.evaluate('''() => { + const status = {revision:4,backend_pools:[],streams:{active:[],desired:[],metrics:{}}}; + window.rustProxyBackendDashboard.render(status); + window.rustProxyStreamDashboard.render(status); + }''') + assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic']) + # Unrelated settings update must not repaint the saved layout over the draft. + page.evaluate('''() => applyAdminPreferences({...window.__savedDashboard, ui_scale:105})''') + assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic']) + assert page.evaluate('window.rustProxyInlineLayout.isEditing()') + page.locator('#overview-inline-cancel').click() + assert not page.evaluate('window.rustProxyInlineLayout.isEditing()') + assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions']) + + # Save under live traffic, ensure the in-flight preference application + # uses the current draft before the editor exits. + page.locator('#overview-layout-unlock').click() + page.wait_for_function('window.rustProxyInlineLayout.isEditing()') + page.evaluate('''() => { + const label = dashboardWidgetLabel('backend_health'); + const item = [...document.querySelectorAll('#overview-inline-hidden-list .overview-inline-hidden-item')] + .find(node => node.firstElementChild.textContent === label); + item.querySelector('button').click(); + document.querySelector('[data-dashboard-widget="backend_traffic"] .overview-inline-hide').click(); + }''') + page.locator('#overview-inline-save').click() + page.wait_for_function('!window.rustProxyInlineLayout.isEditing()') + assert_visible(page, ['backend_health'], ['backend_traffic']) + saved = page.evaluate('''() => window.__savedDashboard.dashboard_hidden_widgets''') + assert 'backend_traffic' in saved and 'backend_health' not in saved + page.evaluate('''() => { + const status={backend_pools:[],streams:{active:[],desired:[],metrics:{}}}; + window.rustProxyBackendDashboard.render(status); + window.rustProxyStreamDashboard.render(status); + }''') + assert_visible(page, ['backend_health'], ['backend_traffic']) + # Restore defaults in the modal after a custom layout was saved. + page.locator('#overview-layout-open').evaluate('(node) => node.hidden = false') + page.locator('#overview-layout-open').click() + page.wait_for_function("document.getElementById('overview-layout-dialog').open") + page.locator('#overview-layout-default').click() + page.locator('#overview-layout-save').click() + page.wait_for_function("!document.getElementById('overview-layout-dialog').open") + 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'] + assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2 + assert not errors, (width, errors) + context.close() + print('PASS inline layout defaults, add/hide, live ticks, other preferences, cancel/save', width) + # An already-persisted sample choice must override the factory 600. + context = browser.new_context() + page = context.new_page() + # This sandbox blocks all network origins; stub browser storage before + # loading the bundle rather than navigating to a real host. + page.evaluate("""() => Object.defineProperty(window, 'localStorage', { + configurable: true, + value: {getItem: key => key === 'rustproxy.overview.liveChartSamples' ? '120' : null, + setItem: () => undefined}, + })""") + load(page) + assert page.evaluate('window.rustProxyOverviewSamples.getLimit()') == 120 + context.close() + browser.close() +print('PASS RustProxy 0.33.1 simulated browser layout regression') diff --git a/scripts/tests/web_observability_0330_source_test.py b/scripts/tests/web_observability_0330_source_test.py index 647db73..ed0b66b 100755 --- a/scripts/tests/web_observability_0330_source_test.py +++ b/scripts/tests/web_observability_0330_source_test.py @@ -10,7 +10,7 @@ read = lambda file: (ROOT / file).read_text() manifest = tomllib.loads(read('Cargo.toml')) lock = tomllib.loads(read('Cargo.lock')) version = manifest['package']['version'] -assert version == '0.33.0' +assert tuple(map(int, version.split('.'))) >= (0, 33, 0) assert [p['version'] for p in lock['package'] if p['name'] == 'rust-proxy'] == [version] registry = json.loads(read('src/admin/web/dashboard-widgets.json')) @@ -61,7 +61,7 @@ assert re.search(r'\["GET", "POST", "PUT", "DELETE", "other"\]', families) assert 'let wl = labels(&[("web_service", service.id.as_str())]);' in families j = json.loads(read('grafana/rustproxy-overview.json')) -assert j['title'].startswith('RustProxy 0.33.0') +assert j['title'].startswith('RustProxy 0.33.') ids = [p['id'] for p in j['panels']] assert len(ids) == len(set(ids)) assert any(v.get('name') == 'web_service' and v.get('multi') and v.get('includeAll') for v in j['templating']['list']) diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 42b9a7c..b803fe1 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.33.0", + "version": "0.33.1", "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." }, "servers": [ diff --git a/src/admin/preferences.rs b/src/admin/preferences.rs index 4c1cd13..635470f 100644 --- a/src/admin/preferences.rs +++ b/src/admin/preferences.rs @@ -25,20 +25,29 @@ fn default_admin_table_density() -> String { "normal".to_owned() } fn default_admin_dashboard_density() -> String { - "compact".to_owned() + DASHBOARD_DEFAULT_PRESET.dashboard_density.clone() } fn default_admin_dashboard_widgets() -> Vec { - DASHBOARD_WIDGETS - .iter() - .map(|widget| widget.id.clone()) - .collect() + let mut order = DASHBOARD_DEFAULT_PRESET.dashboard_widgets.clone(); + for widget in DASHBOARD_WIDGETS.iter() { + if !order.contains(&widget.id) { + order.push(widget.id.clone()); + } + } + order } fn default_hidden_dashboard_widgets() -> Vec { - DASHBOARD_WIDGETS - .iter() - .filter(|widget| widget.default_hidden) - .map(|widget| widget.id.clone()) - .collect() + let mut hidden = DASHBOARD_DEFAULT_PRESET.dashboard_hidden_widgets.clone(); + for widget in DASHBOARD_WIDGETS.iter() { + if !DASHBOARD_DEFAULT_PRESET + .dashboard_widgets + .contains(&widget.id) + && widget.default_hidden + { + hidden.push(widget.id.clone()); + } + } + hidden } // Preserve the user's saved order/visibility, drop identifiers unknown to this diff --git a/src/admin/preferences_columns.rs b/src/admin/preferences_columns.rs index 04d5dd7..60e059e 100644 --- a/src/admin/preferences_columns.rs +++ b/src/admin/preferences_columns.rs @@ -87,3 +87,61 @@ static DASHBOARD_WIDGETS: std::sync::LazyLock> = serde_json::from_str(include_str!("web/dashboard-widgets.json")) .expect("invalid bundled Overview Cards registry") }); + +// A single operator-curated factory preset feeds both the Rust API defaults +// and the authenticated dashboard. New registry widgets remain opt-in unless +// explicitly added to the preset; saved user preferences are never rewritten. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct DashboardDefaultPreset { + dashboard_widgets: Vec, + dashboard_hidden_widgets: Vec, + dashboard_density: String, + overview_samples: u16, +} +static DASHBOARD_DEFAULT_PRESET: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + let preset: DashboardDefaultPreset = + serde_json::from_str(include_str!("web/dashboard-defaults.json")) + .expect("invalid factory Overview layout preset"); + let registry = DASHBOARD_WIDGETS + .iter() + .map(|w| w.id.as_str()) + .collect::>(); + let order = preset + .dashboard_widgets + .iter() + .map(String::as_str) + .collect::>(); + let hidden = preset + .dashboard_hidden_widgets + .iter() + .map(String::as_str) + .collect::>(); + assert_eq!( + order.len(), + preset.dashboard_widgets.len(), + "duplicate factory widget ID" + ); + assert_eq!( + hidden.len(), + preset.dashboard_hidden_widgets.len(), + "duplicate hidden factory widget ID" + ); + assert!( + order.is_subset(®istry) && hidden.is_subset(&order), + "unknown widget in factory layout" + ); + assert!( + matches!( + preset.dashboard_density.as_str(), + "normal" | "compact" | "ultra" + ), + "invalid factory density" + ); + assert!( + matches!(preset.overview_samples, 60 | 120 | 300 | 600), + "invalid factory sample count" + ); + preset + }); diff --git a/src/admin/preferences_tests.rs b/src/admin/preferences_tests.rs index f8f5617..c2a6b62 100644 --- a/src/admin/preferences_tests.rs +++ b/src/admin/preferences_tests.rs @@ -1,62 +1,42 @@ #[test] -fn overview_registry_is_single_source_of_identifiers_and_visibility_defaults() { +fn overview_factory_preset_matches_registry_and_operator_visibility() { let order = default_admin_dashboard_widgets(); let hidden = default_hidden_dashboard_widgets(); let unique: std::collections::HashSet<&str> = order.iter().map(String::as_str).collect(); assert_eq!(unique.len(), order.len()); + assert_eq!(order.len(), DASHBOARD_WIDGETS.len()); assert_eq!(order.first().map(String::as_str), Some("requests")); - assert!(order.contains(&"http3".to_owned())); - assert!(hidden.contains(&"stream_overview".to_owned())); - assert!(hidden.contains(&"stream_backend_health".to_owned())); + assert_eq!(order, DASHBOARD_DEFAULT_PRESET.dashboard_widgets); + assert_eq!(hidden, DASHBOARD_DEFAULT_PRESET.dashboard_hidden_widgets); + assert_eq!(default_admin_dashboard_density(), "compact"); + assert_eq!(DASHBOARD_DEFAULT_PRESET.overview_samples, 600); for id in [ - "backend_health", - "backend_activity", - "backend_errors", - "backend_latency", "backend_traffic", - "backend_protocols", - "backend_requests", - "backend_capacity", - "backend_http5xx", - "backend_timeouts", - "backend_network", - "backend_retries", - "backend_queue", - "backend_rotations", - ] { - assert!(order.contains(&id.to_owned())); - assert!(hidden.contains(&id.to_owned())); - } - for id in [ - "extra_process_cpu", - "extra_process_rss", - "extra_process_fds", - "extra_open_connections", - "extra_websocket_active", - "extra_tls_error_rate", - "extra_request_error_rate", - "extra_cache_hit_rate", - "extra_cache_memory", - "extra_h2_share", + "backend_latency", "extra_response_latency", - "extra_backend_unhealthy", - ] { - assert!(order.contains(&id.to_owned())); - assert!(hidden.contains(&id.to_owned())); - } - for id in [ - "traffic_chart", - "system_chart", - "connection_chart", - "cache_diagnostics", - "process_diagnostics", - "network_diagnostics", + "wide_transform_activity", + "wide_compression_activity", + "cache", "backend_table", ] { - assert!(order.contains(&id.to_owned())); - assert!(!hidden.contains(&id.to_owned())); + assert!(order.contains(&id.to_owned()), "not registered: {id}"); + assert!( + !hidden.contains(&id.to_owned()), + "should start visible: {id}" + ); + } + for id in [ + "bandwidth", + "backend_health", + "extra_process_cpu", + "stream_traffic", + "wide_cache_activity", + "www_status", + "www_requests", + "www_top_services", + ] { + assert!(hidden.contains(&id.to_owned()), "should start hidden: {id}"); } - assert!(!hidden.contains(&"requests".to_owned())); assert!(hidden.iter().all(|id| order.contains(id))); assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok()); } @@ -180,6 +160,38 @@ fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() { assert!(validate_admin_preferences(&prefs).is_ok()); } +#[test] +fn saved_dashboard_order_and_visibility_survive_new_factory_preset() { + let mut prefs: AdminPreferences = serde_json::from_str( + r#"{ + "dashboard_density":"normal", + "dashboard_widgets":["errors","requests","backend_traffic"], + "dashboard_hidden_widgets":["backend_traffic"] + }"#, + ) + .unwrap(); + reconcile_dashboard_widgets(&mut prefs); + assert_eq!(prefs.dashboard_density, "normal"); + assert_eq!( + &prefs.dashboard_widgets[..3], + &["errors", "requests", "backend_traffic"] + ); + assert!(!prefs + .dashboard_hidden_widgets + .contains(&"errors".to_owned())); + assert!(!prefs + .dashboard_hidden_widgets + .contains(&"requests".to_owned())); + assert!(prefs + .dashboard_hidden_widgets + .contains(&"backend_traffic".to_owned())); + // WWW was absent from the saved document and is introduced only as opt-in. + assert!(prefs + .dashboard_hidden_widgets + .contains(&"www_status".to_owned())); + assert!(validate_admin_preferences(&prefs).is_ok()); +} + #[test] fn explicit_visible_stream_opt_in_is_not_reset_when_normalizing_saved_order() { let mut prefs = AdminPreferences::default(); diff --git a/src/admin/router.rs b/src/admin/router.rs index 54b4671..c503136 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -54,6 +54,9 @@ fn admin_app_javascript() -> &'static str { static ASSET: std::sync::LazyLock = std::sync::LazyLock::new(|| { [ "\"use strict\";", + "const ADMIN_DASHBOARD_DEFAULTS = Object.freeze(", + include_str!("web/dashboard-defaults.json"), + ");", // The samples controller is needed by top-level listeners in core.js. // Define it first; DOM initialization and translations happen later. include_str!("web/app/overview-samples.js"), diff --git a/src/admin/web/app/backend-dashboard.js b/src/admin/web/app/backend-dashboard.js index 903884e..e808438 100644 --- a/src/admin/web/app/backend-dashboard.js +++ b/src/admin/web/app/backend-dashboard.js @@ -18,7 +18,8 @@ window.rustProxyBackendDashboard = (() => { } function syncVisibility(hiddenOverride = null) { const prefs = window.rustProxyPreferences?.current?.(); - const hidden = new Set(hiddenOverride ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets); + const hidden = new Set(hiddenOverride ?? window.rustProxyInlineLayout?.currentDraft?.()?.hidden + ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets); for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id); if (![...charts].some(visible)) reset(); } @@ -48,8 +49,7 @@ window.rustProxyBackendDashboard = (() => { window.rustProxyExtraOverview?.meter?.("backend-dashboard-capacity", null); reset(); } - // Counter deltas are matched to stable (pool, member) IDs, not aggregate - // totals: removed/rebuilt pools must never become negative or spike rates. + function memberCounters(members) { const values = new Map(); for (const { pool, member } of members) { @@ -71,7 +71,6 @@ window.rustProxyBackendDashboard = (() => { for (const [id, next] of current) { const old = earlier.get(id); if (!old) continue; - // A generation reset on a member invalidates its entire interval. if (tracked.some(field => next[field] < old[field])) continue; for (const field of tracked) total[field] += next[field] - old[field]; } diff --git a/src/admin/web/app/dashboard-inline-layout.js b/src/admin/web/app/dashboard-inline-layout.js index b375d71..68a90d0 100644 --- a/src/admin/web/app/dashboard-inline-layout.js +++ b/src/admin/web/app/dashboard-inline-layout.js @@ -145,7 +145,7 @@ window.rustProxyInlineLayout = (() => { } async function save() { if (!draft || saving) return; - if (!dirty()) { finish(true); return; } + if (!dirty()) { finish(false); return; } saving = true; saveButton.disabled = cancelButton.disabled = unlock.disabled = true; try { @@ -196,5 +196,9 @@ window.rustProxyInlineLayout = (() => { unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); }); saveButton?.addEventListener("click", () => { void save(); }); cancelButton?.addEventListener("click", cancel); - return { isEditing: () => !!draft, cancel }; + return { + isEditing: () => !!draft, + currentDraft: () => draft ? { order: [...draft.order], hidden: [...draft.hidden] } : null, + cancel, + }; })(); diff --git a/src/admin/web/app/overview-extra-metrics.js b/src/admin/web/app/overview-extra-metrics.js index fac3621..866e672 100644 --- a/src/admin/web/app/overview-extra-metrics.js +++ b/src/admin/web/app/overview-extra-metrics.js @@ -1,5 +1,4 @@ -// Opt-in compact metrics and bounded visualizations. All readings reuse the -// existing Overview live-status payload (no additional requests or timers). + window.rustProxyExtraOverview = (() => { const limit = () => window.rustProxyOverviewSamples?.getLimit?.() ?? 60; const names = [ @@ -87,13 +86,17 @@ window.rustProxyExtraOverview = (() => { } if (id === "extra_backend_active") { const members = Array.isArray(status.backend_pools) ? status.backend_pools.flatMap(pool => pool.members || []) : null; - return { value: members ? members.reduce((sum, member) => sum + (positive(member.active) ?? 0), 0) : null, - unit: "count", detail: "" }; + return { + value: members ? members.reduce((sum, member) => sum + (positive(member.active) ?? 0), 0) : null, + unit: "count", detail: "" + }; } if (id === "extra_backend_unhealthy") { - return { value: Array.isArray(status.backend_pools) ? status.backend_pools.reduce((sum, pool) => - sum + (pool.members || []).filter(member => member.status === "down").length, 0) : null, - unit: "count", detail: "" }; + return { + value: Array.isArray(status.backend_pools) ? status.backend_pools.reduce((sum, pool) => + sum + (pool.members || []).filter(member => member.status === "down").length, 0) : null, + unit: "count", detail: "" + }; } if (id === "extra_cache_memory") return ramBudget(c.resident_bytes, c.hot_limit_bytes); if (id === "extra_cold_queue_memory") return ramBudget(c.cold_queue_bytes, c.cold_queue_limit_bytes); @@ -123,8 +126,10 @@ window.rustProxyExtraOverview = (() => { const { deltas, seconds } = result; if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" }; const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1]; - return { value: sum > 0 ? Math.min(100, deltas[0] / sum * 100) : null, unit, detail: "", - slices: id === "extra_cache_hit_rate" ? [deltas[0], deltas[1]] : null }; + return { + value: sum > 0 ? Math.min(100, deltas[0] / sum * 100) : null, unit, detail: "", + slices: id === "extra_cache_hit_rate" ? [deltas[0], deltas[1]] : null + }; } function format(value, unit) { if (value == null) return "-"; @@ -144,7 +149,7 @@ window.rustProxyExtraOverview = (() => { const now = performance.now(); for (const id of names) { const tile = node(id); - if (!tile || tile.hidden) { lastCounter.delete(id); continue; } + if (!tile) { lastCounter.delete(id); continue; } const metric = read(id, status, now, record); const value = metric.value; if (lastUnits.get(id) !== metric.unit) { diff --git a/src/admin/web/app/overview-samples.js b/src/admin/web/app/overview-samples.js index 561cb2d..fe9454e 100644 --- a/src/admin/web/app/overview-samples.js +++ b/src/admin/web/app/overview-samples.js @@ -1,7 +1,8 @@ window.rustProxyOverviewSamples = (() => { const allowed = new Set([60, 120, 300, 600]); const storageKey = "rustproxy.overview.liveChartSamples"; - let saved = 60; + const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60; + let saved = allowed.has(factory) ? factory : 60; try { const stored = Number(localStorage.getItem(storageKey)); if (allowed.has(stored)) saved = stored; diff --git a/src/admin/web/app/overview-wide-metrics.js b/src/admin/web/app/overview-wide-metrics.js index 2eec524..1419773 100644 --- a/src/admin/web/app/overview-wide-metrics.js +++ b/src/admin/web/app/overview-wide-metrics.js @@ -84,7 +84,8 @@ window.rustProxyWideOverview = (() => { const now = performance.now(); for (const [id, config] of Object.entries(configurations)) { const node = tile(id); - if (!node || node.hidden) { baselines.delete(id); continue; } + if (!node) { baselines.delete(id); continue; } + const values = config.counters ? counterRates(id, config.counters(status), now, record) : config.values(status).map(finite); const series = history.get(id) || config.names.map(() => []); diff --git a/src/admin/web/app/preferences.js b/src/admin/web/app/preferences.js index b8fdd83..13a2837 100644 --- a/src/admin/web/app/preferences.js +++ b/src/admin/web/app/preferences.js @@ -4,12 +4,16 @@ const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contra const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]); const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]); const ADMIN_CORNER_STYLES = new Set(["default", "square", "subtle", "rounded", "extra"]); -const ADMIN_DASHBOARD_WIDGETS = Object.freeze(ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id)); const ADMIN_DASHBOARD_BY_ID = new Map(ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => [widget.id, widget])); -const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze(ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden).map(widget => widget.id)); +const ADMIN_PRESET_ORDER = Object.freeze(ADMIN_DASHBOARD_DEFAULTS.dashboard_widgets.filter(id => ADMIN_DASHBOARD_BY_ID.has(id))); +const ADMIN_DASHBOARD_WIDGETS = Object.freeze([...new Set([...ADMIN_PRESET_ORDER, ...ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id)])]); +const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([ + ...ADMIN_DASHBOARD_DEFAULTS.dashboard_hidden_widgets.filter(id => ADMIN_DASHBOARD_BY_ID.has(id)), + ...ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden && !ADMIN_PRESET_ORDER.includes(widget.id)).map(widget => widget.id), +])]); 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: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], + 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], }); 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; @@ -215,13 +219,19 @@ function formatDateTime(value) { const date = value instanceof Date ? value : ne function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); } function applyAdminPreferences(value, cache = true) { if (!value) return value; - const dashboard = applyDashboardLayout(value.dashboard_widgets, normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets)); + // A live inline edit is a draft, not a saved preference. Background updates + // (including an unrelated preference save) must not repaint the old layout. + const editing = window.rustProxyInlineLayout?.currentDraft?.(); + const savedOrder = normalizeDashboardWidgets(value.dashboard_widgets); + const savedHidden = normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets); + if (editing) applyDashboardLayout(editing.order, editing.hidden); + else applyDashboardLayout(savedOrder, savedHidden); const normalized = { ...value, refresh: normalizeRefreshPreferences(value.refresh), theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache), time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), 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: dashboard.order, dashboard_hidden_widgets: dashboard.hidden, + dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, }; adminPreferences = normalized; window.rustProxyRefresh?.update(normalized.refresh); @@ -376,7 +386,7 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => { const patches = { interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default" }, tables: { table_density: "normal" }, - dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] }, + dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] }, }; void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control); })); diff --git a/src/admin/web/app/stream-dashboard.js b/src/admin/web/app/stream-dashboard.js index d401476..b3ebd63 100644 --- a/src/admin/web/app/stream-dashboard.js +++ b/src/admin/web/app/stream-dashboard.js @@ -16,7 +16,8 @@ window.rustProxyStreamDashboard = (() => { } function syncVisibility(hiddenOverride = null) { const prefs = window.rustProxyPreferences?.current?.(); - const hidden = new Set(hiddenOverride ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets); + const hidden = new Set(hiddenOverride ?? window.rustProxyInlineLayout?.currentDraft?.()?.hidden + ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets); for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id); if (card("stream_traffic")?.hidden && wasVisible) clearTraffic(); wasVisible = !card("stream_traffic")?.hidden; diff --git a/src/admin/web/app/web-overview.js b/src/admin/web/app/web-overview.js index f124990..9e6b61c 100644 --- a/src/admin/web/app/web-overview.js +++ b/src/admin/web/app/web-overview.js @@ -88,7 +88,7 @@ window.rustProxyWebOverview = (() => { } function smallPath(values) { const peak = Math.max(1, ...values.filter(v => v != null)); - return values.map((value, i) => `${i * 300 / Math.max(1, values.length - 1)},${55 - (value || 0) / peak * 50}`).join(" "); + return values.filter(value => value !== null).map((value, i, present) => `${i * 300 / Math.max(1, present.length - 1)},${55 - value / peak * 50}`).join(" "); } function ring(id, amounts) { const total = amounts.reduce((n, value) => n + value, 0); @@ -154,7 +154,7 @@ window.rustProxyWebOverview = (() => { meter("www_error_rate", r?.errorRate); show("www_error_rate-detail", t("webMetricErrorRateHelp")); show("www_php_requests-value", fmtRate(r?.phpRps)); - if (record && !tile("www_php_requests")?.hidden) { + if (record) { const values = histories.get("www_php_requests") || []; values.push(r?.phpRps ?? null); window.rustProxyOverviewSamples.trim(values); histories.set("www_php_requests", values); @@ -166,7 +166,7 @@ window.rustProxyWebOverview = (() => { }; for (const [id, names] of Object.entries(seriesNames)) { const series = histories.get(id) || names.map(() => []); - if (record && !tile(id)?.hidden) { + if (record) { names.forEach((_, i) => { series[i].push(set[id]?.[i] ?? null); window.rustProxyOverviewSamples.trim(series[i]); }); histories.set(id, series); } diff --git a/src/admin/web/dashboard-defaults.json b/src/admin/web/dashboard-defaults.json new file mode 100644 index 0000000..868513e --- /dev/null +++ b/src/admin/web/dashboard-defaults.json @@ -0,0 +1,137 @@ +{ + "dashboard_widgets": [ + "requests", + "active", + "bandwidth", + "errors", + "memory", + "websocket", + "backend_traffic", + "extra_response_latency", + "http_protocol", + "tls", + "websocket_protocol", + "backend_latency", + "stream_overview", + "stream_traffic", + "stream_sessions", + "stream_backend_health", + "backend_health", + "backend_activity", + "backend_errors", + "backend_retries", + "http2", + "backend_protocols", + "backend_requests", + "backend_capacity", + "backend_http5xx", + "backend_timeouts", + "backend_network", + "http3", + "backend_queue", + "extra_open_connections", + "extra_process_cpu", + "extra_process_rss", + "extra_process_fds", + "backend_rotations", + "extra_websocket_active", + "extra_tls_error_rate", + "extra_request_error_rate", + "extra_cache_hit_rate", + "extra_backend_active", + "extra_h2_share", + "extra_cache_memory", + "extra_backend_unhealthy", + "extra_total_rps", + "extra_active_exchanges", + "extra_h1_share", + "extra_h3_share", + "extra_cache_miss_rate", + "extra_cold_storage", + "wide_protocol_rates", + "wide_cpu", + "wide_memory", + "wide_cache_activity", + "wide_connections", + "wide_backend_health", + "wide_latency", + "cache", + "traffic_chart", + "system_chart", + "connection_chart", + "wide_transform_activity", + "wide_compression_activity", + "cache_diagnostics", + "extra_cold_queue_memory", + "process_diagnostics", + "network_diagnostics", + "backend_table", + "www_status", + "www_kind", + "www_capacity", + "www_methods", + "www_error_rate", + "www_php_requests", + "www_requests", + "www_latency", + "www_failures", + "www_traffic", + "www_top_services", + "www_status_trend" + ], + "dashboard_hidden_widgets": [ + "stream_overview", + "stream_backend_health", + "tls", + "stream_sessions", + "backend_health", + "backend_activity", + "backend_errors", + "backend_protocols", + "backend_requests", + "backend_timeouts", + "backend_network", + "extra_process_cpu", + "extra_process_rss", + "extra_process_fds", + "extra_websocket_active", + "extra_tls_error_rate", + "extra_request_error_rate", + "extra_h2_share", + "extra_backend_unhealthy", + "extra_total_rps", + "extra_h1_share", + "extra_h3_share", + "wide_memory", + "wide_connections", + "wide_backend_health", + "wide_protocol_rates", + "extra_active_exchanges", + "extra_cache_miss_rate", + "extra_cache_hit_rate", + "backend_capacity", + "stream_traffic", + "backend_http5xx", + "bandwidth", + "backend_queue", + "wide_cpu", + "wide_latency", + "www_status", + "www_kind", + "www_capacity", + "www_methods", + "www_error_rate", + "www_php_requests", + "www_requests", + "www_latency", + "www_failures", + "www_traffic", + "www_top_services", + "www_status_trend", + "extra_cold_queue_memory", + "wide_cache_activity", + "backend_rotations" + ], + "dashboard_density": "compact", + "overview_samples": 600 +} \ No newline at end of file