From e1ac899ec081759dab5c440a6dc82d3776373005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 14 Oct 2026 10:50:21 +0200 Subject: [PATCH] v0.32.6 --- CHANGELOG.md | 15 +- README.md | 11 +- ROADMAP.md | 8 + SHA256SUMS | 24 +-- docs/TEST_REPORT.md | 38 +++++ src/admin/router.rs | 2 +- src/admin/web/app/runtime.js | 133 +++++++++++----- src/admin/web/style.css | 11 +- src/admin/web/vhosts/render.js | 26 ++- src/transform/css.rs | 7 +- src/transform/tests.rs | 25 +++ tools/checks/ios_scroll_0326_browser.py | 148 ++++++++++++++++++ tools/checks/mobile_protocol_0326.py | 10 ++ ...vhost_protocol_badge_group_0326_browser.py | 99 ++++++++++++ 14 files changed, 480 insertions(+), 77 deletions(-) create mode 100755 tools/checks/ios_scroll_0326_browser.py create mode 100755 tools/checks/vhost_protocol_badge_group_0326_browser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de4ff8..5e9131f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,25 @@ ## 0.32.6 +- **Development-branch follow-up (no version bump):** update existing network-interface + diagnostic chart nodes in place during live refresh, preserving DOM identity + and avoiding scroll-anchor loss around the Process and Network panels on touch + browsers. Keep network-interface history advancing on every live Overview update + using the most recent system measurement; collect fresh system data only at the + separately configured System information interval. Real iOS 27 validation + remains pending; this does not prove all scroll jumps have been eliminated. +- Fix CSS transformation of descendant pseudo-class selectors (including + rounded-corner rules), and mark administration responses `no-transform`. + - Restore two-column small metric cards on narrow mobile Overview grids, ensure the first render uses the same responsive layout and avoid DOM operations that can disrupt scrolling when preferences refresh. - Extend account-selected corner presets to vhost cards, backend badges, entity cards and additional panel surfaces while preserving the square dock. - Report observed HTTP/1.1 or HTTP/2 per backend in authenticated status and - show fresh observations next to configured modes on Vhost cards. Stale or - unobserved modes show `?`, never a guessed Auto selection. No active probes. + show fresh observations next to configured modes in the Vhost policy-badge + group, without changing green/neutral state colors. Stale or unobserved modes + show `?`, never a guessed Auto selection. No active probes. - Add browser, JavaScript and Rust regression coverage. Rust compilation, physical iOS Safari and live HTTP/2 ALPN remain acceptance gates. diff --git a/README.md b/README.md index c76f89e..2814498 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos ## What changed in 0.32.6 +- **Development fixes (same 0.32.6 version):** keep Network diagnostics + chart DOM stable across live refreshes, preventing repeated row/SVG replacement + during scrolling. Network-interface chart history advances at the Overview + live-status cadence using the latest sampled rates; fetching system data still + follows the independent System information interval in Settings. Repair CSS + descendant selector transformation and protect admin responses with + `Cache-Control: no-transform`. Real iOS 27 scrolling remains unverified. + - **Mobile Overview:** display two metric tiles per row on phone-width canvases, including 320-430px layouts and the initial pre-script paint. Keep horizontal overflow disabled, allow vertical touch scrolling and avoid unnecessary DOM @@ -18,7 +26,8 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos existing metric and content cards. Keep the square-style draft dock unchanged. - **Vhosts backend HTTP badges:** show the configured upstream mode alongside the last HTTP/1.1 or HTTP/2 version actually returned by a real upstream - proxy request for each origin. `AUTO → ?` means there is no response from the + proxy request for each origin, in the same lower badge row as the Vhost + policy indicators (retaining the original observed/unknown badge colors). `AUTO → ?` means there is no response from the past five minutes; never guess a negotiation result or run an extra probe. The authenticated status payload includes last observation timestamp and counters per upstream client; the active connection may vary across requests. diff --git a/ROADMAP.md b/ROADMAP.md index 974eca4..803a6f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,14 @@ ## 0.32.6 - Mobile Overview and upstream protocol visibility (runtime acceptance pending) +- [x] Development follow-up: preserve Network diagnostic chart DOM through + periodic refresh and fix descendant CSS selector minification. +- [x] Keep network-interface charts advancing with live Overview status without + increasing the separately configured system-info polling rate; move the + per-backend protocol badges alongside the other Vhost policy badges. +- [ ] Confirm scroll positions with a real iOS 27 phone while live updates are + enabled, including the Process / Network diagnostics boundary. + - [x] Reinstate two small Overview cards per row across phone-size widths. - [x] Apply global corner styles to additional vhost/entity panel surfaces. - [x] Avoid reparenting already-ordered Overview nodes; retain natural touch diff --git a/SHA256SUMS b/SHA256SUMS index 4aad2ec..6eb7a67 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -2,12 +2,12 @@ 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore a402ee07826dfab1b1d63b98c4a9f75809f056b908c92abacf0bba816d7ad133 AGENTS.md -4bc02b731361f3859196a41bdf27f477c67287b1ded6fa2b297ea9c1766a7733 CHANGELOG.md +7714a8877ae51e8ba453c54c4fb0d9684cd50a46688536e0511d7a1346a3e306 CHANGELOG.md 934ec0e7f8a307b00319606a9c5d7e91dac6449a23dd1f3abcefe4304eb4de06 Cargo.lock 8a7606650ce03f1faf36c893a17ccf1c9e8da4259e0248ac2c9bf030a64872d1 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -a26af8c8548c4ab04566d41eab41fc4715fc8bfc05bf281dfb10e808795ec20c README.md -345ac3de56d04e41a8ad577654a51f0b81ddbbba3685378476f566b5732a1ddb ROADMAP.md +7f337f1d8d82cf296859daf17101abb4f095cd0f3e4c1538d2307f1ae1382ddf README.md +5253814978618396d472b5469dcbf66d2e8f7da53e20496d4c8f66bb3cd76f15 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md @@ -63,7 +63,7 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY. 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 5e615c119848cc474f4b005dbe3b58a7ed8f29375ff409282285c39b0bdc196a docs/SETTINGS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md -b5a7bb5bf081098624fba570760375792f0612c6022f80d6e462101912b442a7 docs/TEST_REPORT.md +c2715cfa0bf20185e5cd94b2219a958f0515a488395f9f05a92350369cd96f27 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md @@ -166,7 +166,7 @@ c17ccba5202d375d4cc593f7e20979f2b796362d87660b898feca0af5a135bfc src/admin/pref 055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs -ab8d92d79d0c4d595fdb2558ea877fa8cfa17bf07708375fef4668c4367950fe src/admin/router.rs +223310e1339094941c45a5cb65d650673267b7fe8a3aa0927c8c1e3d72de68cd src/admin/router.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs @@ -184,7 +184,7 @@ fb0ba435beaa91e518537606f5fe55b0eea63c70efd290798c8d9754fa6771b2 src/admin/web/ 79d31e2002f8cf334f87285f6f2e6dbf69f3647de784ae26efadb92c126bc7a0 src/admin/web/app/overview-wide-metrics.js a73d4efc9cf2f6bf7117f6f6dcaaea106e03c328451abbe4c10281179750899d src/admin/web/app/preferences.js 6a544baaf40763b4b612e53845b3bc33396b9bb6518565fd6b7c128c278b8d5d src/admin/web/app/refresh.js -41f635a510d6f08b37484f0e260929926b93a02a792bdfa05b31ed7ebd682fef src/admin/web/app/runtime.js +3b4a7c1538d9e3fe7f9eb3522fcebe077c336d4441c2ee6253d48483cf47c121 src/admin/web/app/runtime.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js cc566f8293cc935f5080ebfb8076293c256bb6a87e1e6a69be03512448871ec9 src/admin/web/app/stream-dashboard.js @@ -222,7 +222,7 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/ c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html e3075af59644e42fa5735574d8e9caa1bcebfe8de184b78aff9f4026b91915f1 src/admin/web/streams.js -691ebee8f87f28f574a8dc5f0020922bd04e06f6d51e72fccd3abb6e68be7bf1 src/admin/web/style.css +9671caf7468d9e7aaf679e6f02a118603c1e9b88b6c3a009297b265c463eda1e src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js @@ -230,7 +230,7 @@ b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/ 8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js 2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js -9b565141991a02f83b46d535aa4f4d4430631270f8c3cabafd373e54b0e7317b src/admin/web/vhosts/render.js +823038b8eb335ca766f03b5c718e093f5f026c85dad06ace05ff70c4a7c1410f src/admin/web/vhosts/render.js 4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js 355684980d1caf8918d1773ce17934b51e3a85a60f38491217d10035bf68ce9f src/admin/web/web-server.js 53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs @@ -436,7 +436,7 @@ c2f802d95da4332fb8480e83f0c7502334457b3408141c1c4516c9ba320a0463 src/traffic.rs 36301bcbd20b557dd3d58e77a9175f3581947f577c0bb0b972899cec6786b03f src/traffic/tests.rs d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs 87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs -38dfd1005b1cd9ae21c1e2964a8b464368d8cdff5e525089b3132e0126f211ea src/transform/css.rs +b2c5236589093454ec60984b785ad6a4af6149d47f6db37d8e08c17fec7a2c1b src/transform/css.rs 5478670cef87777b14a2dab308f5a9182df56d7ea2f5625bdc94aa7e3d661503 src/transform/html.rs aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs a1d2fec07617c8a8dc425bfaeaf77c844152a0a174568a88a5baf4c317cc6c5c src/transform/js.rs @@ -447,7 +447,7 @@ cf5195ab6bacccae0082da7b3f1bd0382b73fdeb3607f4ae569ca6daab9d10d0 src/transform/ fde2fa3aa94b30d8536afec1c11348cfbd922d0b5ff89e91d34266e8e3863e26 src/transform/model.rs a4e1d2aa2913587c250f251356431097b76995aee6e8bf9e35c4978a95491de1 src/transform/svg.rs 0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs -76b3cb4ebdba9cbac8e388d3a9d74bd2055db564d8e31869a60e7a439b46f7b3 src/transform/tests.rs +8ed4baeaf79edfa2a84912cc00f1d13c564e1b997efe6888d2e4b5a16aaa8905 src/transform/tests.rs 6146c59f36cf419623747d497b3de685da6c84964df43317bebdcb21deb9ee09 src/update_check.rs c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs 9c897664a8b3b288b761b84a724b212c3f6a82b856ceebf3b4ecd6eab1846a35 src/upstream.rs @@ -519,9 +519,10 @@ dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/d 72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs c081925edadee3ffb21867765fcd08d6a536093999ee99c5590b1d5980f8e4b3 tools/checks/h3_navigation_0316.py e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py +e1a8ae9ab6b4ef8387ac0a84e8825e2bfed70e9b64f7cb23ed542fce242a141e tools/checks/ios_scroll_0326_browser.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py -bfb48f57a2e87e4b03fdb21115ecdab12187fc4d1ead9db42f5416b061d21596 tools/checks/mobile_protocol_0326.py +c0f08ee32fb506e946a2cf3308c061da7351e6022337aa9114b21d7ad19203c8 tools/checks/mobile_protocol_0326.py c7ddc69c8bfe5a675ae48d02c76c85d0d0490bc1da1833a5f99dc459d7fe9639 tools/checks/mobile_protocol_0326_browser.py af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs 0387a9e29244b58cf37a6673a88ae2b7fea3aa965c032423f3783a84eab4ba05 tools/checks/overview_0321_layout_regression.mjs @@ -555,6 +556,7 @@ bb862620104f925488e36904e258b7c934a097362ef879ecb275c64008cebb83 tools/checks/s e650225258168eeb9623694c6684f9a5ace92ca94debdff2558b5c77f7c998bc tools/checks/upstream_protocols_0323.py a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py 161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs +bdc20ccb7ee8c67eab6ef22b2011a99079d69fea49d03ab634da7de1b1e7b909 tools/checks/vhost_protocol_badge_group_0326_browser.py ea98e8ce2bdc841814e486ea4c13f94e284b2c50a5887598b7a288f75a14deaf tools/checks/vhost_www_ui_test.mjs 37a74b5fea295efc3ff77609bedf0c3c5352b92418157961419b914d738efe6b tools/checks/web_assets.py 8d2e1837f94302d09a704aca452cc367e6892f289aca3dc2d82c086a2e7de26d tools/checks/web_cache_revalidation_0322.py diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index ae93be1..55e92ff 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,5 +1,43 @@ # Verification report - RustProxy 0.32.6 +## 0.32.6 development follow-up — badge grouping and chart cadence + +- Moved each observed upstream HTTP protocol badge into its Vhost's existing + `vhost-policy-pills` row; preserved the badge class, observed flag, green + and neutral CSS styling. No backend or preference changes. +- Kept network-interface rows and SVGs stable (no replacement on ordinary + refresh), but restored **frequent** history progression: each Overview live + status appends the latest cached interface rates, exactly as the aggregate + network chart does. A system-info-only refresh updates visible rates but does + not append a history point. System measurements are fetched only on the + configured `system_info_seconds` cadence; no extra API call was added. +- Chromium tests: `ios_scroll_0326_browser.py` (45 live renders, unchanged + DOM/scroll/Process height, latest-value reuse and a system-info-only redraw); + `vhost_protocol_badge_group_0326_browser.py` (real Vhost render at 393/1024px, + observed green and unknown neutral colors); static source and Node.js protocol + status checks. `mobile_protocol_0326_browser.py` also passed at six + mobile widths (320-550px). `./dev.sh test` passed all pre-Cargo checks, + including its source/SQL/upgrade/release-smoke and Node.js UI stages, then + exited **127** at the missing-Cargo guard; Rust compilation/tests were not run. + These results do **not** establish iOS 27 Safari acceptance or a compiled + RustProxy binary. + +## 0.32.6 development follow-up — iOS 27 scroll / CSS transform + +- User video shows repeated returns toward Process during mobile scrolling; + it does not include finger touch input, so exact root cause cannot be proven + by visual inspection alone. The Network diagnostics renderer removed and + recreated all interface rows/SVGs on every live sample, potentially + invalidating WebKit's scroll anchor directly beneath Process. +- Initially changed to update chart contents, text nodes and SVG points in place + without replacing interface rows. An interim build appended interface RX/TX + history only on new system measurements; this sampling decision was superseded + by the later development follow-up above, which restores live-rate chart + progression independently of slow system-data acquisition. Retest physical + iOS 27 before declaring the scroll problem fixed. +- CSS transformer preserves descendant pseudo-class combinators; admin pages + carry `Cache-Control: no-transform`. Rust/Cargo check still pending. + ## 0.32.6 mobile presentation and upstream protocol visibility - `./dev.sh test` passed all pre-Cargo source, regression, simulated API, diff --git a/src/admin/router.rs b/src/admin/router.rs index db33116..89c0819 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -277,7 +277,7 @@ fn secure(mut response: Response) -> Response { let h = response.headers_mut(); h.insert( "cache-control", - HeaderValue::from_static("private, no-store, no-cache"), + HeaderValue::from_static("private, no-store, no-cache, no-transform"), ); h.insert( "x-content-type-options", diff --git a/src/admin/web/app/runtime.js b/src/admin/web/app/runtime.js index 7582dfd..a5388db 100644 --- a/src/admin/web/app/runtime.js +++ b/src/admin/web/app/runtime.js @@ -206,8 +206,22 @@ function svgNode(tag, attrs = {}) { for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value); return node; } +// Diagnostic charts keep their SVG and label nodes across samples. Updating the +// existing Text node avoids remove/insert mutations while the user is scrolling. +function setChartText(node, value) { + const text = String(value); + if (node.firstChild?.nodeType === 3 && node.firstChild === node.lastChild) { + if (node.firstChild.data !== text) node.firstChild.data = text; + } else if (node.textContent !== text) { + node.textContent = text; + } +} +// Preserve stable Overview DOM while live samples arrive: replacing network SVGs +// during an iOS WebKit momentum scroll can invalidate the current scroll anchor. +let latestNetworkInterfaceSnapshot = []; function renderNetworkInterfaceCharts(interfaces, appendSample) { const current = Array.isArray(interfaces) ? interfaces.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))) : []; + latestNetworkInterfaceSnapshot = current; const names = new Set(current.map(item => item.name)); for (const name of [...networkInterfaceSamples.keys()]) if (!names.has(name)) networkInterfaceSamples.delete(name); if (appendSample) { @@ -222,37 +236,79 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) { } const target = $("network-interface-charts"); if (!target) return; - target.replaceChildren(); const visible = current.filter(item => item?.name && !hiddenNetworkInterfaces.has(item.name)); - $("network-interface-chart-value").textContent = t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length); - if (!current.length) { target.append(el("p", t("networkNoInterfaces"), "empty network-interface-empty")); return; } - for (const item of visible) { - const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] }; - const peak = Math.max(1, ...history.rx, ...history.tx); - const row = el("div", undefined, "network-interface-row"); - const head = el("div", undefined, "mini-live-chart-head network-interface-row-head"); - const rate = el("span", `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`, "fine"); - const hide = action(t("hideMetric"), () => { hiddenNetworkInterfaces.add(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); }); - hide.classList.add("network-interface-hide"); - const actions = el("span", undefined, "network-interface-head-actions"); actions.append(rate, hide); - head.append(el("b", item.name || "-", "mono"), actions); - const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${item.name} ${t("networkInterfacesHistory")}` }); - svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" })); - svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55" })); - svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55" })); - row.append(head, svg); target.append(row); - } - const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name)); - if (hidden.length) { - const hiddenRow = el("div", undefined, "network-interface-hidden"); - hiddenRow.append(el("span", t("hiddenInterfaces"), "fine")); - for (const item of hidden) { - const show = action(`${t("showMetric")} ${item.name}`, () => { hiddenNetworkInterfaces.delete(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); }); - hiddenRow.append(show); + setChartText($("network-interface-chart-value"), t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length)); + const existing = new Map([...target.children].filter(node => node.classList.contains("network-interface-row")) + .map(node => [node.dataset.interfaceName, node])); + const children = []; + if (!current.length) { + const empty = target.querySelector(":scope > .network-interface-empty") || el("p", undefined, "empty network-interface-empty"); + empty.textContent = t("networkNoInterfaces"); + children.push(empty); + } else { + for (const item of visible) { + const name = item.name; + let row = existing.get(name); + if (!row) { + row = el("div", undefined, "network-interface-row"); + row.dataset.interfaceName = name; + const head = el("div", undefined, "mini-live-chart-head network-interface-row-head"); + const rate = el("span", undefined, "fine network-interface-rate"); + const hide = action(t("hideMetric"), () => { + hiddenNetworkInterfaces.add(name); + saveHiddenNetworkInterfaces(); + renderNetworkInterfaceCharts(latestNetworkInterfaceSnapshot, false); + }); + hide.classList.add("network-interface-hide"); + const actions = el("span", undefined, "network-interface-head-actions"); + actions.append(rate, hide); + head.append(el("b", name, "mono"), actions); + const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${name} ${t("networkInterfacesHistory")}` }); + svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" })); + svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: "0,55 300,55" })); + svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: "0,55 300,55" })); + row.append(head, svg); + } + const history = networkInterfaceSamples.get(name) || { rx: [], tx: [] }; + const peak = Math.max(1, ...history.rx, ...history.tx); + setChartText(row.querySelector(".network-interface-rate"), `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`); + setChartText(row.querySelector(".network-interface-hide"), t("hideMetric")); + row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55"); + row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55"); + row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`); + children.push(row); } - target.append(hiddenRow); - } else if (!visible.length) { - target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty")); + const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name)); + if (hidden.length) { + const shelf = target.querySelector(":scope > .network-interface-hidden") || el("div", undefined, "network-interface-hidden"); + const previous = [...shelf.querySelectorAll("button[data-interface-name]")].map(node => node.dataset.interfaceName); + const changed = previous.length !== hidden.length || previous.some((name, i) => name !== hidden[i].name); + if (changed) { + shelf.replaceChildren(el("span", t("hiddenInterfaces"), "fine")); + for (const item of hidden) { + const show = action(`${t("showMetric")} ${item.name}`, () => { + hiddenNetworkInterfaces.delete(item.name); + saveHiddenNetworkInterfaces(); + renderNetworkInterfaceCharts(latestNetworkInterfaceSnapshot, false); + }); + show.dataset.interfaceName = item.name; + shelf.append(show); + } + } else { + setChartText(shelf.querySelector(".fine"), t("hiddenInterfaces")); + for (const button of shelf.querySelectorAll("button[data-interface-name]")) setChartText(button, `${t("showMetric")} ${button.dataset.interfaceName}`); + } + children.push(shelf); + } else if (!visible.length) { + const empty = target.querySelector(":scope > .network-interface-empty") || el("p", undefined, "empty network-interface-empty"); + empty.textContent = t("networkNoVisibleInterfaces"); + children.push(empty); + } + } + // The common live-sample path updates only text and SVG path attributes. + // Reparent rows solely when interfaces or visibility have actually changed. + if (children.length !== target.children.length || children.some((node, index) => node !== target.children[index])) { + target.replaceChildren(...children); } } function renderDiagnostics(status, appendSample = true, systemSample = appendSample) { @@ -316,10 +372,10 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam $("cache-miss-sparkline").setAttribute("points", sparklinePoints(cacheMissSamples, 300, 60, 5, cacheOutcomePeak)); $("cache-bypass-sparkline").setAttribute("points", sparklinePoints(cacheBypassSamples, 300, 60, 5, cacheOutcomePeak)); const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)]; - $("cache-outcome-chart-value").textContent = latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-"; + setChartText($("cache-outcome-chart-value"), latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-"); const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null; $("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak)); - $("cache-chart-value").textContent = occupancyText(c.resident_bytes, c.hot_limit_bytes); + setChartText($("cache-chart-value"), occupancyText(c.resident_bytes, c.hot_limit_bytes)); const transientLimit = Number(c.transient_limit_bytes); const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0 ? Math.max(transientLimit, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples) @@ -328,11 +384,11 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam $("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak)); $("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak)); $("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak)); - $("cache-transient-chart-value").textContent = occupancyText(c.transient_bytes, c.transient_limit_bytes); + setChartText($("cache-transient-chart-value"), occupancyText(c.transient_bytes, c.transient_limit_bytes)); const coldEnabled = envelope?.config.cache?.cold_enabled === true; const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null; $("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak)); - $("cache-cold-chart-value").textContent = coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"); + setChartText($("cache-cold-chart-value"), coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled")); const processPlatform = [d.platform, d.environment].filter(Boolean).join(" / "); setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", processPlatform); setOptionalMetric("process-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`); @@ -362,9 +418,9 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited")); setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory); $("process-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples)); - $("process-cpu-chart-value").textContent = d.process_cpu_percent == null ? "" : `${fmt(d.process_cpu_percent, 1)}%`; + setChartText($("process-cpu-chart-value"), d.process_cpu_percent == null ? "" : `${fmt(d.process_cpu_percent, 1)}%`); $("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples)); - $("process-rss-chart-value").textContent = d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes); + setChartText($("process-rss-chart-value"), d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes)); $("process-cpu-sparkline").closest(".mini-live-chart").hidden = d.process_cpu_percent == null; $("process-rss-sparkline").closest(".mini-live-chart").hidden = d.process_rss_bytes == null; updateCacheMemorySizing(); @@ -381,11 +437,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam const networkPeak = Math.max(1, ...networkRxSamples, ...networkTxSamples); $("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak)); $("network-tx-sparkline").setAttribute("points", sparklinePoints(networkTxSamples, 300, 60, 5, networkPeak)); - $("network-chart-value").textContent = networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : ""; + setChartText($("network-chart-value"), networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : ""); $("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported; + // Advance interface charts at the live Overview cadence, just like the + // aggregate network chart. Between slower system-info reads, repeat the + // latest observed rates without initiating another system data fetch. renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample); $("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples)); - $("latency-chart-value").textContent = m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`; + setChartText($("latency-chart-value"), m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`); $("latency-sparkline").closest(".mini-live-chart").hidden = m.mean_duration_ms == null; $("compression-counters").textContent = [m.compression_gzip, m.compression_br, m.compression_zstd, m.compression_deflate].map(n => fmt(n)).join(" / "); const age = d.sampled_at_ms ? Math.max(0, Math.floor((Date.now() - d.sampled_at_ms) / 1000)) : null; diff --git a/src/admin/web/style.css b/src/admin/web/style.css index cc33276..da82688 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -10732,15 +10732,8 @@ html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner @media (max-width: 640px) { .acl-profile-rule { grid-template-columns: 1fr; } } /* Latest HTTP version observed on a real request to each configured origin. - A badge without an observation must not claim that Auto selected H1 or H2. */ -.vhost-backend-protocols { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 5px; - min-width: 0; - margin: 8px 0; -} + Protocol indicators share the Vhost policy-pills group but retain their + original colors and observed/unobserved states. */ .vhost-backend-protocol-badge { display: inline-flex; gap: 4px; diff --git a/src/admin/web/vhosts/render.js b/src/admin/web/vhosts/render.js index 5e4e0f2..cdeeb4b 100644 --- a/src/admin/web/vhosts/render.js +++ b/src/admin/web/vhosts/render.js @@ -211,20 +211,6 @@ function render() { ]; for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); } box.append(details); - if ((v.backends || []).length && (v.source?.type !== "web_server" || (v.routes || []).some(route => - !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"))) { - const badges = el("div", undefined, "vhost-backend-protocols"); - for (const backend of v.backends) { - const badge = el("span", undefined, "vhost-backend-protocol-badge"); - badge.dataset.upstreamProtocolVhost = v.id; - badge.dataset.upstreamProtocolBackend = backend.id; - badge.textContent = upstreamProtocolBadgeText(backend, null).label; - badge.title = t("vhBackendProtocolUnobserved"); - badges.append(badge); - } - box.append(badges); - } - const live = el("div", undefined, "vhost-live-grid"); const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); }; metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic"); @@ -253,6 +239,18 @@ function render() { policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill")); if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill")); } + // Keep the upstream protocol indicators with the other Vhost policy pills. + // Retain their own badge class so observed H1/H2 keeps its green styling. + if (hasProxySource) { + for (const backend of v.backends || []) { + const badge = el("span", undefined, "vhost-backend-protocol-badge"); + badge.dataset.upstreamProtocolVhost = v.id; + badge.dataset.upstreamProtocolBackend = backend.id; + badge.textContent = upstreamProtocolBadgeText(backend, null).label; + badge.title = t("vhBackendProtocolUnobserved"); + policy.append(badge); + } + } if (policy.children.length) box.append(policy); return box; }); diff --git a/src/transform/css.rs b/src/transform/css.rs index 3490bc7..b41b968 100644 --- a/src/transform/css.rs +++ b/src/transform/css.rs @@ -52,7 +52,9 @@ pub fn minify(body: &[u8]) -> Result> { last_char = Some(' '); } pending_space = false; - if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && last_char == Some(' ') { + // A space before ':' may be a descendant combinator before a pseudo-class. + // E.g. `.card :is(.child)` must not become `.card:is(.child)`. + if matches!(ch, '{' | '}' | ';' | ',' | '>') && last_char == Some(' ') { out.pop(); } out.push(ch); @@ -71,5 +73,6 @@ fn needs_space(previous: Option, next: Option) -> bool { return false; }; !matches!(previous, '{' | '}' | ':' | ';' | ',' | '>') - && !matches!(next, '{' | '}' | ':' | ';' | ',' | '>') + // A ':' can introduce a pseudo-class after a descendant combinator. + && !matches!(next, '{' | '}' | ';' | ',' | '>') } diff --git a/src/transform/tests.rs b/src/transform/tests.rs index 884723a..2e48d00 100644 --- a/src/transform/tests.rs +++ b/src/transform/tests.rs @@ -405,4 +405,29 @@ mod tests { let received = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&received[..], encoded.as_slice()); } + #[test] + fn css_minifier_preserves_descendant_pseudoclasses() { + let source = r#"html:is([data-corner-style="square"], [data-corner-style="rounded"]) :is(.stat, .card) { border-radius: var(--rp-radius-surface) !important; } + .menu :hover { color: red; } + .menu :not(.hidden) { display: block; } + .menu:hover { color: blue; } + body { color: red; }"#; + let result = css::minify(source.as_bytes()).expect("minify CSS"); + let output = std::str::from_utf8(&result).expect("UTF-8 CSS"); + assert!(output.contains("html:is([data-corner-style=\"square\"],[data-corner-style=\"rounded\"]) :is(.stat,.card)"), "{output}"); + assert!(output.contains(".menu :hover{"), "{output}"); + assert!(output.contains(".menu :not(.hidden){"), "{output}"); + assert!(output.contains(".menu:hover{"), "{output}"); + assert!(output.contains("body{color:red;}"), "{output}"); + } + + #[test] + fn css_minifier_preserves_dashboard_corner_selector() { + let input = include_str!("../admin/web/style.css"); + let result = css::minify(input.as_bytes()).expect("minify panel stylesheet"); + let output = std::str::from_utf8(&result).expect("UTF-8 CSS"); + assert!(output.contains("html:is([data-corner-style=\"square\"],[data-corner-style=\"subtle\"],[data-corner-style=\"rounded\"],[data-corner-style=\"extra\"]) :is("), "lost descendant pseudo-class combinator"); + assert!(output.contains(".card,.stat,dialog,.login-card,"), "lost card selector list"); + assert!(output.contains("border-radius:var(--rp-radius-surface)!important"), "lost radius declaration"); + } } diff --git a/tools/checks/ios_scroll_0326_browser.py b/tools/checks/ios_scroll_0326_browser.py new file mode 100755 index 0000000..acf5c94 --- /dev/null +++ b/tools/checks/ios_scroll_0326_browser.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Regression for live Network diagnostic DOM stability beside Process. + +Runs the actual renderer and the shipped dashboard CSS with mocked sample data +in Chromium touch emulation. This does NOT certify Safari/iOS 27 behavior. +""" +from pathlib import Path +import shutil +from bs4 import BeautifulSoup +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] +WEB = ROOT / 'src/admin/web' +soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser') +process = soup.select_one('[data-dashboard-widget="process_diagnostics"]') +network = soup.select_one('[data-dashboard-widget="network_diagnostics"]') +assert process and network +source = (WEB / 'app/runtime.js').read_text() +start = source.index('function renderNetworkInterfaceCharts(interfaces, appendSample) {') +start = source.rfind('let latestNetworkInterfaceSnapshot', 0, start) +assert start >= 0 +renderer = source[start:source.index('function renderDiagnostics(', start)] +helper = source[source.index('function setChartText('):start] +html = ('' + '' + '' + '
Overview metrics
' + '
' + str(process) + str(network) + + '
') +stubs = ''' +const networkInterfaceSamples = new Map(); +const hiddenNetworkInterfaces = new Set(); +const $ = id => document.getElementById(id); +const t = s => ({networkInterfacesVisibleCount:'{visible} / {total}', + networkInterfaceRate:'Rate', networkInterfacesHistory:'Traffic', + hideMetric:'Hide', showMetric:'Show',hiddenInterfaces:'Hidden', + networkNoInterfaces:'No interfaces',networkNoVisibleInterfaces:'None visible'})[s] || s; +const fmtBytes = n => `${n} B`; +const el = (tag, text, cls) => {const node=document.createElement(tag); + if(text !== undefined) node.textContent=text;if(cls) node.className=cls;return node;}; +const action = (label, fn) => {const node=el('button',label);node.addEventListener('click',fn);return node;}; +const svgNode = (tag, attrs={}) => {const node=document.createElementNS('http://www.w3.org/2000/svg',tag); + Object.entries(attrs).forEach(([key,value])=>node.setAttribute(key,value));return node;}; +const sparklinePoints = (values,width=300,height=60,padding=5,peakOverride=null) => { + const peak=Math.max(1,peakOverride??Math.max(0,...values)); + return values.map((value,index)=>`${index*width/Math.max(1,values.length-1)},${height-padding-Math.max(0,value)/peak*(height-padding*2)}`).join(' '); +}; +const saveHiddenNetworkInterfaces = () => {}; +''' + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox']) + context = browser.new_context(viewport={'width':393,'height':844}, + device_scale_factor=3, is_mobile=True, has_touch=True) + page = context.new_page() + page.set_content(html, wait_until='domcontentloaded') + page.add_script_tag(content=stubs + helper + renderer) + baseline = page.evaluate('''() => { + const items = [{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:20}, + {name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}]; + renderNetworkInterfaceCharts(items,true); + const panel = document.querySelector('[data-dashboard-widget="process_diagnostics"]'); + const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]'); + const target = document.getElementById('network-interface-charts'); + window.testNodes = {first:target.firstElementChild, svg:target.querySelector('svg'), + path:target.querySelector('.network-rx-line')}; + window.testInterfaces = items; + window.scrollTo(0,Math.round(network.getBoundingClientRect().top+window.scrollY+180)); + return {processHeight:panel.getBoundingClientRect().height, y:scrollY, + network:network.getBoundingClientRect().top, count:target.children.length, + scrollHeight:document.documentElement.scrollHeight}; + }''') + assert baseline['y'] > 0 and baseline['count'] == 2, baseline + count = page.evaluate('''() => { + const target = document.getElementById('network-interface-charts'); + let mutations=0; + const obs = new MutationObserver(list=>mutations+=list.filter(item=>item.type==='childList').length); + obs.observe(target,{childList:true,subtree:true}); + const beforeY=scrollY; + const beforeHeight=document.documentElement.scrollHeight; + const beforeProcess=document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height; + for(let i=0;i<45;i++){ + renderNetworkInterfaceCharts([{name:'ens18',rx_bytes_per_second:i*109,tx_bytes_per_second:12}, + {name:'lo',rx_bytes_per_second:i*2,tx_bytes_per_second:3}],true); + if(target.firstElementChild!==window.testNodes.first || + target.querySelector('svg')!==window.testNodes.svg || + target.querySelector('.network-rx-line')!==window.testNodes.path) throw Error('Network DOM replaced'); + } + obs.takeRecords().forEach(r=> {if(r.type==='childList')mutations++});obs.disconnect(); + return {mutations, y:scrollY, beforeY, count:target.children.length, + scrollHeight:document.documentElement.scrollHeight, beforeHeight, + processHeight:document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height, beforeProcess, + changed:target.querySelector('.network-interface-rate').textContent.includes('4796')}; + }''') + assert count['mutations'] == 0, count + assert abs(count['y'] - count['beforeY']) < 2, count + assert count['count'] == 2 and count['changed'], count + assert abs(count['scrollHeight'] - count['beforeHeight']) < 2, count + assert abs(count['processHeight'] - count['beforeProcess']) < 2, count + print('PASS: 45 updates preserve SVG nodes, Process height, document height and scroll') + + # Interface charts advance at every Overview live tick like the aggregate + # network chart, repeating the last observed rate until system-info updates. + # A system-info-only refresh or Hide/Show redraw never adds history points. + history = page.evaluate('''() => { + networkInterfaceSamples.clear(); + const one=[{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200}]; + renderNetworkInterfaceCharts(one,true); + const before=networkInterfaceSamples.get('ens18').rx.length; + for(let i=0;i<45;i++) renderNetworkInterfaceCharts(one,true); + const live=networkInterfaceSamples.get('ens18').rx.length; + const measured=[{name:'ens18',rx_bytes_per_second:500,tx_bytes_per_second:250}]; + renderNetworkInterfaceCharts(measured,false); // only new system-info data + const afterSystem=networkInterfaceSamples.get('ens18').rx.length; + const label=document.querySelector('.network-interface-rate').textContent; + renderNetworkInterfaceCharts(measured,true); // next live tick + const afterLive=networkInterfaceSamples.get('ens18').rx.length; + const newest=networkInterfaceSamples.get('ens18').rx.at(-1); + return {before,live,afterSystem,afterLive,label,newest}; + }''') + assert history['before'] == 1 and history['live'] == 46, history + assert history['afterSystem'] == 46 and history['afterLive'] == 47, history + assert history['newest'] == 500 and '500 B' in history['label'], history + assert 'renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample);' in source + refresh = (WEB / 'app/refresh.js').read_text() + assert 'interval: () => options.system_info_seconds' in refresh + assert 'renderDiagnostics(latestStatus, false, true);' in refresh + print('PASS: frequent interface chart history, configured system-info cadence, no extra samples on system-only redraw') + page.evaluate('''() => renderNetworkInterfaceCharts([ + {name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200}, + {name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}],false)''') + + # The Hide / Show controls retain the newest sample on click, not the array + # captured when a chart node was created. + page.locator('.network-interface-hide').first.click() + assert page.locator('.network-interface-row').count() == 1 + assert page.locator('.network-interface-hidden button').count() == 1 + page.locator('.network-interface-hidden button').click() + assert page.locator('.network-interface-row').count() == 2 + assert page.locator('.network-interface-hidden').count() == 0 + page.evaluate('''() => renderNetworkInterfaceCharts([{name:'eth0',rx_bytes_per_second:9,tx_bytes_per_second:11}],true)''') + assert page.locator('.network-interface-row').count() == 1 + assert page.locator('.network-interface-row').first.get_attribute('data-interface-name') == 'eth0' + page.evaluate('''() => renderNetworkInterfaceCharts([],true)''') + assert page.locator('.network-interface-empty').count() == 1 + print('PASS: Hide/Show actions, hotplug/removal and empty state still work') + browser.close() diff --git a/tools/checks/mobile_protocol_0326.py b/tools/checks/mobile_protocol_0326.py index d0d8ae5..584bb24 100755 --- a/tools/checks/mobile_protocol_0326.py +++ b/tools/checks/mobile_protocol_0326.py @@ -23,6 +23,16 @@ assert 'if (ordered.some((node, i) => node !== previous[i])) grid.append(...orde assert html.count('data-dashboard-widget=') >= 62 assert 'function renderUpstreamProtocolBadges(status)' in render assert 'const VHOST_PROTOCOL_FRESH_MS = 5 * 60 * 1000' in render +# Protocol badges live in the existing Vhost policy-pill group, not above the +# live traffic metrics. The class and observed-color state remain unchanged. +policy_start=render.index('const policy = el("div", undefined, "vhost-policy-pills");') +policy_end=render.index('if (policy.children.length) box.append(policy);', policy_start) +policy_block=render[policy_start:policy_end] +assert 'policy.append(badge);' in policy_block +assert 'vhost-backend-protocol-badge' in policy_block +assert 'box.append(badges)' not in render +assert '.vhost-backend-protocol-badge[data-observed="true"]' in css +assert 'color: var(--green)' in css[css.index('.vhost-backend-protocol-badge[data-observed="true"]'):] assert 'self.inner.observe_protocol(value.version())' in client assert '"upstream_protocol_observed"' in pool print('PASS: mobile grid, shape, stable reorder and observed backend protocol source contracts') diff --git a/tools/checks/vhost_protocol_badge_group_0326_browser.py b/tools/checks/vhost_protocol_badge_group_0326_browser.py new file mode 100755 index 0000000..906cd36 --- /dev/null +++ b/tools/checks/vhost_protocol_badge_group_0326_browser.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Render real Vhost cards with mock config/status to verify badge placement/color. + +Headless Chromium fixture only: does not verify authenticated Rust runtime/ALPN. +""" +from pathlib import Path +import shutil +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] +WEB = ROOT / 'src/admin/web' +source = (WEB / 'vhosts/render.js').read_text() +start = source.index('const VHOST_PROTOCOL_FRESH_MS = ') +end = source.index('function backendTrafficRate(', start) +renderer = source[start:end] +css = (WEB / 'style.css').read_text() +html = (f'' + f'' + f'
' + f'
' + f'
' + f'
') +stubs = r''' +const $ = id => document.getElementById(id); +const t = key => key; +const locale = () => 'en-US'; +const el = (tag, value, cls) => { + const node=document.createElement(tag); + if (value !== undefined) node.textContent=value; + if (cls) node.className=cls; + return node; +}; +const text = (tag, key, cls) => el(tag, t(key), cls); +const action = (name, fn) => { + const btn=el('button', name, 'quiet'); + btn.addEventListener('click', fn); + return btn; +}; +const button = (name, fn, cls) => { + const btn=action(name, fn); + if (cls) btn.className=cls; + return btn; +}; +const initialized=true; +let editing=null, latestStatus=null; +const vhostListPrefs={sort:'id',descending:false}; +const envelope={config:{listeners:{endpoints:[]},vhosts:[ + {id:'proxy-test',enabled:true,frontend:{hosts:['proxy.example'],listener_ids:[]}, + source:{type:'reverse_proxy'},force_ssl:true,websockets:true, + backends:[{id:'origin1',protocol:'auto'}, {id:'origin2',protocol:'http1'}]}, + {id:'static-test',enabled:true,frontend:{hosts:['static.example'],listener_ids:[]}, + source:{type:'web_server',web_service_id:'public'}, + backends:[{id:'unused',protocol:'auto'}]}, +]}}; +''' +with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox']) + for width in (393, 1024): + page = browser.new_page(viewport={'width': width, 'height': 844}) + page.set_content(html, wait_until='domcontentloaded') + page.add_script_tag(content=stubs + renderer + '\nrender();') + initial = page.evaluate('''() => { + const card=document.querySelector('[data-vhost-card="proxy-test"]'); + const badge=card.querySelector('.vhost-backend-protocol-badge'); + const group=card.querySelector('.vhost-policy-pills'); + const staticCard=document.querySelector('[data-vhost-card="static-test"]'); + return {badges:group.querySelectorAll('.vhost-backend-protocol-badge').length, + otherPills:group.querySelectorAll('.pill').length, + parent:badge.parentElement===group, + lastChild:card.lastElementChild===group, + separateRows:card.querySelectorAll('.vhost-backend-protocols').length, + staticBadges:staticCard.querySelectorAll('.vhost-backend-protocol-badge').length, + initialText:badge.textContent, + scroll:document.documentElement.scrollWidth,screen:innerWidth}; + }''') + assert initial['badges'] == 2 and initial['otherPills'] == 2, initial + assert initial['parent'] and initial['lastChild'], initial + assert initial['separateRows'] == 0 and initial['staticBadges'] == 0, initial + assert initial['initialText'] == 'origin1 · AUTO → ?', initial + assert initial['scroll'] <= initial['screen'] + 2, initial + appearance = page.evaluate('''() => { + const badge=document.querySelector('[data-upstream-protocol-backend="origin1"]'); + const expected=token=>{const n=document.createElement('span');n.style.color=`var(${token})`; + document.body.append(n);const c=getComputedStyle(n).color;n.remove();return c;}; + const unknown=getComputedStyle(badge).color; + renderUpstreamProtocolBadges({backend_pools:[{id:'proxy-test',members:[ + {id:'origin1',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:Date.now()-1000}, + {id:'origin2',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:Date.now()-1000} + ]}]}); + const h2={text:badge.textContent,observed:badge.dataset.observed, + color:getComputedStyle(badge).color,expected:expected('--green')}; + return {unknown, expectedUnknown:expected('--muted'), h2}; + }''') + assert appearance['unknown'] == appearance['expectedUnknown'], (width, appearance) + assert appearance['h2']['color'] == appearance['h2']['expected'], (width, appearance) + assert appearance['h2']['observed'] == 'true' and appearance['h2']['text'] == 'origin1 · AUTO → H2', appearance + print(f'PASS: {width}px Vhost protocol badges share policy group and preserve green/neutral states') + page.close() + browser.close()