v0.32.6
This commit is contained in:
1 parent
e3de084149
commit
e1ac899ec0
14 files changed
+471
-68
No files matched your search
+13
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-11
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
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 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 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", `${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); });
|
||||
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", item.name || "-", "mono"), actions);
|
||||
const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${item.name} ${t("networkInterfacesHistory")}` });
|
||||
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: 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);
|
||||
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);
|
||||
}
|
||||
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"));
|
||||
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(current, false); });
|
||||
hiddenRow.append(show);
|
||||
const show = action(`${t("showMetric")} ${item.name}`, () => {
|
||||
hiddenNetworkInterfaces.delete(item.name);
|
||||
saveHiddenNetworkInterfaces();
|
||||
renderNetworkInterfaceCharts(latestNetworkInterfaceSnapshot, false);
|
||||
});
|
||||
show.dataset.interfaceName = item.name;
|
||||
shelf.append(show);
|
||||
}
|
||||
target.append(hiddenRow);
|
||||
} 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) {
|
||||
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty"));
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -52,7 +52,9 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
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<char>, next: Option<char>) -> bool {
|
||||
return false;
|
||||
};
|
||||
!matches!(previous, '{' | '}' | ':' | ';' | ',' | '>')
|
||||
&& !matches!(next, '{' | '}' | ':' | ';' | ',' | '>')
|
||||
// A ':' can introduce a pseudo-class after a descendant combinator.
|
||||
&& !matches!(next, '{' | '}' | ';' | ',' | '>')
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Executable
+148
@@ -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 = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
|
||||
'<head><meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
'<style>' + (WEB / 'style.css').read_text() + '</style></head>'
|
||||
'<body style="margin:0"><div style="height:1020px">Overview metrics</div>'
|
||||
'<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned" '
|
||||
'id="dashboard-overview-grid">' + str(process) + str(network) +
|
||||
'</div></section><div style="height:160px"></div></body></html>')
|
||||
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()
|
||||
@@ -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')
|
||||
+99
@@ -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'<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded">'
|
||||
f'<head><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
f'<style>{css}</style></head><body><section id="panel-vhosts">'
|
||||
f'<div id="backend-diagnostics-card"></div><input id="vhost-search">'
|
||||
f'<div id="vhost-list"></div><span id="vhost-editing"></span>'
|
||||
f'</section></body></html>')
|
||||
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()
|
||||
Reference in new issue
Block a user