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..7f292c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,53 @@ > Operator-facing release history. Historical engineering notes are kept in > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.33.7 + +- Apply the same theme-aware subdued `--muted` color used on small metric + tiles to every Overview heading and the chart/metric headings in Cache, + Transform, Traffic and Vhosts. Keep numerical readings, legends, font sizes + and established sentence/uppercase title rules unchanged. +- Make the shared Vhosts Backend/H2 popover (including rows and its close + control) follow the selected interface corner style: default/square, + subtle, rounded and extra-rounded, updating without dismissing the popup. + +- Unify all 78 Overview card headings at 12px in sentence case, with the + same proportional font across metrics, Process, RAM cache, backend tiles + and wide charts. Use one monospaced font for compact descriptions and + numeric readings; preserve HTTP, H1/H2 and RAM acronym capitalization. + Keep the 9px chart legends and other panels' typography unchanged. +- Add 10px separation between the Cache "Domains and effectiveness" and + "Stored objects" cards, leaving other Cache section spacing unchanged. +- Replace the native Vhosts H1/H2 upstream badge tooltip with a click-to-open + panel sharing the backend quick-view's anchored popover, dismissal and + positioning. Keep the panel and its content nodes stable across status + refreshes; show current observations and H1/H2 response counters without + a blinking browser tooltip. Ensure clickable Backend count and Health tiles + keep the same square geometry and typography as adjacent Vhost detail and + live-metric tiles, regardless of the selected interface corner style. +- Align every chart legend and its value labels with the 9px monospace styling + used by **Requests per second**, including compact/ultra dashboard densities. +- Add a clickable Backend count / Health tile to Vhosts: a compact, + keyboard-dismissible backend list shows name, configured IP/hostname and + port, upstream scheme/HTTP mode, health and the last real HTTP response + code. The live status feed refreshes the open list without extra API calls. + +- Replace the legacy Arrange Overview modal with authenticated SQLite-backed, + administrator-scoped named layout presets (16 maximum). Support save, + overwrite, load, rename, delete and current-view factory reset. Include + Overview history sample limits, density, card widths, order and visibility. +- Add SQLite migration `0010_dashboard_presets.sql` (schema 10), protected + preset endpoints and English/Polish UI translations. Existing preferences + and proxy configuration documents remain unchanged. +- Extract only Overview/WWW metric and chart elements into compile-time HTML + partials. Preserve card IDs, DOM order, dashboard controls and API polling. +- Harmonize chart headings at 12px, using the existing proportional heading + font (sentence case on Overview, uppercase in other panels). Use the existing + monospaced chart font for **all** legends and data labels, not only their + numeric values. Covers Overview, Compression activity, Web Server, Cache, + Transform, Traffic, Vhosts and Streams, including dynamically rendered charts + and compact/ultra dashboard densities. + ## 0.33.6 - Normalize the height of all 14 large Overview/WWW plots to 125px, avoiding 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..a1da598 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,59 @@ -# RustProxy 0.33.6 +# RustProxy 0.33.7 + +## 0.33.7: named dashboard presets, panel components and chart typography + +Titles across metric tiles and charts now use the same understated text tone +(`--muted`) as the small Overview counters. The shade follows the active UI +color theme; numerical readings keep their original contrast and fonts. +The shared Vhosts Backend/H2 quick-view popover follows Corner rounding +settings (including square, subtle, rounded and extra-rounded) for its panel, +rows and close button, immediately and without losing the open popover. + +Every Overview card title is now set at 12px in sentence case: counters, +Process, RAM cache, backend tiles, charts and diagnostics use one proportional +heading font. Secondary labels and small numeric readings use consistent +monospace, while established HTTP/RAM/H1/H2 abbreviations retain their case. +This Overview-only refinement does not change the uppercase styling of chart +headings elsewhere in the interface. + +The Cache page separates **Domains and effectiveness** from **Stored objects** +with a consistent 10px gap. Vhosts upstream H1/H2 protocol badges now open +the same anchored click-to-open popover used by the backend quick-view. It +shows protocol observations and H1/H2 totals without a native hover tooltip; +live status updates change its contents in place, without closing it. + +Chart legends use the same 9px monospaced size as **Requests per second** +throughout the interface, including numeric values and Overview density modes. +In the Vhosts list, click the **Backends** count or **Status** metric to show a +compact per-origin overview (ID, configured address and port, HTTP/HTTPS and +HTTP version policy, health and last HTTP status from a real proxied response). +Missing HTTP responses remain explicitly unknown; the configured hostname is +shown rather than inventing a DNS-resolved IP. No additional probe or request +is made to open the panel. Click outside or press Escape to dismiss. +The H1/H2 policy badges use that same click-to-open popover instead of a +transient hover tooltip. Its text and response counters update in place on +live status ticks. Clickable Backend and Health tiles retain the square shape +of adjacent Vhost metrics at every corner-rounding setting. Cache's two +adjacent domain/object tables have a 10px gap. + +- **Dashboard Presets** replaces the former Arrange Overview modal. Save up to + 16 named layouts per administrator, including widget order, visibility, card + widths, density and Overview history sample count. Load, overwrite, rename, + delete or restore factory defaults from one dialog. **Unlock layout** remains + the interactive editor; existing saved preferences are retained. +- Presets are persisted in a separate SQLite `dashboard_presets` table (schema + 10), outside the 4096-byte administrator appearance document. They do not + change the active proxy configuration revision. A factory reset changes the + current dashboard, not existing named presets. +- Chart headings throughout Overview, Web Server, diagnostics, Cache, + Transform, Traffic, Vhosts and Streams use the established proportional font + at **12px** (Overview sentence case, other sections uppercase). Chart labels, values and + entire legends (including Compression activity series names) use the existing + monospaced font. Chart behavior is unchanged; two Overview titles now + use sentence-case English/Polish translations. + Proxy forwarding, trusted-client IP headers and WAF behavior were not + modified. See [API](docs/API.md) and [verification](docs/TEST_REPORT.md). + Rust compilation and live server acceptance remain outstanding. ## 0.33.5: fixed CPU chart scale on Overview diff --git a/ROADMAP.md b/ROADMAP.md index 3e1cb7f..94c59c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,34 @@ # RustProxy roadmap +## 0.33.7 - named dashboard presets, focused HTML extraction and chart typography + +- [x] Apply uniform 12px sentence-case labels to all Overview widgets, + including Process, RAM cache and backend tiles. Normalize secondary + captions and small values to monospace without changing other panels. +- [x] Space the two Cache table cards by 10px without altering other sections. +- [x] Reuse the backend quick-view popover for clickable Vhost upstream H1/H2 + protocol details; keep it stable during repeated live status refreshes. +- [x] Keep the clickable Backend and Health fields square, visually matching + adjacent Vhost tiles under every interface corner style. +- [x] Standardize the full legend text (including every value) at 9px monospace, + matching Requests per second across desktop, mobile and density modes. +- [x] Add a compact, nonmodal Vhost backend quick-view through both backend + count and health metric; use existing configuration and status fields, with + accurate unknown/last HTTP status handling and accessible dismissal. + +- [x] Introduce account-scoped, bounded SQLite storage for 16 named Overview + layout snapshots without changing proxy configuration or existing preferences. +- [x] Replace Arrange Overview modal with save/load/overwrite/rename/delete, + and factory reset; keep Unlock layout editing intact. +- [x] Extract metrics and charts only into compile-time embedded HTML partials + without changing existing card DOM order, IDs or polling. +- [x] Standardize chart headings on 12px proportional type (Overview in + sentence case, all other panels uppercase); use mono for full legends, + captions and chart data across all chart families. +- [x] Validate SQL migration and run available simulated-browser/JS tests. +- [ ] Build/test Rust and run the new endpoint/UI against a real server with + representative persisted data. The production acceptance gate remains open. + ## 0.33.6 - Stable dashboard chart ranges and plot heights - [x] Fix all 14 Overview/WWW full-width SVG heights to 125px and align their diff --git a/SHA256SUMS b/SHA256SUMS index e141187..278e20e 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 +91edfded868ff72e97fbb34d08e1be2df5d5b8fa6582b534a8e4d109cd312acd CHANGELOG.md +d5dc2b778af98f52ad52a7515e2d1b4ba8c2e01a04cc6e02564377075b01c501 Cargo.lock +7fd5dd1ccae8fac77bbbea4bbf960f5cf762f712ff17a2e629a5dd8629eb7ff9 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -dfe12a6a0d360e0febec982f30098123a8974007c7c3fba39509be5c45d3ebbd README.md -65340bb1f93da256043cb6bd75b6c9de8539e31f0417f24c6f05093a27f8319f ROADMAP.md +30708454b96c0dd57c9c621050ace58b92dbdbd85591c7b8199ebb54937dc372 README.md +e796ab6e379b3d71cf5d77ca3eb30bc6a320080af0c9e35f07c82737e09cbc2e ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md @@ -27,7 +27,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md -627f1d8727321ef8961b153aabab7448f239fa383177bb9ef115cfeef48452da docs/API.md +e21a5c3cea78386fcb3ebd6516a66eed2ecd1966481ae0d3b2f57fa01010542e docs/API.md f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md d30551a52ffd34c5963094d2826ba39795210a4e6a4e4d7333066dae7456fc50 docs/ARCHITECTURE.md fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md @@ -64,11 +64,11 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY. 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 4dcf3243a13d9ae1827b5cefdbb9e27bd6310321c50d4d5934527d44437df6ef docs/SETTINGS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md -72baf37ed0989de65123eba73f83d8c31cba7415382dd00e639d8ca412f0ef0d docs/TEST_REPORT.md +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md -fd1c187ea8eef101764670d4ebb0c554173412514e37b909c81ea3f22ba8e7ee docs/VHOSTS.md +711c69c9e05761ba5cb96dadc3c368eb2961e2d2287ca039eb49c7fbca0dd63c docs/VHOSTS.md 09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md 4257729a4c1b1f3949538410af78a12067bd3417c70525a15bd01411155d25fd docs/WEB_SERVER.md c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md @@ -96,8 +96,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md -7783e629ca4b2c1614e776838bf90f3692d521e50ee580aa5e92236c92ada844 langs/en_US.json -58f2fdaf0aa9d3a3ce25d47659501a8b3df622e0c08eee0a4baaad4d5f1ac660 langs/pl_pl.json +1a69ed38b730c3b379b8b14b5707d8ae923320a0c66d084c7ee478e6a01eb53c langs/en_US.json +6513435292eb947935f50eeadf9ef2ee0995f4b57e989699dfc9cda686f21685 langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -107,6 +107,7 @@ faa7e50922bf180c363f7627fbcfe8079831e36282220d17481cf69bc64b57f1 migrations/000 f093aef9639a959a2522946995a02d2e67c464e794c3ef665261a9a5b348c968 migrations/0007_secret_store.sql 90f6dce865496e018c047133d3bc1721fc2a19b2f4ced22e2a3625d9e9ff18ac migrations/0008_config_history.sql fc7585fdcbe99fe3496f0e62f4c4e2161f4a0173222ba4950331aec6c44e50f8 migrations/0009_certificate_api_grants.sql +a8b1e5ec3236a0c2681dfa1746c526eeef77e8bcb5ea8634d954ac398b4642bb migrations/0010_dashboard_presets.sql 478220552ffeea3de00e2866d427d9f87f4cfc4ae5232e5c3a774e62425b1e0c nagios/README.md 91233910aa4dab07ae917fef93d018a7a0beaab697f9a0bb889ac613e595cca4 nagios/check_rustproxy.py a42e01b341725c9da0928edf40c8c3b9c814ef90592f208c52b1f2c56a2ad383 nagios/check_rustproxy_api @@ -123,31 +124,40 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py -63ed91f4cd77450cfe04f4bdb6e5f866b7868afa10207f78041e6fcd86d96fb0 scripts/preflight.py +b5b3135449f6be4e97088b76ef7cf2428e2de3c7944c9cbbcb1edd2016c0fd4a scripts/preflight.py 3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py 174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py -cc7026ec9c5b69850a815816df093de0ffdf4aeadabb3af702783483122ede55 scripts/tests/dashboard_chart_headers_0335_browser_test.py -aa3ca7419d6d7d506c5aaf4d624d20cc7e8ee66bec3cb0cf9b5e7d164bcbab57 scripts/tests/dashboard_chart_width_0333_browser_test.py -fdcea6dd1e3bc160abe1139bf94b45c9a28f6aaf7afbe7e38fb452ee4892b8f1 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py -52eacb467df123dc4a35f14933e006b090c9238df1b14dcff856a16b37c8063f scripts/tests/dashboard_layout_0331_browser_test.py -276e732fc2dfb0d39badb159e4fdcba0d7d73509c60ad5c9df49621c03046f1b scripts/tests/dashboard_metric_visuals_ui_test.mjs -61a0fb2c4de86f25debec9662aedd923bce8c2ccd51e75593d485b2e0c9b8551 scripts/tests/dashboard_modal_0333_browser_test.py -0c8a566979b51cd62601d138697ccd5c4ed47b8fa33e8faa28fa3a085a72ad97 scripts/tests/dashboard_resizing_0333_browser_test.py -f82d57d3c01858eb60aa6882f7d3470e6587d7fc60e7ee6dbd8c5bec1f223c66 scripts/tests/dashboard_resume_rate_charts_ui_test.mjs -aa12d00acdd226602b3efc90221fbc936f69f73bcabbaa6c01dfb0e05d08c505 scripts/tests/overview_samples_0330_ui_test.mjs -0ec69b7811db3dcd51cba60387900ca8df50eb836b22cc2a3ecfcc22b40a2edd scripts/tests/progressbar_0332_browser_test.py -222077dab4d4928ae9110a9f2d64c07cc8386ea14099819b43304bbd2364e5b5 scripts/tests/sidebar_nav_0333_browser_test.py -d5f675b97a85220043a9475347412972db904e559d863ce5058aa18f649f9e16 scripts/tests/system_ram_meter_0332_browser_test.py +efb7a1fde2c74f30cd4a1abb88ae07c1cac7378bb33685dd09d52bb30849daac scripts/tests/dashboard_chart_headers_0335_browser_test.py +eb2c9e5c968938b6fbe4675b547ba1f537519878b9095e1fea133f4d83f96fc8 scripts/tests/dashboard_chart_typography_0337_browser_test.py +6a908fd434d2a8a80adfd06a66d4b83c4174e448d453a8c10b1812475d036677 scripts/tests/dashboard_chart_width_0333_browser_test.py +252b401c29e2db50e105d7fb044bcae14752748261a67da1a4025d71bfa2608c scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py +41a2b3765006c138324ae1312f5c1edfed6ce48051230f85779e8d2aad34d2a3 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py +de583adde771e0d6f64ea0d5aed45b1c942a7c78b7193598eeeb77fff00c2803 scripts/tests/dashboard_layout_0331_browser_test.py +1c813c78dfe96ba4d2c9470c93dd24e81be06e6485ebdc39cbfa30f8d32aa0fe scripts/tests/dashboard_metric_visuals_ui_test.mjs +f14626c5ec006477062b496bdc0025caa3366c7037ee598e15cd55e29041d58f scripts/tests/dashboard_modal_0333_browser_test.py +f934f5ce66105a1394b93761a6081a51133837ac038f5faf67a236f676b15c1b scripts/tests/dashboard_overview_labels_0337_browser_test.py +df87420f16c418aa17a8ba8808e506095681446d32a7d76ae0244222ed37928a scripts/tests/dashboard_presets_sql_test.py +df1457797241aab114a5671ce3f0229815dd29d06ab6f6254c0a1048576af6e1 scripts/tests/dashboard_resizing_0333_browser_test.py +149910e6e8d569443a724fd21701e2fd8f6b5e360991d4f710f5d21364402bce scripts/tests/dashboard_resume_rate_charts_ui_test.mjs +3868dc7b241f1cf15bc297104e29449ec3e35f75cb66078439bc4b5c4ee11040 scripts/tests/dashboard_stable_scales_0336_browser_test.py +fa42ec0475e4398d647b3c094309b4332cd2b5649db223cae0b8e821d6b44a28 scripts/tests/dashboard_title_color_0337_browser_test.py +3c1b2bda6410d3348b0f5a05c70494cca9636339bed8217fc4abc0a30981faae scripts/tests/overview_samples_0330_ui_test.mjs +72185f59f31bf04a4e88d53fe2fca52509f3663cb5f40b1d3bc319a5f945ef23 scripts/tests/panel_html.mjs +b0136544af717b75df2cb107900ea65c3f945fdd0040369dbb93919008fb954c scripts/tests/panel_html.py +8d1903e2b17b3d59cd0a5bec3e298e468b3d88ed2dda8b724239cc45d8353c59 scripts/tests/progressbar_0332_browser_test.py +1bb99b148d0297235b707f16865ca077329246fc19ecbb20a143bdeb56a23f37 scripts/tests/sidebar_nav_0333_browser_test.py +12f2b18b0ed2171c49eb8cde2c2b88fc627054c2f574c04bd9847821b00cd4c1 scripts/tests/system_ram_meter_0332_browser_test.py 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/tests/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/tests/test_stream_udp.py -2da8b8d56b6e53b061664c352df0e7a351602c4e460e5f35ce61b738467a5d23 scripts/tests/web_observability_0330_browser_test.py -fc982558e5c27281d71c2b3cbc926c415ab9134cf595e7ac12b7214f8a180447 scripts/tests/web_observability_0330_source_test.py -651330e4bd38d1f2c45d304e5f7461848f414e80de5089358775af4e5eba5b97 scripts/tests/web_server_0330_ui_test.py -17839b4048e055c55b15c46f95e6745d8d87bde17ea0d04e557a85411526e83e scripts/tests/web_server_0334_browser_test.py +f0a2e3daa0a49aac54723cb5a281360e49a97c076281372c5cca2a85e096c02c scripts/tests/vhost_backend_popup_0337_browser_test.py +23cb1f790cd5cee1b76ecd85f6a2a44ef2e16b0840bf06beaa2ae13048063b2f scripts/tests/web_observability_0330_browser_test.py +3babdd0fd6e2998755ae88615112f6ea056f5c808245abb6de60fbda555594d1 scripts/tests/web_observability_0330_source_test.py +f55efa99245bbdbc98fe3202e52952d224ac3200612561580d52f6e251810049 scripts/tests/web_server_0330_ui_test.py +0dd1cef06a1c3e1546e2fb9b085bf369dfbed02107b9ab01b4fb4f3b894099c0 scripts/tests/web_server_0334_browser_test.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py 55485a513e8c4da2a58daa7abcb15b32ed408a5fc062cf23599fb83d16b90cfe src/acl.rs 63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs @@ -165,6 +175,7 @@ e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs 019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs +b5f6d49f1add583cfc8cd4aa36ffe4db56dac4de3b151fe8239fe42f507019cc src/admin/dashboard_presets.rs 5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs 958546c43c05bf4bed8121b24db3b3096f9243f3c56a71e1762592b755684013 src/admin/documentation/documents.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs @@ -173,34 +184,36 @@ ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs 74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs -33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs -6884210022056d7f2a92160b6d37026e420483c2285368b7ae3c01b1c981df8b src/admin/openapi.json +e552c4b565e3198cc2224d1efa3bef21db02a9872cb80cd96f446181035f19fb src/admin/mod.rs +28dd33ef19d7eab08cfa243b253785012d6c731bc4c56b4f69f28edd3d9e07a7 src/admin/openapi.json d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs 92b3e05f04711d8001b56816405859631d9e917868e15be965c7e76ef4ddef99 src/admin/preferences.rs 4a5cb4c3a3aa8b9a519020854506ce4be5e688a6c4298260322ae641dded7fe9 src/admin/preferences_columns.rs 9a3f13e2477769ae680c03d63336880c3b7b7d5aba4a91e44390a976608b9b1d src/admin/preferences_tests.rs -055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs +4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs -6d12bb97819ff05773d017d6b73bd6659dc9f2345f49d449fbaf9e34b936aab8 src/admin/router.rs +daef5e663c1bc99a22b3b8fa4481ae5e4916b91db132c104bc050f51b1fdf77f src/admin/router.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs 77fe317c15bf2e03301b22e0552518fe2d7e1ad8d80afaba4e16cd2bf8ddf10c src/admin/status.rs 4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -18b778c2c4e723e2fe5cbaaef20effa6ea50fcc35d507ebde2361c9b6eda62b2 src/admin/tests.rs +3d9dcf96d8f05f1f03ad41b6f53d2b743ce6f9fadd608745d04fced0efabb9af src/admin/tests.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js +a085ee336e6669229f3ad2f74536f19bd088412ca79bcaff30dff293f1e1baf9 src/admin/web/app/chart-scales.js d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js 8aa205bc88530e4c4678f84ea324c6ecbc823b4ed681624102ff177f3867bc98 src/admin/web/app/core.js -f3b566d79ba47848021a74653dbd7fec72f0fb819c6b2a07259e047bc8c427fd src/admin/web/app/dashboard-inline-layout.js +afe4e0707fdcdfbce9134f3998e21ef05066da7cb29fe22136a67d288d5a92d6 src/admin/web/app/dashboard-inline-layout.js 851a4337127f332db91f6954f689363ee227fcddde2dda82e55f645940d0ce3b src/admin/web/app/dashboard-layout-editor.js -2f1669cbeb9f49aef43a548e2096825aa14c64c7097ffad520822bbbaefb2b4c src/admin/web/app/overview-extra-metrics.js +a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/app/dashboard-presets.js +bfb3fbac62f9ff762a298d9c06808ae81ae55de7a41e0bb35ce5c167f333ffe2 src/admin/web/app/overview-extra-metrics.js 5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.js -41d9d872ba2f8e1871564fa3f72e88bcb8f118c0ddd5255f256ac7230de945b6 src/admin/web/app/overview-wide-metrics.js +d997f62e8bd7ac9d4f16bb302800c98ad943afab663bb0b212ea26c35b58de11 src/admin/web/app/overview-wide-metrics.js 5bde5f0881320464eb133cd44de9eff5211e6816e79f7f7e9574b16784fab126 src/admin/web/app/preferences.js 8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js 43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js @@ -208,8 +221,8 @@ daa20d634f921191cb4ac37889504f203ab39ad7e8fd8f33e1b182fc4206c24d src/admin/web/ 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js 8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js -655db0b4d5e7ee14dbe69fa22bdb9d99eecc32abcf3b1b76a5819a8015f72309 src/admin/web/app/web-overview.js -0603dc8793a731a0e582cbbec53aa6d1cc6e007faafcec5d8843730b2cdc39e1 src/admin/web/cache-panel.js +7f89bfae66e2a08a05a112733864e908f036bb1ee3d8b5b9ff23783b870acd96 src/admin/web/app/web-overview.js +bb94697a1cbeccaf80069dd47ab84efe14b052d81c472f8309478bbeb4e05f06 src/admin/web/cache-panel.js 02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js aec5755244242424e5091ff57c0258d2618588618fe1d52d36f081d03879f686 src/admin/web/dashboard-defaults.json 797e2935a34b974cb0e07a88056c31e6948cc11385bf2a076d902a332882d0a0 src/admin/web/dashboard-widgets.json @@ -234,25 +247,27 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js -0ebfde7081385aa7158f3b0f8f2b9355836e087c3d91a7c98d5187afbd47abd0 src/admin/web/index.html +6684e7de2540499f9129b78c790f9a820b0e6c5de33e3b60c318d9a1afc931a5 src/admin/web/index.html 03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js 097fdb41aa1cd526c4d9bc65e502b05a62e664ef9fff8e69f706b93b0b124dc2 src/admin/web/management.js +4481a592fcce9a2aa1db5e4a25619e19fd459c39039b86c0549dfc427eec1b45 src/admin/web/partials/overview-charts.html +c1b831617bf3cd03dfa416b43791777c8e71f0711c78f9d467fd0f1930ca2a1e src/admin/web/partials/overview-metrics.html 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js -cc0fdbed4a344487fef7593375177043ee4524b98f58842f37a97e2d2a414183 src/admin/web/style.css +0109870251482b02f5adc63a8359a790bd793e21c827951e9b39a4fcd5fc9451 src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js -69f412bd897e45f4889b71e9982cfff56df9ef20ba2da109b454713a5ecd7c74 src/admin/web/transform-panel.js +dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js 8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js 2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js -b991e81a98dd604a1d7c30158c0cd8cd825479bd2cd94b082b3550de1071bb58 src/admin/web/vhosts/render.js +bb93e2017f40e3a45ff7f4ca92e4188bb464f432704b6d2c39da9d9621b7984c src/admin/web/vhosts/render.js 4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js 0049e5c2a35e3ec25f3c1590879684a7b15165584e96eb1b022fb60d3c548f2c src/admin/web/web-server.js 53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs @@ -323,8 +338,9 @@ a19fc675720c7c2dbf4bba706e1b1c30c83c6df1da3f29e3cbb3b82442e7246a src/config/mod f66c2388f31b19142c12469df23d43451e0f7c48b158d4996920282c06d32076 src/config/routing.rs 818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs 76cbb4f1339de9f13d441561a3c4ee6bc5ff06e90ff0f019b7f0239c74f9542d src/config/tests.rs -c6db84438878accb8a0c96be13fa01a83da824af39c929dc3f6df498f393b7e0 src/db.rs +23541660151d9005555a07db6d491455d61b185f0cc70c3ec3a0bee2aef08dc3 src/db.rs 30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs +20ba52d3755c359d670968efa53b0604ff7cbed63a416f9f765b50a1f45074ac src/db/dashboard_presets.rs 2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs 19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs diff --git a/docs/API.md b/docs/API.md index 7e991cf..fa0afa7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -76,6 +76,37 @@ rejection, 409 for already-completed setup, 422 for password policy and 429 for throttling/busy workers. Error objects usually contain an `error` code/string; framework extraction failures are not guaranteed to have that JSON shape. +## Administrator Overview layout presets (0.33.7) + +The dashboard preset endpoints are private control-plane routes and use the +existing administrator session cookie. The same Origin, request-marker and +CSRF rules as other settings writes apply. They do **not** change the proxy +configuration revision and cannot be called from public vhosts. + +| Method / path | Request or result | +|---|---| +| `GET /api/dashboard-presets` | Array of named layouts for the authenticated administrator | +| `PUT /api/dashboard-presets/{name}` | Create/overwrite a named layout; JSON body described below; returns 204 | +| `PATCH /api/dashboard-presets/{name}` | Rename using `{ "name": "new name" }`; returns 204 | +| `DELETE /api/dashboard-presets/{name}` | Delete the named layout; returns 204 | + +A layout is `{ "dashboard_widgets": ["..."], "dashboard_hidden_widgets": ["..."], +"dashboard_widget_sizes": null, "dashboard_density": "compact", +"overview_samples": 600 }`, using registered widget IDs and the same +width/density validation as administrator view preferences. History accepts +60, 120, 300 or 600 samples. For sizes, `null` preserves legacy grouped +placement; an object maps widget IDs to valid 12-column spans. The account +may store at most 16 named layouts; names are 1-64 UTF-8 bytes with no +control characters, surrounding whitespace or slash. Invalid values receive +422; full capacity or a conflicting rename receives 409. Unknown delete +returns 404. All write endpoints require a session-bound CSRF token. + +The data resides in SQLite schema 10 (`dashboard_presets`), separate from +`admin_preferences`; the factory preset is not stored as a mutable row. +The browser saves the chosen history sample count using its existing local +preference when loading a preset. The client does not use JSON configuration +files to store named presets. + ## Configuration and observations | Method / path | Result | @@ -107,7 +138,7 @@ and rechecks session validity. It does not queue an unlimited per-viewer event l or guarantee replay. The browser falls back to compact status/log polling only after SSE fails and stops polling after its recovery. Invalid SSE intervals are rejected with 422. An active dashboard renews idle activity, not the absolute session deadline. -Configuration history, introduced in SQLite schema 8 and retained in current schema 9, keeps the newest 512 successful manual/configuration revisions. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121. +Configuration history, introduced in SQLite schema 8 and retained in current schema 10, keeps the newest 512 successful manual/configuration revisions. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121. Configuration updates return 409 for a stale revision, 422 for validation failure and 429 when the eight-operation update admission bound is reached. Persistence and diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 5d85606..e69de29 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,44 +0,0 @@ -# RustProxy 0.33.6 - verification report - -## Scope (2026-10-15) - -Additional 0.33.6 chart/UI changes: -- All 14 full-width Overview and Web Server plots retain a fixed 125px SVG - height, regardless of viewport width and dashboard density. -- Grid lines are mapped to the actual plotting range rather than approximate - horizontal positions. -- Transform and Compression activity (plus other unbounded unit series) use - sane zero-based minimum ranges and monotonic, rounded range expansion, so - low values do not look like maximum utilization and spikes remain visible. -- Full Cache and Transform live charts keep an 180px plot height and the same - stable per-second range policy. Compact percent sparklines stay 0-100%. - -Previous 0.33.5 regression scope: - -Correct the Overview **System and process CPU** widget: -- Use a fixed 0-100% vertical range, not the measured-history peak. -- Map 25%, 50% and 75% to the horizontal grid lines. -- Clip plotted CPU values above 100% at the top without modifying their measured values in the legend or summary. -- Fix only this plot's CSS height at 125px, independent of viewport width, dashboard density and CPU samples. -- Leave other chart scales, plot dimensions, status sampling and dashboard preferences unchanged. - -## Checks completed - -- **PASS:** `python scripts/tests/dashboard_stable_scales_0336_browser_test.py` - with real bundled HTML/CSS/JS in simulated Chromium at 320, 375, 768, - 1440 and 1920px and all three density modes. Covered 14 large plots, - 125/180px plot heights, quarter-grid alignment, 0-100% CPU mapping, - steady low/high traffic plotting, minimum count/rate/latency/memory scales, - sticky expanded scales, reset and non-clipped live operation-rate labels. - -- **PASS:** `python scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py` against the actual bundled HTML/CSS/JS in simulated Chromium, at 320, 375, 768, 1440 and 1920px, in normal/compact/ultra densities. Checked low and high CPU histories, missing samples, new status revisions, clipping above 100%, actual percentage labels, consistent grid positions, fixed plot height and no sample-dependent changes to the CPU/adjacent Connection Activity cards' heights. -- **PASS:** All 11 prior `scripts/tests/*browser_test.py` simulated-browser regressions (including the 0.33.5 CPU test) and the new 0.33.6 browser regression: chart headers, resized widths, dashboard grid/modal resizing, sidebar, progress bars, System RAM, Web observability and Web Server metrics. No live RustProxy server was used. -- **PASS:** `node scripts/tests/overview_samples_0330_ui_test.mjs`, `node scripts/tests/dashboard_resume_rate_charts_ui_test.mjs` and `node scripts/tests/dashboard_metric_visuals_ui_test.mjs`. -- **PASS:** `./dev.sh doctor` and `python scripts/preflight.py` (source layout). -- **PASS:** All eight `migrations/*.sql` applied successfully in two successive cycles to a disposable, in-memory SQLite database. This verifies SQL script execution/idempotency in this context, not an upgrade of production data. -- **PASS:** Parsed `scripts/deploy/upgrade.py --help` and `scripts/migration/migrate_npm.py --help`; did not run a live upgrade or migration against an external database or service. - -## Not verified in this environment - -- `./dev.sh check` and `./dev.sh test` both exit 127: Cargo/rustc are unavailable. Therefore no Rust compilation, unit/integration suite, actual proxy runtime, real SSE feed, production database upgrade or live load test has been verified. -- The real-runtime acceptance gate remains open until Rust builds and runs against representative test traffic. diff --git a/docs/VHOSTS.md b/docs/VHOSTS.md index bf5e75f..117279e 100644 --- a/docs/VHOSTS.md +++ b/docs/VHOSTS.md @@ -6,6 +6,17 @@ This is the source implementation contract. Rust compilation and runtime acceptance were not performed in the packaging environment; see TEST_REPORT.md. +## Backend quick-view in Vhosts + +Click the **Backends** count or the **Status** tile of a Vhost to open its +backend list. Each member displays the configured ID, origin host/IP and port, +HTTP/HTTPS scheme and HTTP mode, current reported health and `last_status`. +`last_status` is the most recent HTTP code from a proxied request; zero/no +member means no known HTTP response, not a failed probe. DNS origins show the +configured hostname, as the status API does not report the resolved IP. +The panel updates from the existing status feed without initiating requests, +and supports Escape and click-outside dismissal. + ## One vhost, one backend pool A `vhosts` entry owns an `id`, `enabled`, `frontend`, `source`, `backends`, `load_balancing`, diff --git a/langs/en_US.json b/langs/en_US.json index 0f7e984..68c8093 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -209,7 +209,7 @@ "settingsDescription": "Switch between function tabs. Unsaved changes stay in their forms.", "cacheHitRatio": "Cache hit ratio", "processMemory": "Process memory", - "systemLoad": "SYSTEM LOAD", + "systemLoad": "System load", "systemChart": "Recent system CPU usage", "systemMemory": "RAM used / total", "systemSwap": "System swap", @@ -323,7 +323,6 @@ "vhGenerationRequests": "Gen. requests", "vhGenerationAge": "Gen. age", "vhClientRotations": "Rotations", - "vhIntro": "Each vhost owns its bound IPs, paths and backends. Unlisted addresses cannot reach it. Listener changes require restart.", "vhList": "Virtual hosts", "vhSearch": "Search vhosts", "vhSearchPlaceholder": "Search hostname, vhost or backend", @@ -343,6 +342,14 @@ "vhAddress": "IP:port (IPv6: [address]:port)", "vhTls": "TLS enabled", "vhHttp2": "Prefer HTTP/2 over TLS; retain HTTP/1.1", + "vhBackendPopupOpen": "Show backend details", + "vhBackendPopupTitle": "Backends", + "vhBackendPopupAddress": "IP / host", + "vhBackendPopupProtocol": "Protocol", + "vhBackendPopupLastHttp": "Last HTTP response", + "vhBackendPopupNoResponse": "No HTTP response yet", + "vhBackendPopupEmpty": "No configured backends.", + "vhBackendPopupHint": "The last HTTP response comes from an actual proxied request, not a health probe. DNS names show the configured host, not a resolved IP.", "vhBackendPool": "Backends", "vhAddBackend": "Add backend", "vhMemberId": "Backend ID", @@ -1795,7 +1802,7 @@ "statisticsSource": "Statistics source", "dataDirectory": "Data directory", "databaseLocation": "Database", - "connectionActivity": "CONNECTION ACTIVITY", + "connectionActivity": "Connection activity", "reloadCertificate": "Reload certificate", "certificateApiComment": "Comment", "certificateApiCertificateIds": "Allowed certificate IDs", @@ -2510,7 +2517,7 @@ "backendWidgetNetworkBreakdown": "Connect / DNS / TLS / I/O", "backendWidgetPoolGeneration": "Maximum active pool generation", "backendWidgetPoolAge": "Oldest generation age", - "overviewLayoutEditorOpen": "Arrange Overview", + "overviewLayoutEditorOpen": "Dashboard presets", "overviewLayoutEditorTitle": "Arrange entire Overview", "overviewLayoutEditorHelp": "Move any tile or panel by dragging or using the arrows. Hidden items can be enabled below. Default view previews the standard layout; Reset layout asks for confirmation before saving.", "overviewLayoutEditorVisible": "Visible cards", @@ -2524,7 +2531,7 @@ "overviewLayoutEditorHiddenEmpty": "All cards are visible.", "overviewLayoutEditorMoveBefore": "Move card before", "overviewLayoutEditorSaved": "Dashboard layout saved", - "overviewLayoutEditorOpenSettings": "Visual Overview editor", + "overviewLayoutEditorOpenSettings": "Dashboard presets", "overviewLayoutEditorSearch": "Find hidden card", "overviewLayoutDefaultPreview": "Default view", "overviewLayoutReset": "Reset layout", @@ -2703,10 +2710,33 @@ "webLivePhpCapacity": "PHP capacity", "webLiveKinds": "Static / PHP / other", "webLiveBytes": "Body delivered", - "webLiveNoPhp": "PHP disabled" + "webLiveNoPhp": "PHP disabled", + "dashboardPresetsTitle": "Dashboard presets", + "dashboardPresetsHelp": "Save named arrangements and restore them from any browser. Use Unlock layout to rearrange cards.", + "dashboardPresetsName": "Preset name", + "dashboardPresetsNamePlaceholder": "e.g. Monitoring", + "dashboardPresetsSave": "Save current view", + "dashboardPresetsEmpty": "No presets saved yet.", + "dashboardPresetsRenameHint": "To rename a preset, enter a new name above and click Rename beside it.", + "dashboardPresetsLoad": "Load", + "dashboardPresetsOverwrite": "Overwrite", + "dashboardPresetsRename": "Rename", + "dashboardPresetsDelete": "Delete", + "dashboardPresetsFactory": "Restore factory layout", + "dashboardPresetsResetConfirm": "Restore factory order, visibility, widths, density and history length? Your saved presets will not be deleted.", + "dashboardPresetsDeleteConfirm": "Delete saved preset \"{name}\"?", + "dashboardPresetsOverwriteConfirm": "Overwrite saved preset \"{name}\" with the current view?", + "dashboardPresetsSaved": "Preset saved.", + "dashboardPresetsLoaded": "Preset loaded.", + "dashboardPresetsRenamed": "Preset renamed.", + "dashboardPresetsDeleted": "Preset deleted.", + "dashboardPresetsRestored": "Factory dashboard layout restored.", + "dashboardPresetsNameRequired": "Enter a preset name (up to 64 characters).", + "dashboardPresetsLimit": "You can save up to 16 presets." }, "overviewLayoutNarrow": "Decrease card width", "overviewLayoutWiden": "Increase card width", "overviewLayoutWidth": "Card width in 12-column grid", - "overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert." + "overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert.", + "dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again." } \ No newline at end of file diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 173327f..af4a849 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -209,7 +209,7 @@ "settingsDescription": "Przelaczaj karty funkcji. Niezapisane zmiany pozostaja w formularzach.", "cacheHitRatio": "Trafienia cache", "processMemory": "Pamięć procesu", - "systemLoad": "OBCIĄŻENIE SYSTEMU", + "systemLoad": "Obciążenie systemu", "systemChart": "Ostatnie próbki obciążenia CPU", "systemMemory": "RAM użyta / łącznie", "systemSwap": "Swap systemu", @@ -323,7 +323,6 @@ "vhGenerationRequests": "Żądania gen.", "vhGenerationAge": "Wiek gen.", "vhClientRotations": "Rotacje", - "vhIntro": "Każdy vhost ma własne adresy IP, ścieżki i backendy. Inne adresy go nie obsługują. Zmiana nasłuchu wymaga restartu.", "vhList": "Hosty wirtualne", "vhSearch": "Szukaj vhostów", "vhSearchPlaceholder": "Szukaj domeny, vhosta lub backendu", @@ -343,6 +342,14 @@ "vhAddress": "IP:port (IPv6: [adres]:port)", "vhTls": "TLS włączony", "vhHttp2": "Preferuj HTTP/2 przez TLS; zachowaj HTTP/1.1", + "vhBackendPopupOpen": "Pokaż szczegóły backendów", + "vhBackendPopupTitle": "Backendy", + "vhBackendPopupAddress": "IP / host", + "vhBackendPopupProtocol": "Protokół", + "vhBackendPopupLastHttp": "Ostatnia odpowiedź HTTP", + "vhBackendPopupNoResponse": "Brak odpowiedzi HTTP", + "vhBackendPopupEmpty": "Brak skonfigurowanych backendów.", + "vhBackendPopupHint": "Ostatni kod HTTP pochodzi z rzeczywistego żądania proxy, a nie sondy. Dla DNS wyświetlany jest skonfigurowany host, nie rozwiązany adres IP.", "vhBackendPool": "Backendy", "vhAddBackend": "Dodaj backend", "vhMemberId": "Identyfikator backendu", @@ -1795,7 +1802,7 @@ "statisticsSource": "Źródło statystyk", "dataDirectory": "Katalog danych", "databaseLocation": "Baza danych", - "connectionActivity": "AKTYWNOŚĆ POŁĄCZEŃ", + "connectionActivity": "Aktywność połączeń", "reloadCertificate": "Przeładuj certyfikat", "certificateApiComment": "Komentarz", "certificateApiCertificateIds": "Dozwolone ID certyfikatów", @@ -2510,7 +2517,7 @@ "backendWidgetNetworkBreakdown": "TCP / DNS / TLS / I/O", "backendWidgetPoolGeneration": "Najwyższa generacja puli", "backendWidgetPoolAge": "Najstarsza generacja", - "overviewLayoutEditorOpen": "Ułóż Dashboard", + "overviewLayoutEditorOpen": "Presety dashboardu", "overviewLayoutEditorTitle": "Ułóż cały Dashboard", "overviewLayoutEditorHelp": "Przeciągaj kafelki i panele lub używaj strzałek. Ukryte elementy włączysz poniżej. Widok domyślny pokazuje standardowy układ, a Resetuj układ prosi o potwierdzenie przed zapisem.", "overviewLayoutEditorVisible": "Widoczne kafelki", @@ -2524,7 +2531,7 @@ "overviewLayoutEditorHiddenEmpty": "Wszystkie kafelki są widoczne.", "overviewLayoutEditorMoveBefore": "Przesuń kafelek przed", "overviewLayoutEditorSaved": "Układ Dashboardu zapisany", - "overviewLayoutEditorOpenSettings": "Wizualny edytor Overview", + "overviewLayoutEditorOpenSettings": "Presety dashboardu", "overviewLayoutEditorSearch": "Znajdź ukryty kafelek", "overviewLayoutDefaultPreview": "Widok domyślny", "overviewLayoutReset": "Resetuj układ", @@ -2703,10 +2710,33 @@ "webLivePhpCapacity": "Limit PHP", "webLiveKinds": "Statyczne / PHP / inne", "webLiveBytes": "Wysłana treść", - "webLiveNoPhp": "PHP wyłączone" + "webLiveNoPhp": "PHP wyłączone", + "dashboardPresetsTitle": "Presety dashboardu", + "dashboardPresetsHelp": "Zapisuj układy pod nazwą i wczytuj je w dowolnej przeglądarce. Karty możesz zmieniać przez Odblokuj układ.", + "dashboardPresetsName": "Nazwa presetu", + "dashboardPresetsNamePlaceholder": "np. Monitoring", + "dashboardPresetsSave": "Zapisz bieżący widok", + "dashboardPresetsEmpty": "Brak zapisanych presetów.", + "dashboardPresetsRenameHint": "Aby zmienić nazwę presetu, wpisz nową nazwę powyżej i kliknij Zmień nazwę przy wybranym presecie.", + "dashboardPresetsLoad": "Wczytaj", + "dashboardPresetsOverwrite": "Nadpisz", + "dashboardPresetsRename": "Zmień nazwę", + "dashboardPresetsDelete": "Usuń", + "dashboardPresetsFactory": "Przywróć układ fabryczny", + "dashboardPresetsResetConfirm": "Przywrócić fabryczną kolejność, widoczność, szerokości, gęstość i liczbę próbek? Zapisane presety pozostaną.", + "dashboardPresetsDeleteConfirm": "Usunąć zapisany preset „{name}”?", + "dashboardPresetsOverwriteConfirm": "Nadpisać preset „{name}” bieżącym widokiem?", + "dashboardPresetsSaved": "Preset zapisany.", + "dashboardPresetsLoaded": "Preset wczytany.", + "dashboardPresetsRenamed": "Zmieniono nazwę presetu.", + "dashboardPresetsDeleted": "Preset usunięty.", + "dashboardPresetsRestored": "Przywrócono fabryczny układ dashboardu.", + "dashboardPresetsNameRequired": "Wpisz nazwę presetu (maks. 64 znaki).", + "dashboardPresetsLimit": "Możesz zapisać maksymalnie 16 presetów." }, "overviewLayoutNarrow": "Zmniejsz szerokość karty", "overviewLayoutWiden": "Zwiększ szerokość karty", "overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn", - "overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany." + "overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany.", + "dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie." } \ No newline at end of file diff --git a/migrations/0010_dashboard_presets.sql b/migrations/0010_dashboard_presets.sql new file mode 100644 index 0000000..0ffa73a --- /dev/null +++ b/migrations/0010_dashboard_presets.sql @@ -0,0 +1,10 @@ +-- Named, administrator-scoped Overview presets. Keep this separate from the +-- existing 4096-byte appearance preferences document and proxy configuration. +CREATE TABLE IF NOT EXISTS dashboard_presets ( + administrator_id INTEGER NOT NULL CHECK (administrator_id = 1), + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64), + document TEXT NOT NULL CHECK (length(document) BETWEEN 2 AND 8192), + updated_at INTEGER NOT NULL, + PRIMARY KEY (administrator_id, name), + FOREIGN KEY (administrator_id) REFERENCES administrator(id) ON DELETE CASCADE +); diff --git a/scripts/preflight.py b/scripts/preflight.py index 523f839..34245dc 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -89,6 +89,16 @@ def check(root: Path, repair_known: bool = False) -> list[str]: for doc_path in sorted(embedded_docs): if not (root / doc_path).is_file(): raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration") + # An included Rust source fragment cannot begin with an inner doc comment. + # Catch E0753 here so a source-only release does not ship an unbuildable + # admin module when a compiler is unavailable during packaging. + admin_mod = (root / "src/admin/mod.rs").read_text(encoding="utf-8") + if 'include!("dashboard_presets.rs");' in admin_mod: + fragment = root / "src/admin/dashboard_presets.rs" + if not fragment.is_file(): + raise ValueError(f"Missing included module: {fragment.relative_to(root)}") + if fragment.read_text(encoding="utf-8").lstrip().startswith(("//!", "/*!")): + raise ValueError("src/admin/dashboard_presets.rs starts with an inner doc comment; use /// instead (Rust E0753)") text = (root / "src/lib.rs").read_text(encoding="utf-8") if '#[path = "auth/mod.rs"]' not in text: raise ValueError("src/lib.rs is not the 0.31.9 entry point; extract the complete package, not individual files.") diff --git a/scripts/tests/dashboard_chart_headers_0335_browser_test.py b/scripts/tests/dashboard_chart_headers_0335_browser_test.py index 0a762e5..b824583 100755 --- a/scripts/tests/dashboard_chart_headers_0335_browser_test.py +++ b/scripts/tests/dashboard_chart_headers_0335_browser_test.py @@ -4,12 +4,13 @@ Runs a simulated authenticated DOM rather than a live RustProxy service. """ from pathlib import Path +from panel_html import render_admin_index import json import re from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] -HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') ROUTER = (ROOT / 'src/admin/router.rs').read_text(encoding='utf-8') @@ -76,7 +77,7 @@ with sync_playwright() as p: noTrafficEyebrow: !traffic.querySelector('.small-label'), noTrafficLiveBadge: !traffic.querySelector('.pill.live'), noConnectionLiveBadge: !connection.querySelector('.pill.live'), - connectionTitleIntact: !!connection.querySelector('.card-heading .small-label[data-i18n="connectionActivity"]'), + connectionTitleIntact: !!connection.querySelector('.card-heading h3.overview-chart-heading[data-i18n="connectionActivity"]'), peakInsideCard: peakRect.left >= cardRect.left - 1 && peakRect.right <= cardRect.right + 1, peakInsideHeading: peakRect.top >= headingRect.top - 1 && peakRect.bottom <= headingRect.bottom + 1, browserOverflow: document.documentElement.scrollWidth - innerWidth, diff --git a/scripts/tests/dashboard_chart_typography_0337_browser_test.py b/scripts/tests/dashboard_chart_typography_0337_browser_test.py new file mode 100755 index 0000000..d68a4ad --- /dev/null +++ b/scripts/tests/dashboard_chart_typography_0337_browser_test.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""All chart titles and series labels in simulated Chromium (no live Rust server). + +Covers Overview, diagnostics, Web Server, Traffic, Cache/Transform, Vhosts, +Streams, and DOM-created chart/legend variants. Preserve localized DOM text. +""" +from pathlib import Path +from panel_html import render_admin_index +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] +HTML = render_admin_index(ROOT) +CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') + +CHECK = r'''(settings) => { + const { density, compactBody, theme } = settings; + document.documentElement.dataset.dashboardDensity = density; + document.documentElement.dataset.theme = theme; + document.body.dataset.density = compactBody ? 'compact' : 'comfortable'; + + const titleSelectors = [ + '#panel-overview .overview-wide-chart .card-heading h2', + '#panel-overview .activity-card .card-heading h2', + '#panel-overview :is(.system-card, .connection-card) .overview-chart-heading', + '#panel-overview :is(.stat-chart, .stat-donut) > span:first-child', + '.mini-live-chart > .mini-live-chart-head > span:first-child', + '.traffic-chart-card > h3.subheading', + '.function-live-chart-heading > strong', + '.vhost-chart-head > span:first-child', + ]; + const monoSelectors = [ + '.overview-wide-summary', '.overview-wide-legend', + '.overview-donut-legend', '.www-method-legend', '.stat-chart-legend', + '.mini-chart-legend', '.connection-legend', + '.traffic-protocol-legend', '.traffic-chart-legend', + '.function-live-chart-legend', '.stream-chart-legend', + '.chart-footer', '.vhost-chart-head > :not(:first-child)', + '.mini-live-chart-head > b', '.mini-live-chart-head > .mini-live-chart-actions', + '.network-interface-rate', + '.function-live-chart-summary > b', '.function-live-chart-heading small', + '.function-live-chart-samples', + '#panel-overview .stat-chart > small', + '#panel-overview .overview-wide-chart > .fine', + '#panel-overview .www-top-row', '.traffic-bar-row', + '#panel-overview .system-card .card-heading h2', + '#panel-overview .system-card .card-heading .meta', + '#panel-overview .system-card .memory-line', + '#panel-overview .connection-card .card-heading h2', + ]; + const legendSelectors = [ + '.overview-wide-legend', '.overview-donut-legend', '.www-ring-legend', + '.www-method-legend', '.stat-chart-legend', '.mini-chart-legend', + '.connection-legend', '.traffic-protocol-legend', '.traffic-chart-legend', + '.function-live-chart-legend', '.stream-chart-legend', '.stream-traffic-legend' + ]; + const legendNodes = [...document.querySelectorAll(legendSelectors.join(','))]; + const requestLegend = document.querySelector('[data-dashboard-widget="traffic_chart"] .traffic-protocol-legend'); + const referenceLegendSize = getComputedStyle(requestLegend).fontSize; + const titleNodes = Array.from(document.querySelectorAll(titleSelectors.join(','))); + const monoNodes = Array.from(document.querySelectorAll(monoSelectors.join(','))); + const base = document.createElement('div'); + base.style.fontFamily = 'var(--mono)'; + document.body.append(base); + const mono = getComputedStyle(base).fontFamily; + base.style.fontFamily = 'var(--sans)'; + const sans = getComputedStyle(base).fontFamily; + base.remove(); + + const titleErrors = titleNodes.flatMap(node => { + const css = getComputedStyle(node); + const inOverview = !!node.closest('#panel-overview'); + const isSmallCaption = inOverview && !!node.closest('.mini-live-chart-head'); + const wantedFont = isSmallCaption ? mono : sans; + const wantedSize = isSmallCaption ? '10px' : '12px'; + const wantedCase = inOverview ? 'none' : 'uppercase'; + const problems = []; + if (css.fontFamily !== wantedFont) problems.push(`font=${css.fontFamily}`); + if (css.fontSize !== wantedSize) problems.push(`size=${css.fontSize}`); + if (css.textTransform !== wantedCase) problems.push(`case=${css.textTransform}`); + return problems.map(problem => `${node.outerHTML.slice(0, 100)}: ${problem}`); + }); + const monoErrors = monoNodes.flatMap(node => { + const all = [node, ...node.querySelectorAll('*')].filter(el => el.tagName.toLowerCase() !== 'svg'); + return all.filter(el => getComputedStyle(el).fontFamily !== mono) + .map(el => `${el.outerHTML.slice(0, 100)}: ${getComputedStyle(el).fontFamily}`); + }); + const legendErrors = legendNodes.flatMap(root => { + const children = [root, ...root.querySelectorAll('span, b, strong, small, li, em')]; + return children.flatMap(node => { + const style = getComputedStyle(node); + const issues = []; + if (style.fontSize !== referenceLegendSize) issues.push(`size=${style.fontSize}`); + if (style.fontFamily !== mono) issues.push(`font=${style.fontFamily}`); + return issues.map(issue => `${node.outerHTML.slice(0, 95)} ${issue}`); + }); + }); + // Keep localized Overview titles in sentence case; other sections retain + // their independent uppercase chart-heading convention. + const compression = document.querySelector('[data-dashboard-widget="wide_compression_activity"]'); + const compressionHeading = compression.querySelector('.card-heading h2'); + const compressionLegend = compression.querySelector('.overview-wide-legend'); + const originalTranslation = compressionHeading.textContent.trim() === 'Aktywność kompresji'; + const legendFonts = [...compressionLegend.querySelectorAll('span, b')] + .map(el => getComputedStyle(el).fontFamily); + const unrelatedHeading = document.querySelector('#panel-traffic .card-heading h2'); + const numericReading = document.querySelector('#system-cpu'); + return { + counts: {titles: titleNodes.length, mono: monoNodes.length, legends: legendNodes.length}, + titleErrors, monoErrors, legendErrors, referenceLegendSize, mono, sans, + originalTranslation, compressionTitleSize: getComputedStyle(compressionHeading).fontSize, + compressionTitleCase: getComputedStyle(compressionHeading).textTransform, + compressionLegendIsMono: legendFonts.every(font => font === mono), + unrelatedTitleSize: getComputedStyle(unrelatedHeading).fontSize, + numericReadingIsMono: getComputedStyle(numericReading).fontFamily === mono, + }; +}''' + +with sync_playwright() as api: + browser = api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) + for width in (320, 375, 768, 1440, 1920): + page = browser.new_page(viewport={'width': width, 'height': 960}) + page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '') + .replace('/assets/style___RUSTPROXY_ASSET_ID__.css', '')) + page.add_style_tag(content=CSS) + page.evaluate('''() => { + // Representative dynamically rendered Vhost/Web Service/Stream charts. + for (const panel of ['panel-vhosts','panel-web-server','panel-streams']) { + document.getElementById(panel).insertAdjacentHTML('beforeend', ` +
+ Connection history + 42 req/s
+ +
RX 2 MB/sTX 1 MB/s
+
`); + } + const comp = document.querySelector('[data-dashboard-widget="wide_compression_activity"] .card-heading h2'); + comp.textContent = 'Aktywność kompresji'; + // The traffic renderer inserts these series dynamically. + document.querySelectorAll('.traffic-chart-legend').forEach(root => { + root.innerHTML = `
  • GET requests12
  • `; + }); + const sample = document.createElement('div'); + sample.innerHTML = `
    php-worker11/s
    +
    GET22
    +
    100 Mb/s
    `; + document.getElementById('panel-overview').append(sample); + }''') + for density in ('normal','compact','ultra'): + for compact_body in (False, True): + for theme in ('dark', 'light'): + result = page.evaluate(CHECK, {'density': density, 'compactBody': compact_body, 'theme': theme}) + description = (width, density, compact_body, theme) + assert result['counts']['titles'] >= 43, (description, result['counts']) + assert result['counts']['mono'] >= 90, (description, result['counts']) + assert not result['titleErrors'], (description, result['titleErrors'][:10]) + assert not result['monoErrors'], (description, result['monoErrors'][:10]) + assert result['counts']['legends'] >= 39, (description, result['counts']) + assert result['referenceLegendSize'] == '9px', description + assert not result['legendErrors'], (description, result['legendErrors'][:10]) + assert result['originalTranslation'], description + assert result['compressionTitleSize'] == '12px', description + assert result['compressionTitleCase'] == 'none', description + assert result['compressionLegendIsMono'], description + assert result['numericReadingIsMono'], description + assert result['unrelatedTitleSize'] != '12px', (description, result['unrelatedTitleSize']) + print(f'PASS all chart families at {width}px (3 dashboard densities, 2 interface densities, 2 themes)') + page.close() + browser.close() diff --git a/scripts/tests/dashboard_chart_width_0333_browser_test.py b/scripts/tests/dashboard_chart_width_0333_browser_test.py index 5a15bc8..e34d7d7 100755 --- a/scripts/tests/dashboard_chart_width_0333_browser_test.py +++ b/scripts/tests/dashboard_chart_width_0333_browser_test.py @@ -7,10 +7,11 @@ fake network-interface samples. This does not exercise Rust HTTP/SSE. import json import re from pathlib import Path +from panel_html import render_admin_index from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] -HTML = (ROOT / 'src/admin/web/index.html').read_text() +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text() ROUTER = (ROOT / 'src/admin/router.rs').read_text() diff --git a/scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py b/scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py index a6af4dc..2d0b3c8 100755 --- a/scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py +++ b/scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py @@ -1,16 +1,19 @@ #!/usr/bin/env python3 -"""0.33.5 CPU plot autoscaling regression with production HTML/CSS/JS. +"""Legacy CPU plot regression: low and high real values on a fixed 0-100% axis. -Uses Chromium with simulated authenticated DOM and status samples, not a live -RustProxy server, SSE stream or real CPU telemetry. +Autoscaling was intentionally replaced by 0.33.5 fixed scaling. This test +retains the original history/reset and label checks using the current scale. +Chromium is simulated, not a live RustProxy server. """ from pathlib import Path +from panel_html import render_admin_index import re from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] -HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') +SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8') WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8') @@ -83,6 +86,7 @@ with sync_playwright() as p: window.fmtBytes = value => `${value} B`; window.rustProxyOverviewSamples = {getLimit: () => 60, onChange: () => {}}; }''') + page.add_script_tag(content=SCALE_JS) page.add_script_tag(content=WIDE_JS) for density in ('compact', 'normal', 'ultra'): @@ -97,21 +101,21 @@ with sync_playwright() as p: assert result['svgWidth'] > 100 and abs(result['svgWidth'] - result['drawnWidth']) < 2, result assert result['legendInCard'] and result['overflow'] <= 2, (viewport, density, result) - # Measured 0.5-0.8% must be visible, not flattened by a 100% ceiling. + # Measured 0.5-0.8% stay numerically visible without axis auto-expansion. first = render_cpu(page, 1, 0.5, 0.2) second = render_cpu(page, 1, 0.8, 0.6) assert first['system']['label'] == '0.5%' and second['process']['label'] == '0.6%' - assert ys(second['system']['path']) == [77, 41], (viewport, second) - assert ys(second['process']['path']) == [113, 65], (viewport, second) + assert ys(second['system']['path']) == [136, 136], (viewport, second) + assert ys(second['process']['path']) == [137, 136], (viewport, second) - # The peak follows observed history and also works above 100% (multi-core). + # Multi-core values display accurately but clip at the fixed 100% ceiling. third = render_cpu(page, 1, 3.4, 2.2) - assert ys(third['system']['path'])[-1] == 17, (viewport, third) - assert ys(third['process']['path'])[-1] == 59, (viewport, third) + assert ys(third['system']['path'])[-1] == 133, (viewport, third) + assert ys(third['process']['path'])[-1] == 134, (viewport, third) high = render_cpu(page, 1, 250, 123) assert high['system']['label'] == '250.0%' assert ys(high['system']['path'])[-1] == 17, (viewport, high) - assert 17 <= ys(high['process']['path'])[-1] < 137, (viewport, high) + assert ys(high['process']['path'])[-1] == 17, (viewport, high) # Revision changes reset the history. Zero stays at the plot baseline. idle = render_cpu(page, 2, 0, 0) @@ -119,6 +123,6 @@ with sync_playwright() as p: missing = render_cpu(page, 3, None, None) assert missing['system']['path'] == '' and missing['process']['label'] == '-' assert not errors, (viewport, errors) - print(f'PASS CPU auto Y-scale and standard plot height at {viewport}px (all densities)') + print(f'PASS legacy CPU labels/history on fixed Y-scale at {viewport}px (all densities)') page.close() browser.close() diff --git a/scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py b/scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py index dc735c9..34d2659 100755 --- a/scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py +++ b/scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py @@ -5,11 +5,12 @@ Uses Chromium with simulated authenticated DOM and status samples, not a live RustProxy server, SSE stream or real CPU telemetry. """ from pathlib import Path +from panel_html import render_admin_index import re from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] -HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8') WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8') diff --git a/scripts/tests/dashboard_layout_0331_browser_test.py b/scripts/tests/dashboard_layout_0331_browser_test.py index 6866573..ccdff82 100755 --- a/scripts/tests/dashboard_layout_0331_browser_test.py +++ b/scripts/tests/dashboard_layout_0331_browser_test.py @@ -7,6 +7,7 @@ not a Rust server integration test. import json import re from pathlib import Path +from panel_html import render_admin_index from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] @@ -41,7 +42,7 @@ assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_wi assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600 assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12 -HTML = (ROOT / 'src/admin/web/index.html').read_text() +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text() BUNDLE = app_bundle() assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =') @@ -94,6 +95,8 @@ with sync_playwright() as p: return window.__savedDashboard; }; applyAdminPreferences(window.__savedDashboard); + api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); }; + appConfirm = async () => true; return { order: ADMIN_DASHBOARD_WIDGETS, hidden: ADMIN_DEFAULT_HIDDEN_WIDGETS, @@ -166,13 +169,12 @@ with sync_playwright() as p: window.rustProxyStreamDashboard.render(status); }''') assert_visible(page, ['backend_health'], ['backend_traffic']) - # Restore defaults in the modal after a custom layout was saved. + # Restore factory layout through the new named-presets modal. page.locator('#overview-layout-open').evaluate('(node) => node.hidden = false') page.locator('#overview-layout-open').click() - page.wait_for_function("document.getElementById('overview-layout-dialog').open") - page.locator('#overview-layout-default').click() - page.locator('#overview-layout-save').click() - page.wait_for_function("!document.getElementById('overview-layout-dialog').open") + page.wait_for_function("document.getElementById('overview-presets-dialog').open") + page.locator('#overview-presets-factory').click() + page.wait_for_function("!document.getElementById('overview-presets-dialog').open") assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions', 'www_status']) assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets'] assert page.evaluate('window.__savedDashboard.dashboard_hidden_widgets') == PRESET['dashboard_hidden_widgets'] diff --git a/scripts/tests/dashboard_metric_visuals_ui_test.mjs b/scripts/tests/dashboard_metric_visuals_ui_test.mjs index 0360a82..712975f 100644 --- a/scripts/tests/dashboard_metric_visuals_ui_test.mjs +++ b/scripts/tests/dashboard_metric_visuals_ui_test.mjs @@ -1,3 +1,4 @@ +import { renderAdminIndex } from './panel_html.mjs'; // Deterministic Overview widget regression; simulated DOM/status, no running API. import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -6,7 +7,7 @@ import vm from 'node:vm'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const html = fs.readFileSync(path.join(root, 'src/admin/web/index.html'), 'utf8'); +const html = renderAdminIndex(root); const registry = JSON.parse(fs.readFileSync(path.join(root, 'src/admin/web/dashboard-widgets.json'), 'utf8')); const lang = JSON.parse(fs.readFileSync(path.join(root, 'langs/en_US.json'), 'utf8')).translations; const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8'); diff --git a/scripts/tests/dashboard_modal_0333_browser_test.py b/scripts/tests/dashboard_modal_0333_browser_test.py index fdee4ff..121823c 100755 --- a/scripts/tests/dashboard_modal_0333_browser_test.py +++ b/scripts/tests/dashboard_modal_0333_browser_test.py @@ -1,16 +1,17 @@ #!/usr/bin/env python3 -"""Regression: Overview visual layout editor fits without horizontal overflow. +"""Regression: named Overview presets, responsive modal and browser actions. -Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences. +Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences/API. This is browser layout testing, not Rust server acceptance. """ import json import re from pathlib import Path +from panel_html import render_admin_index from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] -HTML = (ROOT / 'src/admin/web/index.html').read_text() +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text() ROUTER = (ROOT / 'src/admin/router.rs').read_text() PACKS = { @@ -52,7 +53,7 @@ def load(page): defaultLanguage = 'en_US'; language = 'pl_pl'; document.documentElement.lang = 'pl-PL'; - document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n)); + document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n)); }''', PACKS) page.evaluate('''() => { document.documentElement.classList.remove('app-loading', 'app-startup-failed'); @@ -72,98 +73,104 @@ def load(page): applyAdminPreferences(window.__savedDashboard); language = 'pl_pl'; document.documentElement.lang = 'pl-PL'; - document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n)); + document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n)); }''') -def geometry(page): - return page.evaluate('''() => { - const d = document.getElementById('overview-layout-dialog'); - const grid = document.getElementById('overview-layout-visible'); - const rect = node => node.getBoundingClientRect(); - const tiles = Array.from(grid.querySelectorAll('.overview-layout-tile')); - const overflows = []; - for (const tile of tiles) { - const r = rect(tile); - const toolbar = tile.querySelector('.overview-layout-tile-toolbar'); - if (tile.scrollWidth > tile.clientWidth + 2) - overflows.push([tile.dataset.previewWidget, 'tile-scroll', tile.clientWidth, tile.scrollWidth]); - for (const button of toolbar.querySelectorAll('button')) { - const b = rect(button); - if (b.left < r.left - 2 || b.right > r.right + 2) - overflows.push([tile.dataset.previewWidget, 'button-outside', button.className, b.left, b.right, r.left, r.right]); - } - const buttons = Array.from(toolbar.querySelectorAll('button')); - for (let i = 0; i < buttons.length; i++) { - const a = rect(buttons[i]); - for (let j = i + 1; j < buttons.length; j++) { - const b = rect(buttons[j]); - if (a.right - b.left > 1 && b.right - a.left > 1 && a.bottom - b.top > 1 && b.bottom - a.top > 1) - overflows.push([tile.dataset.previewWidget, 'overlapping-buttons', buttons[i].className, buttons[j].className]); - } - } - } - const hiddenList = document.getElementById('overview-layout-hidden'); - for (const row of hiddenList.querySelectorAll('.overview-layout-hidden-item')) { - const r = rect(row), button = row.querySelector('button'); - const b = rect(button); - if (b.right > r.right + 2 || b.left < r.left - 2) - overflows.push(['hidden card', 'button-outside', b.left, b.right, r.left, r.right]); - } - const panelPreview = grid.querySelector('[data-preview-widget="network_diagnostics"]'); - const dialogRect = rect(d); - return { - cols: getComputedStyle(grid).gridTemplateColumns.split(' ').length, - panelWidth: panelPreview ? rect(panelPreview).width : null, - dialogWidth: d.clientWidth, dialogScrollWidth: d.scrollWidth, - gridWidth: grid.clientWidth, gridScrollWidth: grid.scrollWidth, - dialogLeft:dialogRect.left, dialogRight:dialogRect.right, - visibleCount:tiles.length, overflows, - firstTileWidth:rect(tiles[0]).width, - }; - }''') - +# Regression for the replacement named-presets modal, with a simulated SQLite API. with sync_playwright() as p: browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) - for width in (320, 375, 550, 620, 800, 900, 960, 1158, 1440, 1920): - context = browser.new_context(viewport={'width': width, 'height': 823}) + for width in (320, 375, 800, 1440): + context = browser.new_context(viewport={'width': width, 'height': 880}) page = context.new_page() errors = [] - page.on('pageerror', lambda err: errors.append(str(err))) + page.on('pageerror', lambda error: errors.append(str(error))) load(page) + page.evaluate('''() => { + const saved = structuredClone(ADMIN_APPEARANCE_DEFAULTS); + window.__savedDashboard = saved; + window.__presets = new Map(); + window.rustProxyPreferences.load = async () => window.__savedDashboard; + window.rustProxyPreferences.current = () => window.__savedDashboard; + window.rustProxyPreferences.update = async patch => { + window.__savedDashboard = {...window.__savedDashboard, ...patch}; + applyAdminPreferences(window.__savedDashboard); + return window.__savedDashboard; + }; + api = async (path, options={}) => { + const method = options.method || 'GET'; + if (path === '/api/dashboard-presets' && method === 'GET') + return [...window.__presets.entries()].map(([name, data]) => ({ name, ...structuredClone(data) })); + if (path.startsWith('/api/dashboard-presets/')) { + const name = decodeURIComponent(path.split('/').slice(3).join('/')); + if (method === 'PUT') { window.__presets.set(name, JSON.parse(options.body)); return {}; } + if (method === 'DELETE') { window.__presets.delete(name); return {}; } + if (method === 'PATCH') { + const changed = JSON.parse(options.body).name; + if (window.__presets.has(changed)) throw new Error('already exists'); + window.__presets.set(changed, window.__presets.get(name)); + window.__presets.delete(name); + return {}; + } + } + throw new Error('unexpected API: ' + method + ' ' + path); + }; + appConfirm = async () => true; + applyAdminPreferences(window.__savedDashboard); + document.getElementById('overview-layout-open').hidden = false; + }''') page.locator('#overview-layout-open').click() - page.wait_for_function("document.getElementById('overview-layout-dialog').open") - check = geometry(page) - expected = 1 if width <= 620 else 2 if width <= 900 else 3 - assert check['cols'] == expected, (width, check) - assert check['visibleCount'] > 20 - assert check['panelWidth'] is not None - assert abs(check['firstTileWidth'] - check['panelWidth']) < 3, (width, check) - assert check['dialogLeft'] >= -1 and check['dialogRight'] <= width + 1, (width, check) - assert check['dialogScrollWidth'] <= check['dialogWidth'] + 2, (width, check) - assert check['gridScrollWidth'] <= check['gridWidth'] + 2, (width, check) - assert not check['overflows'], (width, check['overflows'][:20]) - tile = '#overview-layout-visible [data-preview-widget="http_protocol"]' - before = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width') - page.locator(tile + ' .overview-layout-widen').click() - assert page.locator(tile).get_attribute('data-dashboard-span') == '3' - after = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width') - assert abs(before - after) < 2, (width, before, after) - assert not geometry(page)['overflows'] - page.locator(tile + ' .overview-layout-narrow').click() - assert page.locator(tile).get_attribute('data-dashboard-span') == '2' - if width == 1158: - page.screenshot(path=str(ROOT.parent / 'modal_preview_1158.png')) - if width == 375: - page.screenshot(path=str(ROOT.parent / 'modal_preview_375.png')) - page.locator(tile + ' .overview-layout-hide').click() - assert page.locator(tile).count() == 0 - assert not geometry(page)['overflows'] - page.locator('#overview-layout-cancel').click() - page.wait_for_function("!document.getElementById('overview-layout-dialog').open") + page.wait_for_function("document.getElementById('overview-presets-dialog').open") + assert page.locator('#overview-presets-list .overview-presets-row').count() == 0 + widths = page.evaluate('''() => { + const d = document.getElementById('overview-presets-dialog'); + const r = d.getBoundingClientRect(); + return {left:r.left,right:r.right,overflow:d.scrollWidth - d.clientWidth}; + }''') + assert widths['left'] >= -1 and widths['right'] <= width+1 and widths['overflow'] <= 2, (width, widths) + # Persist the factory arrangement as a user preset. + page.locator('#overview-presets-name').fill('Daily') + page.locator('#overview-presets-save').click() + page.wait_for_function("window.__presets.has('Daily')") + assert page.locator('#overview-presets-list .overview-presets-row').count() == 1 + assert page.evaluate("window.__presets.get('Daily').overview_samples") == 600 + # Create another view, with customized widths, density and history. + page.evaluate('''async () => { + await window.rustProxyPreferences.update({ + dashboard_hidden_widgets:['requests'], dashboard_widget_sizes:{requests:3}, + dashboard_density:'ultra', + }); + window.rustProxyOverviewSamples.setLimit(120, true); + }''') + page.locator('#overview-presets-name').fill('Detailed') + page.locator('#overview-presets-save').click() + page.wait_for_function("window.__presets.has('Detailed')") + assert page.evaluate('''() => { + const p=window.__presets.get('Detailed'); + return [p.dashboard_widget_sizes.requests,p.dashboard_density,p.overview_samples]; + }''') == [3,'ultra',120] + # Named load must restore full layout and history. + page.locator('.overview-presets-row').filter(has_text='Daily').locator('[data-preset-action="dashboardPresetsLoad"]').click() + page.wait_for_function("!document.getElementById('overview-presets-dialog').open") + assert page.evaluate('''() => [window.__savedDashboard.dashboard_density, + window.__savedDashboard.dashboard_widget_sizes, + window.rustProxyOverviewSamples.getLimit()]''') == ['compact',None,600] + # Rename and delete remain administrator-scoped operations. + page.locator('#overview-layout-open').click() + page.wait_for_function("document.getElementById('overview-presets-dialog').open") + page.locator('#overview-presets-name').fill('Nightly') + page.locator('.overview-presets-row').filter(has_text='Detailed').locator('[data-preset-action="dashboardPresetsRename"]').click() + page.wait_for_function("window.__presets.has('Nightly')") + assert not page.evaluate("window.__presets.has('Detailed')") + page.locator('.overview-presets-row').filter(has_text='Nightly').locator('[data-preset-action="dashboardPresetsDelete"]').click() + page.wait_for_function("!window.__presets.has('Nightly')") + assert page.evaluate("window.__presets.has('Daily')") + page.locator('#overview-presets-factory').click() + page.wait_for_function("!document.getElementById('overview-presets-dialog').open") + assert page.evaluate("window.__savedDashboard.dashboard_widgets") == json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())['dashboard_widgets'] assert not errors, (width, errors) + print('PASS named Dashboard presets save/load/rename/delete/factory and responsive modal:', width) context.close() - print('PASS visual modal grid, no overlap/scroll, width/hide/cancel:', width) browser.close() -print('PASS 0.33.3 modal regression (simulated API)') +print('PASS Overview preset modal (simulated SQLite API; no running Rust server)') diff --git a/scripts/tests/dashboard_overview_labels_0337_browser_test.py b/scripts/tests/dashboard_overview_labels_0337_browser_test.py new file mode 100755 index 0000000..3df398c --- /dev/null +++ b/scripts/tests/dashboard_overview_labels_0337_browser_test.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""All Overview widget labels and small values in simulated Chromium. + +This is a DOM/CSS regression test, not a live RustProxy integration test. +""" +from pathlib import Path +import json +from panel_html import render_admin_index +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] +HTML = render_admin_index(ROOT) +CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') +PACKS = {name: json.loads((ROOT / f'langs/{name}.json').read_text(encoding='utf-8'))['translations'] + for name in ('en_US', 'pl_pl')} + +CHECK = r'''({density, lang, translations}) => { + document.documentElement.dataset.dashboardDensity = density; + document.documentElement.lang = lang === 'pl_pl' ? 'pl-PL' : 'en-US'; + const grid = document.getElementById('dashboard-overview-grid'); + grid.querySelectorAll('[data-i18n]').forEach(node => { + if (Object.hasOwn(translations, node.dataset.i18n)) node.textContent = translations[node.dataset.i18n]; + }); + const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget')); + const font = value => getComputedStyle(value).fontFamily; + const probe = document.createElement('div'); + grid.append(probe); + probe.style.fontFamily = 'var(--sans)'; const sans = font(probe); + probe.style.fontFamily = 'var(--mono)'; const mono = font(probe); + probe.remove(); + + const titleErrors = []; + const titles = []; + const labelErrors = []; + const numericErrors = []; + for (const card of cards) { + let title; + if (card.classList.contains('stat')) title = card.querySelector(':scope > span:first-child'); + else if (card.classList.contains('system-card') || card.classList.contains('connection-card')) + title = card.querySelector('.overview-chart-heading'); + else if (card.querySelector('.mini-heading > h3')) title = card.querySelector('.mini-heading > h3'); + else title = card.querySelector('.card-heading h2'); + if (!title) { titleErrors.push(`${card.dataset.dashboardWidget}: missing title`); continue; } + const style = getComputedStyle(title); + titles.push(title); + if (style.fontFamily !== sans || style.fontSize !== '12px' || style.textTransform !== 'none') + titleErrors.push(`${card.dataset.dashboardWidget}: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`); + if (title.textContent.trim() === title.textContent.trim().toLocaleUpperCase('pl') && + /[a-ząćęłńóśżź]{4}/i.test(title.textContent) && + !/^(?:HIT|MISS|HTTP|CPU|RAM|WWW|RX|TX)/.test(title.textContent.trim())) + titleErrors.push(`${card.dataset.dashboardWidget}: all-uppercase title ${title.textContent.trim()}`); + for (const node of card.querySelectorAll(':scope > small, .mini-metrics dt, .mini-metrics dd, .mini-live-chart-head, .memory-line, .chart-footer, .meta, .small-label')) { + if (font(node) !== mono) labelErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`); + } + const readings = card.classList.contains('stat') + ? [...card.querySelectorAll(':scope > strong'), ...card.querySelectorAll(':scope > small')] + : [...card.querySelectorAll('.mini-metrics dd')]; + for (const node of readings) if (font(node) !== mono) + numericErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`); + } + const nested = [...grid.querySelectorAll('.mini-live-chart > .mini-live-chart-head > span:first-child')]; + for (const node of nested) { + const style = getComputedStyle(node); + if (style.fontFamily !== mono || style.fontSize !== '10px' || style.textTransform !== 'none') + labelErrors.push(`mini-live-chart: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`); + } + const systemNumeric = getComputedStyle(document.querySelector('#system-cpu')); + return { + cardCount: cards.length, titleCount: titles.length, secondaryCount: nested.length, + titleErrors, labelErrors, numericErrors, + systemNumericMono: systemNumeric.fontFamily === mono, + cpuLabel: document.querySelector('[data-dashboard-widget="system_chart"] .overview-chart-heading').textContent, + connectionsLabel: document.querySelector('[data-dashboard-widget="connection_chart"] .overview-chart-heading').textContent, + memoryLabel: document.querySelector('[data-dashboard-widget="memory"] > span:first-child').textContent, + backendLabel: document.querySelector('[data-dashboard-widget="backend_health"] > span:first-child').textContent, + diagnosticsTitle: document.querySelector('[data-dashboard-widget="process_diagnostics"] .mini-heading h3').textContent, + }; +}''' + +with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) + for width in (320, 375, 768, 1440, 1920): + page = browser.new_page(viewport={'width': width, 'height': 900}) + page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '') + .replace('/assets/style___RUSTPROXY_ASSET_ID__.css', '')) + page.add_style_tag(content=CSS) + for density in ('normal', 'compact', 'ultra'): + for lang in ('en_US', 'pl_pl'): + result = page.evaluate(CHECK, {'density': density, 'lang': lang, 'translations': PACKS[lang]}) + descriptor = (width, density, lang) + assert result['cardCount'] == result['titleCount'] >= 75, (descriptor, result) + assert result['secondaryCount'] >= 6, descriptor + assert not result['titleErrors'], (descriptor, result['titleErrors'][:10]) + assert not result['labelErrors'], (descriptor, result['labelErrors'][:10]) + assert not result['numericErrors'], (descriptor, result['numericErrors'][:10]) + assert result['systemNumericMono'], descriptor + expected = ('System load', 'Connection activity') if lang == 'en_US' else ('Obciążenie systemu', 'Aktywność połączeń') + assert (result['cpuLabel'], result['connectionsLabel']) == expected, (descriptor, result) + assert result['memoryLabel'] and result['backendLabel'] and result['diagnosticsTitle'], descriptor + print(f'PASS all Overview titles and small readings at {width}px (3 densities, 2 languages)') + page.close() + browser.close() diff --git a/scripts/tests/dashboard_presets_sql_test.py b/scripts/tests/dashboard_presets_sql_test.py new file mode 100755 index 0000000..9b8827c --- /dev/null +++ b/scripts/tests/dashboard_presets_sql_test.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Offline SQLite regression for additive admin preset migration and 16-slot upsert.""" +from pathlib import Path +import sqlite3 + +ROOT = Path(__file__).resolve().parents[2] +all_scripts = sorted((ROOT / 'migrations').glob('*.sql')) +assert [p.name for p in all_scripts][-1] == '0010_dashboard_presets.sql' +con = sqlite3.connect(':memory:') +con.execute('PRAGMA foreign_keys = ON') +for _ in range(2): + for script in all_scripts: + con.executescript(script.read_text()) +con.execute("INSERT INTO administrator(id,username,password_hash,generation,created_at) VALUES(1,'owner','hash',1,0)") +con.execute("INSERT INTO admin_preferences(administrator_id,document) VALUES(1,'{}')") + +upsert = ''' +INSERT INTO dashboard_presets(administrator_id, name, document, updated_at) +SELECT a.id, ?, ?, ? FROM administrator a WHERE a.id = 1 AND a.username = ? +AND ((SELECT count(*) FROM dashboard_presets WHERE administrator_id = a.id) < 16 + OR EXISTS(SELECT 1 FROM dashboard_presets WHERE administrator_id = a.id AND name = ?)) +ON CONFLICT(administrator_id, name) DO UPDATE SET document = excluded.document, updated_at = excluded.updated_at +''' +for i in range(16): + name = f'Preset {i}' + assert con.execute(upsert, (name,'{}',i,'owner',name)).rowcount == 1 +assert con.execute(upsert, ('Overflow','{}',16,'owner','Overflow')).rowcount == 0 +assert con.execute(upsert, ('Preset 0','{"changed":true}',17,'owner','Preset 0')).rowcount == 1 +assert con.execute(upsert, ('Other account','{}',18,'intruder','Other account')).rowcount == 0 +assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 16 +assert con.execute('SELECT document FROM dashboard_presets WHERE name="Preset 0"').fetchone()[0] == '{"changed":true}' +assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Preset 1','Preset 0')).rowcount == 0 +assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Renamed','Preset 0')).rowcount == 1 +con.execute('DELETE FROM administrator WHERE id=1') +assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 0 +assert con.execute('SELECT count(*) FROM admin_preferences').fetchone()[0] == 0 +print('PASS SQLite migrations repeated, named-preset CRUD, cap, account ownership and cascade') diff --git a/scripts/tests/dashboard_resizing_0333_browser_test.py b/scripts/tests/dashboard_resizing_0333_browser_test.py index 6c8d76c..137bc4b 100755 --- a/scripts/tests/dashboard_resizing_0333_browser_test.py +++ b/scripts/tests/dashboard_resizing_0333_browser_test.py @@ -7,6 +7,7 @@ not a Rust server integration test. import json import re from pathlib import Path +from panel_html import render_admin_index from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] @@ -41,7 +42,7 @@ assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_wi assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600 assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12 -HTML = (ROOT / 'src/admin/web/index.html').read_text() +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text() BUNDLE = app_bundle() assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =') @@ -96,6 +97,8 @@ with sync_playwright() as p: return window.__savedDashboard; }; applyAdminPreferences(window.__savedDashboard); + api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); }; + appConfirm = async () => true; }''') # Unchanged previous accounts preserve historic grouped rows. assert page.evaluate('''() => ({ @@ -142,27 +145,26 @@ with sync_playwright() as p: assert page.locator(selector).get_attribute('data-dashboard-span') == '2' assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '6' assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None - # In a fresh draft, the modal editor also provides width controls. - page.locator('#overview-layout-open').click() - page.wait_for_function("document.getElementById('overview-layout-dialog').open") - tile = '#overview-layout-visible [data-preview-widget="wide_transform_activity"]' - page.locator(tile + ' .overview-layout-narrow').click() + # The actual unlocked dashboard remains the only layout editor. + page.locator('#overview-layout-unlock').click() + page.wait_for_function('window.rustProxyInlineLayout.isEditing()') + tile = '[data-dashboard-widget="wide_transform_activity"]' + page.locator(tile + ' .overview-inline-narrow').click() assert page.locator(tile).get_attribute('data-dashboard-span') == '4' - page.locator(tile + ' .overview-layout-narrow').click() + page.locator(tile + ' .overview-inline-narrow').click() assert page.locator(tile).get_attribute('data-dashboard-span') == '3' - # Modal drag-and-drop also works across former small/wide size groups. page.evaluate('''() => { - const from = document.querySelector('[data-preview-widget="wide_transform_activity"]'); - const to = document.querySelector('[data-preview-widget="requests"]'); + const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip'); + const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip'); const transfer = new DataTransfer(); from.dispatchEvent(new DragEvent('dragstart', {bubbles:true,cancelable:true,dataTransfer:transfer})); - to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer})); + to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer,clientX:0})); from.dispatchEvent(new DragEvent('dragend', {bubbles:true})); }''') - page.locator('#overview-layout-save').click() - page.wait_for_function("!document.getElementById('overview-layout-dialog').open") + page.locator('#overview-inline-save').click() + page.wait_for_function('!window.rustProxyInlineLayout.isEditing()') assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes.wide_transform_activity') == 3 - assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity' + assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity' # Reload account settings: new card width persists. page.evaluate('applyAdminPreferences(window.__savedDashboard)') assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3' @@ -185,10 +187,9 @@ with sync_playwright() as p: assert all(b['w'] > 150 for b in data['boxes']), (width,data) assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2, (width, data) page.locator('#overview-layout-open').click() - page.wait_for_function("document.getElementById('overview-layout-dialog').open") - page.locator('#overview-layout-default').click() - page.locator('#overview-layout-save').click() - page.wait_for_function("!document.getElementById('overview-layout-dialog').open") + page.wait_for_function("document.getElementById('overview-presets-dialog').open") + page.locator('#overview-presets-factory').click() + page.wait_for_function("!document.getElementById('overview-presets-dialog').open") assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets'] assert page.evaluate('document.getElementById("dashboard-overview-grid").dataset.overviewLayoutMode') == 'legacy' diff --git a/scripts/tests/dashboard_resume_rate_charts_ui_test.mjs b/scripts/tests/dashboard_resume_rate_charts_ui_test.mjs index a116463..a81f281 100644 --- a/scripts/tests/dashboard_resume_rate_charts_ui_test.mjs +++ b/scripts/tests/dashboard_resume_rate_charts_ui_test.mjs @@ -1,3 +1,4 @@ +import { renderAdminIndex } from './panel_html.mjs'; // Simulated DOM/status regression: no holes or artificial zeros after tab resume. // This intentionally does not exercise the running Rust server or real browser. import assert from 'node:assert/strict'; @@ -8,7 +9,7 @@ import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8'); -const html = code('index.html'); +const html = renderAdminIndex(root); class Element { constructor() { this.attributes = new Map(); this.style = {}; this.textContent = ''; this.children = []; this.hidden = false; } 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 index 12187e7..df11473 --- a/scripts/tests/dashboard_stable_scales_0336_browser_test.py +++ b/scripts/tests/dashboard_stable_scales_0336_browser_test.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 """0.33.6 plot size/scale checks in simulated Chromium, not a running RustProxy.""" from pathlib import Path +from panel_html import render_admin_index import re from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] -HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') SCALES = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8') RATES = (ROOT / 'src/admin/web/app/rate-sampling.js').read_text(encoding='utf-8') diff --git a/scripts/tests/dashboard_title_color_0337_browser_test.py b/scripts/tests/dashboard_title_color_0337_browser_test.py new file mode 100755 index 0000000..b00b087 --- /dev/null +++ b/scripts/tests/dashboard_title_color_0337_browser_test.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Simulated Chromium regression for unified subdued metric/chart titles. + +Tests computed CSS in the assembled panel DOM; not a live Rust server. +""" +from pathlib import Path +from panel_html import render_admin_index +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] +HTML = render_admin_index(ROOT) +CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') + +CHECK = r'''({theme, density}) => { + document.documentElement.dataset.theme = theme; + document.documentElement.dataset.dashboardDensity = density; + const grid = document.getElementById('dashboard-overview-grid'); + const probe = document.createElement('span'); + probe.style.color = 'var(--muted)'; + document.body.append(probe); + const expected = getComputedStyle(probe).color; + probe.style.color = 'var(--text)'; + const primary = getComputedStyle(probe).color; + probe.remove(); + + const color = node => getComputedStyle(node).color; + const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget')); + const titles = cards.map(card => { + let node; + if (card.classList.contains('stat')) node = card.querySelector(':scope > span:first-child'); + else if (card.matches('.system-card,.connection-card')) node = card.querySelector('.overview-chart-heading'); + else node = card.querySelector('.mini-heading > h3') || card.querySelector('.card-heading h2'); + return [card.dataset.dashboardWidget, node]; + }); + const otherSelectors = [ + '#panel-cache .stats-grid .stat > span:first-child', + '#panel-transform .stats-grid .stat > span:first-child', + '#panel-traffic .stat > span:first-child', + '#panel-cache > .card .card-heading h2', + '#panel-transform > .card .card-heading h2', + '.traffic-chart-card > h3.subheading', + '.function-live-chart-heading > strong', + '.mini-live-chart-head > span:first-child', + '.vhost-chart-head > span:first-child', + '.vhost-live-metric > span', + ]; + // Include dynamically rendered Vhost/Streams chart and Vhost metric titles. + const container = document.getElementById('panel-vhosts'); + const fixture = document.createElement('div'); + fixture.innerHTML = `
    + Connections history42/s +
    Requests42
    `; + container.append(fixture); + const otherTitles = otherSelectors.flatMap(selector => [...document.querySelectorAll(selector)] + .map(node => [selector, node])); + const errors = [...titles, ...otherTitles].flatMap(([name, node]) => { + if (!node) return [`${name} missing title`]; + if (color(node) !== expected) return [`${name}: ${color(node)} != ${expected} (${node.textContent.trim().slice(0, 35)})`]; + return []; + }); + const numeric = [ + document.querySelector('#system-cpu'), + document.querySelector('#panel-overview .system-card .card-heading h2'), + document.querySelector('#panel-overview .connection-card .card-heading h2'), + document.querySelector('[data-dashboard-widget="requests"] > strong'), + fixture.querySelector('.vhost-live-metric strong'), + ].map(node => ({node: node && node.outerHTML.slice(0, 80), color: node && color(node)})); + fixture.remove(); + return {expected, primary, count: titles.length, otherCount: otherTitles.length, + errors, numeric}; +}''' + +with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) + for width in (320, 768, 1440): + page = browser.new_page(viewport={'width': width, 'height': 900}) + page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '') + .replace('/assets/style___RUSTPROXY_ASSET_ID__.css', '')) + page.add_style_tag(content=CSS) + for theme in ('dark', 'light', 'ultra_dark'): + for density in ('normal', 'compact', 'ultra'): + result = page.evaluate(CHECK, {'theme': theme, 'density': density}) + descriptor = width, theme, density + assert result['count'] == 78, (descriptor, result['count']) + assert result['otherCount'] >= 35, (descriptor, result['otherCount']) + assert result['primary'] != result['expected'], (descriptor, result) + assert not result['errors'], (descriptor, result['errors'][:25]) + assert all(item['color'] == result['primary'] for item in result['numeric']), (descriptor, result['numeric']) + print(f'PASS muted titles, primary numeric readings at {width}px (3 themes, 3 densities)') + page.close() + browser.close() +print('PASS subdued metric/chart title CSS (simulated Chromium, no Rust runtime)') diff --git a/scripts/tests/overview_samples_0330_ui_test.mjs b/scripts/tests/overview_samples_0330_ui_test.mjs index 9a50f2b..6833fa1 100644 --- a/scripts/tests/overview_samples_0330_ui_test.mjs +++ b/scripts/tests/overview_samples_0330_ui_test.mjs @@ -1,3 +1,4 @@ +import { renderAdminIndex } from './panel_html.mjs'; // Simulated browser/status contract: this does not exercise the Rust server. import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const read = file => fs.readFileSync(path.join(root, file), 'utf8'); -const html = read('src/admin/web/index.html'); +const html = renderAdminIndex(root); const controllerSource = read('src/admin/web/app/overview-samples.js'); const store = new Map(); diff --git a/scripts/tests/panel_html.mjs b/scripts/tests/panel_html.mjs new file mode 100644 index 0000000..9d015b5 --- /dev/null +++ b/scripts/tests/panel_html.mjs @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// Same four compile-time substitutions as src/admin/public.rs. +export function renderAdminIndex(root) { + const web = path.join(root, 'src/admin/web'); + let html = fs.readFileSync(path.join(web, 'index.html'), 'utf8'); + const metrics = fs.readFileSync(path.join(web, 'partials/overview-metrics.html'), 'utf8') + .split(''); + const charts = fs.readFileSync(path.join(web, 'partials/overview-charts.html'), 'utf8') + .split(''); + if (metrics.length !== 2 || charts.length !== 2) throw new Error('Invalid Overview partials'); + for (const [marker, text] of [ + ['METRICS_MAIN', metrics[0]], ['CHARTS_MAIN', charts[0]], + ['METRICS_WEB', metrics[1]], ['CHARTS_WEB', charts[1]], + ]) { + const token = ``; + if (!html.includes(token)) throw new Error(`Missing ${token}`); + html = html.replace(token, text); + } + return html; +} diff --git a/scripts/tests/panel_html.py b/scripts/tests/panel_html.py new file mode 100755 index 0000000..8285b0f --- /dev/null +++ b/scripts/tests/panel_html.py @@ -0,0 +1,18 @@ +"""Return the actual assembled embedded Overview HTML to simulated-browser tests.""" +from pathlib import Path + + +def render_admin_index(root: Path) -> str: + web = root / 'src/admin/web' + html = (web / 'index.html').read_text(encoding='utf-8') + metrics = (web / 'partials/overview-metrics.html').read_text(encoding='utf-8').split('') + charts = (web / 'partials/overview-charts.html').read_text(encoding='utf-8').split('') + assert len(metrics) == len(charts) == 2 + for key, fragment in ( + ('METRICS_MAIN', metrics[0]), ('CHARTS_MAIN', charts[0]), + ('METRICS_WEB', metrics[1]), ('CHARTS_WEB', charts[1]), + ): + marker = '' + assert html.count(marker) == 1 + html = html.replace(marker, fragment) + return html diff --git a/scripts/tests/progressbar_0332_browser_test.py b/scripts/tests/progressbar_0332_browser_test.py index 4bf2f87..da0e2a0 100755 --- a/scripts/tests/progressbar_0332_browser_test.py +++ b/scripts/tests/progressbar_0332_browser_test.py @@ -7,6 +7,7 @@ from __future__ import annotations from io import BytesIO from pathlib import Path +from panel_html import render_admin_index import re from PIL import Image @@ -14,7 +15,7 @@ from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') -APP_HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') +APP_HTML = render_admin_index(ROOT) assert re.search(r'
    {\n' + '\n'.join( + (ROOT / 'src/admin/web/vhosts' / f'{name}.js').read_text() + for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();') + elif line.startswith('"') and line.endswith('",'): + fragments.append(json.loads(line[:-1])) + return '\n'.join(fragments) + + +CONFIG = { + 'vhosts': [{ + 'id': 'api-one', 'enabled': True, 'source': {'type': 'reverse_proxy'}, + 'frontend': {'hosts': ['api.example.test'], 'listener_ids': []}, + 'backends': [ + {'id': 'origin-http', 'url': 'http://10.12.0.5:8080/api', 'protocol': 'http2', 'enabled': True}, + {'id': '', 'url': 'https://hiddenuser:secret@backend.example.test:8443/path', 'protocol': 'auto', 'enabled': True}, + ] + }, {'id': 'static-only', 'enabled': True, + 'source': {'type': 'web_server', 'web_service_id': 'static-test'}, + 'frontend': {'hosts': ['static.example.test'], 'listener_ids': []}, 'backends': []}], + 'listeners': {'endpoints': []}, 'web_services': [], 'acl_profiles': [], +} + + +def snapshot(first=200): + return {'backend_pools': [{ + 'id': 'api-one', 'health_mode': 'on', + 'members': [ + {'id': 'origin-http', 'status': 'up', 'last_status': first, + 'requests': 12, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0}, + {'id': '', 'status': 'down', 'last_status': 0, + 'requests': 0, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0}, + ] + }], 'vhost_metrics': {}} + + +def protocol_snapshot(h2_count): + status = snapshot() + status['backend_pools'][0]['members'][0].update({ + 'upstream_protocol_observed': 'h2', + 'upstream_protocol_last_seen_ms': int(time.time() * 1000), + 'upstream_protocol_http1_total': 3, + 'upstream_protocol_http2_total': h2_count, + }) + return status + + +with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) + for width in (320, 375, 768, 1440): + page = browser.new_page(viewport={'width': width, 'height': 820}) + errors = [] + page.on('pageerror', lambda error: errors.append(str(error))) + page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '') + .replace('/assets/style___RUSTPROXY_ASSET_ID__.css', '')) + page.add_style_tag(content=CSS) + page.add_script_tag(content=app_bundle()) + page.evaluate('''([config, status, packs]) => { + languagePacks = new Map(Object.entries(packs)); + defaultLanguage = 'en_US'; language = 'pl_pl'; + document.documentElement.lang = 'pl-PL'; + document.documentElement.classList.remove('app-loading', 'app-startup-failed'); + document.getElementById('login').hidden = true; + document.getElementById('workspace').hidden = false; + document.getElementById('panel-vhosts').hidden = false; + currentPanel = 'vhosts'; + envelope = {revision: 12, config}; + latestStatus = status; + window.rustProxyVhosts.render(); + }''', [CONFIG, snapshot(), PACKS]) + vhost = page.locator('[data-vhost-card="api-one"]') + backend = vhost.locator('.vhost-detail.vhost-backend-trigger') + health = vhost.locator('.vhost-live-metric.vhost-backend-trigger') + assert backend.count() == health.count() == 1, width + assert backend.get_attribute('aria-expanded') == 'false' + assert backend.get_attribute('aria-haspopup') == 'dialog' + # Clickable backend/health fields must match their non-button peers, + # including for user-selected interface corner styles. + shapes = page.evaluate('''() => { + const root = document.querySelector('[data-vhost-card="api-one"]'); + const peers = [ + [root.querySelector('.vhost-detail.vhost-backend-trigger'), root.querySelector('.vhost-detail:not(.vhost-backend-trigger)')], + [root.querySelector('.vhost-live-metric.vhost-backend-trigger'), root.querySelector('.vhost-live-metric:not(.vhost-backend-trigger)')] + ]; + const checks = ['square', 'subtle', 'rounded', 'extra'].map(corner => { + document.documentElement.dataset.cornerStyle = corner; + return {corner, tiles: peers.map(([trigger, peer]) => { + const left = getComputedStyle(trigger), right = getComputedStyle(peer); + return {radius: left.borderRadius, peerRadius: right.borderRadius, + padding: left.padding, peerPadding: right.padding, + font: left.fontFamily, peerFont: right.fontFamily}; + })}; + }); + document.documentElement.removeAttribute('data-corner-style'); + return checks; + }''') + for shape in shapes: + for tile in shape['tiles']: + assert tile['radius'] == tile['peerRadius'] == '0px', (width, shape) + assert tile['padding'] == tile['peerPadding'], (width, shape) + assert tile['font'] == tile['peerFont'], (width, shape) + backend.click() + popup = page.locator('#vhost-backend-popup') + assert popup.count() == 1, width + assert backend.get_attribute('aria-expanded') == 'true' + assert popup.get_attribute('role') == 'dialog' + # The anchored popup is a surface, not a permanently rounded tooltip. + # It must follow live interface corner preference changes without + # dismissing/recreating the open backend (or shared H2) popup. + corners = page.evaluate('''() => { + const node = document.getElementById('vhost-backend-popup'); + const saved = document.documentElement.getAttribute('data-corner-style'); + const out = {}; + for (const style of ['default', 'square', 'subtle', 'rounded', 'extra']) { + document.documentElement.dataset.cornerStyle = style; + out[style] = { + popup: getComputedStyle(node).borderRadius, + row: getComputedStyle(node.querySelector('.vhost-backend-popup-row')).borderRadius, + close: getComputedStyle(node.querySelector('.vhost-backend-popup-close')).borderRadius, + reference: getComputedStyle(document.querySelector('[data-vhost-card="api-one"]')).borderRadius, + sameNode: node === document.getElementById('vhost-backend-popup'), + }; + } + if (saved === null) document.documentElement.removeAttribute('data-corner-style'); + else document.documentElement.dataset.cornerStyle = saved; + return out; + }''') + expected_radii = {'default': ('0px', '0px'), 'square': ('0px', '0px'), + 'subtle': ('5px', '4px'), 'rounded': ('12px', '8px'), + 'extra': ('18px', '12px')} + for style, (surface, control) in expected_radii.items(): + actual = corners[style] + assert actual == {'popup': surface, 'row': surface, 'close': control, + 'reference': surface, 'sameNode': True}, (width, style, actual) + values = popup.evaluate('''node => ({ + cards: node.querySelectorAll('.vhost-backend-popup-row').length, + text: node.textContent, html: node.innerHTML, + box: (() => { const r = node.getBoundingClientRect(); + return {left: r.left, right: r.right, top:r.top, bottom:r.bottom}; })(), + })''') + assert values['cards'] == 2, (width, values) + assert '10.12.0.5:8080' in values['text'] and 'HTTP 200' in values['text'], (width, values) + assert 'backend.example.test:8443' in values['text'], width + assert 'secret' not in values['text'], width + assert '' in values['text'] and '<origin-dns>' in values['html'], width + assert 'Brak odpowiedzi HTTP' in values['text'], width + assert 'HTTPS · AUTO' in values['text'], width + assert values['box']['left'] >= -1 and values['box']['right'] <= width + 1, (width, values['box']) + assert values['box']['top'] >= -1 and values['box']['bottom'] <= 821, (width, values['box']) + assert page.evaluate('document.documentElement.scrollWidth - innerWidth') <= 2, width + page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', snapshot(503)) + assert 'HTTP 503' in popup.inner_text(), width + assert popup.locator('.has-error').count() == 1, width + health.click() + assert popup.count() == 1 and health.get_attribute('aria-expanded') == 'true', width + assert backend.get_attribute('aria-expanded') == 'false', width + page.keyboard.press('Escape') + assert popup.count() == 0 and health.get_attribute('aria-expanded') == 'false', width + assert health.evaluate('(node) => document.activeElement === node'), width + backend.click() + page.locator('#vhost-search').click(force=True) + assert popup.count() == 0, width + # A static-only vhost presents a translated empty state, not stale pool values. + page.locator('[data-vhost-card="static-only"] .vhost-detail.vhost-backend-trigger').click() + assert 'Brak skonfigurowanych backendów.' in popup.inner_text(), width + popup.locator('.vhost-backend-popup-close').click() + assert popup.count() == 0, width + backend.click() + assert popup.count() == 1, width + + # The existing backend popover is also used by the H2 policy badge. + protocol = vhost.locator('[data-upstream-protocol-backend="origin-http"]') + assert protocol.count() == 1 and protocol.evaluate('n => n.tagName') == 'BUTTON', width + assert protocol.get_attribute('title') is None, width + protocol.click() + assert backend.get_attribute('aria-expanded') == 'false', width + assert protocol.get_attribute('aria-expanded') == 'true', width + assert 'Brak odpowiedzi backendu' in popup.inner_text(), width + page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(8)) + assert 'origin-http · H2 compatible → H2' in protocol.inner_text(), width + assert 'Ostatnia wersja HTTP' in popup.inner_text(), width + assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '8'], width + page.evaluate('''() => { + window.__popupReference = document.getElementById('vhost-backend-popup'); + window.__headingReference = window.__popupReference.querySelector('h4'); + window.__popupRemoved = 0; + window.__popupObserver = new MutationObserver(records => { + for (const record of records) for (const item of record.removedNodes) { + if (item === window.__popupReference) window.__popupRemoved++; + } + }); + window.__popupObserver.observe(document.body, {childList: true}); + }''') + for count in range(9, 19): + page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(count)) + unchanged = page.evaluate('''() => ({ + samePopup: document.getElementById('vhost-backend-popup') === window.__popupReference, + sameHeading: window.__popupReference.querySelector('h4') === window.__headingReference, + removed: window.__popupRemoved, + expanded: document.querySelector('[data-upstream-protocol-backend="origin-http"]').getAttribute('aria-expanded'), + })''') + assert unchanged == {'samePopup': True, 'sameHeading': True, 'removed': 0, 'expanded': 'true'}, (width, unchanged) + assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '18'], width + page.evaluate('window.__popupObserver.disconnect()') + assert protocol.get_attribute('title') is None, width + # A stale observation should change the displayed information in + # place, not dismiss/recreate the click popover. + stale = protocol_snapshot(18) + stale['backend_pools'][0]['members'][0]['upstream_protocol_last_seen_ms'] -= 600000 + page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', stale) + assert 'H2 compatible → ?' in protocol.inner_text(), width + assert popup.locator('.vhost-backend-popup-list').is_hidden(), width + assert page.evaluate('document.getElementById("vhost-backend-popup") === window.__popupReference'), width + page.keyboard.press('Escape') + assert popup.count() == 0 and protocol.get_attribute('aria-expanded') == 'false', width + assert protocol.evaluate('node => document.activeElement === node'), width + + # Cache Domains and Stored objects are the only directly adjacent + # table cards and need a visible, small gap at any viewport width. + cache_gap = page.evaluate('''() => { + const panel = document.getElementById('panel-cache'); + panel.hidden = false; + const [domains, stored] = panel.querySelectorAll(':scope > article.card'); + const gap = stored.getBoundingClientRect().top - domains.getBoundingClientRect().bottom; + panel.hidden = true; + return gap; + }''') + assert 9.5 <= cache_gap <= 10.5, (width, cache_gap) + page.evaluate('window.rustProxyVhosts.reset()') + assert popup.count() == 0, width + assert not errors, (width, errors) + print(f'PASS Vhosts shared backend/H2 popover and Cache gap at {width}px') + page.close() + browser.close() +print('PASS Vhost quick-view with simulated runtime status (not live Rust)') diff --git a/scripts/tests/web_observability_0330_browser_test.py b/scripts/tests/web_observability_0330_browser_test.py index 2209978..91c8d0d 100755 --- a/scripts/tests/web_observability_0330_browser_test.py +++ b/scripts/tests/web_observability_0330_browser_test.py @@ -1,4 +1,5 @@ from pathlib import Path +from panel_html import render_admin_index import re, json from playwright.sync_api import sync_playwright root=Path(__file__).resolve().parents[2] @@ -26,7 +27,7 @@ bundle=concatenate('admin_app_javascript') assert 'window.rustProxyOverviewSamples =' in bundle assert bundle.index('window.rustProxyOverviewSamples =') < bundle.index('window.rustProxyOverviewSamples.onChange(') css=(root/'src/admin/web/style.css').read_text() -html=(root/'src/admin/web/index.html').read_text() +html=render_admin_index(root) with sync_playwright() as p: browser=p.chromium.launch(headless=True, executable_path='/usr/bin/chromium',args=['--no-sandbox','--allow-file-access-from-files']) for width in [320,375,800,1440]: diff --git a/scripts/tests/web_observability_0330_source_test.py b/scripts/tests/web_observability_0330_source_test.py index ed0b66b..44cf908 100755 --- a/scripts/tests/web_observability_0330_source_test.py +++ b/scripts/tests/web_observability_0330_source_test.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Static contracts for 0.33.0 WWW observability (not Rust compilation).""" from pathlib import Path +from panel_html import render_admin_index import json import re import tomllib @@ -18,7 +19,7 @@ widgets = [w for w in registry if w['id'].startswith('www_')] assert len(widgets) == 12 assert all(w['default_hidden'] is True for w in widgets) assert len({w['id'] for w in registry}) == len(registry) -html = read('src/admin/web/index.html') +html = render_admin_index(ROOT) english = json.loads(read('langs/en_US.json'))['translations'] polish = json.loads(read('langs/pl_pl.json'))['translations'] for widget in widgets: diff --git a/scripts/tests/web_server_0330_ui_test.py b/scripts/tests/web_server_0330_ui_test.py index 1200268..4a6f03f 100755 --- a/scripts/tests/web_server_0330_ui_test.py +++ b/scripts/tests/web_server_0330_ui_test.py @@ -7,6 +7,7 @@ Run: python3 scripts/tests/web_server_0330_ui_test.py from __future__ import annotations import json from pathlib import Path +from panel_html import render_admin_index import re from playwright.sync_api import sync_playwright @@ -58,7 +59,7 @@ with sync_playwright() as p: page = browser.new_page(viewport={'width': width, 'height': 900}) errors = [] page.on('pageerror', lambda err: errors.append(str(err))) - page.set_content((ROOT / 'src/admin/web/index.html').read_text()) + page.set_content(render_admin_index(ROOT)) page.add_style_tag(content=(ROOT / 'src/admin/web/style.css').read_text()) page.add_script_tag(content=BUNDLE) page.evaluate("""() => { diff --git a/scripts/tests/web_server_0334_browser_test.py b/scripts/tests/web_server_0334_browser_test.py index 5c94489..5ef1729 100755 --- a/scripts/tests/web_server_0334_browser_test.py +++ b/scripts/tests/web_server_0334_browser_test.py @@ -5,12 +5,13 @@ Chromium exercises the real assembled admin JS/CSS and responsive layouts. It does not establish Rust, SSE or PHP-FPM runtime acceptance. """ from pathlib import Path +from panel_html import render_admin_index import json import re from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] -HTML = (ROOT / 'src/admin/web/index.html').read_text() +HTML = render_admin_index(ROOT) CSS = (ROOT / 'src/admin/web/style.css').read_text() ROUTER = (ROOT / 'src/admin/router.rs').read_text() diff --git a/src/admin/dashboard_presets.rs b/src/admin/dashboard_presets.rs new file mode 100644 index 0000000..e10cffe --- /dev/null +++ b/src/admin/dashboard_presets.rs @@ -0,0 +1,187 @@ +/// Account-scoped snapshots of Overview order, visibility, widths and display. +use std::collections::BTreeMap; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct DashboardPresetLayout { + dashboard_widgets: Vec, + dashboard_hidden_widgets: Vec, + dashboard_widget_sizes: Option>, + dashboard_density: String, + overview_samples: u16, +} + +#[derive(serde::Serialize)] +struct NamedDashboardPreset { + name: String, + #[serde(flatten)] + layout: DashboardPresetLayout, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct DashboardPresetRename { + name: String, +} + +fn valid_dashboard_preset_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.trim() == value + && !value.chars().any(|ch| ch.is_control() || ch == '/') +} + +fn validate_dashboard_preset_layout(layout: &DashboardPresetLayout) -> Result<(), ApiError> { + if !matches!(layout.overview_samples, 60 | 120 | 300 | 600) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid Overview history samples".into(), + )); + } + let mut pref = AdminPreferences::default(); + pref.dashboard_widgets.clone_from(&layout.dashboard_widgets); + pref.dashboard_hidden_widgets + .clone_from(&layout.dashboard_hidden_widgets); + pref.dashboard_widget_sizes + .clone_from(&layout.dashboard_widget_sizes); + pref.dashboard_density.clone_from(&layout.dashboard_density); + validate_admin_preferences(&pref) +} + +async fn dashboard_presets( + State(state): State>, + Extension(session): Extension, +) -> Result>, ApiError> { + let entries = state + .store + .list_admin_dashboard_presets(&session.username) + .await + .map_err(auth::internal)?; + let mut presets = Vec::with_capacity(entries.len()); + for (name, document) in entries { + let Ok(mut layout) = serde_json::from_str::(&document) else { + tracing::warn!("skipped invalid saved Overview preset"); + continue; + }; + // Compatibility with future/retired widgets: use the same registry + // reconciliation logic as ordinary saved administrator preferences. + let mut prefs = AdminPreferences::default(); + prefs.dashboard_widgets = layout.dashboard_widgets; + prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets; + prefs.dashboard_widget_sizes = layout.dashboard_widget_sizes; + reconcile_dashboard_widgets(&mut prefs); + layout.dashboard_widgets = prefs.dashboard_widgets; + layout.dashboard_hidden_widgets = prefs.dashboard_hidden_widgets; + layout.dashboard_widget_sizes = prefs.dashboard_widget_sizes; + if validate_dashboard_preset_layout(&layout).is_ok() { + presets.push(NamedDashboardPreset { name, layout }); + } + } + Ok(Json(presets)) +} + +async fn save_dashboard_preset( + State(state): State>, + Extension(session): Extension, + Path(name): Path, + Json(layout): Json, +) -> Result { + if !valid_dashboard_preset_name(&name) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid dashboard preset name".into(), + )); + } + validate_dashboard_preset_layout(&layout)?; + let document = serde_json::to_string(&layout).map_err(auth::internal)?; + if !state + .store + .save_admin_dashboard_preset(&session.username, &name, &document) + .await + .map_err(auth::internal)? + { + return Err(ApiError( + StatusCode::CONFLICT, + "dashboard preset limit (16) reached".into(), + )); + } + Ok(StatusCode::NO_CONTENT) +} + +async fn delete_dashboard_preset( + State(state): State>, + Extension(session): Extension, + Path(name): Path, +) -> Result { + if !state + .store + .delete_admin_dashboard_preset(&session.username, &name) + .await + .map_err(auth::internal)? + { + return Err(ApiError( + StatusCode::NOT_FOUND, + "dashboard preset not found".into(), + )); + } + Ok(StatusCode::NO_CONTENT) +} + +async fn rename_dashboard_preset( + State(state): State>, + Extension(session): Extension, + Path(name): Path, + Json(rename): Json, +) -> Result { + if !valid_dashboard_preset_name(&rename.name) || !valid_dashboard_preset_name(&name) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid dashboard preset name".into(), + )); + } + if !state + .store + .rename_admin_dashboard_preset(&session.username, &name, &rename.name) + .await + .map_err(auth::internal)? + { + return Err(ApiError( + StatusCode::CONFLICT, + "dashboard preset missing or name in use".into(), + )); + } + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod dashboard_preset_validation_tests { + use super::*; + #[test] + fn names_are_bounded_and_paths_are_not_accepted() { + assert!(valid_dashboard_preset_name("Work layout")); + assert!(valid_dashboard_preset_name("Układ na telefon")); + for invalid in ["", " name", "name ", "bad/name", "bad\nname"] { + assert!(!valid_dashboard_preset_name(invalid)); + } + assert!(!valid_dashboard_preset_name(&"X".repeat(65))); + } + #[test] + fn layouts_reject_invalid_history_and_widgets() { + let mut layout = DashboardPresetLayout { + dashboard_widgets: default_admin_dashboard_widgets_for_tests(), + dashboard_hidden_widgets: default_hidden_dashboard_widgets(), + dashboard_widget_sizes: None, + dashboard_density: default_admin_dashboard_density(), + overview_samples: 600, + }; + assert!(validate_dashboard_preset_layout(&layout).is_ok()); + layout.overview_samples = 601; + assert!(validate_dashboard_preset_layout(&layout).is_err()); + layout.overview_samples = 600; + layout.dashboard_widgets.push("undefined-widget".into()); + assert!(validate_dashboard_preset_layout(&layout).is_err()); + } + fn default_admin_dashboard_widgets_for_tests() -> Vec { + AdminPreferences::default().dashboard_widgets + } +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs index df46cbe..5412af7 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -4,6 +4,7 @@ include!("router.rs"); include!("access.rs"); include!("session.rs"); include!("preferences.rs"); +include!("dashboard_presets.rs"); include!("status.rs"); include!("logging_api.rs"); include!("file_logs.rs"); diff --git a/src/admin/openapi.json b/src/admin/openapi.json index b5863dd..6f5ce50 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.4", + "version": "0.33.7", "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." }, "servers": [ @@ -1241,15 +1241,37 @@ "type": "object", "description": "Cached OS/process metrics for fast SSE/live charts only; full System details remain on /api/system-info", "properties": { - "sampled_at_ms": { "type": "integer", "format": "int64" }, - "cpu_percent": { "type": "number", "nullable": true }, - "process_cpu_percent": { "type": "number", "nullable": true }, - "process_rss_bytes": { "type": "integer", "format": "int64", "nullable": true }, - "network_rx_bytes_per_second": { "type": "number", "nullable": true }, - "network_tx_bytes_per_second": { "type": "number", "nullable": true }, + "sampled_at_ms": { + "type": "integer", + "format": "int64" + }, + "cpu_percent": { + "type": "number", + "nullable": true + }, + "process_cpu_percent": { + "type": "number", + "nullable": true + }, + "process_rss_bytes": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "network_rx_bytes_per_second": { + "type": "number", + "nullable": true + }, + "network_tx_bytes_per_second": { + "type": "number", + "nullable": true + }, "network_interfaces": { "type": "array", - "items": { "type": "object", "additionalProperties": true } + "items": { + "type": "object", + "additionalProperties": true + } } }, "additionalProperties": false @@ -3570,7 +3592,13 @@ "maxProperties": 78, "additionalProperties": { "type": "integer", - "enum": [2, 3, 4, 6, 12] + "enum": [ + 2, + 3, + 4, + 6, + 12 + ] }, "description": "Nullable account-scoped per-card widths (12-column grid). Null preserves the legacy grouped visual arrangement; an object, including an empty one, enables freely ordered cards. Key must be a registered Overview widget ID. Panels require width >= 4, charts >= 3 and backend table >= 6." }, @@ -6631,6 +6659,82 @@ } } } + }, + "DashboardPresetLayout": { + "type": "object", + "additionalProperties": false, + "description": "Validated Overview snapshot. Factory layout is separate from stored user presets.", + "properties": { + "dashboard_widgets": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Ordered registry widget identifiers" + }, + "dashboard_hidden_widgets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Invisible registry widget IDs" + }, + "dashboard_widget_sizes": { + "nullable": true, + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 2, + "maximum": 12 + }, + "description": "Width overrides in a 12-column grid; null selects legacy groups" + }, + "dashboard_density": { + "type": "string", + "enum": [ + "normal", + "compact", + "ultra" + ] + }, + "overview_samples": { + "type": "integer", + "enum": [ + 60, + 120, + 300, + 600 + ] + } + }, + "required": [ + "dashboard_widgets", + "dashboard_hidden_widgets", + "dashboard_widget_sizes", + "dashboard_density", + "overview_samples" + ] + }, + "NamedDashboardPreset": { + "allOf": [ + { + "$ref": "#/components/schemas/DashboardPresetLayout" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "name" + ] + } + ] } } }, @@ -11931,6 +12035,282 @@ } } } + }, + "/api/dashboard-presets": { + "get": { + "tags": [ + "Administration" + ], + "summary": "List administrator dashboard presets", + "operationId": "get_dashboard_presets", + "responses": { + "200": { + "description": "Named layouts owned by the authenticated administrator", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NamedDashboardPreset" + } + } + } + } + }, + "401": { + "description": "Administrator session required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Invalid Host, Origin, request marker or CSRF token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/dashboard-presets/{name}": { + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "URL-encoded user-defined preset name (1-64 UTF-8 bytes)", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + ], + "put": { + "tags": [ + "Administration" + ], + "summary": "Save or overwrite an Overview dashboard preset", + "operationId": "put_dashboard_preset", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardPresetLayout" + } + } + } + }, + "responses": { + "204": { + "description": "Preset saved" + }, + "409": { + "description": "Account already has 16 named presets", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid preset name or layout", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Administrator session required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Invalid Host, Origin, request marker or CSRF token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Administration" + ], + "summary": "Rename an Overview dashboard preset", + "operationId": "patch_dashboard_preset", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + } + } + } + }, + "responses": { + "204": { + "description": "Preset renamed" + }, + "409": { + "description": "Source name not found or target name already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid preset name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Administrator session required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Invalid Host, Origin, request marker or CSRF token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Administration" + ], + "summary": "Delete an Overview dashboard preset", + "operationId": "delete_dashboard_preset", + "responses": { + "204": { + "description": "Preset deleted" + }, + "404": { + "description": "Preset not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Administrator session required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Invalid Host, Origin, request marker or CSRF token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } } } } diff --git a/src/admin/public.rs b/src/admin/public.rs index 55872e5..128a998 100644 --- a/src/admin/public.rs +++ b/src/admin/public.rs @@ -1,6 +1,14 @@ //! Deliberately small anonymous surface. Operational templates/assets are private. -use crate::{auth::Session, runtime::{ApiError, App}, settings::PanelCompression}; -use axum::{extract::{rejection::JsonRejection, State}, response::{Html, IntoResponse, Response}, Extension}; +use crate::{ + auth::Session, + runtime::{ApiError, App}, + settings::PanelCompression, +}; +use axum::{ + extract::{rejection::JsonRejection, State}, + response::{Html, IntoResponse, Response}, + Extension, +}; use http::{HeaderMap, StatusCode}; use std::sync::{Arc, OnceLock}; @@ -10,7 +18,19 @@ static ADMIN_INDEX: OnceLock = OnceLock::new(); fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response { let content = ADMIN_INDEX.get_or_init(|| { + // Compile-time embedded HTML fragments are assembled once at startup. + // No additional browser requests, changed DOM nodes or script ordering. + let (metrics, web_metrics) = include_str!("web/partials/overview-metrics.html") + .split_once("") + .expect("invalid Overview metrics partial"); + let (charts, web_charts) = include_str!("web/partials/overview-charts.html") + .split_once("") + .expect("invalid Overview charts partial"); let html = include_str!("web/index.html") + .replace("", metrics) + .replace("", charts) + .replace("", web_metrics) + .replace("", web_charts) .replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()) .replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION")); // Exactly one static panel document shared between all administrator accounts. @@ -30,7 +50,10 @@ pub(super) async fn index( return Html(html).into_response(); } let snapshot = state.config.load(); - panel_response(&headers, &snapshot.envelope.config.administration.panel_compression) + panel_response( + &headers, + &snapshot.envelope.config.administration.panel_compression, + ) } pub(super) fn invalid_json(error: JsonRejection) -> ApiError { diff --git a/src/admin/router.rs b/src/admin/router.rs index ce2cec1..18648f1 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -69,7 +69,7 @@ fn admin_app_javascript() -> &'static str { include_str!("web/dashboard-widgets.json"), ");", include_str!("web/app/preferences.js"), - include_str!("web/app/dashboard-layout-editor.js"), + include_str!("web/app/dashboard-presets.js"), include_str!("web/app/dashboard-inline-layout.js"), include_str!("web/app/stream-dashboard.js"), include_str!("web/app/backend-dashboard.js"), @@ -249,6 +249,14 @@ pub fn router(state: Arc) -> Router { .route("/api/file-logs", get(file_logs)) .route("/api/file-logs/query", get(file_logs_query)) .route("/api/preferences", get(preferences).put(update_preferences)) + .route("/api/dashboard-presets", get(dashboard_presets)) + .route( + "/api/dashboard-presets/{name}", + put(save_dashboard_preset) + .patch(rename_dashboard_preset) + .delete(delete_dashboard_preset) + .layer(DefaultBodyLimit::max(10240)), + ) .route("/api/audit", get(audit)) .route("/api/events", get(events)) .route("/metrics", get(metrics)) diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 9dd2e49..ec684b6 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -1639,6 +1639,87 @@ mod tests { store.close().await; } + #[tokio::test] + async fn named_dashboard_presets_are_private_validated_and_persistent() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let preset = DashboardPresetLayout { + dashboard_widgets: AdminPreferences::default().dashboard_widgets, + dashboard_hidden_widgets: default_hidden_dashboard_widgets(), + dashboard_widget_sizes: None, + dashboard_density: "compact".to_owned(), + overview_samples: 600, + }; + let body = serde_json::to_string(&preset).unwrap(); + let anon = router.clone().oneshot( + request("/api/dashboard-presets").body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(anon.status(), StatusCode::UNAUTHORIZED); + let forbidden = router.clone().oneshot( + request("/api/dashboard-presets/Daily").method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("content-type", "application/json") + .body(Body::from(body.clone())).unwrap() + ).await.unwrap(); + assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); + let saved = router.clone().oneshot( + request("/api/dashboard-presets/Daily").method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(body.clone())).unwrap() + ).await.unwrap(); + assert_eq!(saved.status(), StatusCode::NO_CONTENT); + let listing = router.clone().oneshot( + request("/api/dashboard-presets") + .header("cookie", &cookie) + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(listing.status(), StatusCode::OK); + let entries = json_body(listing).await; + assert_eq!(entries[0]["name"], "Daily"); + assert_eq!(entries[0]["overview_samples"], 600); + assert_eq!(entries[0]["dashboard_widget_sizes"], Value::Null); + assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap().len(), 1); + assert!(store.list_admin_dashboard_presets("someone-else").await.unwrap().is_empty()); + let rejected = router.clone().oneshot( + request("/api/dashboard-presets/Invalid").method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(body.replace("\"overview_samples\":600", "\"overview_samples\":999"))).unwrap() + ).await.unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + let renamed = router.clone().oneshot( + request("/api/dashboard-presets/Daily").method("PATCH") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"Nightly"}"#)).unwrap() + ).await.unwrap(); + assert_eq!(renamed.status(), StatusCode::NO_CONTENT); + assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap()[0].0, "Nightly"); + let deleted = router.oneshot( + request("/api/dashboard-presets/Nightly").method("DELETE") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + assert!(store.list_admin_dashboard_presets("owner").await.unwrap().is_empty()); + store.close().await; + } + #[tokio::test] async fn admin_preferences_are_account_scoped_and_csrf_guarded() { let (router, store, _dir, secret, csrf) = fixture().await; @@ -3010,6 +3091,7 @@ mod tests { "/api/system-info", "/api/config", "/api/preferences", + "/api/dashboard-presets", "/api/cache", "/api/cache/bans", "/api/audit", @@ -3044,6 +3126,9 @@ mod tests { ("DELETE", "/api/cache/bans/1"), ("PUT", "/api/config/cache"), ("PUT", "/api/preferences"), + ("PUT", "/api/dashboard-presets/example"), + ("DELETE", "/api/dashboard-presets/example"), + ("PATCH", "/api/dashboard-presets/example"), ("POST", "/api/auth/logout-all"), ] { let no_session = request(path).method(method).body(Body::empty()).unwrap(); diff --git a/src/admin/web/app/dashboard-inline-layout.js b/src/admin/web/app/dashboard-inline-layout.js index 30d15df..18093de 100644 --- a/src/admin/web/app/dashboard-inline-layout.js +++ b/src/admin/web/app/dashboard-inline-layout.js @@ -162,7 +162,7 @@ window.rustProxyInlineLayout = (() => { draft = original = null; } async function begin() { - if (draft || opening || saving || $("overview-layout-dialog")?.open) return; + if (draft || opening || saving || $("overview-presets-dialog")?.open) return; opening = true; unlock.disabled = true; try { diff --git a/src/admin/web/app/dashboard-presets.js b/src/admin/web/app/dashboard-presets.js new file mode 100644 index 0000000..8204a36 --- /dev/null +++ b/src/admin/web/app/dashboard-presets.js @@ -0,0 +1,144 @@ +window.rustProxyDashboardLayout = (() => { + const dialog = $("overview-presets-dialog"); + const list = $("overview-presets-list"); + const input = $("overview-presets-name"); + const message = $("overview-presets-message"); + const save = $("overview-presets-save"); + const factory = $("overview-presets-factory"); + const close = $("overview-presets-close"); + let presets = []; + let busy = false; + + const presetPath = name => `/api/dashboard-presets/${encodeURIComponent(name)}`; + const nameValid = name => name.length > 0 && new TextEncoder().encode(name).length <= 64 + && !/[\x00-\x1f\x7f/]/.test(name); + const snapshot = () => { + const current = window.rustProxyPreferences.current() || ADMIN_APPEARANCE_DEFAULTS; + return { + dashboard_widgets: [...normalizeDashboardWidgets(current.dashboard_widgets)], + dashboard_hidden_widgets: [...normalizeHiddenDashboardWidgets(current.dashboard_hidden_widgets)], + dashboard_widget_sizes: normalizeDashboardWidgetSizes(current.dashboard_widget_sizes), + dashboard_density: normalizeAdminDensity(current.dashboard_density, "compact"), + overview_samples: window.rustProxyOverviewSamples.getLimit(), + }; + }; + const translateName = (key, name) => t(key).replace("{name}", name); + function notify(key, isError = false) { + message.textContent = isError ? key : t(key); + message.classList.toggle("error", isError); + } + function lock(value) { + busy = value; + for (const control of [save, factory, close, input, ...list.querySelectorAll("button")]) + if (control) control.disabled = value; + } + async function run(fn) { + if (busy) return; + lock(true); + try { await fn(); } + catch (error) { + const message = error.status === 409 + ? (String(error.code || "").includes("limit (16)") + ? t("dashboardPresetsLimit") : t("dashboardPresetsConflict")) + : (error.message || String(error)); + notify(message, true); + } + finally { lock(false); } + } + async function reload() { + presets = await api("/api/dashboard-presets"); + render(); + } + function entryButton(key, className, fn, presetName) { + const button = el("button", t(key), className); + button.type = "button"; + button.setAttribute("aria-label", `${t(key)}: ${presetName}`); + button.dataset.presetAction = key; + button.addEventListener("click", () => void run(fn)); + return button; + } + function render() { + const elements = presets.map(preset => { + const row = el("div", undefined, "overview-presets-row"); + row.setAttribute("role", "listitem"); + const actions = el("div", undefined, "overview-presets-actions"); + actions.append( + entryButton("dashboardPresetsLoad", "primary", async () => { + await apply(preset); + dialog.close(); + toast(t("dashboardPresetsLoaded")); + }, preset.name), + entryButton("dashboardPresetsOverwrite", "outline", async () => { + if (!(await appConfirm(translateName("dashboardPresetsOverwriteConfirm", preset.name)))) return; + await api(presetPath(preset.name), { method: "PUT", body: JSON.stringify(snapshot()) }); + await reload(); notify("dashboardPresetsSaved"); + }, preset.name), + entryButton("dashboardPresetsRename", "quiet", async () => { + const name = input.value.trim(); + if (!nameValid(name)) { notify("dashboardPresetsNameRequired"); return; } + if (name === preset.name) return; + await api(presetPath(preset.name), { method: "PATCH", body: JSON.stringify({ name }) }); + input.value = ""; + await reload(); notify("dashboardPresetsRenamed"); + }, preset.name), + entryButton("dashboardPresetsDelete", "quiet", async () => { + if (!(await appConfirm(translateName("dashboardPresetsDeleteConfirm", preset.name), { danger: true }))) return; + await api(presetPath(preset.name), { method: "DELETE" }); + await reload(); notify("dashboardPresetsDeleted"); + }, preset.name), + ); + row.append(el("strong", preset.name), actions); + return row; + }); + list.replaceChildren(...(elements.length ? elements : [el("p", t("dashboardPresetsEmpty"), "fine")])); + } + async function apply(layout) { + await window.rustProxyPreferences.update({ + dashboard_widgets: [...layout.dashboard_widgets], + dashboard_hidden_widgets: [...layout.dashboard_hidden_widgets], + dashboard_widget_sizes: layout.dashboard_widget_sizes === null ? null : { ...layout.dashboard_widget_sizes }, + dashboard_density: layout.dashboard_density, + }); + window.rustProxyOverviewSamples.setLimit(layout.overview_samples, true); + } + async function open() { + if (dialog?.open || busy || window.rustProxyInlineLayout?.isEditing?.()) return; + await run(async () => { + await window.rustProxyPreferences.load(); + await reload(); + input.value = ""; + notify(""); + dialog.showModal(); + input.focus(); + }); + } + save?.addEventListener("click", () => void run(async () => { + const name = input.value.trim(); + if (!nameValid(name)) { notify("dashboardPresetsNameRequired"); return; } + if (presets.length >= 16 && !presets.some(preset => preset.name === name)) { + notify("dashboardPresetsLimit"); return; + } + if (presets.some(preset => preset.name === name) + && !(await appConfirm(translateName("dashboardPresetsOverwriteConfirm", name)))) return; + await api(presetPath(name), { method: "PUT", body: JSON.stringify(snapshot()) }); + input.value = ""; + await reload(); notify("dashboardPresetsSaved"); + })); + factory?.addEventListener("click", () => void run(async () => { + if (!(await appConfirm(t("dashboardPresetsResetConfirm")))) return; + await apply({ + dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], + dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], + dashboard_widget_sizes: null, + dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, + overview_samples: ADMIN_DASHBOARD_DEFAULTS.overview_samples, + }); + dialog.close(); + toast(t("dashboardPresetsRestored")); + })); + close?.addEventListener("click", () => { if (!busy) dialog.close(); }); + dialog?.addEventListener("cancel", event => { if (busy) event.preventDefault(); }); + for (const id of ["overview-layout-open", "overview-layout-open-settings"]) + $(id)?.addEventListener("click", () => { void open(); }); + return { open, refresh: () => { if (dialog?.open) render(); } }; +})(); diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 237feae..1fbe8a8 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -139,7 +139,7 @@ + type="button" hidden>Dashboard presets