diff --git a/AGENTS.md b/AGENTS.md index fdde1fd..589c3ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for -compatibility. Current source version: 0.32.5. +compatibility. Current source version: 0.32.6. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index ff9ee4a..dbb01e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,68 @@ > Operator-facing release history. Historical engineering notes are kept in > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.32.6 + +- **Development-branch Appearance layout:** replace the long + half-width Overview cards settings list with a full-width, compact horizontal + ribbon. Keep every visibility switch and reorder control operational, add + left/right browsing, and provide an optional expanded grid. Preserve stored + layout preferences, theme shapes, and the visual layout editor. + +- **Development-branch dashboard polish:** keep the Cache + hits/misses/bypasses per-second chart visually continuous across browser-tab + minimize/restore by reusing the last valid rate sample until a fresh interval + is available again, rather than leaving a visible gap. Preserve the 3 s + Overview cadence and do not change server polling. +- **Development-branch mobile/layout polish:** make the + Overview page-intro action buttons equal-height and symmetric on mobile, + tighten the Appearance → Dashboard layout → Overview cards editor into a more + horizontal wrapped control strip, increase the gap between the `p` and the right border of the + RustProxy logo mark, and normalize the Overview canvas gutter so small-small, + small-large and large-large dashboard block transitions use the same spacing. + + +- **Development-branch diagnostics charts:** publish cached + CPU, process CPU/RSS and per-interface RX/TX samples in compact live status; + charts follow the Overview update cadence (default 3 s, accepting saved 2 s) + while all Process details still follow the selected System-info refresh. The + OS collector cadence remains unchanged, with no extra probe per SSE event. + Remove nested mobile scrolling in the interface chart list as a mitigation + for reported WebKit jumps near Process/Network; physical iOS 27 validation is + still required. Match the Origin protocol badge's radius to neighboring + policy pills for both default and configured corner styles, preserving colors. +- **Development-branch rate continuity:** preserve cache + HIT/MISS and other counter-derived chart histories when a browser tab is + suspended, minimized or restored from BFCache. Treat resumed/overdue + observations as new counter baselines, not artificial 0/s samples. Show + a missing-rate marker until the next fresh interval; retain real 0/s + values. Apply one shared rule to Cache, Transform, Overview, backend, + vhost and Stream charts without adding polling or changing System-info + scheduling. Add isolated JavaScript pause/resume and reset tests. +- **Development-branch follow-up:** 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 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. + ## 0.32.5 -- Fix the administration panel startup `ReferenceError: Cannot access 'i18n' before initialization` in the new DNS/DDNS ACL editor. Define its localization helper before creating the DNS hint; preserve all ACL settings and translations. Add a JavaScript startup regression test to the release checks. -- Fix Rust `E0505` during dynamic ACL DNS resolution: give `lookup_host` an independently owned hostname, while returning the original normalized cache key. The resolver timeout, allowlist expiration and fail-closed behavior are unchanged. - Add bounded DNS/DDNS hostnames as **allow-only** client ACL rules in reusable profiles, host policies, vhosts, locations and Docker ACL assignments. - Refresh A/AAAA answers asynchronously every 60 seconds and after config @@ -19,13 +77,6 @@ ## 0.32.4 -- Fix Rust `E0382` in retry construction: clone the upstream URI before - passing it into the request builder so authority normalization can borrow - the original URI afterward. Keep backend destination, Host and retry rules. -- Reduce Overview gutters uniformly (normal 4px, dense 3px, comfortable - 12px). Override the older dashboard-density spacing selectors for the - aligned grid so tile-to-tile and tile-to-panel transitions match in both - directions without changing row symmetry. - Allow `preserve_host` for HTTP/1.1, HTTPS Auto H2/H1 ALPN, legacy compatible HTTP/2 and strict H2/H2C. Forward the public Host consistently as HTTP/2 `:authority` while keeping DNS/TCP pinned to the configured origin URL and diff --git a/Cargo.lock b/Cargo.lock index 566f14b..07eb6ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.32.5" +version = "0.32.6" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 56f9bb5..ed4cf35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.32.5" +version = "0.32.6" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index 7f299ad..fff1c0e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustProxy 0.32.5 +# RustProxy 0.32.6 **0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md). @@ -7,7 +7,58 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos **0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.** -## What changed in 0.32.5 +## What changed in 0.32.6 + +- **Appearance Dashboard layout ** Overview cards now use a + full-width horizontal strip instead of a long list in the right half of + Settings. Navigate sideways with the arrow buttons, toggle card visibility + or reorder in place, or select **Show all cards** to expand to a full grid. + The existing visual editor and saved per-account preferences are unchanged. + +- **Rate-chart pause recovery:** share one + counter continuity policy across Cache HIT/MISS/BYPASS, Transform, HTTP + Overview/protocols, optional wide/small charts, backend and Stream/Vhost + traffic charts. After tab hide/pagehide (including BFCache) or a delayed + refresh, keep plotted history, use the first reading as a new baseline, + and resume rate points on the next normal interval. A genuine measured + zero still appears as 0/s. Cache and Transform intervals and the separate + System information interval remain configurable; no additional polling. + +- **Development fixes :** 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. Current + CPU/RSS and per-interface RX/TX chart points now come from the existing + background OS sampler through compact SSE/fallback data, every **3 s** by + default (saved **2 s** settings remain valid), while Process details still + follow the configured System information refresh. On mobile the Network + interface chart list no longer traps touch scrolling in a nested scroll area. + Origin protocol badges use exactly the same rounding as adjacent pills for + all corner presets, without changing their color. 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 + reparenting/scroll anchoring during Dashboard preference refreshes. +- **Global corner presets:** apply the selected Appearance shape consistently to + vhost cards, status badges, entity cards and secondary panels as well as the + 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, 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. +- **Tests:** Chromium simulated-mobile layout/scroll and shape presets, + JavaScript live-status protocol badges and Rust response-version recorder test. + The actual Rust build, iOS Safari interaction and end-to-end negotiated ALPN + verification remain pending on a deployment host. + +## Previously in 0.32.5 - **ACL editor startup hotfix:** fix the JavaScript `i18n` initialization order that could prevent the entire administration UI from loading. This does not @@ -102,7 +153,7 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos - **Overview readability at 90% UI scale:** compare the supplied 0.31.9 source with 0.32.0, remove the latter's forced desktop card heights and restore content-sized rows. The six-column desktop layout and visible chart legends remain intact. Increase compact Dashboard headings (10 → 11px), values (22 → 25px), secondary text (8.5 → 10px) and the Payload breakdown (9 → 10px). - **Consistent tile spacing:** Overview now uses `--ui-gap`, the same density-aware spacing variable as other Dashboard sections, instead of its own fixed 10/8/6px spacing in normal/compact/ultra Dashboard modes. UI scale (including 90%) affects all gaps equally. -- **Appearance hints without horizontal scrolling:** Dashboard layout Stream card descriptions now use native, localized browser tooltips and accessible button descriptions instead of absolute-positioned custom bubbles. The settings grid fits narrower containers and obsolete tooltip CSS has been removed. +- **Appearance hints without overflow-causing tooltips:** Dashboard layout Stream card descriptions now use native, localized browser tooltips and accessible button descriptions instead of absolute-positioned custom bubbles. The settings grid fits narrower containers and obsolete tooltip CSS has been removed. - Preserve Dashboard visibility/order preferences, density choices, responsive column breakpoints and opt-in Stream cards. No runtime, API, SQLite, proxy routing or language-pack changes. The Rust-independent `./dev.sh test` checks passed, then stopped with code 127 because Cargo is unavailable; **Cargo/build and authenticated-browser validation remain open** until executed on a supported runtime. ## Previously in 0.32.0 @@ -201,7 +252,7 @@ Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost - Improved mobile panel loading behind reverse proxies: one ordered JavaScript resource, versioned assets and retry of temporary configuration errors. - **Panel startup/mobile follow-up:** replace the former white loading state with a RustProxy spinner, an automatic delayed reload suggestion and an error screen with reload link. Paint the shell before waiting for preferences/configuration/initial status; the header's **Refresh now** action sits beside the Overview title without adding a row. Negotiate Zstd/Brotli/gzip for fixed panel HTML and assets, with one-time encoding per resource; preserve public login and identity clients. -- Refresh Overview through a shared, authenticated SSE channel with per-administrator live pacing (1, 2, 5 or 10 seconds); fallback status/log polling is active only after SSE failure. Full configuration and system diagnostics are not serialized on every live event. +- Refresh Overview through a shared, authenticated SSE channel with per-administrator live pacing (1, 2, 3, 5 or 10 seconds; default 3); fallback status/log polling is active only after SSE failure. Detailed system diagnostics remain in the separately scheduled API; live status carries a small cached CPU/RSS/network chart snapshot. - **Settings → Appearance → Data refresh** now stores per-account Live enable/disable and independent Traffic, Cache and System information intervals. Manual mode (`0`) and the global Live switch disable automatic requests while preserving manual refresh, charts, filters and pagination. - Poll only the current Traffic/Cache view, and fetch system diagnostics independently from `/api/system-info` when Overview or the relevant Settings section needs them. The previous full `/api/status` response remains supported, while `?view=live` provides compact fallback. - Centralize dashboard polling with cancellation/in-flight guards, hidden-tab pause and logout cleanup; retain SQLite schema 9, previous preferences, proxy traffic and the existing authentication model. diff --git a/ROADMAP.md b/ROADMAP.md index eee9222..9eda0da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,36 @@ # RustProxy roadmap +## 0.32.6 - Mobile Overview and upstream protocol visibility (runtime acceptance pending) + +- [x] Share counter-rate pause/resume handling between Cache, Transform, + Overview, backend, Stream and Vhost plots. Retain historical points after + backgrounding, skip suspended intervals and restart normal samples after + one fresh counter baseline, with no new polling. +- [ ] Validate actual minimized-tab/BFCache chart behavior in iOS 27 Safari. +- [x] Development follow-up: preserve Network diagnostic chart DOM through + periodic refresh and fix descendant CSS selector minification. +- [x] Feed Process CPU/RSS and per-interface network charts from the cached + background OS sampler through live status (default 3 s). Leave slow Process + details on `system_info_seconds` and retain valid 2-s saved preferences; do + not add per-request probes. Group upstream badges with other Vhost badges + and match their corner radius while preserving observed protocol colors. +- [x] On phone-size Overview, remove the nested Network interface chart scroll + region, while retaining chart SVG/text nodes on ordinary live updates. +- [ ] 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 + scroll and stable values/row boundaries during live updates. +- [x] Include last HTTP protocol observed from real proxy upstream responses + in the authenticated status and each Vhost's per-backend badge. Show unknown + when no fresh observation exists instead of guessing Auto's ALPN outcome. +- [x] Add simulated mobile Chromium and mocked status UI regressions plus a + Rust unit regression for protocol-version observation. +- [ ] Run Cargo checks/tests, real iPhone Safari scrolling and live upstream + TLS ALPN H1/H2 behavior before production approval. + ## 0.32.5 - Dynamic DNS client ACLs (runtime acceptance pending) - [x] Preserve CIDR/IP rules and introduce allow-only FQDNs for client ACLs. diff --git a/SHA256SUMS b/SHA256SUMS index 842b779..7fb0f6c 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -ad0382153576411fb19861072635c02bdf33de740d6f122d8f27bd9c52ce112a AGENTS.md -4a58695f0ef64344228cb43caf2a78aeba724cf0cc573a6a8745da847681aeed CHANGELOG.md -2f1de87bae58761fc8690ec7225520e3a16213c77cbbb5981e34f12a95810668 Cargo.lock -8c76ffaf4adba93df26e30f3dd163e0e5e201d069d1ae69c071ca42d7fc7264e Cargo.toml +a402ee07826dfab1b1d63b98c4a9f75809f056b908c92abacf0bba816d7ad133 AGENTS.md +e4a565f39f4ecca622f0eb27822c7399e45b4c4d1a2fd1f669185d53196a969b CHANGELOG.md +934ec0e7f8a307b00319606a9c5d7e91dac6449a23dd1f3abcefe4304eb4de06 Cargo.lock +8a7606650ce03f1faf36c893a17ccf1c9e8da4259e0248ac2c9bf030a64872d1 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -4ee3c626d1e5da5079fe65c6343f085cbb8bb53c2f2fa97e354f9633e8d415b3 README.md -fc03a8d19055642313f0b34bcd22e1cce17efc4d91be7fe7e4819e33b91c531d ROADMAP.md +0f8cedf595f57f96644e8d4dc608b43e6ebd9a73b31157a210d624492a6c5206 README.md +da519345b2bce492e747c268df2730971543d1bdb198c3e854671a7d107e5609 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service -4d9baf35c70d9f5cfb10f8bd4caab543772fdda1e979ff74c17f1e3ebc8e3cd0 dev.sh +26a607151fd343a59c6f147e70897c4b1bfeab376348f3270eb8a373503d7be5 dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example @@ -27,7 +27,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md -1dc71311873be1372ae2383487b6c5924e288f5774496e315edf24faa35cf08a docs/API.md +627f1d8727321ef8961b153aabab7448f239fa383177bb9ef115cfeef48452da docs/API.md f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md 6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md @@ -61,9 +61,9 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md -25a06e7ddbb95d286e849b2fc5ba4adeedbddba0bad948e55bd91d962fd606de docs/SETTINGS.md +a41cef4a4da5e098fe3e9dfa0fffb3625f2e09792eacdf5dbf8d8fd532146d26 docs/SETTINGS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md -97299efb6288ee29493eb7df243055d1ff76e8d0faf04bd195e184bce2e8bb67 docs/TEST_REPORT.md +130befc0f8216214567dd4b2df2574ea751b56b22c8f2cc3ba48e8d0aaccc3b5 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md @@ -95,8 +95,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md -1c3bdb963bc4fa4eef48439721d7fee54375458f3a1d44f9e31c0c4ed7aca4f0 langs/en_US.json -70d8ad7d503118356721ff7953b829049da793934dd73f4abbbbd7c7f0b4fc63 langs/pl_pl.json +a1f873bfe87d090bac46962598aeb90ddfa62ae257b3b76ec1af011c74197bac langs/en_US.json +ca95ffe6919c07699155d1c49fa711916f91f0fbe928e5a36be23f05519ffbbb langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -154,9 +154,9 @@ b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/cert ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs -7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs +74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs 33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs -c1b8ab15455a8199fbe011c6a7dd203572c4d4772c107d30531a6fb38a92ace2 src/admin/openapi.json +9426fba372656cb5abcc76e88e3885d1e9abc7ecf04653abc8c31803aeece019 src/admin/openapi.json d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs @@ -165,30 +165,31 @@ cd0fda2b5a7b86a6619d6fa7cf5cef5a2598f033a8e9304475c4a05a169c958c src/admin/pref c17ccba5202d375d4cc593f7e20979f2b796362d87660b898feca0af5a135bfc src/admin/preferences_tests.rs 055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs -6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs -ab8d92d79d0c4d595fdb2558ea877fa8cfa17bf07708375fef4668c4367950fe src/admin/router.rs +9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs +a9a8b9cce86990d5759eba2f9b29dce885fef056d4f81775bcbd429f4c00bba3 src/admin/router.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs -e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs +479eca6162df5152ce536727b1090dd8e2e262775f2c8cf5bb2c8fa40cb2a600 src/admin/status_live.rs c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -7be070a259d534fd6d1069207a464434602d94143f8f463b1dddb1c45f3dc797 src/admin/tests.rs +18b778c2c4e723e2fe5cbaaef20effa6ea50fcc35d507ebde2361c9b6eda62b2 src/admin/tests.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js -f7760d52336fa3968ed793806e68d8ffa15299e567cef90cd7a1ce66dd30488d src/admin/web/app/backend-dashboard.js +326e404ea67b23318a22cc6161ea42b93644b16c3897e5d72e95636a6ea2fccc src/admin/web/app/backend-dashboard.js d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js -446ad63b6ff96e9ebc6455c454c539703aa751d4c26386abbb8512ceff9e3d42 src/admin/web/app/core.js +acb778341181553585b66ba35e7b71fda33246570f125ffce349ba2cf0394fa4 src/admin/web/app/core.js dec0b875086e3567486191231056bfc6c1edef006f7aad7c88f19ef37c034570 src/admin/web/app/dashboard-inline-layout.js dc097b9c6ec8ebd4fa53ed8b0a641c9446fe44184c74246b521cfe49cf5a32b3 src/admin/web/app/dashboard-layout-editor.js -fc5b025bad26684c8cf0a9917a7a24fdac3aa79414001335f29fb7360c3fa703 src/admin/web/app/overview-extra-metrics.js +e5928664c259feab3e9cfd3be69d05571f3aa60445ca373560f50857ecc8bfc0 src/admin/web/app/overview-extra-metrics.js fb0ba435beaa91e518537606f5fe55b0eea63c70efd290798c8d9754fa6771b2 src/admin/web/app/overview-masonry.js -79d31e2002f8cf334f87285f6f2e6dbf69f3647de784ae26efadb92c126bc7a0 src/admin/web/app/overview-wide-metrics.js -947c53fe10d97f5d3aa64ae3b819d7bcdb2a83a792747826acbc3750e313014f src/admin/web/app/preferences.js -6a544baaf40763b4b612e53845b3bc33396b9bb6518565fd6b7c128c278b8d5d src/admin/web/app/refresh.js -41f635a510d6f08b37484f0e260929926b93a02a792bdfa05b31ed7ebd682fef src/admin/web/app/runtime.js +c4d59e129837db854f2851ac69130325f6d2f25fd1b67a009354b83da1dfbf02 src/admin/web/app/overview-wide-metrics.js +7e0a4dc7274a1d1e4cc928e360a6b77c48a71d586b585a9e28c3bb2eeb17d46a src/admin/web/app/preferences.js +8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js +43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js +7eee9fd6f930288ee3f87dd7a2c64bddb24ae5326cf69600accc376fb015fc87 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 -a854e08f947a4d13fc4b27286a096dcb2773dc0358a28b27703a2320ba7e8bf7 src/admin/web/cache-panel.js +fbd2c402828088695f5f5f4b210d1ea8da24ae6425f61248c82c5b07b14afd56 src/admin/web/app/stream-dashboard.js +0603dc8793a731a0e582cbbec53aa6d1cc6e007faafcec5d8843730b2cdc39e1 src/admin/web/cache-panel.js 02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js da3683f3aa0cec4f86b0ff0fb105959caac4ce65c41a9391f5adb3c289e8ff1c src/admin/web/dashboard-widgets.json 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html @@ -212,7 +213,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js -c24411dd62afaecfc631682554d4a037c7dd27100d817e9901ccdfd505c2ed5b src/admin/web/index.html +1335cf75c5da63bdb2b9978aa79ec3ad37fb66e6c3cb38647375227c37c2442f src/admin/web/index.html 03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html @@ -221,16 +222,16 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/ 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html -e3075af59644e42fa5735574d8e9caa1bcebfe8de184b78aff9f4026b91915f1 src/admin/web/streams.js -065a377512c146254a19ce03c299f9d07f54e4f2cb0bccae965ac63e3a96063f src/admin/web/style.css +c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js +cd89785fc8f27353983846c799f9256a1a893d775cb4853fb62b76c29e42f8c7 src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js -a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js +69f412bd897e45f4889b71e9982cfff56df9ef20ba2da109b454713a5ecd7c74 src/admin/web/transform-panel.js d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js 8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js 2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js -dfab53c9b9d4c633db639c37e99a46169aa86f7a28e6731e305c27ac8587a7ef src/admin/web/vhosts/render.js +b991e81a98dd604a1d7c30158c0cd8cd825479bd2cd94b082b3550de1071bb58 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 @@ -250,7 +251,7 @@ a1e5d43f5417d71f3974812cb539c0932f206e2290090917f067494f3587fc72 src/balance/le 4d3aa415af2fbb42a4e19f2ca10be8b1d1ad42f366a013cd9c33806abc1fbea2 src/balance/model.rs 41c3c020a60c372dbf965bd277c330dd9cf2ab079f6608b472adf72a656f4cb3 src/balance/pool_core.rs 477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs -8f73e8fe7f57d8d720a8ce59ae94fc55ab03f6da59d50bfe6ae929815f4f3bfa src/balance/pool_view.rs +8abb6048303ee8c022546a528d5693167766e7450efd8e20f37ec6fd0fa9ef5e src/balance/pool_view.rs 656a6ab3c9855f92b7e3e0dd6de1bcc408da003db8bcfcda52b71c5234c56693 src/balance/tests.rs 5049d2bd01a9c866f3bad2288d8d594df96bba6045dfe15cd22b4173562b7388 src/basic_auth.rs 9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs @@ -436,7 +437,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,12 +448,12 @@ 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 -6d7a1798911add0905e316d5ef682b0977a42b8bf9e4c1976733aaf393a027bd src/upstream/client.rs -4aa2420a92d33c50f7027f57ac4eccdc050bdb7e9ec8fa6e0eaa809228b71cd8 src/upstream/client_tests.rs +9f05552d0cdc523e37bc73214e35e472f271250fd4f95ddbbefda4c39f96ebd9 src/upstream/client.rs +ad29f3fc15c9b627b5d752af5adc9f99ee1364fec2df63f1e4509cb223988efb src/upstream/client_tests.rs 75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs 8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs @@ -497,63 +498,72 @@ e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/a 0060f8217640403c6af2d5091fdc9087d0fb2fc30cd545a3c58277a396d8121c tools/checks/admin_public_origin_0314_sim.mjs fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py c9d0bef4b8ed7a2f49b0fad3e116cace62d3e6e1ca31e02a3073d1fdde5157d8 tools/checks/admin_themes_0323_ui_test.mjs -02b02ad7831524f963da3b80b1b42bb6efef9c4a63ce89649b18f70a0110d32d tools/checks/appearance_ux_0323_browser.py +5b7d146a4558b2435fb4e8813db9e20e446988003fff884b3e132b28d926fa3c tools/checks/appearance_ux_0323_browser.py 8b6e1a355cae58901118b399fa2d165f7e3fd8465c89cac82776906b8b23f0a6 tools/checks/appearance_ux_0323_ui_test.mjs f3dba206c60cd2e6fcc5f4a2c8d652ccf47362694448312cc1f8dc4112d93e7e tools/checks/backend_cache_0297_ui_test.mjs -f34f0f78edc4b6c6226006337090c72d1f8e57ddd77939b37cf00538a05abfa8 tools/checks/backend_dashboard_0323_ui_test.mjs +e82765ae946461952e68369f0b16f0a99f01e625bd61d2551513504f7b2f4d46 tools/checks/backend_dashboard_0323_ui_test.mjs 45cd21192248ffe6f66c20bab03da1a5ce299f040883764d09a640859e19924d tools/checks/backend_health_0300_ui_test.mjs -48359559ca415890acbef0f2717bb4b7acacdc26b6cf5f78135a9d9637f83547 tools/checks/certificate_api_0319.py +a135c9456143975279f18233caab44b8c64f5025ef4ceeb1f2ad22090b588019 tools/checks/backend_protocol_0326_ui_test.mjs +a1fb118e3a32819689cb607d574b0060ca29ff1f6a68c505231a4e88ea93b5de tools/checks/certificate_api_0319.py 526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs -d48c5080b34d2df18d0f3bf16cf7b58a36efbe4f6f2b96724581dea2b1a91544 tools/checks/check_source.py +71cf48ba645ae2b7a95c73faa362d12942553e37d79680b796edf29c1484d9eb tools/checks/check_source.py +a1ac2ab24958c6e8527c7797f224f94e765abd7173d4e5085a64362b193112c1 tools/checks/dashboard_appearance_ribbon_0326_browser.py +0ab28370c06b2258321e20d27d63098c339fd9dc3b6f62aef6f5266c81ff28b5 tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs 7508f232a9e48049002dd4c9f34f9737bc92d304c51219f50430578dbc721855 tools/checks/dashboard_inline_layout_0323_ui_test.mjs dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/dashboard_layout_editor_0323_ui_test.mjs 259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs -f6a6d12275bc8ba627b4616a546d075511094bb064cd9058231cc86ca489743e tools/checks/dependency_upgrade_0312.py +7b119f62f11d65600b19010d600682955afe269d798df15676b397f70ffaa37d tools/checks/dependency_upgrade_0312.py 38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs -3396e5a510787e70e2ad8d1fad90356af461a88fd84429525d56fa82113da0f3 tools/checks/docs_check.py -3fc765a7c2723ec783f269cfebc29ad2c09a90f33ea4af58285f883a026c4fa2 tools/checks/dynamic_acl_0325.py +6ca752ed5e767dc1eed4e1d7b60270391e1dd0bd5c887de8fd0f9dcaf219d7bd tools/checks/docs_check.py +48f0901a709f415f518d2a5eeb779180ac18c879e18f1ad7af748b1be9ac92e1 tools/checks/dynamic_acl_0325.py 9c77764561b6ebfeecbc228f43ea29a0dd9f44121ad773c2e4299b92758e45c2 tools/checks/dynamic_acl_startup_0325_ui_test.mjs 5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs 72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs -1b5ead59bb2ea0e1ff32bf085f791018149cb9cc29b071fae4fb98ea80010824 tools/checks/h3_navigation_0316.py +c081925edadee3ffb21867765fcd08d6a536093999ee99c5590b1d5980f8e4b3 tools/checks/h3_navigation_0316.py e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py +fb8619444edf81afa604194f750dc76335394a73c74324eda59e392874f29ee8 tools/checks/ios_scroll_0326_browser.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py +c56a7763250f8cf1ac011feb0c90a06309bd680aeb6da6e1d1f825fbb2a573aa tools/checks/live_diagnostics_0326_browser.py a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py +c0f08ee32fb506e946a2cf3308c061da7351e6022337aa9114b21d7ad19203c8 tools/checks/mobile_protocol_0326.py +db40b3940989dd6e32459574b4a5ba3219ebec8e2e68dea1f71ec4a9e53e78b0 tools/checks/mobile_protocol_0326_browser.py af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs 0387a9e29244b58cf37a6673a88ae2b7fea3aa965c032423f3783a84eab4ba05 tools/checks/overview_0321_layout_regression.mjs -53917376134d73fff32a68239b513c4eedf98bb65224c1c41c0e5cfc8fcb119b tools/checks/overview_extra_metrics_0323_ui_test.mjs -bc047e51ba17d7062a8a46a273c871c9b0b0ace16ef738a86e1517d0792d4c0a tools/checks/overview_grid_spacing_0323_browser.py +7fda61ae66a6e3f1ada1304440c09e1f43d0e577b3a5eac8331afe86fc5eebcb tools/checks/overview_extra_metrics_0323_ui_test.mjs +48dd1e13b83dc897c735028c93e17200cf4ed9ce175475dc5641ec3236569c38 tools/checks/overview_grid_spacing_0323_browser.py d530cb9b783d6e7ea3afd400a35a64865d16e11800bbf8910cb7edb9648f5510 tools/checks/overview_gutters_0324_browser.py b565aef4594265350240c24067a554f91fd605d0e920c03eef0b5b62c3934aad tools/checks/overview_registry_0323_ui_test.mjs f450e97dccf75c24e228e696f21ed4ae826a823ea4edde2429894a6b6b36eaab tools/checks/overview_uniform_grid_0323_ui_test.mjs -a1e68daf3a72b65545b0f5346094cbc0357c80d88504603090b776ef770f08de tools/checks/overview_wide_masonry_0323_ui_test.mjs +644ca805fd7072a3087cfb8c4a41fc53eefe0e902e5d1524d8f892657970493f tools/checks/overview_wide_masonry_0323_ui_test.mjs 1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/preflight.py -200e86cb2598bd3b422187c6421d10017f0b39afb137aaf7614858e1b3924592 tools/checks/preserve_host_0324.py +cdd59fdc84eea535584a52143e0c69285c76dfcc6d02587cd707aa4a5f220ca3 tools/checks/preserve_host_0324.py 3352941648add6d8a8044c0b5cad6bf264546d38c884a5beed64cd95addd850c tools/checks/preserve_host_0324_ui_test.mjs -021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs +64984e55d93617ac61c83f19b91a2f70402057a47a1962dcbcfd0b7d904f6098 tools/checks/rate_pause_0326_ui_test.mjs +599e0fa12fe95c7087a25801a26a590bbb473f24f038e0be157449daecec5593 tools/checks/refresh_0310_ui_test.mjs c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs -56f8f6839df0e65a1c9a4b64bdd5d8ce7bd3dcd76280f85181d2a6c333308e13 tools/checks/release_0318.py +c122954d9ce7165dfef1ce3e71a786b7bc86e9076f66d9a87baf14d269392757 tools/checks/release_0318.py e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs -62350e7fb5d4c4cfbe1b68ada1176c655922706dc2ebabc70913f5189ffa4155 tools/checks/release_smoke.py +9fe94e73c3156edb5015a1c03021c76ac96535e0dfc56b5efdab17fcd197955c tools/checks/release_smoke.py 31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py 23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py -518b6d271931df62fc61d21688399d3c9b1986b58201061ac566667a16334823 tools/checks/security_logs_0315_ui_test.mjs +db8881e46622ca2dbab4c29df003020006c611cd7fe087878c3e9c930cdd9853 tools/checks/security_logs_0315_ui_test.mjs 011bbe65dae072999a3951aba3d74a520926abf836b784cdb2395023d2942867 tools/checks/source_module_contracts.py 37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py -bb862620104f925488e36904e258b7c934a097362ef879ecb275c64008cebb83 tools/checks/stream_0320_ui_test.mjs -19cb347c87b0fa462fc2f3f2f5bef62e93628334ab4b8a1776df417e47c91ef0 tools/checks/stream_0320_visibility_regression.mjs +be78df1e438d76be83e15398b310c9843ae5965b0ec6b41279b3a9650c313d8a tools/checks/stream_0320_ui_test.mjs +6c7d20fa2abb55de997c19abd07ec1afa8d5121c91e07f231976bcdfbfbba778 tools/checks/stream_0320_visibility_regression.mjs 8542f5c668155aa9f70de11e6fdaf64c5a80629a15ecc5f1d8c40f3d655f9b55 tools/checks/system_info_0301_ui_test.mjs 543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py -760188db7aaae178c5f4570c1bcfac8b6cffe2e2e0522bb317331dc44515cbe5 tools/checks/upstream_protocols_0323.py +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 +2df48af5b438eb811a5babd6401db830c6fa77ed5bb03bf80f689da8440c93e1 tools/checks/web_assets.py 8d2e1837f94302d09a704aca452cc367e6892f289aca3dc2d82c086a2e7de26d tools/checks/web_cache_revalidation_0322.py 8ec055a16198701dd4fa35eac42a71b666d2b9bbe90edb1c797c7ed759615487 tools/checks/web_server_contracts.py 35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs diff --git a/dev.sh b/dev.sh index 2c1c6cc..d7e589a 100755 --- a/dev.sh +++ b/dev.sh @@ -101,6 +101,7 @@ if [[ "$mode" == "test" ]]; then python3 tools/checks/upstream_protocols_0323.py python3 tools/checks/preserve_host_0324.py python3 tools/checks/dynamic_acl_0325.py + python3 tools/checks/mobile_protocol_0326.py python3 tools/checks/check_source.py python3 tools/checks/release_smoke.py python3 tools/checks/memory_contracts.py @@ -127,7 +128,10 @@ if [[ "$mode" == "test" ]]; then node tools/checks/dashboard_inline_layout_0323_ui_test.mjs node tools/checks/admin_themes_0323_ui_test.mjs node tools/checks/appearance_ux_0323_ui_test.mjs + node tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs + node tools/checks/backend_protocol_0326_ui_test.mjs node tools/checks/overview_extra_metrics_0323_ui_test.mjs + node tools/checks/rate_pause_0326_ui_test.mjs node tools/checks/overview_wide_masonry_0323_ui_test.mjs node tools/checks/overview_uniform_grid_0323_ui_test.mjs node tools/checks/overview_0321_layout_regression.mjs diff --git a/docs/API.md b/docs/API.md index bb23c3d..7e991cf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -94,7 +94,7 @@ framework extraction failures are not guaranteed to have that JSON shape. `status.streams` contains `active`, `desired`, `restart_required` and a `metrics` map keyed by startup-active stream ID. Stream counters include active/total/completed sessions, rejects, errors, RX/TX bytes and UDP packet counts; the dashboard derives rates from byte-counter deltas. | `GET /api/logs` | Recent bounded access entries, ring capacity and contention-drop count | | `GET /api/audit` | Last 100 audit entries; database retention is 10,000 | -| `GET /api/events` | SSE `snapshot` events containing status and logs | +| `GET /api/events` | SSE `snapshot` events containing status and logs; the compact status includes `live_system` for CPU/RSS/network charts | | `GET /metrics` | Prometheus text exposition | | `GET /api/metrics` | Alias of `/metrics` | @@ -102,7 +102,7 @@ Except for health and authentication/bootstrap routes, these endpoints require a session. These administrative previews are not anonymous. The separate configurable exporter uses its own CIDRs and optional bearer token; see the scrape section below. -SSE has eight viewer slots, generates a snapshot every 1/2/5/10 seconds per authenticated client (via `?interval=`, default 2) +SSE has eight viewer slots, generates a snapshot every 1/2/3/5/10 seconds per authenticated client (via `?interval=`, default 3) and rechecks session validity. It does not queue an unlimited per-viewer event log or guarantee replay. The browser falls back to compact status/log polling only after SSE fails and stops polling after its recovery. Invalid SSE intervals are rejected with 422. An active dashboard renews idle activity, not the absolute session deadline. diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 844587d..9ea3c68 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -6,6 +6,21 @@ The scale applies to the complete authenticated administration surface, including navigation, workspace content, dialogs, overlays and the draft dock. Text fields, selects, textareas, checkboxes and radios also receive a small proportional control-size adjustment on top of global zoom so field proportions track the selected scale more visibly. Button style is independent: **Normal** keeps the refreshed control sizing and **Compact** restores the smaller source-style sizing, including generated entity actions and tab buttons inside dialogs. Table/log and dashboard density are additional layout controls and do not change the whole-interface scale. The dashboard editor can hide/show and reorder the telemetry cards at the top of Overview; this changes presentation only, not metric collection. Stream card explanations appear on hover and keyboard focus instead of expanding the card list. Stream cards default hidden but, once switched on, remain present even when no Stream is active (neutral no-data state). The selected time format is shared by logs, backend/runtime diagnostics, audit/session timestamps, certificate views and configuration-history timestamps. Chart presets change colors, surfaces, grid and line presentation only; they do not change sampled data, and the palette selector exposes the main colors for each preset. Each Appearance subsection has its own reset action, and **Reset appearance** restores all documented Appearance defaults without touching unrelated account preferences. A small local cache is retained only to avoid a theme/scale flash before authentication, while the authenticated SQLite preference remains authoritative. +The **Corner style** choice applies to standard dashboard/metric cards, vhost +cards, entities, notices, backend protocol badges and inputs independently from +color themes; the default preset retains the original appearance. On narrow +screens the Overview uses two small metric tiles per row rather than one. +Status refreshes update values without resetting widget order or scroll. + +The Vhosts list shows one upstream protocol badge per configured reverse-proxy +backend: the configured mode (H1, Auto, H2 compatible or H2 only) plus the last +HTTP/1.1 or HTTP/2 response version observed by RustProxy while proxying a +request. An unknown or stale observation (over five minutes old) appears as `?`, +including in Auto mode; no new probe is issued, and parallel connections can +use different negotiated versions. Hover/focus displays the observation note +and aggregate response-version counters. These are authenticated live-status +observations, not the configured frontend HTTP version. + Unfinished Web Server, Vhost, Redirect and Stream editors remain session-only drafts. Their bottom-right dock uses square corners, can collapse to a single title bar, shows the age of each suspended draft and does not persist draft form values to SQLite. The Settings page includes a local search field that filters category tabs using translated category labels and page content. Filtering never rebuilds settings forms, so unsaved values remain in the DOM. Category navigation remains sticky on desktop and marks categories containing unsaved forms. @@ -22,6 +37,17 @@ Open **Settings → Certificate API** (`/settings?tab=certificate-api`) to manag The existing **Generate / rotate token** form still creates new Key IDs (and retains its previous ability to rotate a Key ID by supplying it). Use **Edit settings** to modify settings without rotating, and **Regenerate token** to rotate without changing any settings. See [API](API.md) and [access control](API_ACCESS.md). +## Compact Overview card controls (0.32.6 development branch) + +**Settings → Appearance → Dashboard layout** now puts Dashboard density on a +compact row and gives the Overview card controls the full width of the section. +The card selector is a single horizontally scrollable strip by default, with +left/right browsing buttons and accessible keyboard focus. **Show all cards** +expands the same 62 visibility/reorder controls into a wrapped grid. Use the +existing visual layout editor for drag-and-drop arrangement. Reordering and +visibility still save through `/api/preferences` per account; this UI-only +change does not alter server polling, proxy routing or backend metrics. + ## Refresh preferences (0.31.0) **Settings -> Appearance -> Data refresh** saves an additional `refresh` object in the existing `admin_preferences` document for the signed-in account. It does not modify proxy configuration or create any migration. Older documents, including partially populated `refresh` objects, use defaults. Browser and server enforce exact allowed values: @@ -29,14 +55,14 @@ The existing **Generate / rotate token** form still creates new Key IDs (and ret | Preference | Default | Allowed seconds | |---|---:|---| | `refresh.live_enabled` | `true` | `true`, `false` | -| `refresh.overview_seconds` | 2 | 1, 2, 5, 10 | +| `refresh.overview_seconds` | 3 | 1, 2, 3, 5, 10 | | `refresh.traffic_seconds` | 5 | 0, 3, 5, 10, 30 | | `refresh.cache_seconds` | 5 | 0, 3, 5, 10, 30 | | `refresh.system_info_seconds` | 60 | 0, 15, 30, 60, 300 | `live_enabled=false` disables automatic SSE **and all polling**, including system information. `0` disables only that source's automatic polling. Overview and System information have **Refresh now** buttons; Traffic and Cache retain their own buttons, filters, charts and pagination. The per-view Auto checkboxes for Traffic/Cache/Transform and the Docker live switch remain independent opt-outs, and none changes another administrator's preferences. **Reset section** restores refresh defaults without changing theme or unrelated preferences. -The main `/api/events?interval=N` connection (allowlisted 1/2/5/10, default 2 seconds) carries compact live status plus the existing bounded log snapshot. The server checks the session on every event and retains its heartbeat, viewer cap and normal disconnect cleanup. On an SSE error the browser polls `/api/status?view=live` and `/api/logs` until snapshots resume, not concurrently during healthy streaming. The old full `/api/status` response is kept for API clients and initial diagnostics. The separate authenticated `/api/system-info` reuses cached diagnostics and ACME metadata, never runs diagnostic subprocesses per request, and is fetched automatically only for Overview or the relevant Settings pages. A hidden browser tab pauses the channel and all automatic polling; returning revalidates its session before normal operation. All interval changes take effect immediately, without reloading or changing any server-global cadence. +The main `/api/events?interval=N` connection (allowlisted 1/2/3/5/10, default 3 seconds) carries compact live status, cached CPU/RSS/aggregate and per-interface network chart samples from the server's existing background OS collector, plus the existing bounded log snapshot. The server checks the session on every event and retains its heartbeat, viewer cap and normal disconnect cleanup. On an SSE error the browser polls `/api/status?view=live` and `/api/logs` until snapshots resume, not concurrently during healthy streaming. The old full `/api/status` response is kept for API clients and initial diagnostics. Process/System detail values still update only after the separate authenticated `/api/system-info` read at `refresh.system_info_seconds`, while CPU/RSS and network **charts** follow the Overview live cadence (3 seconds by default). The `/api/system-info` endpoint reuses cached diagnostics and ACME metadata, never runs diagnostic subprocesses per request, and is fetched automatically only for Overview or the relevant Settings pages. A hidden browser tab pauses the channel and all automatic polling; returning revalidates its session before normal operation. All interval changes take effect immediately, without reloading or changing any server-global cadence. ## System information (0.30.1) diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 0fc6ae8..0e42dc2 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,6 +1,151 @@ -# Verification report - RustProxy 0.32.5 +# Verification report - RustProxy 0.32.6 -## Verification performed for this source package +## 0.32.6 development follow-up — compact Appearance Dashboard layout + +- The previous right-half, wrapping Overview card list left most of the screen + empty and grew excessively tall with the 62-card registry. Moved Dashboard + density to a compact full-width row and made the selector a full-width, + single-line horizontal ribbon. Added previous/next browsing, a **Show all** + expanded grid, horizontal earlier/later order buttons and Polish/English + strings. Every existing toggle, order save, visual editor and account-scoped + `/api/preferences` path remains intact. +- Chromium fixture with real CSS, real 62-card renderer and mocked preferences: + at 1500px, the default ribbon is 47px high and the Appearance dashboard + section about 274px high; at 393px, the ribbon is 47px, the section about + 419px, and there is no page-level horizontal overflow. Programmatic browse, + visibility-toggle, retained scroll position and expand/collapse all passed. + `tools/checks/dashboard_appearance_ribbon_0326_browser.py` reproduces this + test. This is a simulated interface test, not a compiled Rust or iOS Safari test. +- Source/Node checks additionally verify translations, all card IDs and the + unchanged preference persistence calls. Full Rust build and physical iOS + acceptance remain pending. + + +## 0.32.6 development follow-up — logo right-edge spacing + +- Corrected the previous logo-spacing adjustment: the desired clearance is + between the final `p` glyph and the **right border of the logo frame**, + not between `/` and `p`. Removed extra slash margins and added a 2px + right-side flex-box inset without changing the frame size or color. +- Browser regression checks the text glyph's bounding rectangle against the + frame at desktop and mobile widths; Chromium measures approximately 5px + clearance compared with approximately 2px before correction. This does not + require Rust compilation or imply a physical iOS Safari verification. + +## 0.32.6 development follow-up — live diagnostic charts / mobile scroll + +- The existing server diagnostics collector samples OS and process metrics in + the background, with a 2-second idle delay between collection passes + (sampling work can add time). Compact live status / SSE now + exposes its **cached** `live_system` (sample timestamp, host/process CPU, + process RSS, aggregate RX/TX rates and interface rates), without invoking a + new OS measurement or fetching `/api/system-info` on each live event. +- Live Overview charts use the administrator's `overview_seconds` (default **3**, + accepted 1/2/3/5/10, including existing persisted 2-second preferences). + Process/System *detail text* still updates only with the configured + `system_info_seconds` (default **60**). Network chart RX/TX now reflects + recently sampled interface rates rather than 60-second-old repeated values. +- Disabled the nested `overflow:auto` network-interface list on narrow + Overview containers. Stable SVG/text DOM remains in place, to reduce + unexpected WebKit touch/momentum scroll anchoring. This is a mitigation; + the exact source of the iOS 27 jump remains unverified on physical Safari. +- Protocol badge Origin uses the same control radius as the adjacent policy + pills for custom presets, and the same 0px baseline for the default preset; + observed green/unknown neutral colors are unchanged. +- `live_diagnostics_0326_browser.py`: real diagnostic renderer with 35 live + updates verifies fast chart data and slow Process details, no chart DOM + replacement or scroll movement, no extra sample from a slow-system-only + refresh, touch-sized network list and both custom/default badge radii. + `mobile_protocol_0326_browser.py` covers all custom radii at six widths. +- Prior source/UI tests: all pre-Cargo checks passed; compilation and Rust + integration tests not run (Cargo absent). The Chromium fixture does **not** + establish Safari/iOS 27 acceptance. + +## 0.32.6 development follow-up — suspended rate charts + +- Added `src/admin/web/app/rate-sampling.js` to the authenticated Rust + administration bundle. The shared policy records tab background/pagehide + epochs and ignores intervals longer than three configured refresh periods + (at least 10 seconds). It validates counter monotonicity and declines + counter-derived rates across the gap, preserving existing history. +- Updated Cache HIT/MISS/BYPASS, Transform metrics, Overview HTTP/protocol/ + cache-outcome deltas, optional small/wide rate and share charts, HTTP + backend monitor, Stream traffic and Vhost/Stream detailed charts. Fresh + absolute CPU/RAM/system observations and independent system-info reads + retain their previous behavior. Visible missing rates show `-`, not `0/s`. +- Added `tools/checks/rate_pause_0326_ui_test.mjs`: real Cache/Transform + chart functions with a simulated DOM and clock. Tests normal idle 0/s, + long gaps, short hide/show, pagehide/BFCache, counter resets, sample + continuity and 30-second refresh support. This is not a live Safari test. +- **Pending:** Cargo checks/compilation, real server/SSE data, mobile Safari + tab suspension and BFCache tests. + +## 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, + upgrade/release-smoke, SQL/schema, documentation, localization and Node.js UI + checks. It then stopped at the expected missing-Cargo guard (`exit 127`). + `./dev.sh check` also stopped there (`exit 127`). A production Rust build + and execution of Rust unit tests **have not been performed**. +- `tools/checks/mobile_protocol_0326_browser.py`, + `appearance_ux_0323_browser.py` and `overview_grid_spacing_0323_browser.py` + passed separately with headless Chromium. These optional browser tests + require Playwright and are not a dependency of `./dev.sh test`. +- Shipped static CSS and real 62-card grid tested under Chromium mobile touch + emulation at 320, 375, 393, 402, 430 and 550px: two metric columns, no + horizontal overflow, stable viewport scroll positions after metric updates + and repeated no-op preference application. **This does not test Safari on + a physical iPhone 17 Pro.** +- Global Appearance radius presets in Chromium tested on vhost, backend badge, + entity and metric surfaces; square draft dock remains excluded. +- JavaScript Vhost badge test checks fresh H1/H2 response observations, Auto + no-observation and stale states, response counters and isolation across + vhosts with identical member IDs. +- Rust test added to assert response-version observation and counters. **The + Rust compiler and a running HTTP peer are absent; this is source coverage, + not a completed Rust unit/integration test.** +- Tests do not establish real backend HTTP/2 ALPN operation; staging validation + and full Rust acceptance remain required before production use. + +## Previous 0.32.5 verification history - **0.32.5 administration UI startup fix:** the DNS ACL translation helper in `src/admin/web/management.js` is now initialized before the DDNS hint uses diff --git a/langs/en_US.json b/langs/en_US.json index 15f3039..660d702 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -353,6 +353,8 @@ "vhDrain": "Drain new sessions", "vhPrivate": "Allow private origin IP", "vhPreserveHost": "Preserve original client Host / HTTP/2 authority", + "vhBackendProtocolRecent": "Last HTTP version returned to a real proxy request by this origin; pooled/concurrent requests may use a different protocol. Total observed responses:", + "vhBackendProtocolUnobserved": "No backend HTTP response observed in the last five minutes; the protocol is not inferred from Auto configuration.", "vhProtocol": "Origin protocol", "vhProtocolHttp1": "HTTP/1.1 (TLS or cleartext)", "vhProtocolAuto": "Auto (HTTPS ALPN: H2 + H1; HTTP: H1)", @@ -2208,6 +2210,13 @@ "dashboardDensityHelp": "Controls Overview telemetry card size without changing the rest of the interface.", "dashboardWidgets": "Overview cards", "dashboardWidgetsHelp": "Show, hide and reorder all Overview blocks, including charts, diagnostics and the backend table.", + "dashboardWidgetsScrollHelp": "Scroll horizontally to browse cards, or expand the list.", + "dashboardWidgetsMoveEarlier": "Move card earlier", + "dashboardWidgetsMoveLater": "Move card later", + "dashboardWidgetsScrollNext": "Scroll cards right", + "dashboardWidgetsScrollBack": "Scroll cards left", + "dashboardWidgetsCompact": "Compact view", + "dashboardWidgetsShowAll": "Show all cards", "dashboardWidgetVisible": "Visible", "moveUp": "Move up", "moveDown": "Move down", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index b363a4c..16d1f9d 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -353,6 +353,8 @@ "vhDrain": "Wstrzymaj nowe sesje", "vhPrivate": "Zezwól na prywatny IP backendu", "vhPreserveHost": "Zachowaj Host klienta / HTTP/2 :authority", + "vhBackendProtocolRecent": "Ostatnia wersja HTTP z odpowiedzi backendu na rzeczywiste żądanie. Inne połączenia mogą używać innego protokołu. Łącznie odpowiedzi:", + "vhBackendProtocolUnobserved": "Brak odpowiedzi backendu z ostatnich pięciu minut. Tryb Auto nie oznacza potwierdzonego protokołu.", "vhProtocol": "Protokół backendu", "vhProtocolHttp1": "HTTP/1.1 (TLS lub bez TLS)", "vhProtocolAuto": "Auto (HTTPS ALPN: H2 + H1; HTTP: H1)", @@ -2208,6 +2210,13 @@ "dashboardDensityHelp": "Steruje rozmiarem kafelków telemetrii w Overview bez zmiany reszty interfejsu.", "dashboardWidgets": "Kafelki Overview", "dashboardWidgetsHelp": "Pokazuj, ukrywaj i układaj wszystkie bloki Overview, także wykresy, diagnostykę i tabelę backendów.", + "dashboardWidgetsScrollHelp": "Przewijaj kafelki poziomo lub rozwiń całą listę.", + "dashboardWidgetsMoveEarlier": "Przesuń kafelek wcześniej", + "dashboardWidgetsMoveLater": "Przesuń kafelek później", + "dashboardWidgetsScrollNext": "Przewiń kafelki w prawo", + "dashboardWidgetsScrollBack": "Przewiń kafelki w lewo", + "dashboardWidgetsCompact": "Widok kompaktowy", + "dashboardWidgetsShowAll": "Pokaż wszystkie", "dashboardWidgetVisible": "Widoczny", "moveUp": "Przenieś wyżej", "moveDown": "Przenieś niżej", diff --git a/src/admin/logging_api.rs b/src/admin/logging_api.rs index 0cffded..99636ef 100644 --- a/src/admin/logging_api.rs +++ b/src/admin/logging_api.rs @@ -309,8 +309,8 @@ async fn events( headers: HeaderMap, Query(query): Query, ) -> Result { - let interval = query.interval.unwrap_or(2); - if ![1, 2, 5, 10].contains(&interval) { + let interval = query.interval.unwrap_or(3); + if ![1, 2, 3, 5, 10].contains(&interval) { return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "invalid SSE interval".into())); } let secret = auth::cookie_secret(&headers) diff --git a/src/admin/openapi.json b/src/admin/openapi.json index cd94912..e8f4d52 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.32.5", + "version": "0.32.6", "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." }, "servers": [ @@ -1237,6 +1237,23 @@ "properties": {}, "additionalProperties": true }, + "live_system": { + "type": "object", + "description": "Cached OS/process metrics for fast SSE/live charts only; full System details remain on /api/system-info", + "properties": { + "sampled_at_ms": { "type": "integer", "format": "int64" }, + "cpu_percent": { "type": "number", "nullable": true }, + "process_cpu_percent": { "type": "number", "nullable": true }, + "process_rss_bytes": { "type": "integer", "format": "int64", "nullable": true }, + "network_rx_bytes_per_second": { "type": "number", "nullable": true }, + "network_tx_bytes_per_second": { "type": "number", "nullable": true }, + "network_interfaces": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + } + }, + "additionalProperties": false + }, "backend_pools": { "type": "array", "items": { @@ -3826,10 +3843,11 @@ "enum": [ 1, 2, + 3, 5, 10 ], - "default": 2 + "default": 3 }, "traffic_seconds": { "type": "integer", @@ -9037,7 +9055,7 @@ } } }, - "description": "SSE snapshot events contain compact volatile status and bounded logs. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/5/10 second pacing; invalid values return 422.", + "description": "SSE snapshot events contain compact volatile status and bounded logs. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.", "parameters": [ { "name": "interval", @@ -9049,10 +9067,11 @@ "enum": [ 1, 2, + 3, 5, 10 ], - "default": 2 + "default": 3 } } ] diff --git a/src/admin/refresh_preferences.rs b/src/admin/refresh_preferences.rs index 5bb8a03..3cc3d9c 100644 --- a/src/admin/refresh_preferences.rs +++ b/src/admin/refresh_preferences.rs @@ -13,7 +13,7 @@ impl Default for RefreshPreferences { fn default() -> Self { Self { live_enabled: true, - overview_seconds: 2, + overview_seconds: 3, traffic_seconds: 5, cache_seconds: 5, system_info_seconds: 60, @@ -22,7 +22,7 @@ impl Default for RefreshPreferences { } fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiError> { - if ![1, 2, 5, 10].contains(&refresh.overview_seconds) + if ![1, 2, 3, 5, 10].contains(&refresh.overview_seconds) || ![0, 3, 5, 10, 30].contains(&refresh.traffic_seconds) || ![0, 3, 5, 10, 30].contains(&refresh.cache_seconds) || ![0, 15, 30, 60, 300].contains(&refresh.system_info_seconds) diff --git a/src/admin/router.rs b/src/admin/router.rs index db33116..6fc2932 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -56,6 +56,7 @@ fn admin_app_javascript() -> &'static str { include_str!("web/app/core.js"), include_str!("web/app/session-resume.js"), include_str!("web/app/refresh.js"), + include_str!("web/app/rate-sampling.js"), "const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(", include_str!("web/dashboard-widgets.json"), ");", @@ -277,7 +278,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/status_live.rs b/src/admin/status_live.rs index 9bd0507..ff351a5 100644 --- a/src/admin/status_live.rs +++ b/src/admin/status_live.rs @@ -1,3 +1,19 @@ +// The OS collector already samples in the background. Expose just the +// chart inputs in compact SSE/fallback snapshots; the full, slower System +// information document remains behind /api/system-info. +fn live_chart_system_payload(state: &App) -> Value { + let view = state.diagnostics.view(); + json!({ + "sampled_at_ms": view.sampled_at_ms, + "cpu_percent": view.cpu_percent, + "process_cpu_percent": view.process_cpu_percent, + "process_rss_bytes": view.process_rss_bytes, + "network_rx_bytes_per_second": view.network_rx_bytes_per_second, + "network_tx_bytes_per_second": view.network_tx_bytes_per_second, + "network_interfaces": view.network_interfaces + }) +} + fn live_status_payload(state: &App) -> Value { let snapshot = state.config.load_full(); let stream_metrics = state.active_streams.iter().filter_map(|stream| { @@ -13,6 +29,7 @@ fn live_status_payload(state: &App) -> Value { "restart_required": snapshot.listeners != state.active_listeners }, "metrics": state.metrics.view(), + "live_system": live_chart_system_payload(state), "cache": state.cache.view(), "transform_metrics": state.transform_metrics.total(), "logs_dropped": state.logs.dropped.load(Ordering::Relaxed), diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 8f1e820..9dd2e49 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -1663,7 +1663,7 @@ mod tests { assert_eq!( initial["refresh"], json!({ - "live_enabled":true, "overview_seconds":2, "traffic_seconds":5, + "live_enabled":true, "overview_seconds":3, "traffic_seconds":5, "cache_seconds":5, "system_info_seconds":60 }) ); @@ -2914,6 +2914,13 @@ mod tests { assert_eq!(live.status(), StatusCode::OK); let live = json_body(live).await; assert!(live.get("metrics").is_some()); + // Compact snapshots contain only already-collected data required for + // fast Overview charts, not the expensive System details document. + assert!(live["live_system"].get("sampled_at_ms").is_some()); + assert!(live["live_system"].get("process_cpu_percent").is_some()); + assert!(live["live_system"].get("process_rss_bytes").is_some()); + assert!(live["live_system"].get("network_interfaces").is_some()); + assert!(live["live_system"].get("open_fds").is_none()); assert!(live.get("backend_pools").is_some()); assert!(live["listeners"].get("active_endpoints").is_some()); assert!(live.get("system").is_none()); @@ -2966,6 +2973,7 @@ mod tests { assert_eq!(partial.traffic_seconds, 0); assert_eq!(partial.system_info_seconds, 60); assert!(validate_admin_preferences(&loaded).is_ok()); + assert_eq!(loaded.refresh.overview_seconds, 3); } #[test] @@ -2975,6 +2983,10 @@ mod tests { value.overview_seconds = seconds; assert!(validate_refresh_preferences(&value).is_err()); } + value.overview_seconds = 3; + assert!(validate_refresh_preferences(&value).is_ok()); + value.overview_seconds = 2; // previous installations stay valid + assert!(validate_refresh_preferences(&value).is_ok()); value.overview_seconds = 1; for seconds in [1, 2, 4, 31] { value.traffic_seconds = seconds; diff --git a/src/admin/web/app/backend-dashboard.js b/src/admin/web/app/backend-dashboard.js index 6bc9d59..19d302f 100644 --- a/src/admin/web/app/backend-dashboard.js +++ b/src/admin/web/app/backend-dashboard.js @@ -136,12 +136,14 @@ window.rustProxyBackendDashboard = (() => { let inPerSecond = samples.inbound.at(-1) ?? 0; let outPerSecond = samples.outbound.at(-1) ?? 0; const additional = { http5xx: 0, timeouts: 0, network: 0, retries: 0, queue: 0 }; - let fraction = null; + let fraction = null, pausedRate = false; if (recordSample) { const now = performance.now(); const next = memberCounters(members); - const elapsed = previous ? (now - previous.when) / 1000 : 0; - if (elapsed > 0) { + const valid = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds")); + pausedRate = previous !== null && !valid; + if (valid) { + const elapsed = (now - previous.at) / 1000; const diff = deltas(next, previous.counters); requestsPerSecond = diff.requests / elapsed; errorsPerSecond = diff.errors / elapsed; @@ -150,23 +152,24 @@ window.rustProxyBackendDashboard = (() => { fraction = diff.requests ? 100 * diff.errors / diff.requests : null; for (const name of Object.keys(additional)) additional[name] = diff[name] / elapsed; } else { requestsPerSecond = 0; errorsPerSecond = 0; inPerSecond = 0; outPerSecond = 0; } - previous = { when: now, counters: next }; + previous = { ...rustProxyRateSampling.stamp(now), counters: next }; if (visible("backend_activity")) push("active", active); - if (visible("backend_requests")) push("requests", requestsPerSecond); + if (!pausedRate && visible("backend_requests")) push("requests", requestsPerSecond); if (visible("backend_capacity") && capacityPercent !== null) push("capacity", capacityPercent); - if (visible("backend_errors")) push("errors", errorsPerSecond); + if (!pausedRate && visible("backend_errors")) push("errors", errorsPerSecond); if (visible("backend_latency") && latency !== null) push("latency", latency); - if (visible("backend_traffic")) { push("inbound", inPerSecond); push("outbound", outPerSecond); } - for (const name of Object.keys(additional)) if (visible(`backend_${name === "http5xx" ? "http5xx" : name}`)) push(name, additional[name]); + if (!pausedRate && visible("backend_traffic")) { push("inbound", inPerSecond); push("outbound", outPerSecond); } + if (!pausedRate) for (const name of Object.keys(additional)) if (visible(`backend_${name === "http5xx" ? "http5xx" : name}`)) push(name, additional[name]); } if (!recordSample) for (const name of Object.keys(additional)) additional[name] = samples[name].at(-1) ?? 0; + const displayRate = value => pausedRate ? "-" : shortRate(value); if (visible("backend_activity")) { set("backend-dashboard-activity", fmt(active)); set("backend-dashboard-activity-detail", t("backendWidgetInFlight")); paintSeries("active", "backend-dashboard-active-line", Math.max(1, ...samples.active)); } if (visible("backend_requests")) { - set("backend-dashboard-requests", shortRate(requestsPerSecond)); + set("backend-dashboard-requests", displayRate(requestsPerSecond)); set("backend-dashboard-requests-detail", `${t("backendWidgetRequestsTotal")}: ${fmt(totalRequests)}`); paintSeries("requests", "backend-dashboard-request-line", Math.max(1, ...samples.requests)); } @@ -178,7 +181,7 @@ window.rustProxyBackendDashboard = (() => { } if (visible("backend_errors")) { set("backend-dashboard-errors", fmt(totalErrors)); - set("backend-dashboard-errors-detail", `${t("backendWidgetErrorsRate")}: ${shortRate(errorsPerSecond)}${fraction === null ? "" : ` · ${fraction.toFixed(1)}% ${t("backendWidgetErrorPercent")}`}`); + set("backend-dashboard-errors-detail", `${t("backendWidgetErrorsRate")}: ${displayRate(errorsPerSecond)}${fraction === null ? "" : ` · ${fraction.toFixed(1)}% ${t("backendWidgetErrorPercent")}`}`); paintSeries("errors", "backend-dashboard-error-line", Math.max(1, ...samples.errors)); } if (visible("backend_latency")) { @@ -187,9 +190,9 @@ window.rustProxyBackendDashboard = (() => { paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency)); } if (visible("backend_traffic")) { - set("backend-dashboard-traffic", `${bytes(Math.round(inPerSecond + outPerSecond))}/s`); - set("backend-dashboard-in-rate", `${bytes(Math.round(inPerSecond))}/s`); - set("backend-dashboard-out-rate", `${bytes(Math.round(outPerSecond))}/s`); + set("backend-dashboard-traffic", pausedRate ? "-" : `${bytes(Math.round(inPerSecond + outPerSecond))}/s`); + set("backend-dashboard-in-rate", pausedRate ? "-" : `${bytes(Math.round(inPerSecond))}/s`); + set("backend-dashboard-out-rate", pausedRate ? "-" : `${bytes(Math.round(outPerSecond))}/s`); const peak = Math.max(1, ...samples.inbound, ...samples.outbound); paintSeries("inbound", "backend-dashboard-in-line", peak); paintSeries("outbound", "backend-dashboard-out-line", peak); @@ -207,7 +210,7 @@ window.rustProxyBackendDashboard = (() => { name === "network" ? ["connect_errors_total", "dns_errors_total", "tls_errors_total", "io_errors_total"].reduce((v, key) => v + counter(member[key]), 0) : counter(member.queue_timeouts_total) + counter(member.capacity_rejections_total)), 0); set(field, fmt(total)); - let detail = `${t("backendWidgetRateDetail")}: ${shortRate(additional[name])}`; + let detail = `${t("backendWidgetRateDetail")}: ${displayRate(additional[name])}`; if (name === "retries") detail += ` · ${t("backendWidgetRetriesSuccess")}: ${fmt(members.reduce((n, { member }) => n + counter(member.retry_successes_total), 0))}`; if (name === "queue") detail += ` · ${t("backendWidgetQueueRejected")}: ${fmt(members.reduce((n, { member }) => n + counter(member.capacity_rejections_total), 0))}`; if (name === "network") detail += ` · ${t("backendWidgetNetworkBreakdown")}`; diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index b38f437..a729ecd 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -230,18 +230,17 @@ function renderStatus(s, systemSample = false) { const now = performance.now(); const transformed = s.transform_metrics || {}; const counters = { total: Number(m.requests) || 0, h1: Number(m.http1_requests) || 0, h2: Number(m.http2_requests) || 0, h3: Number(m.http3_requests) || 0, transform: (Number(transformed.applied) || 0) + (Number(transformed.cache_hits) || 0) }; - const rates = { total: 0, h1: 0, h2: 0, h3: 0, transform: 0 }; - if (previous) { - const elapsed = Math.max(.001, (now - previous.time) / 1000); - for (const key of ["total", "h1", "h2", "h3", "transform"]) if (counters[key] >= previous[key]) rates[key] = (counters[key] - previous[key]) / elapsed; + const keys = ["total", "h1", "h2", "h3", "transform"]; const rateSample = rustProxyRateSampling.difference(previous, keys.map(key => counters[key]), now, rustProxyRateSampling.cadence("overview_seconds")); + const pausedRate = previous !== null && rateSample.deltas === null; previous = rateSample.next; + const rates = Object.fromEntries(keys.map((key, i) => [key, rateSample.deltas ? rateSample.deltas[i] / rateSample.seconds : 0])); + if (!pausedRate) { + samples.push(rates.total); samples = samples.slice(-30); + for (const key of ["h1", "h2", "h3"]) { protocolSamples[key].push(rates[key]); protocolSamples[key] = protocolSamples[key].slice(-30); } } - previous = { time: now, ...counters }; - samples.push(rates.total); samples = samples.slice(-30); - for (const key of ["h1", "h2", "h3"]) { protocolSamples[key].push(rates[key]); protocolSamples[key] = protocolSamples[key].slice(-30); } websocketProtocolSamples.h1.push(Number(m.websocket_h1_active) || 0); websocketProtocolSamples.h1 = websocketProtocolSamples.h1.slice(-30); websocketProtocolSamples.h2.push(Number(m.websocket_h2_active) || 0); websocketProtocolSamples.h2 = websocketProtocolSamples.h2.slice(-30); websocketProtocolSamples.h3.push(Number(m.websocket_h3_active) || 0); websocketProtocolSamples.h3 = websocketProtocolSamples.h3.slice(-30); - transformSamples.push(rates.transform); transformSamples = transformSamples.slice(-30); + if (!pausedRate) { transformSamples.push(rates.transform); transformSamples = transformSamples.slice(-30); } const peak = Math.max(1, ...samples, ...transformSamples); const points = values => values.map((n, i) => `${i * 600 / Math.max(1, values.length - 1)},${120 - n / peak * 105}`).join(" "); $("sparkline").setAttribute("points", points(samples)); @@ -249,12 +248,11 @@ function renderStatus(s, systemSample = false) { $("sparkline-h2").setAttribute("points", points(protocolSamples.h2)); $("sparkline-h3").setAttribute("points", points(protocolSamples.h3)); $("sparkline-transform").setAttribute("points", points(transformSamples)); - $("request-rate").textContent = `${rates.total.toFixed(1)} req/s`; - $("rate-total").textContent = `${rates.total.toFixed(1)}/s`; $("rate-h1").textContent = `${rates.h1.toFixed(1)}/s`; $("rate-h2").textContent = `${rates.h2.toFixed(1)}/s`; $("rate-h3").textContent = `${rates.h3.toFixed(1)}/s`; $("rate-transform").textContent = `${rates.transform.toFixed(1)}/s`; - $("metric-http-proto-total").textContent = `${rates.total.toFixed(1)}/s`; - $("metric-http-proto-h1").textContent = `${rates.h1.toFixed(1)}/s`; - $("metric-http-proto-h2").textContent = `${rates.h2.toFixed(1)}/s`; - $("metric-http-proto-h3").textContent = `${rates.h3.toFixed(1)}/s`; + const rateText = key => pausedRate ? "-" : `${rates[key].toFixed(1)}/s`; + $("request-rate").textContent = pausedRate ? "- req/s" : `${rates.total.toFixed(1)} req/s`; + $("rate-total").textContent = rateText("total"); $("rate-h1").textContent = rateText("h1"); $("rate-h2").textContent = rateText("h2"); $("rate-h3").textContent = rateText("h3"); $("rate-transform").textContent = rateText("transform"); + $("metric-http-proto-total").textContent = rateText("total"); $("metric-http-proto-h1").textContent = rateText("h1"); + $("metric-http-proto-h2").textContent = rateText("h2"); $("metric-http-proto-h3").textContent = rateText("h3"); $("http-protocol-h1").setAttribute("points", sparklinePoints(protocolSamples.h1, 300, 60, 5)); $("http-protocol-h2").setAttribute("points", sparklinePoints(protocolSamples.h2, 300, 60, 5)); $("http-protocol-h3").setAttribute("points", sparklinePoints(protocolSamples.h3, 300, 60, 5)); @@ -302,7 +300,7 @@ function stopLive() { } function startLive() { if (!authenticated || document.hidden || typeof EventSource === "undefined") return; - const seconds = window.rustProxyRefresh?.current().overview_seconds || 2; + const seconds = window.rustProxyRefresh?.current().overview_seconds || 3; if (liveEvents && liveInterval === seconds) return; stopLive(); liveInterval = seconds; diff --git a/src/admin/web/app/overview-extra-metrics.js b/src/admin/web/app/overview-extra-metrics.js index e41e90f..107b09f 100644 --- a/src/admin/web/app/overview-extra-metrics.js +++ b/src/admin/web/app/overview-extra-metrics.js @@ -16,16 +16,15 @@ window.rustProxyExtraOverview = (() => { const node = id => document.querySelector(`[data-dashboard-widget="${id}"]`); function elapsedDelta(id, counters, now) { const old = lastCounter.get(id); - const next = { values: counters, at: now }; - lastCounter.set(id, next); - if (!old || now <= old.at || counters.some((v, i) => v < old.values[i])) return null; - return { deltas: counters.map((v, i) => v - old.values[i]), seconds: (now - old.at) / 1000 }; + const result = rustProxyRateSampling.difference(old, counters, now, rustProxyRateSampling.cadence("overview_seconds")); + lastCounter.set(id, result.next); + return result.deltas ? result : null; } function read(id, status, now, record) { - const m = status.metrics || {}, d = status.system || {}, c = status.cache || {}; + const m = status.metrics || {}, d = status.system || {}, live = status.live_system || d, c = status.cache || {}; const direct = { - extra_process_cpu: d.process_cpu_percent, - extra_process_rss: d.process_rss_bytes, + extra_process_cpu: live.process_cpu_percent, + extra_process_rss: live.process_rss_bytes, extra_process_fds: d.open_fds, extra_open_connections: m.active_connections, extra_websocket_active: m.websocket_active, diff --git a/src/admin/web/app/overview-wide-metrics.js b/src/admin/web/app/overview-wide-metrics.js index 1193cb3..982fae7 100644 --- a/src/admin/web/app/overview-wide-metrics.js +++ b/src/admin/web/app/overview-wide-metrics.js @@ -7,11 +7,11 @@ window.rustProxyWideOverview = (() => { }, wide_cpu: { unit: "percent", names: ["system", "process"], values: s => - [s.system?.cpu_percent, s.system?.process_cpu_percent] + [(s.live_system || s.system)?.cpu_percent, (s.live_system || s.system)?.process_cpu_percent] }, wide_memory: { unit: "bytes", names: ["process", "cache"], values: s => - [s.system?.process_rss_bytes, s.cache?.resident_bytes] + [(s.live_system || s.system)?.process_rss_bytes, s.cache?.resident_bytes] }, wide_cache_activity: { unit: "rate", names: ["hits", "misses"], counters: s => @@ -51,12 +51,9 @@ window.rustProxyWideOverview = (() => { const counters = values.map(finite); if (counters.some(n => n === null)) { baselines.delete(id); return counters.map(() => null); } if (!record) return last.get(id) || counters.map(() => null); - const previous = baselines.get(id); - baselines.set(id, { counters, now }); - if (!previous || now <= previous.now || counters.some((n, i) => n < previous.counters[i])) - return counters.map(() => null); - const elapsed = (now - previous.now) / 1000; - return counters.map((n, i) => (n - previous.counters[i]) / elapsed); + const result = rustProxyRateSampling.difference(baselines.get(id), counters, now, rustProxyRateSampling.cadence("overview_seconds")); + baselines.set(id, result.next); + return result.deltas ? result.deltas.map(delta => delta / result.seconds) : counters.map(() => null); } function path(values, peak) { const count = values.length; diff --git a/src/admin/web/app/preferences.js b/src/admin/web/app/preferences.js index 7a1c245..94ee171 100644 --- a/src/admin/web/app/preferences.js +++ b/src/admin/web/app/preferences.js @@ -96,7 +96,10 @@ function applyDashboardLayout(order, hidden) { const normalizedOrder = normalizeDashboardWidgets(order), hiddenSet = new Set(normalizeHiddenDashboardWidgets(hidden)); const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] }; const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node])); - for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); } + const orderedNodes = normalizedOrder.map(id => nodes.get(id)).filter(Boolean); + for (const node of orderedNodes) node.hidden = hiddenSet.has(node.dataset.dashboardWidget); + const currentNodes = [...grid.querySelectorAll("[data-dashboard-widget]")]; + if (orderedNodes.some((node, index) => node !== currentNodes[index])) grid.append(...orderedNodes); window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]); window.rustProxyBackendDashboard?.syncVisibility?.([...hiddenSet]); window.rustProxyMasonry?.refresh?.(); @@ -124,23 +127,25 @@ function dashboardWidgetTypeBadge(id) { } function renderDashboardWidgetSettings() { const list = $("dashboard-widget-list"); if (!list) return; + const previousScroll = list.scrollLeft; + const focusedRow = document.activeElement?.closest?.("[data-dashboard-widget-setting]"); + const focusedId = focusedRow && list.contains?.(focusedRow) ? focusedRow.dataset.dashboardWidgetSetting : null; + const focusedAction = focusedId ? document.activeElement?.dataset.dashboardWidgetAction : null; const current = adminPreferences || ADMIN_APPEARANCE_DEFAULTS, order = normalizeDashboardWidgets(current.dashboard_widgets), hidden = new Set(normalizeHiddenDashboardWidgets(current.dashboard_hidden_widgets)); const rows = order.map((id, index) => { const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id; const visible = !hidden.has(id); - const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`); + const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.dataset.dashboardWidgetAction = "toggle"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`); const descriptionKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key; if (descriptionKey) { const description = t(descriptionKey); row.title = description; toggle.setAttribute("aria-description", description); } - // Type badges belong in the visual layout editor, not the long - // Appearance settings list where they crowd the visibility controls. toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label")); const actions = el("span", undefined, "dashboard-widget-setting-actions"); - const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp")); - const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown")); + const up = el("button", "←", "quiet"); up.type = "button"; up.dataset.dashboardWidgetAction = "up"; up.disabled = index === 0; up.setAttribute("aria-label", t("dashboardWidgetsMoveEarlier")); + const down = el("button", "→", "quiet"); down.type = "button"; down.dataset.dashboardWidgetAction = "down"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("dashboardWidgetsMoveLater")); toggle.addEventListener("click", () => { const previous = [...hidden], next = new Set(hidden); if (visible) next.add(id); else next.delete(id); if (adminPreferences) adminPreferences.dashboard_hidden_widgets = [...next]; applyDashboardLayout(order, [...next]); renderDashboardWidgetSettings(); @@ -155,6 +160,46 @@ function renderDashboardWidgetSettings() { up.addEventListener("click", () => move(-1)); down.addEventListener("click", () => move(1)); actions.append(up, down); row.append(toggle, actions); return row; }); list.replaceChildren(...rows); + list.scrollLeft = previousScroll; + if (focusedId && focusedAction) { + const row = [...list.children].find(item => item.dataset.dashboardWidgetSetting === focusedId); + row?.querySelector(`[data-dashboard-widget-action="${focusedAction}"]`)?.focus({ preventScroll: true }); + } + syncDashboardWidgetListView(); + if (typeof requestAnimationFrame === "function") requestAnimationFrame(syncDashboardWidgetScrollButtons); +} +let dashboardWidgetListExpanded = false; +function syncDashboardWidgetListView() { + const list = $("dashboard-widget-list"), expand = $("dashboard-widget-expand"); + if (!list || !expand) return; + list.classList.toggle("is-expanded", dashboardWidgetListExpanded); + expand.setAttribute("aria-expanded", String(dashboardWidgetListExpanded)); + const key = dashboardWidgetListExpanded ? "dashboardWidgetsCompact" : "dashboardWidgetsShowAll"; + expand.dataset.i18n = key; + expand.textContent = t(key); + for (const id of ["dashboard-widget-scroll-prev", "dashboard-widget-scroll-next"]) { + const button = $(id); if (button) button.hidden = dashboardWidgetListExpanded; + } + syncDashboardWidgetScrollButtons(); +} +function syncDashboardWidgetScrollButtons() { + const list = $("dashboard-widget-list"); if (!list) return; + const previous = $("dashboard-widget-scroll-prev"), next = $("dashboard-widget-scroll-next"); + if (previous) previous.disabled = dashboardWidgetListExpanded || list.scrollLeft <= 1; + if (next) next.disabled = dashboardWidgetListExpanded || list.scrollLeft + list.clientWidth >= list.scrollWidth - 1; +} +$("dashboard-widget-expand")?.addEventListener("click", () => { + dashboardWidgetListExpanded = !dashboardWidgetListExpanded; + syncDashboardWidgetListView(); +}); +$("dashboard-widget-list")?.addEventListener?.("scroll", syncDashboardWidgetScrollButtons, { passive: true }); +window.addEventListener?.("resize", syncDashboardWidgetScrollButtons); +for (const [id, direction] of [["dashboard-widget-scroll-prev", -1], ["dashboard-widget-scroll-next", 1]]) { + $(id)?.addEventListener("click", () => { + const list = $("dashboard-widget-list"); + if (!list) return; + list.scrollBy({ left: direction * Math.max(160, list.clientWidth * .8), behavior: "smooth" }); + }); } function refreshChartStylePalette(active = adminPreferences?.chart_style || "default") { const target = $("chart-style-palette"); if (!target || !languagePacks.size) return; diff --git a/src/admin/web/app/rate-sampling.js b/src/admin/web/app/rate-sampling.js new file mode 100644 index 0000000..1ace0d5 --- /dev/null +++ b/src/admin/web/app/rate-sampling.js @@ -0,0 +1,32 @@ +// One continuity policy for all browser-side counter-derived rates. +// Background tabs cannot supply a valid instantaneous rate for an interval +// spanning the pause. Keep the plotted history and rebase on the next sample. +const rustProxyRateSampling = (() => { + let generation = 0; + const pause = () => { generation += 1; }; + document.addEventListener?.("visibilitychange", () => { if (document.hidden) pause(); }); + window.addEventListener?.("pagehide", pause); + function cadence(key) { + const value = window.rustProxyRefresh?.current?.()?.[key]; + const fallback = key === "cache_seconds" ? 5 : 3; + return Number.isFinite(value) && value > 0 ? value : fallback; + } + function stamp(at) { return { at, generation }; } + function continuous(previous, at, intervalSeconds = 3) { + const seconds = Number.isFinite(intervalSeconds) && intervalSeconds > 0 ? intervalSeconds : 3; + return Boolean(previous && !document.hidden && previous.generation === generation && + Number.isFinite(at) && Number.isFinite(previous.at) && at > previous.at && + at - previous.at <= Math.max(10000, seconds * 3000)); + } + function difference(previous, values, at, intervalSeconds = 3) { + const next = { ...stamp(at), values: [...values] }; + if (!continuous(previous, at, intervalSeconds) || + values.some((value, i) => !Number.isFinite(value) || value < 0 || + !Number.isFinite(previous.values[i]) || value < previous.values[i])) { + return { next, deltas: null, seconds: null }; + } + return { next, deltas: values.map((value, i) => value - previous.values[i]), + seconds: (at - previous.at) / 1000 }; + } + return { cadence, stamp, continuous, difference }; +})(); diff --git a/src/admin/web/app/refresh.js b/src/admin/web/app/refresh.js index 5890029..eedcbbd 100644 --- a/src/admin/web/app/refresh.js +++ b/src/admin/web/app/refresh.js @@ -1,9 +1,9 @@ const REFRESH_DEFAULTS = Object.freeze({ - live_enabled: true, overview_seconds: 2, traffic_seconds: 5, + live_enabled: true, overview_seconds: 3, traffic_seconds: 5, cache_seconds: 5, system_info_seconds: 60 }); const REFRESH_CHOICES = Object.freeze({ - overview_seconds: [1, 2, 5, 10], traffic_seconds: [0, 3, 5, 10, 30], + overview_seconds: [1, 2, 3, 5, 10], traffic_seconds: [0, 3, 5, 10, 30], cache_seconds: [0, 3, 5, 10, 30], system_info_seconds: [0, 15, 30, 60, 300] }); function normalizeRefreshPreferences(input = {}) { diff --git a/src/admin/web/app/runtime.js b/src/admin/web/app/runtime.js index 7582dfd..2ba1ce3 100644 --- a/src/admin/web/app/runtime.js +++ b/src/admin/web/app/runtime.js @@ -1,4 +1,3 @@ - function verifyAdminModules() { const required = [ ["rustProxyIntegrations", "bindings"], @@ -12,7 +11,6 @@ function verifyAdminModules() { const missing = required.find(([module, method]) => typeof window[module]?.[method] !== "function"); if (missing) throw new Error(`Panel resources incomplete (${missing[0]}). Reload to retry.`); } - async function startAdminPanel() { try { verifyAdminModules(); @@ -33,7 +31,6 @@ async function startAdminPanel() { } if (document.readyState === "complete") startAdminPanel(); else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true }); - function certificateUsesExternalFiles(cert) { return String(cert?.cert_file || "").startsWith("/") && String(cert?.key_file || "").startsWith("/"); } @@ -53,7 +50,6 @@ function syncCertificateFileSource() { $("certificate-external-acl-example").hidden = !external; } $("certificate-file-source").addEventListener("change", syncCertificateFileSource); - function renderCertificates() { if (!envelope) return; const settings = envelope.config.tls || {}; @@ -69,7 +65,6 @@ function renderCertificates() { if (settings.default_certificate === cert.id) used.push(t("defaultLabel")); const managed = !!cert.issuer, inUse = used.length > 0; const box = el("div", undefined, "entity certificate-card"); - const head = el("div", undefined, "certificate-card-head"); const identity = el("div", undefined, "certificate-card-identity"); identity.append(el("span", cert.id, "entity-title")); @@ -152,7 +147,7 @@ $("reload-certificates").addEventListener("click", async () => { $("reset-certificate").addEventListener("click", () => { $("certificate-form").reset(); $("certificate-file-source").value = "managed"; syncCertificateFileSource(); }); syncCertificateFileSource(); -let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, +let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, lastCacheOutcomeRates = null, processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set(); const hiddenNetworkInterfaces = (() => { try { @@ -206,8 +201,20 @@ function svgNode(tag, attrs = {}) { for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value); return node; } + +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; + } +} + +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,64 +229,104 @@ 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); + } + } + + if (children.length !== target.children.length || children.some((node, index) => node !== target.children[index])) { + target.replaceChildren(...children); } } function renderDiagnostics(status, appendSample = true, systemSample = appendSample) { - const d = status.system || {}, c = status.cache || {}, m = status.metrics || {}; + const d = status.system || {}, live = status.live_system || d, c = status.cache || {}, m = status.metrics || {}; $("metric-h3").textContent = fmt(m.http3_requests); $("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`; $("cache-summary").textContent = `${fmt(c.hits)} HIT / ${fmt(c.misses)} MISS`; - if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes); - $("process-summary").hidden = d.process_threads == null; - if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`; - const systemCpuSupported = d.cpu_percent != null; - setOptionalMetric("system-cpu", systemCpuSupported, systemCpuSupported ? `${fmt(d.cpu_percent, 1)}%` : ""); - $("system-chart").hidden = !systemCpuSupported; - const sourceSupported = !!d.source && d.source !== "unsupported"; - $("system-source").hidden = !sourceSupported; - if (sourceSupported) $("system-source").textContent = t("realSample"); - if (systemSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); } + if (systemSample) { + if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes); + $("process-summary").hidden = d.process_threads == null; + if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`; + setOptionalMetric("system-cpu", d.cpu_percent != null, `${fmt(d.cpu_percent, 1)}%`); + const sourceSupported = !!d.source && d.source !== "unsupported"; + $("system-source").hidden = !sourceSupported; if (sourceSupported) $("system-source").textContent = t("realSample"); + const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value)); + setOptionalMetric("system-memory", systemMemory != null, systemMemory); + $("memory-meter").hidden = systemMemory == null; + if (d.memory_used_bytes != null && d.memory_total_bytes > 0) $("memory-meter").value = d.memory_used_bytes / d.memory_total_bytes * 100; else $("memory-meter").removeAttribute("value"); + const systemSwap = knownPair(d.system_swap_used_bytes, d.system_swap_total_bytes, value => fmtBytes(value)); + setOptionalMetric("system-swap", systemSwap != null, systemSwap); + const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0; + setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : ""); + setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : uptime(d.system_uptime_seconds)); + } + $("system-chart").hidden = live.cpu_percent == null; if (appendSample && live.cpu_percent != null) { systemSamples.push(Number(live.cpu_percent)); systemSamples = systemSamples.slice(-30); } $("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" ")); - const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value)); - setOptionalMetric("system-memory", systemMemory != null, systemMemory); - $("memory-meter").hidden = systemMemory == null; - if (d.memory_used_bytes != null && d.memory_total_bytes > 0) $("memory-meter").value = d.memory_used_bytes / d.memory_total_bytes * 100; else $("memory-meter").removeAttribute("value"); - const systemSwap = knownPair(d.system_swap_used_bytes, d.system_swap_total_bytes, value => fmtBytes(value)); - setOptionalMetric("system-swap", systemSwap != null, systemSwap); - const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0; - setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : ""); - setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : uptime(d.system_uptime_seconds)); $("cache-state").textContent = t(envelope?.config.cache?.enabled ? "enabled" : "notEnabled"); $("cache-counters").textContent = [c.hits, c.misses, c.bypasses].map(n => fmt(n)).join(" / "); $("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`; @@ -301,25 +348,29 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam if (c.cold_bytes != null) { cacheColdStorageSamples.push(Number(c.cold_bytes)); cacheColdStorageSamples = cacheColdStorageSamples.slice(-60); } const currentCounters = [c.hits, c.misses, c.bypasses].map(Number); if (currentCounters.every(Number.isFinite)) { - const delta = lastCacheCounters ? currentCounters.map((value, index) => value >= lastCacheCounters[index] ? value - lastCacheCounters[index] : 0) : [0, 0, 0]; - cacheHitSamples.push(delta[0]); cacheMissSamples.push(delta[1]); cacheBypassSamples.push(delta[2]); - cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60); - lastCacheCounters = currentCounters; + const delta = rustProxyRateSampling.difference(lastCacheCounters, currentCounters, performance.now(), rustProxyRateSampling.cadence("overview_seconds")); + const values = delta.deltas || lastCacheOutcomeRates || (!lastCacheCounters ? [0, 0, 0] : null); + if (values) { + cacheHitSamples.push(values[0]); cacheMissSamples.push(values[1]); cacheBypassSamples.push(values[2]); + cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60); + lastCacheOutcomeRates = [...values]; + } + lastCacheCounters = delta.next; } } - if (systemSample) { - if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); } - if (d.process_rss_bytes != null) { processRssSamples.push(Number(d.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); } + if (appendSample) { + if (live.process_cpu_percent != null) { processCpuSamples.push(Number(live.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); } + if (live.process_rss_bytes != null) { processRssSamples.push(Number(live.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); } } const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples); $("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak)); $("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,52 +379,54 @@ 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"); - 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)}%`); - const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited")); - setOptionalMetric("process-fds", fds != null, fds); - setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads)); - const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited")); - setOptionalMetric("process-virtual-memory", virtualMemory != null, virtualMemory); - setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : formatDateTime(Number(d.process_start_time_seconds) * 1000)); - setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds)); - setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes)); - const memory = status.memory_accounting || {}; - const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value)); - const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value)); - const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value)); - const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value)); - setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory); - setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory); - setOptionalMetric("memory-transform", transformMemory != null, transformMemory); - setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight); - setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes)); - setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes)); - const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value)); - setOptionalMetric("process-rss-file", rssFile != null, rssFile); - const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value)); - setOptionalMetric("process-data-swap", dataSwap != null, dataSwap); - const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited")); - setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory); + setChartText($("cache-cold-chart-value"), coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled")); + if (systemSample) { + 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)}%`); + const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited")); + setOptionalMetric("process-fds", fds != null, fds); + setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads)); + const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited")); + setOptionalMetric("process-virtual-memory", virtualMemory != null, virtualMemory); + setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : formatDateTime(Number(d.process_start_time_seconds) * 1000)); + setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds)); + setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes)); + const memory = status.memory_accounting || {}; + const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value)); + const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value)); + const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value)); + const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value)); + setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory); + setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory); + setOptionalMetric("memory-transform", transformMemory != null, transformMemory); + setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight); + setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes)); + setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes)); + const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value)); + setOptionalMetric("process-rss-file", rssFile != null, rssFile); + const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value)); + setOptionalMetric("process-data-swap", dataSwap != null, dataSwap); + 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"), live.process_cpu_percent == null ? "" : `${fmt(live.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); - $("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; + setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes)); + $("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null; + $("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null; updateCacheMemorySizing(); - const networkRateSupported = d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null; - setOptionalMetric("system-network", networkRateSupported, networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : ""); + const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null; + if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`); setOptionalMetric("mean-latency", m.mean_duration_ms != null, m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`); if (appendSample) { - if (d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null) { - networkRxSamples.push(Number(d.network_rx_bytes_per_second)); networkTxSamples.push(Number(d.network_tx_bytes_per_second)); + if (networkRateSupported) { + networkRxSamples.push(Number(live.network_rx_bytes_per_second)); networkTxSamples.push(Number(live.network_tx_bytes_per_second)); networkRxSamples = networkRxSamples.slice(-60); networkTxSamples = networkTxSamples.slice(-60); } if (m.mean_duration_ms != null) { responseLatencySamples.push(Number(m.mean_duration_ms)); responseLatencySamples = responseLatencySamples.slice(-60); } @@ -381,11 +434,12 @@ 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(live.network_rx_bytes_per_second)} / ${fmtBytes(live.network_tx_bytes_per_second)}` : ""); $("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported; - renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample); + // Cache-sampled OS chart rates are independent from slow System details. + renderNetworkInterfaceCharts(live.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/app/stream-dashboard.js b/src/admin/web/app/stream-dashboard.js index 45d5011..89961aa 100644 --- a/src/admin/web/app/stream-dashboard.js +++ b/src/admin/web/app/stream-dashboard.js @@ -88,17 +88,17 @@ window.rustProxyStreamDashboard = (() => { const rxTotal = views.reduce((sum, view) => sum + (Number(view.bytes_in) || 0), 0); const txTotal = views.reduce((sum, view) => sum + (Number(view.bytes_out) || 0), 0); const now = performance.now(); - let rx = 0, tx = 0; - if (history.last) { - const seconds = Math.max(.001, (now - history.last.when) / 1000); - if (rxTotal >= history.last.rx) rx = (rxTotal - history.last.rx) / seconds; - if (txTotal >= history.last.tx) tx = (txTotal - history.last.tx) / seconds; + const sample = rustProxyRateSampling.difference(history.last, [rxTotal, txTotal], now, rustProxyRateSampling.cadence("overview_seconds")); + const pausedRate = history.last !== null && sample.deltas === null; + history.last = sample.next; + const rx = sample.deltas ? sample.deltas[0] / sample.seconds : 0; + const tx = sample.deltas ? sample.deltas[1] / sample.seconds : 0; + if (!pausedRate) { + history.rx.push(rx); history.tx.push(tx); + if (history.rx.length > 60) { history.rx.shift(); history.tx.shift(); } } - history.last = { when: now, rx: rxTotal, tx: txTotal }; - history.rx.push(rx); history.tx.push(tx); - if (history.rx.length > 60) { history.rx.shift(); history.tx.shift(); } - set("stream-dashboard-traffic", `${bytes(Math.round(rx))}/s`); - trafficRates(`${bytes(Math.round(rx))}/s`, `${bytes(Math.round(tx))}/s`); + set("stream-dashboard-traffic", pausedRate ? "-" : `${bytes(Math.round(rx))}/s`); + trafficRates(pausedRate ? "-" : `${bytes(Math.round(rx))}/s`, pausedRate ? "-" : `${bytes(Math.round(tx))}/s`); $("stream-dashboard-rx")?.setAttribute("points", line(history.rx)); $("stream-dashboard-tx")?.setAttribute("points", line(history.tx)); } diff --git a/src/admin/web/cache-panel.js b/src/admin/web/cache-panel.js index db80f1a..7b57210 100644 --- a/src/admin/web/cache-panel.js +++ b/src/admin/web/cache-panel.js @@ -1,7 +1,7 @@ "use strict"; (() => { let loading = false, offset = 0, generation = 0; - let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null; + let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null; const liveChartSamples = { hits: [], misses: [], bypasses: [] }; const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); const LIVE_CHART_STORAGE_KEY = "rustproxy.cache.liveChartSamples"; @@ -68,7 +68,7 @@ }; const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; }; function resetLiveChart(selected = scope()) { - liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; + liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null; for (const values of Object.values(liveChartSamples)) values.length = 0; for (const id of ["cache-live-hit-line", "cache-live-miss-line", "cache-live-bypass-line"]) $(id)?.setAttribute("points", "0,170 900,170"); for (const id of ["cache-live-hit-rate", "cache-live-miss-rate", "cache-live-bypass-rate"]) if ($(id)) $(id).textContent = "0.0/s"; @@ -100,18 +100,21 @@ hits: Math.max(0, Number(counters.hits) || 0), misses: Math.max(0, Number(counters.misses) || 0), bypasses: Math.max(0, Number(counters.bypasses) || 0) - }, rates = { hits: 0, misses: 0, bypasses: 0 }; - if (liveChartPrevious && liveChartPreviousAt != null) { - const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000); - for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed; + }; + const keys = Object.keys(current); + const previous = liveChartPrevious && liveChartPreviousAt != null + ? { values: keys.map(key => liveChartPrevious[key]), at: liveChartPreviousAt, generation: liveChartGeneration } : null; + const sample = rustProxyRateSampling.difference(previous, keys.map(key => current[key]), now, rustProxyRateSampling.cadence("cache_seconds")); + liveChartPrevious = current; liveChartPreviousAt = now; liveChartGeneration = sample.next.generation; + if (sample.deltas) { + keys.forEach((key, i) => { liveChartSamples[key].push(sample.deltas[i] / sample.seconds); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); }); } - liveChartPrevious = current; liveChartPreviousAt = now; - for (const key of Object.keys(rates)) { liveChartSamples[key].push(rates[key]); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); } redrawLiveChart(); - $("cache-live-hit-rate").textContent = `${rates.hits.toFixed(1)}/s`; - $("cache-live-miss-rate").textContent = `${rates.misses.toFixed(1)}/s`; - $("cache-live-bypass-rate").textContent = `${rates.bypasses.toFixed(1)}/s`; - $("cache-live-chart-value").textContent = `Σ ${(rates.hits + rates.misses + rates.bypasses).toFixed(1)}/s`; + const rate = key => sample.deltas ? `${(sample.deltas[keys.indexOf(key)] / sample.seconds).toFixed(1)}/s` : "-"; + $("cache-live-hit-rate").textContent = rate("hits"); + $("cache-live-miss-rate").textContent = rate("misses"); + $("cache-live-bypass-rate").textContent = rate("bypasses"); + $("cache-live-chart-value").textContent = sample.deltas ? `Σ ${(sample.deltas.reduce((a, b) => a + b, 0) / sample.seconds).toFixed(1)}/s` : "-"; } function configure() { const selected = scope(); const items = [el("option", t("logAllVhosts"))]; items[0].value = "*"; diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 7a4bf26..b14caf2 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -2477,8 +2477,9 @@ data-appearance-reset="dashboard" data-i18n="resetSection" type="button">Reset section -
- -
Overview cardsShow, hide and reorder the telemetry - cards at the top of Overview. - -
+
+
+
+ Overview cards + Show, hide and + reorder all Overview blocks, including charts, diagnostics and + the backend table. +
+
+ + + + +
+
+
+ Scroll horizontally to browse + cards, or expand the list. Stream cards are opt-in and only change Dashboard presentation, not Stream routing or monitoring. @@ -2520,7 +2544,8 @@ diff --git a/src/admin/web/streams.js b/src/admin/web/streams.js index df8352e..696e8f2 100644 --- a/src/admin/web/streams.js +++ b/src/admin/web/streams.js @@ -250,14 +250,17 @@ const set = (suffix, value) => document.querySelectorAll(`[data-${suffix.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${CSS.escape(id)}"]`).forEach(node => node.textContent = value); set("streamState", state); set("streamActive", data ? fmt(data.active) : "-"); set("streamSessions", data ? fmt(data.sessions_total) : "-"); set("streamErrors", data ? fmt(data.errors) : "-"); set("streamRejected", data ? fmt(data.rejected) : "-"); set("streamTransferred", data ? bytes((Number(data.bytes_in) || 0) + (Number(data.bytes_out) || 0)) : "-"); if (!data || currentPanel !== "streams") continue; - const history = chartHistory.get(id) || { lastTime: now, lastIn: Number(data.bytes_in) || 0, lastOut: Number(data.bytes_out) || 0, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 }; - const elapsed = Math.max(.001, (now - history.lastTime) / 1000), inBytes = Number(data.bytes_in) || 0, outBytes = Number(data.bytes_out) || 0; - const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0; - history.lastTime = now; history.lastIn = inBytes; history.lastOut = outBytes; addPoint(history, rx, tx); chartHistory.set(id, history); + const history = chartHistory.get(id) || { last: null, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 }; + const sample = rustProxyRateSampling.difference(history.last, [Number(data.bytes_in) || 0, Number(data.bytes_out) || 0], now, rustProxyRateSampling.cadence("overview_seconds")); + const pausedRate = history.last !== null && sample.deltas === null; + history.last = sample.next; + const rx = sample.deltas ? sample.deltas[0] / sample.seconds : 0, tx = sample.deltas ? sample.deltas[1] / sample.seconds : 0; + if (!pausedRate) addPoint(history, rx, tx); + chartHistory.set(id, history); const points = history.pendingCount ? [...history.points, [history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]] : history.points; const peak = Math.max(1, ...points.flat()); const toPoints = index => points.map((pair, pointIndex) => `${pointIndex * 320 / Math.max(1, points.length - 1)},${50 - pair[index] / peak * 44}`).join(" "); const svg = document.querySelector(`[data-stream-chart="${CSS.escape(id)}"]`); svg?.querySelector(".stream-chart-rx")?.setAttribute("points", toPoints(0)); svg?.querySelector(".stream-chart-tx")?.setAttribute("points", toPoints(1)); - const label = document.querySelector(`[data-stream-chart-value="${CSS.escape(id)}"]`); if (label) label.textContent = `RX ${bytes(Math.round(rx))}/s · TX ${bytes(Math.round(tx))}/s`; + const label = document.querySelector(`[data-stream-chart-value="${CSS.escape(id)}"]`); if (label) label.textContent = pausedRate ? "RX - · TX -" : `RX ${bytes(Math.round(rx))}/s · TX ${bytes(Math.round(tx))}/s`; } if (editing) { const data = metrics[editing]; $("stream-live-summary").textContent = data ? `${t("streamActiveSessions")}: ${fmt(data.active)} · RX ${bytes(data.bytes_in)} · TX ${bytes(data.bytes_out)} · ${t("streamErrors")}: ${fmt(data.errors)}` : t("streamNoLiveMetrics"); } } diff --git a/src/admin/web/style.css b/src/admin/web/style.css index ac2e468..a28aeba 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -45,11 +45,11 @@ --warn-line: #786647; --blue: #8ebcff; --transform-chart: #d7adff; - --backdrop: rgb(0 0 0 / .68); + --backdrop: rgba(0, 0, 0, .68); --backdrop-soft: #070d12b8; --shadow-mid: #0006; --shadow-strong: #0009; - --neutral-soft: rgba(127, 127, 127, .05); + --neutral-soft: hsla(0, 0%, 49.8%, .05); --scrollbar-track: #151a16; --scrollbar-thumb: #4f5d50; --scrollbar-thumb-hover: #6b7b69; @@ -83,10 +83,10 @@ --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif } -html[data-theme="light"] { +html[data-theme=light] { color-scheme: light; --bg: #f4f6f2; - --panel: #ffffff; + --panel: #fff; --panel2: #edf1eb; --line: #cbd3c7; --muted: #5e685f; @@ -101,7 +101,7 @@ html[data-theme="light"] { --text-subtle: #687369; --separator: #9ba69b; --border-hover: #839083; - --field-bg: #ffffff; + --field-bg: #fff; --field-line: #b7c1b5; --placeholder: #7a867b; --row-line: #e0e5dd; @@ -111,9 +111,9 @@ html[data-theme="light"] { --code-alt-bg: #eef3f6; --code-alt-text: #2d4049; --nav-selected-bg: #e1e9db; - --accent-ink: #ffffff; + --accent-ink: #fff; --accent-hover: #a84f30; - --selected-ink: #ffffff; + --selected-ink: #fff; --notice-text: #685b45; --notice-line: #d4c2a5; --notice-bg: #fff9ef; @@ -124,8 +124,8 @@ html[data-theme="light"] { --warn-line: #c6b27e; --blue: #4f77a9; --transform-chart: #7d4fa3; - --shadow-mid: rgb(31 39 33 / .16); - --shadow-strong: rgb(31 39 33 / .26); + --shadow-mid: rgba(31, 39, 33, .16); + --shadow-strong: rgba(31, 39, 33, .26); --neutral-soft: rgba(70, 78, 72, .05); --scrollbar-track: #e7ece4; --scrollbar-thumb: #a9b5a6; @@ -135,7 +135,7 @@ html[data-theme="light"] { /* Optional AMOLED palette: near-black surfaces, readable borders and restrained mint/coral emphasis. Does not change the established dark appearance. */ -html[data-theme="ultra_dark"] { +html[data-theme=ultra_dark] { color-scheme: dark; --bg: #030404; --panel: #0b0d0e; @@ -148,7 +148,7 @@ html[data-theme="ultra_dark"] { --logo-secondary: #cfe0ac; --green: #b1dcb0; --red: #ff9899; - --sidebar-bg: #000000; + --sidebar-bg: #000; --brand-secondary: #a9cfb5; --nav-active-bg: #172422; --nav-hover-bg: #121819; @@ -184,11 +184,11 @@ html[data-theme="ultra_dark"] { --warn-line: #89754c; --blue: #8bbdfb; --transform-chart: #cfaeff; - --backdrop: rgb(0 0 0 / .86); - --backdrop-soft: rgb(0 0 0 / .77); - --shadow-mid: rgb(0 0 0 / .64); - --shadow-strong: rgb(0 0 0 / .84); - --neutral-soft: rgb(132 171 158 / .045); + --backdrop: rgba(0, 0, 0, .86); + --backdrop-soft: rgba(0, 0, 0, .77); + --shadow-mid: rgba(0, 0, 0, .64); + --shadow-strong: rgba(0, 0, 0, .84); + --neutral-soft: rgba(132, 171, 158, .045); --scrollbar-track: #060808; --scrollbar-thumb: #364243; --scrollbar-thumb-hover: #617373; @@ -196,7 +196,7 @@ html[data-theme="ultra_dark"] { /* Warm, muted ivory. No pure-white canvas; numeric values and charts retain clear contrast against their slightly lighter, neutral card surfaces. */ -html[data-theme="cream"] { +html[data-theme=cream] { color-scheme: light; --bg: #f2ede3; --panel: #fcf9f2; @@ -224,9 +224,9 @@ html[data-theme="cream"] { --code-alt-bg: #e7ebe9; --code-alt-text: #364f4e; --nav-selected-bg: #d9cbb7; - --accent-ink: #ffffff; + --accent-ink: #fff; --accent-hover: #774632; - --selected-ink: #ffffff; + --selected-ink: #fff; --notice-text: #765e40; --notice-line: #d9c3a1; --notice-bg: #f8efdf; @@ -243,22 +243,22 @@ html[data-theme="cream"] { --warn-line: #b49c6b; --blue: #4d7292; --transform-chart: #83659a; - --backdrop: rgb(37 27 16 / .44); - --backdrop-soft: rgb(47 35 21 / .48); - --shadow-mid: rgb(64 49 34 / .13); - --shadow-strong: rgb(64 49 34 / .24); - --neutral-soft: rgb(104 91 69 / .045); + --backdrop: rgba(37, 27, 16, .44); + --backdrop-soft: rgba(47, 35, 21, .48); + --shadow-mid: rgba(64, 49, 34, .13); + --shadow-strong: rgba(64, 49, 34, .24); + --neutral-soft: rgba(104, 91, 69, .045); --scrollbar-track: #ece4d7; --scrollbar-thumb: #b5a997; --scrollbar-thumb-hover: #978a76; } -/* Theme palettes change color only; corners are controlled independently. */ -html[data-theme="ultra_dark"] :is(.card, .stat, dialog) { - box-shadow: 0 6px 24px rgb(0 0 0 / .18); +html[data-theme=ultra_dark] :is(.card, .stat, dialog) { + box-shadow: 0 6px 24px rgba(0, 0, 0, .18); } -html[data-theme="cream"] :is(.card, .stat, dialog) { - box-shadow: 0 6px 20px rgb(78 58 38 / .055); + +html[data-theme=cream] :is(.card, .stat, dialog) { + box-shadow: 0 6px 20px rgba(78, 58, 38, .055); } * { @@ -267,26 +267,26 @@ html[data-theme="cream"] :is(.card, .stat, dialog) { scrollbar-width: thin } -*::-webkit-scrollbar { +::-webkit-scrollbar { width: 10px; height: 10px } -*::-webkit-scrollbar-track { +::-webkit-scrollbar-track { background: var(--scrollbar-track) } -*::-webkit-scrollbar-thumb { +::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border: 2px solid var(--scrollbar-track); border-radius: 999px } -*::-webkit-scrollbar-thumb:hover { +::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover) } -*::-webkit-scrollbar-corner { +::-webkit-scrollbar-corner { background: var(--scrollbar-track) } @@ -365,8 +365,8 @@ html:not(.app-loading) .app-startup { display: inline-flex } -html.app-startup-failed .app-startup-spinner, -html.app-startup-failed .app-startup-message { +html.app-startup-failed .app-startup-message, +html.app-startup-failed .app-startup-spinner { display: none } @@ -381,7 +381,7 @@ html.app-startup-failed .app-startup-recovery { @keyframes app-startup-spin { to { - transform: rotate(360deg) + transform: rotate(1turn) } } @@ -391,10 +391,14 @@ html.app-startup-failed .app-startup-recovery { } } -@media (prefers-reduced-motion: reduce) { +@media(prefers-reduced-motion:reduce) { .app-startup-spinner { animation: none } + + * { + scroll-behavior: auto !important + } } body { @@ -413,21 +417,22 @@ textarea { font: inherit } -button, a, +button, input, -textarea, -select { +select, +textarea { -webkit-tap-highlight-color: transparent } -button, -a { +a, +button { touch-action: manipulation } button { - cursor: pointer + cursor: pointer; + border: 0 } button:disabled { @@ -451,16 +456,12 @@ p { margin: 0 } -button:focus-visible, -a:focus-visible { +a:focus-visible, +button:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px } -button { - border: 0 -} - h2 { letter-spacing: -.5px } @@ -493,7 +494,9 @@ h2 { border: 1px solid var(--logo-accent, var(--accent)); color: var(--logo-accent, var(--accent)); place-content: center; - position: relative + position: relative; + box-sizing: border-box; + padding-right: 2px } .brand-mark span { @@ -515,13 +518,18 @@ h2 { margin-top: 5px } +.transport-grid>div, nav { - display: flex; flex-direction: column; } +.local-tag, +.nav, +nav { + display: flex +} + .nav { - display: flex; align-items: center; gap: 13px; text-align: left; @@ -569,7 +577,6 @@ nav { .local-tag { font: 9px var(--mono); color: var(--green); - display: flex; gap: 8px; align-items: center } @@ -618,13 +625,16 @@ main { } .version { - font: 10px var(--mono); + font: 10px var(--mono) +} + +.quiet, +.version { color: var(--muted) } .quiet { background: transparent; - color: var(--muted); border: 1px solid var(--line); border-radius: 3px } @@ -674,7 +684,7 @@ main { } h1 { - font-size: clamp(32px, 3.6vw, 52px); + font-size: calc(clamp(32px, 3.6vw, 52px)); line-height: 1.1; font-weight: 520; letter-spacing: -2px @@ -723,6 +733,7 @@ h1 { border-top: 2px solid var(--accent) } +.stat small, .stat>span { color: var(--muted); } @@ -734,10 +745,6 @@ h1 { letter-spacing: -1px; } -.stat small { - color: var(--muted); -} - .stat:first-child small { color: var(--green) } @@ -775,7 +782,8 @@ h1 { color: var(--green) } -#traffic-chart { +#traffic-chart, +table { width: 100%; } @@ -807,7 +815,6 @@ h1 { } table { - width: 100%; border-collapse: collapse; text-align: left; font-size: 11px; @@ -841,8 +848,8 @@ td:first-child { color: var(--red) } -.meta, -.fine { +.fine, +.meta { font-size: 11px; line-height: 1.7; color: var(--muted) @@ -920,7 +927,7 @@ td:first-child { margin: 16px 0 7px } -input:not([type="checkbox"]):not([type="radio"]), +input:not([type=checkbox]):not([type=radio]), select, textarea { display: block; @@ -935,7 +942,7 @@ textarea { min-width: 0 } -input:not([type="checkbox"]):not([type="radio"]):focus, +input:not([type=checkbox]):not([type=radio]):focus, select:focus, textarea:focus { outline: 1px solid var(--green); @@ -946,8 +953,8 @@ input::placeholder { color: var(--placeholder) } -input[type="checkbox"], -input[type="radio"] { +input[type=checkbox], +input[type=radio] { display: inline-block; width: var(--ui-check-size); height: var(--ui-check-size); @@ -963,16 +970,16 @@ input.field-invalid, select.field-invalid, textarea.field-invalid { border-color: var(--red) !important; - outline: 1px solid rgb(255 170 162 / .72); + outline: 1px solid rgba(255, 170, 162, .72); } -.listener-choices.field-invalid, -.host-chip-editor.field-invalid { +.host-chip-editor.field-invalid, +.listener-choices.field-invalid { border-color: var(--red) !important; - box-shadow: 0 0 0 1px rgb(255 170 162 / .38); + box-shadow: 0 0 0 1px rgba(255, 170, 162, .38); } -.settings-tabs button.has-unsaved::after { +.settings-tabs button.has-unsaved:after { content: ""; display: inline-block; width: 6px; @@ -1046,7 +1053,7 @@ button.is-saving { } .login-section h1 { - font-size: clamp(42px, 5vw, 68px); + font-size: calc(clamp(42px, 5vw, 68px)); letter-spacing: -3px; max-width: 640px } @@ -1105,7 +1112,7 @@ button.is-saving { position: fixed; bottom: 26px; right: 26px; - max-width: min(580px, calc(100vw - 40px)); + max-width: calc(min(580px, 100vw - 40px)); background: var(--toast-bg); color: var(--toast-text); border: 1px solid var(--toast-line); @@ -1219,14 +1226,11 @@ button.is-saving { grid-template-columns: repeat(2, minmax(0, 1fr)) } + .config-columns, .overview-grid { grid-template-columns: 1fr 1fr } - .config-columns { - grid-template-columns: 1fr 1fr - } - .login-section { padding-top: 42px } @@ -1242,7 +1246,8 @@ button.is-saving { } - .nav span { + .nav span, + .version { display: none } @@ -1275,10 +1280,6 @@ button.is-saving { gap: 6px } - .version { - display: none - } - .quiet { font-size: 11px; padding: 7px 9px @@ -1319,8 +1320,8 @@ button.is-saving { font-size: 30px } - .overview-grid, - .config-columns { + .config-columns, + .overview-grid { grid-template-columns: 1fr; gap: 16px } @@ -1347,12 +1348,13 @@ button.is-saving { font-size: 20px } - .chart-footer { + .chart-footer, + .login-footer, + .page-footer { font-size: 8px } .page-footer { - font-size: 8px; flex-wrap: wrap } @@ -1364,10 +1366,6 @@ button.is-saving { padding: 20px } - .login-footer { - font-size: 8px - } - .login-footer span { margin: 0 5px } @@ -1382,12 +1380,6 @@ button.is-saving { } } -@media(prefers-reduced-motion:reduce) { - * { - scroll-behavior: auto !important - } -} - .brand-mark { display: flex; align-items: center; @@ -1454,9 +1446,6 @@ button.is-saving { } @media(max-width:600px) { - .sidebar { - flex-wrap: wrap - } .brand { font-size: 16px @@ -1476,6 +1465,7 @@ button.is-saving { } .sidebar { + flex-wrap: wrap; padding: 12px 16px } @@ -1515,7 +1505,6 @@ button.is-saving { .transport-grid>div { display: flex; - flex-direction: column; border: 1px solid var(--line, #d8dfdb); min-width: 0 } @@ -1524,13 +1513,16 @@ button.is-saving { opacity: .7 } +.command, +.fine, +.meta, +.mini-metrics dd, .transport-grid b { overflow-wrap: anywhere } .command { - white-space: normal; - overflow-wrap: anywhere + white-space: normal } #panel-tls .section-heading { @@ -1753,8 +1745,7 @@ progress::-moz-progress-bar { .mini-metrics dd { font: 10px var(--mono); margin: 0; - text-align: end; - overflow-wrap: anywhere + text-align: end } .mini-live-chart-stack { @@ -1815,14 +1806,11 @@ progress::-moz-progress-bar { vector-effect: non-scaling-stroke } +.cache-hit-line, .network-tx-line { stroke: var(--chart-secondary) } -.cache-hit-line { - stroke: var(--chart-secondary) -} - .cache-miss-line { stroke: var(--chart-accent) } @@ -1844,7 +1832,7 @@ progress::-moz-progress-bar { color: var(--muted) } -.mini-chart-legend span::before { +.mini-chart-legend span:before { content: ""; display: inline-block; width: 12px; @@ -1896,7 +1884,7 @@ progress::-moz-progress-bar { z-index: 80; inset-inline-end: 0; top: calc(100% + 4px); - width: min(190px, calc(100vw - 24px)); + width: calc(min(190px, 100vw - 24px)); overflow: visible; padding: 4px; display: grid; @@ -1920,7 +1908,8 @@ progress::-moz-progress-bar { white-space: nowrap } -.log-column-option:hover { +.log-column-option:hover, +.logs-card th { background: var(--panel) } @@ -1928,7 +1917,7 @@ progress::-moz-progress-bar { .log-column-options { top: auto; bottom: calc(100% + 4px); - width: min(360px, calc(100vw - 24px)); + width: calc(min(360px, 100vw - 24px)); grid-template-columns: repeat(2, minmax(0, 1fr)) } } @@ -1939,8 +1928,7 @@ progress::-moz-progress-bar { .logs-card th { position: sticky; - top: 0; - background: var(--panel) + top: 0 } th { @@ -1965,7 +1953,7 @@ td { margin: 10px 0 5px } -.edit-form input:not([type="checkbox"]):not([type="radio"]), +.edit-form input:not([type=checkbox]):not([type=radio]), .edit-form select, .edit-form textarea { padding: 8px 10px @@ -2078,34 +2066,29 @@ td { line-height: 1.5 } -.fine, -.meta { - overflow-wrap: anywhere -} - -.mini-metrics dd, -.chart-footer>span { +.chart-footer>span, +.mini-metrics dd { min-width: 0 } -[dir="rtl"] .nav { +[dir=rtl] .nav { text-align: right; border-left: 0; border-right: 2px solid transparent } -[dir="rtl"] .nav.active { +[dir=rtl] .nav.active { border-right-color: var(--green) } -[dir="rtl"] table { +[dir=rtl] table { text-align: right } -[dir="rtl"] .entity-details, -[dir="rtl"] input[type="url"], -[dir="rtl"] #config-editor { +[dir=rtl] #config-editor, +[dir=rtl] .entity-details, +[dir=rtl] input[type=url] { direction: ltr; text-align: left } @@ -2158,8 +2141,8 @@ td { padding-top: 18px } - .settings-grid, - .config-columns { + .config-columns, + .settings-grid { gap: 12px } @@ -2188,7 +2171,9 @@ td { } - .nav span { + .breadcrumb b, + .nav span, + .topbar .version { display: none } @@ -2202,10 +2187,6 @@ td { margin: 0 5px } - .topbar .version { - display: none - } - .language-select { max-width: 110px; font-size: 10px @@ -2259,7 +2240,7 @@ td { width: 100% } - .log-tools input:not([type="checkbox"]):not([type="radio"]) { + .log-tools input:not([type=checkbox]):not([type=radio]) { flex: 1; min-width: 95px; width: 95px @@ -2304,8 +2285,8 @@ td { gap: 8px } - .primary, - .outline { + .outline, + .primary { white-space: normal } @@ -2332,10 +2313,6 @@ td { .topbar { gap: 7px } - - .breadcrumb b { - display: none - } } .vhost-layout { @@ -2344,15 +2321,16 @@ td { align-items: start } +.vh-field, .vh-form, +.vh-group, .vhost-layout>.card { min-width: 0 } .vh-group { border: 1px solid var(--line); - border-radius: 6px; - min-width: 0 + border-radius: 6px } .vh-group legend, @@ -2376,7 +2354,6 @@ td { display: flex; flex-direction: column; gap: 4px; - min-width: 0; margin: 0; font-size: 11px; color: var(--muted) @@ -2515,7 +2492,7 @@ td { } #pool-search { - width: min(250px, 30vw); + width: calc(min(250px, 30vw)); min-width: 170px } @@ -2579,11 +2556,6 @@ td { min-width: 640px } -.pool-table th, -.pool-table td { - white-space: nowrap -} - .pool-table td:nth-child(n+4) { font-variant-numeric: tabular-nums; text-align: right @@ -2600,6 +2572,12 @@ td { text-overflow: ellipsis } +#toast[popover], +.app-confirm-message, +.cache-table td, +.location-summary, +.vh-health-summary, +.vh-live-summary, .vhost-layout .entity-details { overflow-wrap: anywhere } @@ -2671,14 +2649,14 @@ body { font-size: var(--ui-font) } -body[data-density="dense"] { +body[data-density=dense] { --ui-gap: 4px; --ui-card: 7px; --ui-font: 11.5px; --ui-control: 25px } -body[data-density="comfortable"] { +body[data-density=comfortable] { --ui-gap: 13px; --ui-card: 17px; --ui-font: 14px; @@ -2750,16 +2728,12 @@ main { margin-bottom: 3px } -.overview-grid, +.config-columns, .diagnostics-grid, +.overview-grid, .settings-grid, -.config-columns { - gap: var(--ui-gap); - margin-bottom: var(--ui-gap) -} - -.stats-grid, -.stats { +.stats, +.stats-grid { gap: var(--ui-gap); margin-bottom: var(--ui-gap) } @@ -2770,7 +2744,8 @@ main { .stat p, -.stat small { +.stat small, +.transport-grid span { font-size: 10px } @@ -2785,10 +2760,6 @@ main { border-radius: 4px } -.transport-grid span { - font-size: 10px -} - .transport-grid b { font-size: 14px } @@ -2810,8 +2781,9 @@ main { margin-bottom: var(--ui-gap) } -.pool-table th, -.pool-table td { +.pool-table td, +.pool-table th { + white-space: nowrap; padding: 5px 7px } @@ -2832,10 +2804,10 @@ main { margin-top: 9px } -.edit-form input:not([type="checkbox"]):not([type="radio"]), +.edit-form input:not([type=checkbox]):not([type=radio]), .edit-form select, .edit-form textarea, -input:not([type="checkbox"]):not([type="radio"]), +input:not([type=checkbox]):not([type=radio]), select, textarea { padding: var(--ui-field-pad-y) var(--ui-field-pad-x); @@ -2844,16 +2816,16 @@ textarea { } -button, -.primary, .outline, -.quiet { +.primary, +.quiet, +button { padding: var(--button-padding); font-size: var(--button-font-size); min-height: var(--button-min-height) } -html[data-button-style="compact"] body { +html[data-button-style=compact] body { --button-padding: 3px 7px; --button-font-size: 10px; --button-min-height: 22px; @@ -2884,14 +2856,13 @@ html[data-button-style="compact"] body { .vh-health-summary { grid-column: 1/-1; color: var(--muted); - font: 10px var(--mono); - overflow-wrap: anywhere + font: 10px var(--mono) } -.vhost-dialog, +.audit-dialog, .logs-dialog, -.audit-dialog { +.vhost-dialog { color: var(--text); background: var(--bg); border: 1px solid var(--line); @@ -2907,9 +2878,9 @@ html[data-button-style="compact"] body { overflow: auto } -.vhost-dialog::backdrop, +.audit-dialog::backdrop, .logs-dialog::backdrop, -.audit-dialog::backdrop { +.vhost-dialog::backdrop { background: var(--shadow-strong); backdrop-filter: blur(2px) } @@ -2935,7 +2906,6 @@ html[data-button-style="compact"] body { .vh-live-summary { font: 10px var(--mono); line-height: 1.7; - overflow-wrap: anywhere; color: var(--muted); margin: 5px 0 } @@ -3107,15 +3077,15 @@ html[data-button-style="compact"] body { width: 100% } -.log-tools select, -.log-tools input:not([type="checkbox"]):not([type="radio"]) { +.log-tools input:not([type=checkbox]):not([type=radio]), +.log-tools select { width: auto; max-width: 180px; min-height: 30px; font-size: 11px } -.log-tools input:not([type="checkbox"]):not([type="radio"]) { +.log-tools input:not([type=checkbox]):not([type=radio]) { flex: 1; min-width: 110px } @@ -3126,7 +3096,7 @@ html[data-button-style="compact"] body { } .logs-dialog .table-scroll { - max-height: min(60dvh, 580px); + max-height: calc(min(60dvh, 580px)); overflow: auto; border-top: 1px solid var(--line) } @@ -3135,8 +3105,8 @@ html[data-button-style="compact"] body { font: 11px var(--mono) } -.logs-dialog th, -.logs-dialog td { +.logs-dialog td, +.logs-dialog th { padding: 6px 7px; max-width: 360px; overflow: hidden; @@ -3158,7 +3128,7 @@ html[data-button-style="compact"] body { position: fixed; inset: auto 12px 10px auto; margin: 0; - width: min(1180px, calc(100vw - 24px)); + width: calc(min(1180px, 100vw - 24px)); max-height: 46dvh; z-index: 20 } @@ -3257,10 +3227,10 @@ html[data-button-style="compact"] body { font-size: 23px } - .overview-grid, + .config-columns, .diagnostics-grid, - .settings-grid, - .config-columns { + .overview-grid, + .settings-grid { grid-template-columns: minmax(0, 1fr) } @@ -3287,6 +3257,8 @@ html[data-button-style="compact"] body { grid-template-columns: 85px 1fr } + .page-label, + .page-source, .vh-header-row>:nth-child(3) { grid-column: 1/-1 } @@ -3341,14 +3313,6 @@ html[data-button-style="compact"] body { .page-editor { grid-template-columns: 1fr 1fr } - - .page-label { - grid-column: 1/-1 - } - - .page-source { - grid-column: 1/-1 - } } @media(max-width:380px) { @@ -3380,11 +3344,11 @@ main { } .stat strong { - font-size: clamp(20px, 1.8vw, 26px); + font-size: calc(clamp(20px, 1.8vw, 26px)); margin: 4px 0 } -body[data-density="dense"] .stat strong { +body[data-density=dense] .stat strong { font-size: 22px; margin: 2px 0 } @@ -3394,13 +3358,13 @@ body[data-density="dense"] .stat strong { inset: auto 18px 18px auto; margin: 0; width: max-content; - max-width: min(520px, calc(100vw - 28px)); + max-width: calc(min(520px, 100vw - 28px)); padding: 12px 16px; color: var(--text); background: var(--panel); border: 1px solid var(--green); box-shadow: 0 10px 40px var(--shadow-strong); - overflow-wrap: anywhere + pointer-events: none } #toast[popover].error { @@ -3444,14 +3408,12 @@ body[data-density="dense"] .stat strong { cursor: pointer } +.h3-editor .fine, .h3-editor .vh-grid { margin-top: 7px } -.h3-editor .fine { - margin-top: 7px -} - +.cache-toolbar, .listener-choices { flex-wrap: wrap; } @@ -3498,7 +3460,6 @@ body[data-density="dense"] .stat strong { display: flex; align-items: center; gap: 10px; - flex-wrap: wrap; padding: 12px } @@ -3551,7 +3512,6 @@ body[data-density="dense"] .stat strong { .cache-table td { max-width: 260px; - overflow-wrap: anywhere; vertical-align: top } @@ -3593,6 +3553,7 @@ body[data-density="dense"] .stat strong { grid-template-columns: minmax(0, 1fr) } + .cache-toolbar select, .cache-toolbar>* { max-width: 100% } @@ -3601,10 +3562,6 @@ body[data-density="dense"] .stat strong { flex-wrap: wrap } - .cache-toolbar select { - max-width: 100% - } - .cache-stats strong { font-size: 19px } @@ -3617,7 +3574,7 @@ body[data-density="dense"] .stat strong { } #cache-object-scroll { - max-height: min(49vh, 520px); + max-height: calc(min(49vh, 520px)); overflow: auto; scrollbar-gutter: stable } @@ -3655,18 +3612,21 @@ body[data-density="dense"] .stat strong { border-radius: 8px } -.settings-tabs button[aria-selected="true"] { +.settings-tabs button[aria-selected=true] { background: var(--accent); color: var(--selected-ink); border-color: var(--accent); font-weight: 700 } +.cache-operation-dialog .cache-dialog-heading>div:first-child, +.certificate-dialog .vh-grid label, .settings-pages { min-width: 0 } -.settings-page { +.settings-page, +.settings-page>.edit-form { width: 100%; max-width: none } @@ -3675,11 +3635,6 @@ body[data-density="dense"] .stat strong { display: none !important } -.settings-page>.edit-form { - width: 100%; - max-width: none -} - .settings-page>.edit-form+.edit-form { margin-top: var(--ui-gap) } @@ -3690,7 +3645,7 @@ body[data-density="dense"] .stat strong { .certificate-dialog { - width: min(820px, calc(100vw - 28px)); + width: calc(min(820px, 100vw - 28px)); max-height: 90dvh; padding: 0; border: 1px solid var(--line); @@ -3742,10 +3697,6 @@ body[data-density="dense"] .stat strong { gap: 10px 14px } -.certificate-dialog .vh-grid label { - min-width: 0 -} - .metrics-example, .metrics-preview { font: 11px/1.55 ui-monospace, SFMono-Regular, monospace; @@ -3790,12 +3741,12 @@ body[data-density="dense"] .stat strong { } -dialog.vhost-dialog, -dialog.logs-dialog, dialog.audit-dialog, dialog.certificate-dialog, dialog.config-history-dialog, -dialog.config-history-json-dialog { +dialog.config-history-json-dialog, +dialog.logs-dialog, +dialog.vhost-dialog { border-radius: 0; background: var(--bg); color: var(--text); @@ -3809,18 +3760,16 @@ dialog::backdrop { backdrop-filter: blur(2px) !important } +#toast[popover], +.settings-page>.card, +.settings-tabs, +.settings-tabs button, dialog .card, dialog .vh-form, dialog .window-heading { border-radius: 0 } -.settings-tabs, -.settings-tabs button, -.settings-page>.card { - border-radius: 0 -} - .certificate-dialog>.window-heading { padding: 10px 12px } @@ -3848,10 +3797,6 @@ dialog .window-heading { padding: 9px 0 } -.cache-operation-dialog .cache-dialog-heading>div:first-child { - min-width: 0 -} - .cache-operation-dialog .cache-dialog-heading button { margin-inline-start: auto; flex: 0 0 auto @@ -3868,9 +3813,9 @@ dialog .window-heading { gap: 9px } -.location-toolbar, +.location-actions, .location-header-toolbar, -.location-actions { +.location-toolbar { display: flex; align-items: center; flex-wrap: wrap; @@ -3891,7 +3836,6 @@ dialog .window-heading { cursor: pointer; padding: 10px 12px; font: 600 11px/1.45 var(--mono); - overflow-wrap: anywhere; color: var(--text) } @@ -3936,8 +3880,8 @@ dialog .window-heading { } #location-help-dialog { - width: min(760px, calc(100vw - 28px)); - max-height: min(82vh, 760px) + width: calc(min(760px, 100vw - 28px)); + max-height: calc(min(82vh, 760px)) } #location-help-body { @@ -3991,8 +3935,8 @@ dialog .window-heading { @media(max-width:640px) { - .basic-user, - .acl-rule { + .acl-rule, + .basic-user { grid-template-columns: 1fr 1fr } @@ -4005,13 +3949,8 @@ dialog .window-heading { } } -#toast[popover] { - border-radius: 0; - pointer-events: none -} - .app-confirm-dialog { - width: min(520px, calc(100vw - 28px)); + width: calc(min(520px, 100vw - 28px)); max-width: none; padding: 0; border: 1px solid var(--line); @@ -4034,8 +3973,7 @@ dialog .window-heading { .app-confirm-message { margin-top: 14px; color: var(--text); - white-space: pre-line; - overflow-wrap: anywhere + white-space: pre-line } .app-confirm-actions { @@ -4050,7 +3988,7 @@ dialog .window-heading { .app-confirm-danger:hover { border-color: var(--red); - background: rgb(255 170 162 / .08) + background: rgba(255, 170, 162, .08) } #panel-tls>.config-columns { @@ -4077,7 +4015,8 @@ dialog .window-heading { gap: 10px } -.certificate-card-identity { +.certificate-card-identity, +.location-header-editor { min-width: 0 } @@ -4142,8 +4081,8 @@ dialog .window-heading { margin-top: 10px } -.auth-page.auth-loading .auth-top, -.auth-page.auth-loading .auth-main { +.auth-page.auth-loading .auth-main, +.auth-page.auth-loading .auth-top { visibility: hidden } @@ -4196,15 +4135,11 @@ dialog .window-heading { } .login-section #auth-message.error { - border-color: rgb(255 170 162 / .58); - background: rgb(255 170 162 / .07); + border-color: rgba(255, 170, 162, .58); + background: rgba(255, 170, 162, .07); color: var(--red) } -.location-header-editor { - min-width: 0 -} - .location-header-editor h3 { font-size: 12px; margin: 8px 0 @@ -4219,13 +4154,13 @@ dialog .window-heading { grid-template-columns: 1fr 1fr } - .location-toolbar, - .location-actions { + .location-actions, + .location-toolbar { align-items: stretch } - .location-toolbar>button, - .location-actions>button { + .location-actions>button, + .location-toolbar>button { flex: 1 1 150px } @@ -4298,12 +4233,12 @@ dialog .window-heading { .vh-header-rule-fields { display: grid; - grid-template-columns: minmax(100px, .55fr) minmax(140px, 1fr) minmax(180px, 1.35fr); align-items: end; gap: 8px } -.vh-header-row.is-remove .vh-header-rule-fields { +.vh-header-row.is-remove .vh-header-rule-fields, +.vh-header-rule-fields { grid-template-columns: minmax(100px, .55fr) minmax(140px, 1fr) minmax(180px, 1.35fr) } @@ -4388,9 +4323,9 @@ dialog .window-heading { margin-top: 5px } - .vh-header-rule-fields, + .location-preview-grid, .vh-header-row.is-remove .vh-header-rule-fields, - .location-preview-grid { + .vh-header-rule-fields { grid-template-columns: 1fr } } @@ -4409,20 +4344,14 @@ dialog .window-heading { text-align: center } -.settings-pages, .settings-page, -.settings-page>.edit-form { +.settings-page>.edit-form, +.settings-pages { width: 100%; max-width: none } -.logging-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - margin: 14px 0 -} - +.logging-grid, .traffic-chart-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -4450,7 +4379,7 @@ dialog .window-heading { box-shadow: inset 0 0 0 1px var(--line) } -.traffic-donut::before { +.traffic-donut:before { content: ""; position: absolute; inset: 23%; @@ -4566,12 +4495,6 @@ dialog .window-heading { font: 700 10px var(--mono) } -@media(max-width:1100px) { - .traffic-wide-chart { - grid-column: span 1 - } -} - @media(max-width:650px) { .traffic-bar-row { grid-template-columns: minmax(88px, 1.2fr) minmax(90px, 2fr) minmax(60px, auto); @@ -4594,6 +4517,10 @@ dialog .window-heading { } @media(max-width:1100px) { + .traffic-wide-chart { + grid-column: span 1 + } + .traffic-chart-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) } @@ -4601,8 +4528,8 @@ dialog .window-heading { @media(max-width:720px) { - .traffic-chart-grid, - .logging-grid { + .logging-grid, + .traffic-chart-grid { grid-template-columns: 1fr } @@ -4621,10 +4548,10 @@ dialog .window-heading { gap: 10px } -#panel-vhosts .card-heading, -#panel-web-server .card-heading, +#panel-notfound .card-heading, #panel-redirects .card-heading, -#panel-notfound .card-heading { +#panel-vhosts .card-heading, +#panel-web-server .card-heading { flex-wrap: wrap } @@ -4636,11 +4563,11 @@ dialog .window-heading { margin-left: auto } -#vhost-search, -#web-service-search, +#notfound-search, #redirect-search, -#notfound-search { - width: min(260px, 32vw); +#vhost-search, +#web-service-search { + width: calc(min(260px, 32vw)); min-width: 180px } @@ -4662,8 +4589,8 @@ dialog .window-heading { min-width: 0 } -.vhost-title-line, -.vhost-policy-pills { +.vhost-policy-pills, +.vhost-title-line { display: flex; align-items: center; flex-wrap: wrap; @@ -4691,8 +4618,8 @@ dialog .window-heading { background: var(--bg) } -.vhost-detail span, -.vhost-detail b { +.vhost-detail b, +.vhost-detail span { display: block } @@ -4769,9 +4696,10 @@ dialog .window-heading { flex-direction: column } - #panel-vhosts .card-heading, + #panel-notfound .card-heading, #panel-redirects .card-heading, - #panel-notfound .card-heading { + #panel-vhosts .card-heading, + .backend-card-heading { align-items: stretch; flex-direction: column } @@ -4781,19 +4709,14 @@ dialog .window-heading { margin-left: 0 } - #vhost-search, + #notfound-search, #redirect-search, - #notfound-search { + #vhost-search { flex: 1 1 auto; width: auto; min-width: 0 } - .backend-card-heading { - align-items: stretch; - flex-direction: column - } - .backend-table-tools { width: 100%; justify-content: flex-start; @@ -4810,7 +4733,7 @@ dialog .window-heading { right: auto; left: 0; grid-template-columns: 1fr; - min-width: min(290px, 82vw) + min-width: calc(min(290px, 82vw)) } .vhost-card-head .entity-actions { @@ -4890,7 +4813,6 @@ dialog .window-heading { color: var(--red) } -/* Vhost names and canonical WWW routing share one visual decision surface. */ .vh-www-panel { padding: 12px; border: 1px solid var(--line); @@ -4932,8 +4854,8 @@ dialog .window-heading { font-size: 11px } -.vh-www-inactive, -.vh-www-help { +.vh-www-help, +.vh-www-inactive { margin: 9px 0 0; font-size: 11px } @@ -4976,7 +4898,7 @@ dialog .window-heading { gap: 8px } -@media (max-width: 650px) { +@media(max-width:650px) { .vh-www-pair-directions { grid-template-columns: minmax(0, 1fr) } @@ -4993,8 +4915,8 @@ dialog .window-heading { cursor: pointer } -.vh-www-choice:hover, -.vh-www-choice:focus-within { +.vh-www-choice:focus-within, +.vh-www-choice:hover { border-color: var(--green) } @@ -5046,13 +4968,16 @@ dialog .window-heading { .vh-www-alias { font: 11px var(--mono); padding: 4px 6px; - border: 1px solid var(--line); background: var(--panel) } +.vh-www-alias, +.vhost-chart { + border: 1px solid var(--line) +} + .vhost-chart { margin-top: 9px; - border: 1px solid var(--line); background: var(--chart-surface-alt) } @@ -5135,7 +5060,7 @@ dialog .window-heading { } } -.acl-profile-rule-rows:empty::before { +.acl-profile-rule-rows:empty:before { content: "—"; display: block; color: var(--muted); @@ -5175,12 +5100,6 @@ dialog .window-heading { color: var(--muted) } -@media(max-width:640px) { - .acl-profile-rule { - grid-template-columns: 1fr - } -} - #stream-list { display: grid; gap: 10px @@ -5191,7 +5110,7 @@ dialog .window-heading { } #stream-search { - width: min(260px, 32vw); + width: calc(min(260px, 32vw)); min-width: 180px } @@ -5307,10 +5226,8 @@ dialog .window-heading { } @media(max-width:760px) { - .redirect-card .vhost-detail-strip { - grid-template-columns: 1fr - } + .redirect-card .vhost-detail-strip, .redirect-rule .vh-grid { grid-template-columns: 1fr } @@ -5388,6 +5305,7 @@ dialog .window-heading { font-size: 13px } +.docker-agent-security .fine, .vh-backend-endpoint { overflow-wrap: anywhere } @@ -5419,8 +5337,8 @@ dialog .window-heading { border-top: 1px solid var(--line) } -.vh-backend-advanced>summary, -.auth-advanced>summary { +.auth-advanced>summary, +.vh-backend-advanced>summary { cursor: pointer; color: var(--muted); font-size: 11px; @@ -5451,6 +5369,7 @@ dialog .window-heading { max-width: 620px } +#redirect-listeners>.listener-choices, .vh-backend-health-editor select { width: 100% } @@ -5513,10 +5432,6 @@ dialog .window-heading { display: none } -#redirect-listeners>.listener-choices { - width: 100% -} - @media(max-width:760px) { .protocol-toggle-row { display: grid; @@ -5595,10 +5510,6 @@ dialog .window-heading { min-width: 0; } -.docker-agent-security .fine { - overflow-wrap: anywhere; -} - .docker-agent-error { display: grid; gap: 5px; @@ -5664,23 +5575,23 @@ dialog .window-heading { gap: 10px; } -.docker-route-grid>label:not(.check), -.docker-health-grid>label { +.docker-health-grid>label, +.docker-route-grid>label:not(.check) { display: grid; gap: 5px; min-width: 0; } -.docker-route-grid>label:not(.check)>span, -.docker-health-grid>label>span { +.docker-health-grid>label>span, +.docker-route-grid>label:not(.check)>span { color: var(--muted); font-size: 11px; } -.docker-route-grid input:not([type="checkbox"]), -.docker-route-grid select, -.docker-health-grid input:not([type="checkbox"]), -.docker-health-grid select { +.docker-health-grid input:not([type=checkbox]), +.docker-health-grid select, +.docker-route-grid input:not([type=checkbox]), +.docker-route-grid select { min-width: 0; width: 100%; } @@ -5725,7 +5636,7 @@ dialog .window-heading { max-width: 100%; } -.docker-passive-health-editor .docker-checkbox-field input[type="checkbox"] { +.docker-passive-health-editor .docker-checkbox-field input[type=checkbox] { width: auto; } @@ -5776,8 +5687,8 @@ dialog .window-heading { color: var(--red); } -.docker-toolbar input[type="search"] { - width: min(280px, 38vw); +.docker-toolbar input[type=search] { + width: calc(min(280px, 38vw)); } .cache-toolbar-status { @@ -5788,7 +5699,7 @@ dialog .window-heading { white-space: nowrap; } -@media (max-width: 1320px) { +@media(max-width:1320px) { .docker-runtime { grid-column: 2; @@ -5811,7 +5722,7 @@ dialog .window-heading { } } -@media (max-width: 760px) { +@media(max-width:760px) { .docker-service-head { align-items: stretch; } @@ -5822,12 +5733,9 @@ dialog .window-heading { text-align: left; } - .docker-route-grid, - .docker-health-grid { - grid-template-columns: 1fr; - } - - .docker-passive-health-editor .docker-health-grid { + .docker-health-grid, + .docker-passive-health-editor .docker-health-grid, + .docker-route-grid { grid-template-columns: 1fr; } @@ -5836,17 +5744,13 @@ dialog .window-heading { grid-column: auto; } + .docker-agent-security, .docker-toolbar { align-items: flex-start; flex-direction: column; } - .docker-agent-security { - align-items: flex-start; - flex-direction: column; - } - - .docker-toolbar input[type="search"] { + .docker-toolbar input[type=search] { width: 100%; } @@ -5865,12 +5769,12 @@ dialog .window-heading { } #panel-settings .settings-shortcuts { - grid-column: 1 / -1; + grid-column: 1/-1; width: 100% } #panel-settings .settings-filter-row { - grid-column: 1 / -1; + grid-column: 1/-1; display: flex; align-items: end; justify-content: space-between; @@ -5910,7 +5814,7 @@ dialog .window-heading { min-width: 0 } -@media (max-width: 900px) { +@media(max-width:900px) { #panel-settings:not([hidden]) { display: block; } @@ -5997,7 +5901,7 @@ dialog .window-heading { font-family: var(--mono); } -@media (max-width: 1000px) { +@media(max-width:1000px) { .docker-acl-grid, .docker-timeout-grid { @@ -6005,7 +5909,7 @@ dialog .window-heading { } } -@media (max-width: 760px) { +@media(max-width:760px) { .docker-acl-grid, .docker-timeout-grid { @@ -6084,7 +5988,7 @@ dialog .window-heading { } .audit-dialog { - width: min(1040px, calc(100vw - 24px)); + width: calc(min(1040px, 100vw - 24px)); max-height: calc(100dvh - 28px); overflow: hidden; } @@ -6096,7 +6000,7 @@ dialog .window-heading { } .audit-dialog .table-scroll { - max-height: min(68dvh, 680px); + max-height: calc(min(68dvh, 680px)); overflow: auto; border-top: 1px solid var(--line); } @@ -6105,8 +6009,8 @@ dialog .window-heading { font: 11px var(--mono); } -.audit-dialog th, -.audit-dialog td { +.audit-dialog td, +.audit-dialog th { padding: 7px 8px; max-width: 440px; overflow: hidden; @@ -6204,7 +6108,7 @@ dialog .window-heading { border-top: 1px solid var(--line); } -@media (max-width: 1320px) { +@media(max-width:1320px) { .docker-service-head { grid-template-columns: auto 1fr; } @@ -6216,7 +6120,7 @@ dialog .window-heading { } } -@media (max-width: 760px) { +@media(max-width:760px) { .security-page-heading { flex-direction: column; } @@ -6225,8 +6129,9 @@ dialog .window-heading { width: 100%; } - .security-editor>summary, - .docker-service-editor>summary { + .docker-service-editor>summary, + .docker-service-head-actions, + .security-editor>summary { align-items: flex-start; flex-direction: column; } @@ -6237,8 +6142,6 @@ dialog .window-heading { .docker-service-head-actions { grid-column: auto; - align-items: flex-start; - flex-direction: column; } .docker-service-editor .docker-config-hint { @@ -6321,7 +6224,7 @@ dialog .window-heading { font-weight: 600; } -body[data-density="compact"] { +body[data-density=compact] { --ui-gap: 2px; --ui-card: 5px; --ui-font: 10.75px; @@ -6329,150 +6232,147 @@ body[data-density="compact"] { line-height: 1.32; } -body[data-density="compact"] .shell { +body[data-density=compact] .shell { grid-template-columns: 164px minmax(0, 1fr); } -body[data-density="compact"] main { +body[data-density=compact] main { padding: 0 10px; } -body[data-density="compact"] .sidebar { +body[data-density=compact] .sidebar { padding: 14px 9px; } -body[data-density="compact"] .sidebar nav { +body[data-density=compact] .sidebar nav { margin-top: 14px; } -body[data-density="compact"] .nav { +body[data-density=compact] .nav { padding: 6px 7px; } -body[data-density="compact"] .topbar { +body[data-density=compact] .topbar { height: 42px; } -body[data-density="compact"] .page-intro { +body[data-density=compact] .page-intro { padding: 7px 0 6px; gap: 7px; } -body[data-density="compact"] .page-intro h1 { +body[data-density=compact] .page-intro h1 { font-size: 20px; } -body[data-density="compact"] .page-intro .lead { +body[data-density=compact] .page-intro .lead { font-size: 10px; margin-top: 2px; } -body[data-density="compact"] .card-heading { +body[data-density=compact] .card-heading { margin-bottom: 5px; } -body[data-density="compact"] .card-heading h2 { +body[data-density=compact] .card-heading h2 { font-size: 14px; } -body[data-density="compact"] .stat { +body[data-density=compact] .stat { padding: 6px 7px; } -body[data-density="compact"] .stat strong { +body[data-density=compact] .stat strong { font-size: 18px; margin: 1px 0; } -body[data-density="compact"] .stat p, -body[data-density="compact"] .stat small { +body[data-density=compact] .stat p, +body[data-density=compact] .stat small, +body[data-density=compact] .transport-grid span { font-size: 9px; } -body[data-density="compact"] .transport-grid>div { +body[data-density=compact] .transport-grid>div { padding: 4px 7px !important; } -body[data-density="compact"] .transport-grid span { - font-size: 9px; -} - -body[data-density="compact"] .transport-grid b { +body[data-density=compact] .transport-grid b { font-size: 12px; } -body[data-density="compact"] .activity-card svg { +body[data-density=compact] .activity-card svg { height: 84px; } -body[data-density="compact"] .system-card svg { +body[data-density=compact] .system-card svg { height: 66px; } -body[data-density="compact"] .mini-metrics div { +body[data-density=compact] .mini-metrics div { margin-top: 2px; } -body[data-density="compact"] .pool-table th, -body[data-density="compact"] .pool-table td { +body[data-density=compact] .pool-table td, +body[data-density=compact] .pool-table th { padding: 3px 5px; font-size: 10px; } -body[data-density="compact"] .pool-card .table-scroll { +body[data-density=compact] .pool-card .table-scroll { max-height: 170px; } -body[data-density="compact"] input:not([type="checkbox"]):not([type="radio"]), -body[data-density="compact"] select, -body[data-density="compact"] textarea, -body[data-density="compact"] button, -body[data-density="compact"] .primary, -body[data-density="compact"] .outline, -body[data-density="compact"] .quiet { +body[data-density=compact] .outline, +body[data-density=compact] .primary, +body[data-density=compact] .quiet, +body[data-density=compact] button, +body[data-density=compact] input:not([type=checkbox]):not([type=radio]), +body[data-density=compact] select, +body[data-density=compact] textarea { padding: 3px 7px; font-size: 10px; } -body[data-density="compact"] .docker-service-row { +body[data-density=compact] .docker-service-row { padding: 6px 8px; gap: 4px; } -body[data-density="compact"] .docker-service-head { +body[data-density=compact] .docker-service-head { gap: 7px; } -body[data-density="compact"] .docker-agent-overview { +body[data-density=compact] .docker-agent-overview { gap: 4px 12px; margin: 4px 0 6px; font-size: 10px; } -body[data-density="compact"] .docker-agent-details { +body[data-density=compact] .docker-agent-details { margin-bottom: 6px; padding-top: 5px; } -body[data-density="compact"] .docker-service-editor { +body[data-density=compact] .docker-service-editor { padding-top: 5px; } -body[data-density="compact"] .docker-service-editor[open]>summary { +body[data-density=compact] .docker-service-editor[open]>summary { margin-bottom: 7px; } -body[data-density="compact"] .security-stack { +body[data-density=compact] .security-stack { gap: 7px; } -body[data-density="compact"] .security-editor { +body[data-density=compact] .security-editor { margin-top: 8px; padding-top: 6px; } -body[data-density="compact"] .page-footer { +body[data-density=compact] .page-footer { padding: 7px 0; margin-top: 4px; } @@ -6499,16 +6399,16 @@ body[data-density="compact"] .page-footer { white-space: nowrap; } -@media (max-width: 850px) { - body[data-density="compact"] .sidebar nav { +@media(max-width:850px) { + body[data-density=compact] .sidebar nav { margin-top: 0; } - body[data-density="compact"] .sidebar { + body[data-density=compact] .sidebar { padding: 8px 9px; } - body[data-density="compact"] .topbar { + body[data-density=compact] .topbar { height: 42px; } } @@ -6546,13 +6446,13 @@ body[data-density="compact"] .page-footer { margin: 0 !important } -.docker-agent-grid>label>span:first-child, -.docker-agent-field-label { +.docker-agent-field-label, +.docker-agent-grid>label>span:first-child { min-height: 14px; line-height: 14px } -.docker-agent-grid input:not([type="checkbox"]), +.docker-agent-grid input:not([type=checkbox]), .docker-agent-grid select { width: 100%; min-width: 0; @@ -6601,7 +6501,7 @@ body[data-density="compact"] .page-footer { border-top: 1px solid var(--line) } -@media (max-width: 760px) { +@media(max-width:760px) { .docker-agent-heading-actions { @@ -6674,10 +6574,10 @@ body[data-density="compact"] .page-footer { font-size: 11px } -.docker-route-grid .docker-field input:not([type="checkbox"]), -.docker-route-grid .docker-field select, -.docker-acl-grid .docker-field input:not([type="checkbox"]), -.docker-acl-grid .docker-field select { +.docker-acl-grid .docker-field input:not([type=checkbox]), +.docker-acl-grid .docker-field select, +.docker-route-grid .docker-field input:not([type=checkbox]), +.docker-route-grid .docker-field select { width: 100%; min-width: 0; min-height: 38px; @@ -6699,7 +6599,7 @@ body[data-density="compact"] .page-footer { line-height: 1.25 } -.check input[type="checkbox"] { +.check input[type=checkbox] { flex: 0 0 auto; align-self: center; transform: translateY(-1px) @@ -6769,7 +6669,7 @@ body[data-density="compact"] .page-footer { box-sizing: border-box } -@media (max-width: 760px) { +@media(max-width:760px) { .network-interface-row-head { align-items: flex-start; flex-direction: column; @@ -6801,7 +6701,7 @@ body[data-density="compact"] .page-footer { box-sizing: border-box } -.docker-route-grid .docker-alias-editor input:not([type="checkbox"]) { +.docker-route-grid .docker-alias-editor input:not([type=checkbox]) { flex: 1 1 180px; width: auto; min-width: 150px; @@ -6816,29 +6716,27 @@ body[data-density="compact"] .page-footer { line-height: 1.35 } -@media (max-width: 1320px) { +@media(max-width:1320px) { .docker-route-grid { grid-template-columns: 1fr 1fr } .docker-alias-field, .docker-switch-row { - grid-column: 1 / -1 + grid-column: 1/-1 } } -@media (max-width: 760px) { - .docker-route-grid { +@media(max-width:760px) { + + .docker-route-grid, + .transform-diagnostics { grid-template-columns: 1fr } .docker-alias-field { grid-column: auto } - - .transform-diagnostics { - grid-template-columns: 1fr; - } } #panel-tls .certificate-workspace-heading { @@ -6846,14 +6744,14 @@ body[data-density="compact"] .page-footer { margin-bottom: 12px } -#panel-tls .certificate-workspace-heading>div, -#panel-tls .certificate-subheading>div { +#panel-tls .certificate-subheading>div, +#panel-tls .certificate-workspace-heading>div { min-width: 0 } -#panel-tls .certificate-workspace-heading p, +#panel-tls .certificate-section-intro p, #panel-tls .certificate-subheading p, -#panel-tls .certificate-section-intro p { +#panel-tls .certificate-workspace-heading p { margin: 5px 0 0 } @@ -6902,17 +6800,17 @@ body[data-density="compact"] .page-footer { border-left: 3px solid var(--line) } -#panel-tls .certificate-operation-card[data-state="running"] { +#panel-tls .certificate-operation-card[data-state=running] { border-left-color: var(--accent) } -#panel-tls .certificate-operation-card[data-state="ready"], -#panel-tls .certificate-operation-card[data-state="complete"] { +#panel-tls .certificate-operation-card[data-state=complete], +#panel-tls .certificate-operation-card[data-state=ready] { border-left-color: var(--green) } -#panel-tls .certificate-operation-card[data-state="failed"], -#panel-tls .certificate-operation-card[data-state="unavailable"] { +#panel-tls .certificate-operation-card[data-state=failed], +#panel-tls .certificate-operation-card[data-state=unavailable] { border-left-color: var(--red) } @@ -6933,15 +6831,15 @@ body[data-density="compact"] .page-footer { overflow-wrap: anywhere } -#panel-tls .certificate-operation-card[data-state="failed"] .certificate-operation-heading strong, -#panel-tls .certificate-operation-card[data-state="failed"] .pill, -#panel-tls .certificate-operation-card[data-state="unavailable"] .certificate-operation-heading strong, -#panel-tls .certificate-operation-card[data-state="unavailable"] .pill { +#panel-tls .certificate-operation-card[data-state=failed] .certificate-operation-heading strong, +#panel-tls .certificate-operation-card[data-state=failed] .pill, +#panel-tls .certificate-operation-card[data-state=unavailable] .certificate-operation-heading strong, +#panel-tls .certificate-operation-card[data-state=unavailable] .pill { color: var(--red) } -#panel-tls .certificate-operation-card[data-state="ready"] .pill, -#panel-tls .certificate-operation-card[data-state="complete"] .pill { +#panel-tls .certificate-operation-card[data-state=complete] .pill, +#panel-tls .certificate-operation-card[data-state=ready] .pill { color: var(--green) } @@ -6973,7 +6871,7 @@ body[data-density="compact"] .page-footer { margin-bottom: 0 } -#panel-tls .renewal-entry[data-state="failed"] { +#panel-tls .renewal-entry[data-state=failed] { border-left: 3px solid var(--red) } @@ -7066,23 +6964,20 @@ body[data-density="compact"] .page-footer { @media(max-width:700px) { - #panel-tls .certificate-workspace-heading, - #panel-tls .certificate-subheading, #panel-tls .certificate-operation-heading, + #panel-tls .certificate-subheading, + #panel-tls .certificate-workspace-heading, #panel-tls .renewal-entry-head { align-items: stretch; flex-direction: column } - #panel-tls .certificate-workspace-heading>button, - #panel-tls .certificate-subheading>button { + #panel-tls .certificate-subheading>button, + #panel-tls .certificate-workspace-heading>button { width: 100% } - #panel-tls .certificate-tabs { - grid-template-columns: 1fr - } - + #panel-tls .certificate-tabs, #panel-tls .renewal-meta { grid-template-columns: 1fr } @@ -7112,8 +7007,8 @@ body[data-density="compact"] .page-footer { } .operation-message.error { - border-color: rgb(255 170 162 / .58); - background: rgb(255 170 162 / .07); + border-color: rgba(255, 170, 162, .58); + background: rgba(255, 170, 162, .07); color: var(--red); } @@ -7123,7 +7018,7 @@ body[data-density="compact"] .page-footer { } .cache-operation-dialog { - width: min(920px, calc(100vw - 32px)); + width: calc(min(920px, 100vw - 32px)); } .cache-operation-dialog .card { @@ -7174,7 +7069,6 @@ body[data-density="compact"] .page-footer { background: currentColor; } -/* The legend keys must use the same theme/preset palette as the SVG strokes. */ .stat-chart .stat-chart-legend .legend-h1 i { background: var(--traffic-h1); } @@ -7194,6 +7088,11 @@ body[data-density="compact"] .page-footer { .proto-h1-line { stroke: var(--traffic-h1); +} + +.proto-h1-line, +.proto-h2-line, +.proto-h3-line { fill: none; stroke-width: var(--chart-line-width); stroke-linecap: var(--chart-linecap); @@ -7201,16 +7100,10 @@ body[data-density="compact"] .page-footer { .proto-h2-line { stroke: var(--traffic-h2); - fill: none; - stroke-width: var(--chart-line-width); - stroke-linecap: var(--chart-linecap); } .proto-h3-line { stroke: var(--traffic-h3); - fill: none; - stroke-width: var(--chart-line-width); - stroke-linecap: var(--chart-linecap); } .stat-chart .chart-grid { @@ -7238,14 +7131,14 @@ body[data-density="compact"] .page-footer { display: none } -.function-live-chart-summary::before { +.function-live-chart-summary:before { content: "▸"; flex: 0 0 auto; color: var(--muted); font: 12px var(--mono) } -.function-live-chart[open]>.function-live-chart-summary::before { +.function-live-chart[open]>.function-live-chart-summary:before { content: "▾" } @@ -7282,7 +7175,7 @@ body[data-density="compact"] .page-footer { .function-live-chart svg { display: block; width: 100%; - height: clamp(180px, 22vw, 270px); + height: calc(clamp(180px, 22vw, 270px)); overflow: visible } @@ -7292,26 +7185,26 @@ body[data-density="compact"] .page-footer { vector-effect: non-scaling-stroke } -.transform-applied-line, -.transform-applied-key { +.transform-applied-key, +.transform-applied-line { stroke: var(--chart-quaternary); color: var(--chart-quaternary) } -.transform-cache-line, -.transform-cache-key { +.transform-cache-key, +.transform-cache-line { stroke: var(--chart-secondary); color: var(--chart-secondary) } -.transform-skipped-line, -.transform-skipped-key { +.transform-skipped-key, +.transform-skipped-line { stroke: var(--chart-septenary); color: var(--chart-septenary) } -.transform-error-line, -.transform-error-key { +.transform-error-key, +.transform-error-line { stroke: var(--chart-quinary); color: var(--chart-quinary) } @@ -7366,7 +7259,7 @@ body[data-density="compact"] .page-footer { font-weight: 650 } -@media (max-width: 760px) { +@media(max-width:760px) { .function-live-chart-summary { align-items: flex-start } @@ -7430,9 +7323,9 @@ body[data-density="compact"] .page-footer { white-space: nowrap; } -.payload-split b, +.connection-card .chart-footer b, .connection-legend b, -.connection-card .chart-footer b { +.payload-split b { color: var(--text); font-weight: 650; } @@ -7456,8 +7349,8 @@ body[data-density="compact"] .page-footer { overflow: visible; } -.connection-open-line, -.connection-exchange-line { +.connection-exchange-line, +.connection-open-line { fill: none; vector-effect: non-scaling-stroke; stroke-width: 2; @@ -7546,9 +7439,9 @@ body[data-density="compact"] .page-footer { margin-top: 10px; } -#certificate-api-token-field[hidden], +#certificate-api-cancel-edit[hidden], #certificate-api-edit-note[hidden], -#certificate-api-cancel-edit[hidden] { +#certificate-api-token-field[hidden] { display: none; } @@ -7599,7 +7492,7 @@ body[data-density="compact"] .page-footer { overflow-wrap: anywhere; } -@media (max-width: 760px) { +@media(max-width:760px) { .certificate-api-credential-row, .network-interface-row-head { @@ -7615,7 +7508,7 @@ body[data-density="compact"] .page-footer { margin: 12px 0; padding: 12px; border: 1px solid var(--line); - border-radius: 0px; + border-radius: 0; background: var(--panel2); } @@ -7663,8 +7556,8 @@ body[data-density="compact"] .page-footer { display: none; } -.metric-info>summary:hover, .metric-info>summary:focus-visible, +.metric-info>summary:hover, .metric-info[open]>summary { color: var(--text); border-color: var(--border-hover); @@ -7676,7 +7569,7 @@ body[data-density="compact"] .page-footer { z-index: 40; top: calc(100% + 7px); right: 0; - width: min(360px, calc(100vw - 44px)); + width: calc(min(360px, 100vw - 44px)); padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; @@ -7698,8 +7591,8 @@ body[data-density="compact"] .page-footer { stroke: var(--chart-secondary); } -.cache-queue-line, -.cache-cold-line { +.cache-cold-line, +.cache-queue-line { stroke: var(--chart-tertiary); } @@ -7743,7 +7636,7 @@ body[data-density="compact"] .page-footer { } .config-history-dialog { - width: min(1180px, calc(100vw - 24px)); + width: calc(min(1180px, 100vw - 24px)); max-width: calc(100vw - 24px); max-height: calc(100dvh - 28px); overflow: hidden; @@ -7752,7 +7645,7 @@ body[data-density="compact"] .page-footer { .config-history-dialog[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); - height: min(820px, calc(100dvh - 28px)); + height: calc(min(820px, 100dvh - 28px)); } .config-history-layout { @@ -7796,8 +7689,8 @@ body[data-density="compact"] .page-footer { font-size: 12px; } -.config-history-weekdays, -.config-history-days { +.config-history-days, +.config-history-weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; @@ -7921,14 +7814,14 @@ button.config-history-day.is-selected { font: 10px var(--mono); } -@media (max-width: 860px) { +@media(max-width:860px) { .config-history-layout { display: block; overflow-y: auto; } .config-history-browser { - max-height: min(300px, 38dvh); + max-height: calc(min(300px, 38dvh)); overflow-y: auto; margin-bottom: 10px; } @@ -7994,7 +7887,7 @@ button.config-history-day.is-selected { } .mobile-menu-toggle:hover, - .mobile-menu-toggle[aria-expanded="true"] { + .mobile-menu-toggle[aria-expanded=true] { color: var(--text); border-color: var(--border-hover); background: var(--nav-active-bg); @@ -8010,7 +7903,7 @@ button.config-history-day.is-selected { } .sidebar .main-navigation { - grid-column: 1 / -1; + grid-column: 1/-1; display: none; width: 100%; margin: 0; @@ -8031,7 +7924,7 @@ button.config-history-day.is-selected { gap: 8px; padding: 9px 10px; border: 1px solid transparent; - border-left: 2px solid transparent; + border-left-width: 2px; text-align: left; } @@ -8049,7 +7942,7 @@ button.config-history-day.is-selected { } .about-dialog { - width: min(560px, calc(100vw - 24px)); + width: calc(min(560px, 100vw - 24px)); overflow: hidden; } @@ -8166,11 +8059,6 @@ button.config-history-day.is-selected { min-width: 0; } - -.file-log-column-picker { - align-self: end; -} - .file-logs-table { max-height: 62vh; } @@ -8197,12 +8085,9 @@ button.config-history-day.is-selected { text-align: left; } -.file-logs-table th .file-log-sort-button:hover { - color: var(--accent); -} - -.file-logs-table th[aria-sort="ascending"] .file-log-sort-button, -.file-logs-table th[aria-sort="descending"] .file-log-sort-button { +.file-logs-table th .file-log-sort-button:hover, +.file-logs-table th[aria-sort=ascending] .file-log-sort-button, +.file-logs-table th[aria-sort=descending] .file-log-sort-button { color: var(--accent); } @@ -8259,13 +8144,13 @@ button.config-history-day.is-selected { gap: 16px; } -@media (max-width: 1100px) { +@media(max-width:1100px) { .file-logs-filter { grid-template-columns: repeat(3, minmax(140px, 1fr)); } } -@media (max-width: 700px) { +@media(max-width:700px) { .file-logs-filter .file-log-search { grid-column: auto; @@ -8281,20 +8166,20 @@ button.config-history-day.is-selected { position: relative; } -.top-log-menu>summary, -.file-log-search-help>summary { +.file-log-search-help>summary, +.top-log-menu>summary { list-style: none; cursor: pointer; user-select: none; } -.top-log-menu>summary::-webkit-details-marker, -.file-log-search-help>summary::-webkit-details-marker { +.file-log-search-help>summary::-webkit-details-marker, +.top-log-menu>summary::-webkit-details-marker { display: none; } -.top-log-menu[open]>summary, -.file-log-search-help[open]>summary { +.file-log-search-help[open]>summary, +.top-log-menu[open]>summary { color: var(--text); border-color: var(--border-hover); } @@ -8338,14 +8223,15 @@ button.config-history-day.is-selected { inset-inline-end: 0; display: grid; gap: 7px; - width: min(420px, calc(100vw - 36px)); + width: calc(min(420px, 100vw - 36px)); padding: 12px; border: 1px solid var(--line); background: var(--panel2); box-shadow: 0 12px 28px var(--shadow-strong); } -.file-log-search-help-popover p { +.file-log-search-help-popover p, +.file-logs-table-toolbar .fine { margin: 0; } @@ -8375,21 +8261,19 @@ button.config-history-day.is-selected { min-height: 32px; } -.file-logs-table-toolbar .fine { - margin: 0; -} - .file-log-column-picker { + align-self: end; margin-inline-start: auto; } .file-log-column-picker .log-column-options { - width: min(360px, calc(100vw - 24px)); + width: calc(min(360px, 100vw - 24px)); max-height: 420px; overflow: auto; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.dashboard-widget-setting-label, .file-log-column-picker .log-column-option span { overflow: hidden; text-overflow: ellipsis; @@ -8429,7 +8313,7 @@ button.config-history-day.is-selected { line-height: 1; } -@media (max-width: 700px) { +@media(max-width:700px) { .file-logs-heading-actions, .file-logs-table-toolbar { @@ -8437,6 +8321,8 @@ button.config-history-day.is-selected { flex-wrap: wrap; } + .file-log-column-picker .log-column-options, + .file-log-field, .file-logs-filter { grid-template-columns: 1fr; } @@ -8446,13 +8332,8 @@ button.config-history-day.is-selected { } .file-log-field { - grid-template-columns: 1fr; gap: 3px; } - - .file-log-column-picker .log-column-options { - grid-template-columns: 1fr; - } } .appearance-settings-card { @@ -8510,7 +8391,7 @@ button.config-history-day.is-selected { font-variant-numeric: tabular-nums; } -.appearance-scale-control input[type="range"] { +.appearance-scale-control input[type=range] { width: 100%; } @@ -8591,7 +8472,7 @@ button.config-history-day.is-selected { border: 1px solid var(--line) } -.chart-style-swatches i:nth-child(1) { +.chart-style-swatches i:first-child { background: var(--sw1); } @@ -8607,56 +8488,56 @@ button.config-history-day.is-selected { background: var(--sw4); } -.chart-style-choice[data-chart-preset="default"] { +.chart-style-choice[data-chart-preset=default] { --sw1: var(--accent); --sw2: var(--green); --sw3: var(--blue); --sw4: var(--transform-chart); } -.chart-style-choice[data-chart-preset="soft"] { +.chart-style-choice[data-chart-preset=soft] { --sw1: #e8a998; --sw2: #95c293; --sw3: #8daef6; --sw4: #b49de6; } -.chart-style-choice[data-chart-preset="technical"] { +.chart-style-choice[data-chart-preset=technical] { --sw1: #79c9ff; --sw2: #9adf90; --sw3: #ffd56e; --sw4: #c39cff; } -.chart-style-choice[data-chart-preset="high_contrast"] { +.chart-style-choice[data-chart-preset=high_contrast] { --sw1: #ffd54f; --sw2: #8dff80; --sw3: #6fd9ff; --sw4: #ff78d7; } -.chart-style-choice[data-chart-preset="neon"] { +.chart-style-choice[data-chart-preset=neon] { --sw1: #2cf6ff; --sw2: #7eff85; --sw3: #ff5cd0; --sw4: #8b7cff; } -.chart-style-choice[data-chart-preset="monochrome"] { +.chart-style-choice[data-chart-preset=monochrome] { --sw1: #f2f2f2; --sw2: #b8b8b8; --sw3: #858585; --sw4: #595959; } -.chart-style-choice[data-chart-preset="warm"] { +.chart-style-choice[data-chart-preset=warm] { --sw1: #ff9c5c; --sw2: #f4c46b; --sw3: #ff7b72; --sw4: #f4a261; } -.chart-style-choice[data-chart-preset="cool"] { +.chart-style-choice[data-chart-preset=cool] { --sw1: #5fb9ff; --sw2: #45d3c3; --sw3: #7d8fff; @@ -8680,7 +8561,6 @@ button.config-history-day.is-selected { .dashboard-widget-setting { display: grid; grid-template-columns: minmax(0, 1fr) auto; - align-items: center; gap: 4px; min-width: 0; min-height: 34px; @@ -8725,8 +8605,6 @@ button.config-history-day.is-selected { } .dashboard-widget-setting-label { - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; font-size: 11px } @@ -8751,7 +8629,7 @@ button.config-history-day.is-selected { border-top: 1px solid var(--line) } -html[data-chart-style="default"] { +html[data-chart-style=default] { --chart-accent: var(--accent); --chart-secondary: var(--green); --chart-tertiary: var(--blue); @@ -8770,7 +8648,7 @@ html[data-chart-style="default"] { --chart-svg-filter: none; } -html[data-chart-style="soft"] { +html[data-chart-style=soft] { --chart-accent: #e8a998; --chart-secondary: #95c293; --chart-tertiary: #8daef6; @@ -8779,8 +8657,8 @@ html[data-chart-style="soft"] { --chart-senary: #d0b78e; --chart-septenary: #99a790; --chart-octonary: #728070; - --chart-surface: color-mix(in srgb, var(--panel2) 88%, white 12%); - --chart-surface-alt: color-mix(in srgb, var(--panel) 92%, white 8%); + --chart-surface: color-mix(in srgb, var(--panel2) 88%, #fff 12%); + --chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #fff 8%); --chart-line-width: 2.8; --chart-linecap: round; --chart-linejoin: round; @@ -8789,7 +8667,7 @@ html[data-chart-style="soft"] { --chart-svg-filter: saturate(.88) brightness(1.04); } -html[data-chart-style="technical"] { +html[data-chart-style=technical] { --chart-accent: #79c9ff; --chart-secondary: #9adf90; --chart-tertiary: #ffd56e; @@ -8808,7 +8686,7 @@ html[data-chart-style="technical"] { --chart-svg-filter: saturate(.76) contrast(1.08); } -html[data-chart-style="high_contrast"] { +html[data-chart-style=high_contrast] { --chart-accent: #ffd54f; --chart-secondary: #8dff80; --chart-tertiary: #6fd9ff; @@ -8817,17 +8695,24 @@ html[data-chart-style="high_contrast"] { --chart-senary: #f2e48f; --chart-septenary: #d7f0d9; --chart-octonary: #cfcfcf; - --chart-surface: color-mix(in srgb, var(--panel2) 94%, black 6%); - --chart-surface-alt: color-mix(in srgb, var(--panel) 94%, black 6%); + --chart-surface: color-mix(in srgb, var(--panel2) 94%, #000 6%); + --chart-surface-alt: color-mix(in srgb, var(--panel) 94%, #000 6%); --chart-line-width: 3; - --chart-linecap: round; - --chart-linejoin: round; --chart-grid-opacity: .95; --chart-grid-dash: 1 0; --chart-svg-filter: saturate(1.6) contrast(1.22) brightness(1.08); } -html[data-chart-style="neon"] { +html[data-chart-style=cool], +html[data-chart-style=high_contrast], +html[data-chart-style=monochrome], +html[data-chart-style=neon], +html[data-chart-style=warm] { + --chart-linecap: round; + --chart-linejoin: round; +} + +html[data-chart-style=neon] { --chart-accent: #2cf6ff; --chart-secondary: #7eff85; --chart-tertiary: #ff5cd0; @@ -8839,33 +8724,29 @@ html[data-chart-style="neon"] { --chart-surface: #0b1117; --chart-surface-alt: #101720; --chart-line-width: 2.45; - --chart-linecap: round; - --chart-linejoin: round; --chart-grid-opacity: .62; --chart-grid-dash: 2 4; --chart-svg-filter: saturate(1.25) brightness(1.12); } -html[data-chart-style="monochrome"] { - --chart-accent: color-mix(in srgb, var(--text) 92%, white 8%); +html[data-chart-style=monochrome] { + --chart-accent: color-mix(in srgb, var(--text) 92%, #fff 8%); --chart-secondary: color-mix(in srgb, var(--muted) 80%, var(--text) 20%); --chart-tertiary: color-mix(in srgb, var(--separator) 72%, var(--text) 28%); --chart-quaternary: color-mix(in srgb, var(--line) 45%, var(--text) 55%); - --chart-quinary: color-mix(in srgb, var(--muted) 55%, white 45%); + --chart-quinary: color-mix(in srgb, var(--muted) 55%, #fff 45%); --chart-senary: color-mix(in srgb, var(--outline-line) 58%, var(--text) 42%); --chart-septenary: color-mix(in srgb, var(--field-line) 70%, var(--text) 30%); --chart-octonary: color-mix(in srgb, var(--scrollbar-thumb) 75%, var(--text) 25%); --chart-surface: color-mix(in srgb, var(--panel2) 96%, var(--text) 4%); --chart-surface-alt: color-mix(in srgb, var(--panel) 96%, var(--text) 4%); --chart-line-width: 2.1; - --chart-linecap: round; - --chart-linejoin: round; --chart-grid-opacity: .4; --chart-grid-dash: 2 3; --chart-svg-filter: saturate(0) contrast(1.05); } -html[data-chart-style="warm"] { +html[data-chart-style=warm] { --chart-accent: #ff9c5c; --chart-secondary: #f4c46b; --chart-tertiary: #ff7b72; @@ -8877,14 +8758,12 @@ html[data-chart-style="warm"] { --chart-surface: color-mix(in srgb, var(--panel2) 88%, #3d1f10 12%); --chart-surface-alt: color-mix(in srgb, var(--panel) 90%, #32180d 10%); --chart-line-width: 2.4; - --chart-linecap: round; - --chart-linejoin: round; --chart-grid-opacity: .36; --chart-grid-dash: 1 0; --chart-svg-filter: saturate(1.1) brightness(1.04); } -html[data-chart-style="cool"] { +html[data-chart-style=cool] { --chart-accent: #5fb9ff; --chart-secondary: #45d3c3; --chart-tertiary: #7d8fff; @@ -8896,8 +8775,6 @@ html[data-chart-style="cool"] { --chart-surface: color-mix(in srgb, var(--panel2) 88%, #0d2740 12%); --chart-surface-alt: color-mix(in srgb, var(--panel) 90%, #0d2033 10%); --chart-line-width: 2.2; - --chart-linecap: round; - --chart-linejoin: round; --chart-grid-opacity: .46; --chart-grid-dash: 2 4; --chart-svg-filter: saturate(1.08) brightness(1.03); @@ -8914,131 +8791,122 @@ html[data-chart-style] { filter: var(--chart-svg-filter); } -/* Overview inter-tile spacing follows the same UI density variable as the - traffic, diagnostics and other Dashboard blocks, also at 90% UI zoom. */ -html[data-dashboard-density="normal"] #panel-overview .stats-grid { +html[data-dashboard-density=compact] #panel-overview .stats-grid, +html[data-dashboard-density=normal] #panel-overview .stats-grid, +html[data-dashboard-density=ultra] #panel-overview .stats-grid { gap: var(--ui-gap); } -html[data-dashboard-density="normal"] #panel-overview .stat { +html[data-dashboard-density=normal] #panel-overview .stat { padding: 12px 13px; } -html[data-dashboard-density="normal"] #panel-overview .stat>span { +html[data-dashboard-density=normal] #panel-overview .stat>span { font-size: 11px; } -html[data-dashboard-density="normal"] #panel-overview .stat strong { +html[data-dashboard-density=normal] #panel-overview .stat strong { font-size: 25px; margin: 5px 0 3px; line-height: 1.3; } -html[data-dashboard-density="normal"] #panel-overview .stat small { +html[data-dashboard-density=normal] #panel-overview .stat small { font-size: 9px; } -html[data-dashboard-density="compact"] #panel-overview .stats-grid { - gap: var(--ui-gap); -} - -html[data-dashboard-density="compact"] #panel-overview .stat { +html[data-dashboard-density=compact] #panel-overview .stat { padding: 10px 11px; } -html[data-dashboard-density="compact"] #panel-overview .stat>span { +html[data-dashboard-density=compact] #panel-overview .stat>span { font-size: 11px; line-height: 1.35; } -html[data-dashboard-density="compact"] #panel-overview .stat strong { +html[data-dashboard-density=compact] #panel-overview .stat strong { font-size: 25px; margin: 4px 0 2px; line-height: 1.2; } -html[data-dashboard-density="compact"] #panel-overview .stat small { +html[data-dashboard-density=compact] #panel-overview .stat small { font-size: 10px; line-height: 1.35; } -/* Override the older fixed 9px mono caption in the Payload card as well. */ -html[data-dashboard-density="compact"] #panel-overview .payload-split { +html[data-dashboard-density=compact] #panel-overview .payload-split { font-size: 10px !important; } -html[data-dashboard-density="ultra"] #panel-overview .stats-grid { - gap: var(--ui-gap); -} - -html[data-dashboard-density="ultra"] #panel-overview .stat { +html[data-dashboard-density=ultra] #panel-overview .stat { padding: 7px 8px; } -html[data-dashboard-density="ultra"] #panel-overview .stat>span { +html[data-dashboard-density=ultra] #panel-overview .stat>span { font-size: 9px; line-height: 1.08; } -html[data-dashboard-density="ultra"] #panel-overview .stat strong { +html[data-dashboard-density=ultra] #panel-overview .stat strong { font-size: 18px; margin: 2px 0 1px; line-height: 1.08; } -html[data-dashboard-density="ultra"] #panel-overview .stat small { +html[data-dashboard-density=ultra] #panel-overview .stat small { font-size: 7.5px; line-height: 1.12; } -html[data-dashboard-density="ultra"] #panel-overview .stat-chart svg { +html[data-dashboard-density=ultra] #panel-overview .stat-chart svg { height: 38px; } -html[data-table-density="compact"] :is(table th, table td) { +html[data-table-density=compact] :is(table th, table td) { padding-top: 6px; padding-bottom: 6px; font-size: 11px; line-height: 1.28; } -html[data-table-density="compact"] .file-log-field { +html[data-table-density=compact] .file-log-field { padding: 2px 6px; line-height: 1.18; } -html[data-table-density="compact"] .file-log-inline-detail pre { +html[data-table-density=compact] .file-log-inline-detail pre { line-height: 1.18; } -html[data-table-density="ultra"] :is(table th, table td) { +html[data-table-density=ultra] :is(table th, table td) { padding-top: 3px; padding-bottom: 3px; font-size: 10px; line-height: 1.15; } -html[data-table-density="ultra"] .file-log-inline-detail { +html[data-table-density=ultra] .file-log-inline-detail { padding: 5px 7px; } -html[data-table-density="ultra"] .file-log-field-grid { +html[data-table-density=ultra] .file-log-field-grid { margin-top: 2px; } -html[data-table-density="ultra"] .file-log-field { +html[data-table-density=ultra] .file-log-field { gap: 6px; padding: 1px 5px; font-size: 10px; line-height: 1.08; } -html[data-table-density="ultra"] .file-log-raw-json { +html[data-table-density=ultra] .file-log-raw-json { margin-top: 3px; } -html[data-table-density="ultra"] .file-log-inline-detail pre { +html[data-table-density=ultra] .file-log-inline-detail pre { margin-top: 3px; line-height: 1.08; } @@ -9068,7 +8936,7 @@ html[data-table-density="ultra"] .file-log-inline-detail pre { line-height: 1.28; } -@media (max-width: 760px) { +@media(max-width:760px) { .appearance-settings-grid { grid-template-columns: 1fr; } @@ -9083,7 +8951,7 @@ html[data-table-density="ultra"] .file-log-inline-detail pre { right: 14px; bottom: 14px; z-index: 900; - width: min(330px, calc(100vw - 28px)); + width: calc(min(330px, 100vw - 28px)); padding: 0; border: 1px solid var(--border-hover); border-radius: 0; @@ -9147,7 +9015,7 @@ html[data-table-density="ultra"] .file-log-inline-detail pre { .draft-dock-list { display: grid; gap: 0; - max-height: min(42vh, 360px); + max-height: calc(min(42vh, 360px)); overflow: auto; } @@ -9239,24 +9107,24 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { stroke-linejoin: var(--chart-linejoin); } +#panel-settings .settings-filter, .backend-table-tools, -.vhost-list-tools, .docker-toolbar, -.log-tools, .file-logs-filter, -#panel-settings .settings-filter { +.log-tools, +.vhost-list-tools { --toolbar-control-height: min(var(--button-min-height), var(--ui-field-min-height)); } +#panel-settings .settings-filter input, .backend-table-tools input, .backend-table-tools select, -.vhost-list-tools input[type="search"], -.docker-toolbar input[type="search"], -.log-tools input:not([type="checkbox"]):not([type="radio"]), -.log-tools select, +.docker-toolbar input[type=search], .file-logs-filter input, .file-logs-filter select, -#panel-settings .settings-filter input { +.log-tools input:not([type=checkbox]):not([type=radio]), +.log-tools select, +.vhost-list-tools input[type=search] { box-sizing: border-box; height: var(--toolbar-control-height); min-height: var(--toolbar-control-height); @@ -9275,21 +9143,22 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--bg) } +.acl-policy-preview-head, .acl-policy-step-head, -.acl-policy-step-head>div, -.acl-policy-preview-head { +.acl-policy-step-head>div { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px } -.acl-policy-step-head>div { +.acl-policy-step-head>div, +.php-switch-row { justify-content: flex-start } -.acl-policy-step-head strong, -.acl-policy-preview-head>span { +.acl-policy-preview-head>span, +.acl-policy-step-head strong { display: block; font-size: 12px; color: var(--text) @@ -9355,7 +9224,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } .acl-policy-mode-custom { - grid-column: 1 / -1 + grid-column: 1/-1 } .acl-policy-custom-controls { @@ -9394,13 +9263,13 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { font-weight: 700 } -.acl-profile-rule-action-badge.is-allow, -.acl-policy-preview-head .is-allow { +.acl-policy-preview-head .is-allow, +.acl-profile-rule-action-badge.is-allow { color: var(--green) } -.acl-profile-rule-action-badge.is-deny, -.acl-policy-preview-head .is-deny { +.acl-policy-preview-head .is-deny, +.acl-profile-rule-action-badge.is-deny { color: var(--red) } @@ -9444,7 +9313,8 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { color: var(--muted) } -.acl-policy-condition span { +.acl-policy-condition span, +.listener-active-address { overflow-wrap: anywhere } @@ -9471,9 +9341,9 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { @media(max-width:900px) { - .acl-profile-layout, + .acl-policy-custom-controls, .acl-policy-modes, - .acl-policy-custom-controls { + .acl-profile-layout { grid-template-columns: 1fr } @@ -9484,14 +9354,14 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { @media(max-width:640px) { - .acl-policy-step-head, - .acl-policy-preview-head { + .acl-policy-preview-head, + .acl-policy-step-head { align-items: stretch; flex-direction: column } - .acl-policy-switch, - .acl-policy-step-head>button { + .acl-policy-step-head>button, + .acl-policy-switch { width: 100% } } @@ -9533,10 +9403,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { color: var(--accent) } -.listener-active-address { - overflow-wrap: anywhere -} - .listener-active-feature { padding: 2px 5px; border: 1px solid var(--line); @@ -9566,7 +9432,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { .php-switch-row { display: flex; align-items: center; - justify-content: flex-start; gap: 10px; min-width: 0; min-height: 30px; @@ -9583,7 +9448,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { line-height: 1.5; } -.php-switch-row .php-switch-label input[type="checkbox"] { +.php-switch-row .php-switch-label input[type=checkbox] { position: static; flex: 0 0 var(--ui-check-size); margin: 0; @@ -9621,7 +9486,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } .php-origin-errors { - grid-column: 1 / -1; + grid-column: 1/-1; display: grid; gap: 8px; padding: 10px 0; @@ -9663,15 +9528,47 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { white-space: nowrap } -@media (max-width: 480px) { +.page-intro-title-row>:is(.quiet, .outline) { + min-height: 35px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +@media(max-width:700px) { + .page-intro-title-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: stretch; + gap: 8px; + } + + .page-intro-title-row .overview-inline-actions, .page-intro-title-row h1 { - font-size: clamp(18px, 5vw, 24px); - letter-spacing: -.6px + grid-column: 1/-1; } .page-intro-title-row .overview-refresh { + margin-left: 0; + } + + .page-intro-title-row>:is(.quiet, .outline) { + width: 100%; + min-width: 0; padding: 7px 9px; - font-size: 11px + white-space: normal; + text-align: center; + } +} + +@media(max-width:480px) { + .page-intro-title-row h1 { + font-size: calc(clamp(18px, 5vw, 24px)); + letter-spacing: -.6px + } + + .page-intro-title-row>:is(.quiet, .outline) { + font-size: 11px; } } @@ -9704,9 +9601,9 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } .cache-maintenance-info .metric-info-popover { - max-height: min(60vh, 420px); + max-height: calc(min(60vh, 420px)); overflow-y: auto; - width: min(440px, calc(100vw - 56px)); + width: calc(min(440px, 100vw - 56px)); } .cache-maintenance-info .metric-info-popover code { @@ -9734,7 +9631,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { min-height: 0; color: var(--muted); font-size: .8rem; - font-weight: normal; + font-weight: 400; white-space: nowrap; cursor: pointer; } @@ -9794,7 +9691,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { font-size: 1rem; } -@media (max-width: 700px) { +@media(max-width:700px) { .file-logs-heading-actions .file-log-consolidate { margin-inline-end: auto; } @@ -9804,9 +9701,8 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } } -/* 0.31.5 traffic/log analysis actions */ -.traffic-copy-value, -.file-log-aggregate-copy { +.file-log-aggregate-copy, +.traffic-copy-value { appearance: none; border: 0; background: transparent; @@ -9818,10 +9714,10 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { cursor: copy; } -.traffic-copy-value:hover, -.traffic-copy-value:focus-visible, +.file-log-aggregate-copy:focus-visible, .file-log-aggregate-copy:hover, -.file-log-aggregate-copy:focus-visible { +.traffic-copy-value:focus-visible, +.traffic-copy-value:hover { text-decoration: underline; } @@ -9844,31 +9740,28 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { margin-top: .75rem; } -.file-log-aggregate-table th:last-child, -.file-log-aggregate-table td:last-child { +.file-log-aggregate-table td:last-child, +.file-log-aggregate-table th:last-child { width: 8rem; text-align: right; } -@media (max-width: 800px) { +@media(max-width:800px) { .file-log-aggregate-form { grid-template-columns: 1fr; } } -/* Stored Logs on narrow screens: bounded container and a compact record list. - The complete parsed JSON is available by opening any row, while the desktop - table and the persisted column picker keep their original behavior. */ -@media (max-width: 700px) { +@media(max-width:700px) { - #panel-filelogs, - #panel-filelogs .file-logs-workspace, - #file-log-view-entries, #file-log-view-counts, + #file-log-view-entries, + #panel-filelogs, + #panel-filelogs .file-log-field-grid, #panel-filelogs .file-logs-table, #panel-filelogs .file-logs-table-toolbar, - #panel-filelogs .file-log-field-grid { + #panel-filelogs .file-logs-workspace { box-sizing: border-box; min-width: 0; max-width: 100%; @@ -9899,11 +9792,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } #panel-filelogs .file-log-search-help-popover { - width: min(320px, calc(100vw - 54px)); + width: calc(min(320px, 100vw - 54px)); } - #panel-filelogs .file-logs-filter, - #panel-filelogs .file-log-aggregate-form { + #panel-filelogs .file-log-aggregate-form, + #panel-filelogs .file-logs-filter { grid-template-columns: minmax(0, 1fr); } @@ -9911,12 +9804,12 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { max-width: none; } - #panel-filelogs .file-logs-filter label, - #panel-filelogs .file-logs-filter input, - #panel-filelogs .file-logs-filter select, - #panel-filelogs .file-log-aggregate-form label, #panel-filelogs .file-log-aggregate-form input, - #panel-filelogs .file-log-aggregate-form select { + #panel-filelogs .file-log-aggregate-form label, + #panel-filelogs .file-log-aggregate-form select, + #panel-filelogs .file-logs-filter input, + #panel-filelogs .file-logs-filter label, + #panel-filelogs .file-logs-filter select { min-width: 0; max-width: 100%; } @@ -9989,34 +9882,32 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { overflow-wrap: anywhere; } - /* Every user-selected column is available even when the administrator - replaces all default columns with custom parsed fields. */ - #panel-filelogs .file-logs-table td[data-mobile-column="record"] { - grid-column: 1 / -1; + #panel-filelogs .file-logs-table td[data-mobile-column=record] { + grid-column: 1/-1; font-weight: 600; line-height: 1.45; padding-inline-end: 16px; } - #panel-filelogs .file-logs-table td[data-mobile-column="time"], - #panel-filelogs .file-logs-table td[data-mobile-column="event"], - #panel-filelogs .file-logs-table td[data-mobile-column="status"], - #panel-filelogs .file-logs-table td[data-mobile-column="vhost"] { + #panel-filelogs .file-logs-table td[data-mobile-column=event], + #panel-filelogs .file-logs-table td[data-mobile-column=status], + #panel-filelogs .file-logs-table td[data-mobile-column=time], + #panel-filelogs .file-logs-table td[data-mobile-column=vhost] { color: var(--muted); font-size: 11px; } - #panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td::before { + #panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td:before { content: attr(data-mobile-label) ": "; color: var(--text-subtle); font-weight: 400; } - #panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td[data-mobile-column="record"]::before { + #panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td[data-mobile-column=record]:before { content: ""; } - #panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)::after { + #panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row):after { content: "+"; position: absolute; inset-block-end: 4px; @@ -10026,7 +9917,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { pointer-events: none; } - #panel-filelogs .file-logs-table tbody tr.selected::after { + #panel-filelogs .file-logs-table tbody tr.selected:after { content: "-"; } @@ -10082,11 +9973,10 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } } -/* Tablet/narrow-desktop log tables scroll locally instead of expanding
. */ -#panel-filelogs, -#panel-filelogs .file-logs-workspace, +#file-log-view-counts, #file-log-view-entries, -#file-log-view-counts { +#panel-filelogs, +#panel-filelogs .file-logs-workspace { min-width: 0; max-width: 100%; } @@ -10095,36 +9985,35 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { grid-template-columns: minmax(0, 1fr); } -#panel-filelogs .file-logs-table, -#panel-filelogs .file-log-aggregate-table { +#panel-filelogs .file-log-aggregate-table, +#panel-filelogs .file-logs-table { box-sizing: border-box; min-width: 0; max-width: 100%; overflow-x: auto; } -@media (max-width: 700px) { +@media(max-width:700px) { #panel-filelogs .file-logs-table { overflow: visible; } - #panel-filelogs .file-logs-table td[data-mobile-column="time"], - #panel-filelogs .file-logs-table td[data-mobile-column="vhost"] { - grid-column: 1 / -1; + #panel-filelogs .file-logs-table td[data-mobile-column=time], + #panel-filelogs .file-logs-table td[data-mobile-column=vhost] { + grid-column: 1/-1; } - #panel-filelogs .file-logs-table td[data-mobile-column="record"] { + #panel-filelogs .file-logs-table td[data-mobile-column=record] { max-height: 4.4em; overflow: hidden; } - #panel-filelogs .file-logs-table tr.selected td[data-mobile-column="record"] { + #panel-filelogs .file-logs-table tr.selected td[data-mobile-column=record] { max-height: none; overflow-wrap: anywhere; } } -/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */ .listener-protocol-settings-actions { display: flex; gap: 14px; @@ -10141,7 +10030,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { display: none; } -/* 0.31.7 revision preview shows only changed leaf fields. */ .config-history-change-values { display: flex; flex-wrap: wrap; @@ -10164,7 +10052,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } .config-history-json-dialog { - width: min(1200px, calc(100vw - 24px)); + width: calc(min(1200px, 100vw - 24px)); max-width: calc(100vw - 24px); max-height: calc(100dvh - 28px); overflow: auto; @@ -10191,7 +10079,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { font: 11px/1.5 var(--mono); } -@media (max-width: 760px) { +@media(max-width:760px) { .config-history-json-columns { grid-template-columns: 1fr; } @@ -10211,11 +10099,12 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { font-size: 12px; } -.acl-profile-attached p { +#panel-overview .stats-grid .stat>small, +.acl-profile-attached p, +.stream-backend-status-row>span:first-child { overflow-wrap: anywhere; } -/* v0.32 Stream extensions reuse the responsive Vhost editor and stat cards. */ .stream-backend-pool { display: grid; gap: .8rem; @@ -10258,11 +10147,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { font-size: .85em; } -.stream-backend-status-row>span:first-child { - overflow-wrap: anywhere; -} - -[data-dashboard-widget^="stream_"][hidden] { +[data-dashboard-widget^=stream_][hidden] { display: none !important; } @@ -10275,18 +10160,15 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { grid-template-rows: auto; } -.vh-enable-control.vh-enable-no-hint> :is(input, .vh-enable-state) { +.vh-enable-control.vh-enable-no-hint>:is(input, .vh-enable-state) { grid-row: 1; } +#panel-overview .backend-mode-bar span, #panel-overview .stats-grid>.stat { min-width: 0; } -#panel-overview .stats-grid .stat>small { - overflow-wrap: anywhere; -} - #panel-overview .stream-traffic-legend .legend-rx i { background: var(--chart-accent); } @@ -10300,12 +10182,12 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { white-space: normal; } -@media (min-width: 1201px) { +@media(min-width:1201px) { #panel-overview .stats-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } } -/* Optional HTTP backend mode distribution (configured modes, not negotiated ALPN). */ + #panel-overview .backend-mode-bar { display: flex; height: 7px; @@ -10316,94 +10198,304 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--line); margin: 7px 0 5px; } -#panel-overview .backend-mode-bar span { min-width: 0; } -#panel-overview .backend-mode-h1 { background: var(--traffic-h1); } -#panel-overview .backend-mode-auto { background: var(--traffic-h2); } -#panel-overview .backend-mode-compat { background: var(--traffic-h2); opacity: .5; } -#panel-overview .backend-mode-h2 { background: var(--traffic-h3); } -#panel-overview .backend-mode-bar[aria-label] { background-clip: padding-box; } -.vh-origin-protocol-info { color: var(--muted); } -.vh-origin-protocol-warning { color: var(--muted); } +#panel-overview .backend-mode-h1 { + background: var(--traffic-h1); +} + +#panel-overview .backend-mode-auto { + background: var(--traffic-h2); +} + +#panel-overview .backend-mode-compat { + background: var(--traffic-h2); + opacity: .5; +} + +#panel-overview .backend-mode-h2 { + background: var(--traffic-h3); +} + +#panel-overview .backend-mode-bar[aria-label] { + background-clip: padding-box; +} + +.vh-origin-protocol-info, +.vh-origin-protocol-warning { + color: var(--muted); +} -/* Overview arrangement dialog: visual clones never participate in metric updates. */ .overview-layout-dialog { - width: min(1180px, calc(100vw - 24px)); + width: calc(min(1180px, 100vw - 24px)); max-height: calc(100dvh - 24px); overflow: auto; } -.overview-layout-dialog > .window-heading { + +.overview-layout-dialog>.window-heading { position: sticky; top: 0; z-index: 2; background: var(--bg); border-bottom: 1px solid var(--line); } -.overview-layout-body { display: grid; gap: 16px; padding: 14px; } -.overview-layout-body h3 { font-size: 14px; margin: 0 0 10px; } -.overview-layout-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--ui-gap, 10px); } -.overview-layout-tile { min-width: 0; border: 1px solid var(--line); background: var(--panel); cursor: grab; } -.overview-layout-tile:active { cursor: grabbing; } -.overview-layout-tile.is-dragging { opacity: .4; } -.overview-layout-tile.is-drop-target { outline: 2px solid var(--accent); outline-offset: -2px; } -.overview-layout-tile-toolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border-bottom: 1px solid var(--line); } -.overview-layout-tile-toolbar button { min-height: 28px; min-width: 28px; padding: 3px 7px; } -.overview-layout-grip { padding: 0 5px; color: var(--muted); user-select: none; } -.overview-layout-hide { margin-inline-start: auto; font-size: 11px; } -.overview-layout-grid .stat { border: 0; padding: 12px; min-height: 98px; pointer-events: none; } -.overview-layout-grid .stat > span { font-size: 11px; } -.overview-layout-grid .stat strong { font-size: 24px; margin-top: 7px; overflow-wrap: anywhere; } -.overview-layout-grid .stat small { display: block; font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; } -.overview-layout-grid .stat svg { width: 100%; height: 53px; } -.overview-layout-grid .stat-chart-legend { display: flex; flex-wrap: wrap; gap: 8px; } -.overview-layout-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.overview-layout-section-heading input { max-width: 230px; } -.overview-layout-hidden-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } -.overview-layout-hidden-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--line); padding: 8px; background: var(--panel); min-width: 0; } -.overview-layout-hidden-item span { font-size: 12px; min-width: 0; overflow-wrap: anywhere; } -.overview-layout-hidden-item button { flex-shrink: 0; } -.overview-layout-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px; border-top: 1px solid var(--line); background: var(--bg); z-index: 2; } -.overview-layout-footer p { margin: 0 auto 0 0; font-size: 11px; overflow-wrap: anywhere; } -.dashboard-layout-open-settings { margin: 5px 0; justify-self: start; } -@media (max-width: 850px) { - .overview-layout-grid, .overview-layout-hidden-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} -@media (max-width: 550px) { - .overview-layout-grid, .overview-layout-hidden-list { grid-template-columns: minmax(0, 1fr); } - .overview-layout-section-heading { align-items: stretch; flex-direction: column; } - .overview-layout-section-heading input { max-width: none; width: 100%; } - .overview-layout-footer { flex-wrap: wrap; } - .overview-layout-footer p { width: 100%; } + +.overview-layout-body { + display: grid; + gap: 16px; + padding: 14px; +} + +.overview-layout-body h3 { + font-size: 14px; + margin: 0 0 10px; +} + +.overview-layout-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--ui-gap, 10px); +} + +.overview-layout-tile { + min-width: 0; + border: 1px solid var(--line); + background: var(--panel); + cursor: grab; +} + +.overview-layout-tile:active { + cursor: grabbing; +} + +.overview-layout-tile.is-dragging { + opacity: .4; +} + +.overview-layout-tile.is-drop-target { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.overview-layout-tile-toolbar { + display: flex; + align-items: center; + gap: 3px; + padding: 4px; + border-bottom: 1px solid var(--line); +} + +.overview-layout-tile-toolbar button { + min-height: 28px; + min-width: 28px; + padding: 3px 7px; +} + +.overview-layout-grip { + padding: 0 5px; + color: var(--muted); + user-select: none; +} + +.overview-layout-hide { + margin-inline-start: auto; + font-size: 11px; +} + +.overview-layout-grid .stat { + border: 0; + padding: 12px; + min-height: 98px; + pointer-events: none; +} + +.overview-layout-grid .stat>span { + font-size: 11px; +} + +.overview-layout-grid .stat strong { + font-size: 24px; + margin-top: 7px; + overflow-wrap: anywhere; +} + +.overview-layout-grid .stat small { + display: block; + font-size: 10px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.overview-layout-grid .stat svg { + width: 100%; + height: 53px; +} + +.overview-layout-grid .stat-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.overview-layout-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.overview-layout-section-heading input { + max-width: 230px; +} + +.overview-layout-hidden-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.overview-layout-hidden-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid var(--line); + padding: 8px; + background: var(--panel); + min-width: 0; +} + +.overview-layout-hidden-item span { + font-size: 12px; + min-width: 0; + overflow-wrap: anywhere; +} + +.overview-layout-hidden-item button { + flex-shrink: 0; +} + +.overview-layout-footer { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--line); + background: var(--bg); + z-index: 2; +} + +.overview-layout-footer p { + margin: 0 auto 0 0; + font-size: 11px; + overflow-wrap: anywhere; +} + +.dashboard-layout-open-settings { + margin: 5px 0; + justify-self: start; +} + +@media(max-width:850px) { + + .overview-layout-grid, + .overview-layout-hidden-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media(max-width:550px) { + + .overview-layout-grid, + .overview-layout-hidden-list { + grid-template-columns: minmax(0, 1fr); + } + + .overview-layout-section-heading { + align-items: stretch; + flex-direction: column; + } + + .overview-layout-section-heading input { + max-width: none; + width: 100%; + } + + .overview-layout-footer { + flex-wrap: wrap; + } + + .overview-layout-footer p { + width: 100%; + } } -/* One rearrangeable Overview canvas. Existing large charts, diagnostics and the - backend table keep their original nodes/IDs and their data update handlers. */ #panel-overview .dashboard-overview-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--ui-gap); align-items: stretch; } -#panel-overview .dashboard-overview-grid > [data-dashboard-widget] { min-width: 0; } -#panel-overview .dashboard-overview-grid > [hidden] { display: none !important; } -#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: span 2; } -#panel-overview .dashboard-overview-grid > .pool-card { grid-column: 1 / -1; margin-bottom: 0; } -#panel-overview .dashboard-overview-grid > .activity-card svg { min-width: 0; } -@media (max-width: 1200px) { - #panel-overview .dashboard-overview-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } - #panel-overview .dashboard-overview-grid > .stat { grid-column: span 2; } - #panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: span 2; } + +#panel-overview .dashboard-overview-grid>.activity-card svg, +#panel-overview .dashboard-overview-grid>[data-dashboard-widget] { + min-width: 0; } -@media (max-width: 850px) { - #panel-overview .dashboard-overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - #panel-overview .dashboard-overview-grid > .stat { grid-column: span 1; } - #panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: 1 / -1; } + +#panel-overview .dashboard-overview-grid>[hidden] { + display: none !important; } -@media (max-width: 550px) { - #panel-overview .dashboard-overview-grid { grid-template-columns: minmax(0, 1fr); } - #panel-overview .dashboard-overview-grid > .stat { grid-column: 1; } + +#panel-overview .dashboard-overview-grid>.card:not(.pool-card) { + grid-column: span 2; } -.overview-layout-tile.is-panel { grid-column: span 2; } + +#panel-overview .dashboard-overview-grid>.pool-card { + grid-column: 1/-1; + margin-bottom: 0; +} + +@media(max-width:1200px) { + #panel-overview .dashboard-overview-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + #panel-overview .dashboard-overview-grid>.card:not(.pool-card), + #panel-overview .dashboard-overview-grid>.stat { + grid-column: span 2; + } +} + +@media(max-width:850px) { + #panel-overview .dashboard-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + #panel-overview .dashboard-overview-grid>.stat { + grid-column: span 1; + } + + #panel-overview .dashboard-overview-grid>.card:not(.pool-card) { + grid-column: 1/-1; + } +} + +@media(max-width:550px) { + #panel-overview .dashboard-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + #panel-overview .dashboard-overview-grid>.stat { + grid-column: auto/span 1; + } +} + +.overview-layout-tile.is-panel { + grid-column: span 2; +} + .overview-layout-grid .overview-layout-panel-preview { display: grid; gap: 7px; @@ -10412,17 +10504,45 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--panel); pointer-events: none; } -.overview-layout-panel-preview > strong { font-size: 13px; } -.overview-layout-panel-preview svg { width: 100%; height: 80px; } -.overview-layout-panel-preview small { color: var(--muted); } -.overview-layout-footer #overview-layout-default { margin-right: auto; } -.overview-reset-confirm { width: min(460px, calc(100vw - 24px)); padding: 16px; } -.overview-reset-confirm > p { line-height: 1.5; } -@media (max-width: 850px) { .overview-layout-tile.is-panel { grid-column: span 2; } } -@media (max-width: 550px) { .overview-layout-tile.is-panel { grid-column: span 1; } } -/* Overview block kinds come from the same server-validated registry as the - saved order. Labels distinguish numbers, plots, tables and diagnostic panels. */ +.overview-layout-panel-preview>strong { + font-size: 13px; +} + +.overview-layout-panel-preview svg { + width: 100%; + height: 80px; +} + +.overview-layout-panel-preview small { + color: var(--muted); +} + +.overview-layout-footer #overview-layout-default { + margin-right: auto; +} + +.overview-reset-confirm { + width: calc(min(460px, 100vw - 24px)); + padding: 16px; +} + +.overview-reset-confirm>p { + line-height: 1.5; +} + +@media(max-width:850px) { + .overview-layout-tile.is-panel { + grid-column: span 2; + } +} + +@media(max-width:550px) { + .overview-layout-tile.is-panel { + grid-column: span 1; + } +} + .overview-widget-type { display: inline-block; width: fit-content; @@ -10436,31 +10556,70 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { line-height: 1.25; overflow-wrap: anywhere; } -.dashboard-widget-setting .dashboard-widget-toggle { flex-wrap: wrap; } -.dashboard-widget-setting .overview-widget-type { margin-left: 15px; } -.overview-layout-tile-toolbar .overview-widget-type { margin-left: 3px; } -.overview-layout-hidden-item .overview-widget-type { flex: 0 0 auto; } -.overview-layout-filters { display: flex; flex-wrap: wrap; gap: 8px; } -.overview-layout-filters select, .overview-layout-filters input { width: min(220px, 100%); max-width: 100%; } -/* Inline editing of the actual Overview blocks; live charts and original DOM - IDs remain in place. Dragging is enabled only on the explicit grip button. */ -.overview-inline-actions { display: inline-flex; gap: 7px; align-items: center; flex-wrap: wrap; } -.overview-inline-actions[hidden], .overview-inline-hint[hidden] { display: none !important; } -.overview-inline-hint { margin: 0 0 10px; } -#panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { +.dashboard-widget-setting .dashboard-widget-toggle { + flex-wrap: wrap; +} + +.dashboard-widget-setting .overview-widget-type { + margin-left: 15px; +} + +.overview-layout-tile-toolbar .overview-widget-type { + margin-left: 3px; +} + +.overview-layout-hidden-item .overview-widget-type { + flex: 0 0 auto; +} + +.overview-layout-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.overview-layout-filters input, +.overview-layout-filters select { + width: min(220px, 100%); + max-width: 100%; +} + +.overview-inline-actions { + display: inline-flex; + gap: 7px; + align-items: center; + flex-wrap: wrap; +} + +.overview-inline-actions[hidden], +.overview-inline-hint[hidden] { + display: none !important; +} + +.overview-inline-hint { + margin: 0 0 10px; +} + +#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] { position: relative; outline: 1px dashed var(--accent); outline-offset: -2px; padding-top: 66px; } -#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-dragging { opacity: .45; } -#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-drop-before { + +#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-dragging { + opacity: .45; +} + +#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-before { box-shadow: inset 4px 0 var(--accent); } -#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-drop-after { + +#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-after { box-shadow: inset -4px 0 var(--accent); } + .overview-inline-toolbar { position: absolute; top: 3px; @@ -10473,37 +10632,57 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { gap: 3px; pointer-events: auto; } + #panel-overview .overview-inline-toolbar button { min-width: 28px; min-height: 27px; padding: 2px 7px; font-size: 12px; } -#panel-overview .overview-inline-toolbar .overview-inline-grip { cursor: grab; user-select: none; } -#panel-overview .overview-inline-toolbar .overview-inline-grip:active { cursor: grabbing; } -#panel-overview .overview-inline-toolbar .overview-widget-type { margin-left: auto; } -@media (max-width: 600px) { - .overview-layout-hidden-item { flex-wrap: wrap; } - .overview-layout-filters select, .overview-layout-filters input { width: 100%; } - #panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { padding-top: 68px; } + +#panel-overview .overview-inline-toolbar .overview-inline-grip { + cursor: grab; + user-select: none; +} + +#panel-overview .overview-inline-toolbar .overview-inline-grip:active { + cursor: grabbing; +} + +#panel-overview .overview-inline-toolbar .overview-widget-type { + margin-left: auto; +} + +@media(max-width:600px) { + .overview-layout-hidden-item { + flex-wrap: wrap; + } + + .overview-layout-filters input, + .overview-layout-filters select { + width: 100%; + } + + #panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] { + padding-top: 68px; + } } -/* Direct Overview editing: hide is reversible until Save and remains - recoverable later from the block selector or the compact hidden shelf. */ #panel-overview .overview-inline-toolbar .overview-inline-hide { color: var(--red); border: 1px solid var(--danger-line); background: var(--panel); - /* Use the same .quiet radius/corner preset as the arrow buttons. */ font-size: 18px; line-height: 1; } -#panel-overview .overview-inline-toolbar .overview-inline-hide:hover, -#panel-overview .overview-inline-toolbar .overview-inline-hide:focus-visible { + +#panel-overview .overview-inline-toolbar .overview-inline-hide:focus-visible, +#panel-overview .overview-inline-toolbar .overview-inline-hide:hover { border-color: var(--danger-line-strong); background: var(--notice-bg); } + .overview-inline-hidden { margin: 0 0 10px; border: 1px solid var(--line); @@ -10511,22 +10690,29 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--panel); color: var(--text); } -.overview-inline-hidden > summary { + +.overview-inline-hidden>summary { padding: 9px 12px; cursor: pointer; color: var(--muted); font-size: 12px; } -.overview-inline-hidden > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.overview-inline-hidden>summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .overview-inline-hidden-list { display: flex; flex-wrap: wrap; gap: 7px; - max-height: min(30vh, 240px); + max-height: calc(min(30vh, 240px)); overflow-y: auto; padding: 10px 12px; border-top: 1px solid var(--line); } + .overview-inline-hidden-item { display: inline-flex; align-items: center; @@ -10539,8 +10725,12 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--panel2); font-size: 12px; } -.overview-inline-hidden-item > span:not(.overview-widget-type) { overflow-wrap: anywhere; } -.overview-inline-hidden-item > button { + +.overview-inline-hidden-item>span:not(.overview-widget-type) { + overflow-wrap: anywhere; +} + +.overview-inline-hidden-item>button { flex: 0 0 auto; min-width: 27px; min-height: 27px; @@ -10550,23 +10740,28 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { color: var(--green); background: var(--panel); } -.overview-inline-hidden-item > button:hover { border-color: var(--accent); } -@media (max-width: 600px) { - #panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { padding-top: 82px; } - .overview-inline-hidden-item { max-width: 100%; } + +.overview-inline-hidden-item>button:hover { + border-color: var(--accent); +} + +@media(max-width:600px) { + #panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] { + padding-top: 82px; + } + + .overview-inline-hidden-item { + max-width: 100%; + } +} + +#panel-overview { + container: overview-canvas / inline-size; } -/* The original fixed Overview grid, also used for personalized layouts. - Metrics, wide charts, diagnostic panels and tables are separate rows; - every tile in a row stretches to the SAME height. This intentionally - favors symmetry over masonry or floating short cards beside tall panels. - Existing density controls define the identical horizontal/vertical gap. */ -#panel-overview { container: overview-canvas / inline-size; } #panel-overview .dashboard-overview-grid.is-aligned { display: grid; - /* A compact gutter throughout: tiles, large panels, rows and columns. - Keep the density setting, but avoid the former larger tile-to-tile gap. */ - --overview-gap: max(3px, calc(var(--ui-gap) - 1px)); + --overview-gap: var(--ui-gap); margin: 0; row-gap: var(--overview-gap); column-gap: var(--overview-gap); @@ -10576,15 +10771,15 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { align-items: stretch; align-content: start; gap: var(--overview-gap); + overflow-anchor: none; } -/* The earlier html[data-dashboard-density] .stats-grid rules have greater - specificity than .is-aligned and otherwise restore the old, larger gutter. - Keep the compact value in the combined Overview grid at every density. */ + #panel-overview .dashboard-overview-grid.is-aligned.stats-grid { gap: var(--overview-gap); } -#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget] { - grid-column: auto / span 2; + +#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget] { + grid-column: auto/span 2; grid-row: auto; align-self: stretch; width: 100%; @@ -10594,117 +10789,474 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { margin-block: 0; position: relative; } -#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: auto / span 4; } -#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-wide-chart { grid-column: auto / span 6; } -#panel-overview .dashboard-overview-grid.is-aligned > .pool-card { grid-column: 1 / -1; } -/* Each size category starts at the first track, even after drag/drop. */ -#panel-overview .dashboard-overview-grid.is-aligned > .overview-row-start, -#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-row-start { grid-column-start: 1; } + +#panel-overview .dashboard-overview-grid.is-aligned>.card:not(.pool-card) { + grid-column: auto/span 4; +} + +#panel-overview .dashboard-overview-grid.is-aligned>.card.overview-wide-chart { + grid-column: auto/span 6; +} + +#panel-overview .dashboard-overview-grid.is-aligned>.pool-card { + grid-column: 1/-1; +} + +#panel-overview .dashboard-overview-grid.is-aligned>.card.overview-row-start, +#panel-overview .dashboard-overview-grid.is-aligned>.overview-row-start { + grid-column-start: 1; +} + @container overview-canvas (max-width: 1080px) { - #panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(6, minmax(0, 1fr)); } - #panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 2; } - #panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: auto / span 3; } - #panel-overview .dashboard-overview-grid.is-aligned > .card.overview-wide-chart, - #panel-overview .dashboard-overview-grid.is-aligned > .pool-card { grid-column: 1 / -1; } + #panel-overview .dashboard-overview-grid.is-aligned { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + + #panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget].stat { + grid-column: auto/span 2; + } + + #panel-overview .dashboard-overview-grid.is-aligned>.card:not(.pool-card) { + grid-column: auto/span 3; + } + + #panel-overview .dashboard-overview-grid.is-aligned>.card.overview-wide-chart, + #panel-overview .dashboard-overview-grid.is-aligned>.pool-card { + grid-column: 1/-1; + } } + @container overview-canvas (max-width: 700px) { - #panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(2, minmax(0, 1fr)); } - #panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 1; } - #panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: 1 / -1; } -} -@container overview-canvas (max-width: 440px) { - #panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: minmax(0, 1fr); } - #panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: 1; } -} -/* Keep category boundaries intact at every responsive breakpoint. */ -#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].overview-row-start { grid-column-start: 1; } + #panel-overview .dashboard-overview-grid.is-aligned { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } -/* Full-size optional charts: static SVG geometry and bounded historical data. */ -#panel-overview .overview-wide-chart { padding: 15px; overflow: hidden; } -#panel-overview .overview-wide-chart .card-heading { min-width: 0; align-items: baseline; gap: 10px; } -#panel-overview .overview-wide-chart h2 { min-width: 0; overflow-wrap: anywhere; } -#panel-overview .overview-wide-summary { color: var(--accent); font: 500 12px var(--mono); white-space: nowrap; } -#panel-overview .overview-wide-svg { display: block; width: 100%; height: clamp(125px, 13vw, 190px); margin-block: 13px 9px; } -#panel-overview .overview-wide-svg .chart-grid { stroke: var(--line); fill: none; stroke-width: 1; opacity: .6; } -#panel-overview .overview-wide-series { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } -#panel-overview .overview-wide-series-0 { stroke: var(--chart-accent); } -#panel-overview .overview-wide-series-1 { stroke: var(--chart-secondary); } -#panel-overview .overview-wide-series-2 { stroke: var(--chart-tertiary); } -#panel-overview .overview-wide-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; color: var(--muted); font-size: 11px; } -#panel-overview .overview-wide-legend > span { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } -#panel-overview .overview-wide-legend i { width: 8px; height: 8px; border-radius: 100%; background: var(--chart-accent); } -#panel-overview .overview-wide-legend .overview-wide-key-1 { background: var(--chart-secondary); } -#panel-overview .overview-wide-legend .overview-wide-key-2 { background: var(--chart-tertiary); } -#panel-overview .overview-wide-legend b { font: 500 11px var(--mono); color: var(--text); } -/* Block metadata stays to the left of every action group. */ -.dashboard-widget-setting .dashboard-widget-toggle { flex-wrap: nowrap; } -.dashboard-widget-setting .dashboard-widget-toggle .overview-widget-type { margin: 0 0 0 auto; flex: 0 0 auto; } -.dashboard-widget-setting .dashboard-widget-setting-actions { justify-self: end; flex-shrink: 0; } -.overview-layout-tile-toolbar { flex-wrap: wrap; } -.overview-layout-tile-toolbar .overview-widget-type { margin: 0; } -.overview-layout-tile-toolbar .overview-layout-controls { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; } -.overview-layout-hidden-item > button { margin-left: auto; } -.overview-layout-hidden-item > .overview-widget-type { margin: 0; } -#panel-overview .overview-inline-toolbar .overview-widget-type { margin: 0 auto 0 0; } -#panel-overview .overview-inline-toolbar .overview-inline-hide { margin-left: 0; } -.overview-inline-hidden-item > .overview-widget-type { margin-right: 0; } -.overview-inline-hidden-item > .overview-inline-restore { order: 3; margin-left: auto; } + #panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget].stat { + grid-column: auto/span 1; + touch-action: pan-y pinch-zoom; + overflow-wrap: anywhere; + } -/* Appearance selector: always show the block-kind label, independently from - the visibility button, with actions pinned to the right edge. */ -.dashboard-widget-list { - grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); + #panel-overview .dashboard-overview-grid.is-aligned>.stat.stat-with-action { + padding-right: 11px; + } + + #panel-overview .dashboard-overview-grid.is-aligned .stat-with-action .stat-corner-action { + position: static; + display: block; + width: max-content; + max-width: 100%; + margin: 6px 0 0; + white-space: normal; + } + + html[data-dashboard-density=compact] #panel-overview .dashboard-overview-grid.is-aligned>.stat strong, + html[data-dashboard-density=normal] #panel-overview .dashboard-overview-grid.is-aligned>.stat strong { + font-size: calc(clamp(18px, 5.5vw, 25px)); + } + + #panel-overview .dashboard-overview-grid.is-aligned>.card:not(.pool-card) { + grid-column: 1/-1; + } } -.dashboard-widget-setting { - grid-template-columns: minmax(0, 1fr) auto; - gap: 6px; + +#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget].overview-row-start { + grid-column-start: 1; } -.dashboard-widget-setting .dashboard-widget-toggle { + +#panel-overview .overview-wide-chart { + padding: 15px; + overflow: hidden; +} + +#panel-overview .overview-wide-chart .card-heading { + min-width: 0; + align-items: baseline; + gap: 10px; +} + +#panel-overview .overview-wide-chart h2 { + min-width: 0; + overflow-wrap: anywhere; +} + +#panel-overview .overview-wide-summary { + color: var(--accent); + font: 500 12px var(--mono); + white-space: nowrap; +} + +#panel-overview .overview-wide-svg { + display: block; width: 100%; + height: calc(clamp(125px, 13vw, 190px)); + margin-block: 13px 9px; +} + +#panel-overview .overview-wide-svg .chart-grid { + stroke: var(--line); + fill: none; + stroke-width: 1; + opacity: .6; +} + +#panel-overview .overview-wide-series { + fill: none; + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +#panel-overview .overview-wide-series-0 { + stroke: var(--chart-accent); +} + +#panel-overview .overview-wide-series-1 { + stroke: var(--chart-secondary); +} + +#panel-overview .overview-wide-series-2 { + stroke: var(--chart-tertiary); +} + +#panel-overview .overview-wide-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + color: var(--muted); + font-size: 11px; +} + +#panel-overview .overview-wide-legend>span { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +#panel-overview .overview-wide-legend i { + width: 8px; + height: 8px; + border-radius: 100%; + background: var(--chart-accent); +} + +#panel-overview .overview-wide-legend .overview-wide-key-1 { + background: var(--chart-secondary); +} + +#panel-overview .overview-wide-legend .overview-wide-key-2 { + background: var(--chart-tertiary); +} + +#panel-overview .overview-wide-legend b { + font: 500 11px var(--mono); + color: var(--text); +} + +.dashboard-widget-setting .dashboard-widget-toggle .overview-widget-type { + margin: 0 0 0 auto; + flex: 0 0 auto; +} + +.dashboard-widget-setting .dashboard-widget-setting-actions { + justify-self: end; + flex-shrink: 0; +} + +.overview-layout-tile-toolbar { + flex-wrap: wrap; +} + +.overview-layout-tile-toolbar .overview-widget-type { + margin: 0; +} + +.overview-layout-tile-toolbar .overview-layout-controls { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; +} + +.overview-layout-hidden-item>button { + margin-left: auto; +} + +.overview-layout-hidden-item>.overview-widget-type { + margin: 0; +} + +#panel-overview .overview-inline-toolbar .overview-widget-type { + margin: 0 auto 0 0; +} + +#panel-overview .overview-inline-toolbar .overview-inline-hide { + margin-left: 0; +} + +.overview-inline-hidden-item>.overview-widget-type { + margin-right: 0; +} + +.overview-inline-hidden-item>.overview-inline-restore { + order: 3; + margin-left: auto; +} + +.dashboard-widget-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.dashboard-widget-setting { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 1 1 280px; + min-width: min(280px, 100%); + max-width: 100%; + padding: 4px 6px; +} + +.dashboard-widget-setting .dashboard-widget-toggle { + width: auto; + flex: 1 1 auto; overflow: hidden; min-width: 0; flex-wrap: nowrap; } + .dashboard-widget-setting .dashboard-widget-setting-label { flex: 1 1 auto; min-width: 0; } -.dashboard-widget-setting > .overview-widget-type { + +.dashboard-widget-setting>.overview-widget-type { flex: none; margin: 0; white-space: nowrap; overflow-wrap: normal; } -.dashboard-widget-setting > .dashboard-widget-setting-actions { + +.dashboard-widget-setting>.dashboard-widget-setting-actions { justify-self: end; white-space: nowrap; } -/* Global shape preference. "default" applies NO overrides so existing dark, - light, cream and Ultra Dark keep the same established corner geometry. - Circular status dots, spinners, switches and chart markers retain their - semantic shape; underline tabs and navigation accent strips stay intact. */ -html[data-corner-style="square"] { --rp-radius-surface: 0px; --rp-radius-control: 0px; } -html[data-corner-style="subtle"] { --rp-radius-surface: 5px; --rp-radius-control: 4px; } -html[data-corner-style="rounded"] { --rp-radius-surface: 12px; --rp-radius-control: 8px; } -html[data-corner-style="extra"] { --rp-radius-surface: 18px; --rp-radius-control: 12px; } -html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is( - .card, .stat, dialog, .login-card, .appearance-section, .settings-tabs, - .dashboard-widget-setting, .overview-layout-tile, .overview-layout-hidden-item, - .overview-inline-hidden-item, .notice, .toast, - .chart-style-choice, .stream-dashboard-card, .pool-card, - .file-log-advanced-parser, .table-container, .metric-info-popover -) { border-radius: var(--rp-radius-surface) !important; } -html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is( - button:not(.nav):not(.file-log-tab), input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), - select, textarea, .pill, .overview-widget-type, .badge, .quiet, .outline, .primary, - .code, .command, .dashboard-widget-toggle, .settings-tabs button, - .overview-inline-hidden-toggle, .overview-inline-restore -) { border-radius: var(--rp-radius-control) !important; } +@media(max-width:700px) { + .dashboard-widget-list { + display: grid; + grid-template-columns: minmax(0, 1fr); + } -/* Appearance stays badge-free; badges remain useful in the visual editor. */ -#settings-page-appearance .dashboard-widget-setting .overview-widget-type { display: none; } + .dashboard-widget-setting { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 0; + } +} -/* ACL DDNS input: keep the rule type separate from its hostname. */ -.acl-profile-rule { grid-template-columns: minmax(100px, .6fr) minmax(105px, .6fr) minmax(175px, 1.5fr) auto; } -@media (max-width: 640px) { .acl-profile-rule { grid-template-columns: 1fr; } } +.appearance-settings-grid.dashboard-appearance-grid { + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +.appearance-settings-grid.dashboard-appearance-grid>.dashboard-density-setting { + display: grid; + grid-template-columns: minmax(145px, 200px) minmax(180px, 290px) minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.dashboard-density-setting>.fine, +.dashboard-density-setting>select { + min-width: 0; +} + +.dashboard-appearance-grid>.dashboard-widget-settings { + grid-column: 1/-1; + gap: 6px; +} + +.dashboard-widget-settings-head { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 8px 14px; +} + +.dashboard-widget-settings-intro { + display: grid; + gap: 3px; + min-width: 0; + flex: 1 1 290px; +} + +.dashboard-widget-settings-controls { + display: flex; + flex: 0 0 auto; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + margin-left: auto; +} + +.dashboard-widget-settings-controls>button { + min-height: 30px; + padding: 5px 10px; +} + +.dashboard-widget-settings-controls>.dashboard-widget-scroll-button { + min-width: 30px; + padding-inline: 6px; +} + +.dashboard-widget-settings-controls .dashboard-layout-open-settings { + margin: 0; + justify-self: auto; +} + +.dashboard-appearance-grid .dashboard-widget-list { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: 6px; + max-width: 100%; + min-width: 0; + margin: 0; + padding: 2px 1px 9px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; + -webkit-overflow-scrolling: touch; +} + +.dashboard-appearance-grid .dashboard-widget-list>.dashboard-widget-setting { + flex: 0 0 226px; + width: 226px; + min-width: 226px; + max-width: 226px; + min-height: 33px; + box-sizing: border-box; + scroll-snap-align: start; +} + +.dashboard-appearance-grid .dashboard-widget-list.is-expanded { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(225px, 100%), 1fr)); + gap: 7px; + overflow: visible; + padding-bottom: 0; + scroll-snap-type: none; +} + +.dashboard-appearance-grid .dashboard-widget-list.is-expanded>.dashboard-widget-setting { + width: 100%; + max-width: none; + min-width: 0; +} + +#settings-page-appearance .dashboard-widget-setting .overview-widget-type, +.dashboard-appearance-grid .dashboard-widget-list.is-expanded~.dashboard-widget-scroll-hint { + display: none; +} + +@media(max-width:760px) { + .appearance-settings-grid.dashboard-appearance-grid>.dashboard-density-setting { + grid-template-columns: minmax(0, 1fr); + gap: 7px; + } + + .dashboard-widget-settings-head { + align-items: stretch; + } + + .dashboard-widget-settings-controls { + margin-left: 0; + justify-content: flex-start; + } + + .dashboard-appearance-grid .dashboard-widget-list>.dashboard-widget-setting { + flex-basis: 210px; + width: 210px; + min-width: 210px; + max-width: 210px; + } + + .dashboard-appearance-grid .dashboard-widget-list.is-expanded>.dashboard-widget-setting { + min-width: 0; + max-width: none; + } +} + +html[data-corner-style=square] { + --rp-radius-surface: 0px; + --rp-radius-control: 0px; +} + +html[data-corner-style=subtle] { + --rp-radius-surface: 5px; + --rp-radius-control: 4px; +} + +html[data-corner-style=rounded] { + --rp-radius-surface: 12px; + --rp-radius-control: 8px; +} + +html[data-corner-style=extra] { + --rp-radius-surface: 18px; + --rp-radius-control: 12px; +} + +html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(.card, .stat, dialog, .login-card, .appearance-section, .settings-tabs, .dashboard-widget-setting, .overview-layout-tile, .overview-layout-hidden-item, .overview-inline-hidden-item, .overview-inline-hidden, .notice, .toast, .chart-style-choice, .stream-dashboard-card, .pool-card, .vhost-card, .vh-backend-card, .entity, .settings-filter-row, .stream-backend-row, .file-log-advanced-parser, .table-container, .metric-info-popover) { + border-radius: var(--rp-radius-surface) !important; +} + +html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(button:not(.nav):not(.file-log-tab), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-hidden-toggle, .overview-inline-restore) { + border-radius: var(--rp-radius-control) !important; +} + +.acl-profile-rule { + grid-template-columns: minmax(100px, .6fr) minmax(105px, .6fr) minmax(175px, 1.5fr) auto; +} + +@media(max-width:640px) { + .acl-profile-rule { + grid-template-columns: 1fr; + } +} + +.vhost-backend-protocol-badge { + display: inline-flex; + gap: 4px; + align-items: baseline; + max-width: 100%; + min-width: 0; + border: 1px solid var(--pill-line); + border-radius: 4px; + background: var(--panel2); + color: var(--muted); + padding: 3px 7px; + font: 10px var(--mono); + overflow-wrap: anywhere; +} + +.vhost-backend-protocol-badge[data-observed=true] { + color: var(--green); + border-color: var(--pill-live-line); +} + +@container overview-canvas (max-width: 700px) { + #panel-overview .network-interface-charts { + max-height: none; + overflow: visible; + overscroll-behavior: auto; + } +} \ No newline at end of file diff --git a/src/admin/web/transform-panel.js b/src/admin/web/transform-panel.js index adc02c2..cd78299 100644 --- a/src/admin/web/transform-panel.js +++ b/src/admin/web/transform-panel.js @@ -1,7 +1,7 @@ "use strict"; (() => { let loading = false, generation = 0, lastData = null; - let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null; + let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null; const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] }; const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); const LIVE_CHART_STORAGE_KEY = "rustproxy.transform.liveChartSamples"; @@ -20,7 +20,7 @@ }; const emptyCounters = () => ({ examined: 0, applied: 0, skipped: 0, errors: 0, cache_hits: 0, original_bytes: 0, transformed_bytes: 0, saved_bytes: 0, duration_us: 0, by_kind: {}, reasons: {} }); function resetLiveChart(selected = scope()) { - liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; + liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null; for (const values of Object.values(liveChartSamples)) values.length = 0; for (const id of ["transform-live-applied-line", "transform-live-cache-line", "transform-live-skipped-line", "transform-live-error-line"]) $(id)?.setAttribute("points", "0,170 900,170"); for (const id of ["transform-live-applied-rate", "transform-live-cache-rate", "transform-live-skipped-rate", "transform-live-error-rate"]) if ($(id)) $(id).textContent = "0.0/s"; @@ -54,19 +54,22 @@ cache_hits: Math.max(0, Number(counters.cache_hits) || 0), skipped: Math.max(0, Number(counters.skipped) || 0), errors: Math.max(0, Number(counters.errors) || 0) - }, rates = { applied: 0, cache_hits: 0, skipped: 0, errors: 0 }; - if (liveChartPrevious && liveChartPreviousAt != null) { - const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000); - for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed; + }; + const keys = Object.keys(current); + const previous = liveChartPrevious && liveChartPreviousAt != null + ? { values: keys.map(key => liveChartPrevious[key]), at: liveChartPreviousAt, generation: liveChartGeneration } : null; + const sample = rustProxyRateSampling.difference(previous, keys.map(key => current[key]), now, 3); + liveChartPrevious = current; liveChartPreviousAt = now; liveChartGeneration = sample.next.generation; + if (sample.deltas) { + keys.forEach((key, i) => { liveChartSamples[key].push(sample.deltas[i] / sample.seconds); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); }); } - liveChartPrevious = current; liveChartPreviousAt = now; - for (const key of Object.keys(rates)) { liveChartSamples[key].push(rates[key]); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); } redrawLiveChart(); - $("transform-live-applied-rate").textContent = `${rates.applied.toFixed(1)}/s`; - $("transform-live-cache-rate").textContent = `${rates.cache_hits.toFixed(1)}/s`; - $("transform-live-skipped-rate").textContent = `${rates.skipped.toFixed(1)}/s`; - $("transform-live-error-rate").textContent = `${rates.errors.toFixed(1)}/s`; - $("transform-live-chart-value").textContent = `Σ ${(rates.applied + rates.cache_hits + rates.skipped + rates.errors).toFixed(1)}/s`; + const rate = key => sample.deltas ? `${(sample.deltas[keys.indexOf(key)] / sample.seconds).toFixed(1)}/s` : "-"; + $("transform-live-applied-rate").textContent = rate("applied"); + $("transform-live-cache-rate").textContent = rate("cache_hits"); + $("transform-live-skipped-rate").textContent = rate("skipped"); + $("transform-live-error-rate").textContent = rate("errors"); + $("transform-live-chart-value").textContent = sample.deltas ? `Σ ${(sample.deltas.reduce((a, b) => a + b, 0) / sample.seconds).toFixed(1)}/s` : "-"; } function countersFor(data) { if (scope() === "*") return data?.metrics?.total || emptyCounters(); diff --git a/src/admin/web/vhosts/render.js b/src/admin/web/vhosts/render.js index 324804a..64e11e9 100644 --- a/src/admin/web/vhosts/render.js +++ b/src/admin/web/vhosts/render.js @@ -101,6 +101,30 @@ async function toggleVhostEnabled(vhost) { if (await saveSection("vhosts", next.vhosts || [])) render(); } +const VHOST_PROTOCOL_FRESH_MS = 5 * 60 * 1000; +function upstreamProtocolBadgeText(backend, member, now = Date.now()) { + const mode = { http1: "H1", auto: "AUTO", http2: "H2 compatible", h2: "H2 only" }[backend.protocol || "http1"] || "H1"; + const seen = Number(member?.upstream_protocol_last_seen_ms); + const recent = member && (member.upstream_protocol_observed === "h1" || member.upstream_protocol_observed === "h2") && + Number.isFinite(seen) && seen > 0 && now >= seen && now - seen <= VHOST_PROTOCOL_FRESH_MS; + return { label: `${backend.id} · ${mode} → ${recent ? member.upstream_protocol_observed.toUpperCase() : "?"}`, recent }; +} +function renderUpstreamProtocolBadges(status) { + const members = new Map((status.backend_pools || []).flatMap(pool => (pool.members || []).map(member => + [`${pool.id}\u0000${member.id}`, member]))); + const now = Date.now(); + document.querySelectorAll("[data-upstream-protocol-vhost]").forEach(node => { + const vhost = node.dataset.upstreamProtocolVhost, id = node.dataset.upstreamProtocolBackend; + const backend = (envelope?.config.vhosts || []).find(host => host.id === vhost)?.backends?.find(item => item.id === id); + if (!backend) return; + const member = members.get(`${vhost}\u0000${id}`); + const observed = upstreamProtocolBadgeText(backend, member, now); + node.textContent = observed.label; + node.dataset.observed = String(!!observed.recent); + const counts = member && observed.recent ? ` H1: ${member.upstream_protocol_http1_total || 0}; H2: ${member.upstream_protocol_http2_total || 0}.` : ""; + node.title = `${t(observed.recent ? "vhBackendProtocolRecent" : "vhBackendProtocolUnobserved")}${counts}`; + }); +} function render() { if (!envelope) return; if (!initialized) load(defaults(), [], false); @@ -187,7 +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); - 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"); @@ -216,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; }); @@ -229,10 +264,10 @@ function backendTrafficRate(poolId, member, snapshot, now, seen) { const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0); const previous = backendTrafficHistory.get(key); if (previous?.snapshot === snapshot) return previous.rate; - const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; - const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; + const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds")); + const observed = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0; const rate = reported > 0 ? reported : observed; - backendTrafficHistory.set(key, { total, at: now, snapshot, rate }); + backendTrafficHistory.set(key, { ...rustProxyRateSampling.stamp(now), total, snapshot, rate }); return rate; } function vhostTrafficRate(id, metrics, snapshot, now, seen) { @@ -240,12 +275,13 @@ function vhostTrafficRate(id, metrics, snapshot, now, seen) { const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0); const previous = vhostTrafficHistory.get(id); if (previous?.snapshot === snapshot) return previous.rate; - const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; - const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; - vhostTrafficHistory.set(id, { total, at: now, snapshot, rate }); + const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds")); + const rate = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0; + vhostTrafficHistory.set(id, { ...rustProxyRateSampling.stamp(now), total, snapshot, rate }); return rate; } function renderStatus(status) { + renderUpstreamProtocolBadges(status); form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.()); const rows = [], summaries = new Map(), aggregate = new Map(); const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set(); @@ -321,14 +357,19 @@ function renderStatus(status) { fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`); const now = performance.now(); for (const [id, data] of aggregate) { - const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 }; - const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests; - history.pendingSum += rate; history.pendingCount += 1; - if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; } + const history = chartHistory.get(id) || { last: null, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 }; + const sample = rustProxyRateSampling.difference(history.last, [data.requests], now, rustProxyRateSampling.cadence("overview_seconds")); + const pausedRate = history.last !== null && sample.deltas === null; + history.last = sample.next; + const rate = sample.deltas ? sample.deltas[0] / sample.seconds : 0; + if (!pausedRate) { + history.pendingSum += rate; history.pendingCount += 1; + if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; } + } if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; } chartHistory.set(id, history); const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" "); - const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`; + const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${pausedRate ? "-" : rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`; } const editingVhost = editing ? configuredVhosts.get(editing) : null; const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")); diff --git a/src/balance/pool_view.rs b/src/balance/pool_view.rs index ddb892e..480fdc2 100644 --- a/src/balance/pool_view.rs +++ b/src/balance/pool_view.rs @@ -85,6 +85,13 @@ impl Pool { "upstream_client_rotations_total".into(), json!(client_rotation.rotations), ); + // Negotiated HTTP version from real backend response headers, + // never from the configured Auto/HTTP2 policy or health probes. + let observed = m.backend.client.protocol_stats(); + item.insert("upstream_protocol_observed".into(), json!(observed.last_version)); + item.insert("upstream_protocol_last_seen_ms".into(), json!(observed.observed_at_ms)); + item.insert("upstream_protocol_http1_total".into(), json!(observed.http1_responses)); + item.insert("upstream_protocol_http2_total".into(), json!(observed.http2_responses)); item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0)); item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0)); item.insert("last_probe_ms".into(), json!(h.last_probe_ms)); 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/src/upstream/client.rs b/src/upstream/client.rs index 15f4ae5..1ed8dfa 100644 --- a/src/upstream/client.rs +++ b/src/upstream/client.rs @@ -6,7 +6,7 @@ use crate::config::{Backend, UpstreamProtocol}; use anyhow::{Context, Result}; use arc_swap::ArcSwap; use axum::body::Body; -use http::{Request, Response}; +use http::{Request, Response, Version}; use hyper::body::Incoming; use hyper_rustls::HttpsConnectorBuilder; use hyper_util::{ @@ -50,6 +50,10 @@ struct RotatingClient { generation_started_ms: AtomicU64, rotations: AtomicU64, rotating: AtomicBool, + observed_version: AtomicU64, + observed_at_ms: AtomicU64, + observed_h1: AtomicU64, + observed_h2: AtomicU64, } /// Backend HTTP client with bounded generation lifetime. @@ -71,6 +75,14 @@ pub struct ClientRotationStats { pub rotations: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UpstreamProtocolStats { + pub last_version: Option<&'static str>, + pub observed_at_ms: u64, + pub http1_responses: u64, + pub http2_responses: u64, +} + impl std::fmt::Debug for ProxyClient { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -91,13 +103,25 @@ impl ProxyClient { generation_started_ms: AtomicU64::new(monotonic_millis()), rotations: AtomicU64::new(0), rotating: AtomicBool::new(false), + observed_version: AtomicU64::new(0), + observed_at_ms: AtomicU64::new(0), + observed_h1: AtomicU64::new(0), + observed_h2: AtomicU64::new(0), }), } } pub async fn request(&self, request: Request) -> Result, ClientError> { let client = self.inner.client_for_request(); - client.request(request).await + let response = client.request(request).await; + if let Ok(value) = &response { + self.inner.observe_protocol(value.version()); + } + response + } + + pub fn protocol_stats(&self) -> UpstreamProtocolStats { + self.inner.protocol_stats() } pub fn rotation_stats(&self) -> ClientRotationStats { @@ -119,6 +143,35 @@ impl ProxyClient { } impl RotatingClient { + fn observe_protocol(&self, version: Version) { + let code = match version { + Version::HTTP_2 => 2, + Version::HTTP_10 | Version::HTTP_11 => 1, + _ => return, + }; + if code == 2 { + self.observed_h2.fetch_add(1, Ordering::Relaxed); + } else { + self.observed_h1.fetch_add(1, Ordering::Relaxed); + } + self.observed_version.store(code, Ordering::Relaxed); + self.observed_at_ms + .store(crate::logging::now_ms(), Ordering::Relaxed); + } + + fn protocol_stats(&self) -> UpstreamProtocolStats { + UpstreamProtocolStats { + last_version: match self.observed_version.load(Ordering::Relaxed) { + 1 => Some("h1"), + 2 => Some("h2"), + _ => None, + }, + observed_at_ms: self.observed_at_ms.load(Ordering::Relaxed), + http1_responses: self.observed_h1.load(Ordering::Relaxed), + http2_responses: self.observed_h2.load(Ordering::Relaxed), + } + } + fn stats(&self) -> ClientRotationStats { let now = monotonic_millis(); ClientRotationStats { diff --git a/src/upstream/client_tests.rs b/src/upstream/client_tests.rs index 46e366c..aa63d4e 100644 --- a/src/upstream/client_tests.rs +++ b/src/upstream/client_tests.rs @@ -11,6 +11,23 @@ mod tests { .unwrap() } + #[test] + fn protocol_observation_requires_a_real_http_response_version() { + let backend = backend("protocol-observed", 18090); + let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); + assert_eq!(client.protocol_stats().last_version, None); + let shared = client.clone(); + // Exercise the response-version recorder independently of the network; + // a live TLS ALPN acceptance test must still run on staging. + shared.inner.observe_protocol(Version::HTTP_11); + shared.inner.observe_protocol(Version::HTTP_2); + let observed = client.protocol_stats(); + assert_eq!(observed.last_version, Some("h2")); + assert_eq!(observed.http1_responses, 1); + assert_eq!(observed.http2_responses, 1); + assert!(observed.observed_at_ms > 0); + } + #[test] fn generation_recycles_after_request_budget() { let backend = backend("rotation-test", 18081); diff --git a/tools/checks/appearance_ux_0323_browser.py b/tools/checks/appearance_ux_0323_browser.py index 6db8347..539bb87 100755 --- a/tools/checks/appearance_ux_0323_browser.py +++ b/tools/checks/appearance_ux_0323_browser.py @@ -65,6 +65,18 @@ with sync_playwright() as playwright: assert page.locator('#test-action').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'button') assert page.locator('#test-input').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'input') page.evaluate('document.documentElement.dataset.theme = "dark"; document.documentElement.dataset.cornerStyle = "default"') + # The last glyph must have room before the outlined logo's right edge, + # including the 34px mobile variant; the slash-to-p spacing is unchanged. + for width in (1320, 790, 450, 345): + page.set_viewport_size({'width': width, 'height': 950}) + inset = page.locator('#test-logo').evaluate('''mark => { + const p = [...mark.childNodes].find(node => node.nodeType === 3 && node.textContent.trim() === 'p'); + const range = document.createRange(); + range.selectNodeContents(p); + return mark.getBoundingClientRect().right - range.getBoundingClientRect().right; + }''') + assert inset >= 4, (width, inset) + page.set_viewport_size({'width': 1320, 'height': 950}) original_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor') page.evaluate('document.documentElement.dataset.theme = "ultra_dark"') black_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor') diff --git a/tools/checks/backend_dashboard_0323_ui_test.mjs b/tools/checks/backend_dashboard_0323_ui_test.mjs index 9ac6e3c..cf8deed 100644 --- a/tools/checks/backend_dashboard_0323_ui_test.mjs +++ b/tools/checks/backend_dashboard_0323_ui_test.mjs @@ -29,6 +29,7 @@ const ctx = { bytes: v => `${v} B`, currentPanel: 'overview', performance: { now: () => now }, }; vm.createContext(ctx); +vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx); vm.runInContext(source, ctx); const module = window.rustProxyBackendDashboard; const data = { diff --git a/tools/checks/backend_protocol_0326_ui_test.mjs b/tools/checks/backend_protocol_0326_ui_test.mjs new file mode 100644 index 0000000..f5dabb4 --- /dev/null +++ b/tools/checks/backend_protocol_0326_ui_test.mjs @@ -0,0 +1,54 @@ +// Verifies real-status-to-badge mapping without guessing a configured Auto result. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8'); +const block = source.slice(source.indexOf('const VHOST_PROTOCOL_FRESH_MS = '), source.indexOf('function render() {')); +assert(block.includes('function renderUpstreamProtocolBadges(status)')); +const rustClient = fs.readFileSync('src/upstream/client.rs', 'utf8'); +const rustPool = fs.readFileSync('src/balance/pool_view.rs', 'utf8'); +assert(rustClient.includes('self.inner.observe_protocol(value.version())')); +assert(rustClient.includes('let response = client.request(request).await;')); +for (const name of ['upstream_protocol_observed','upstream_protocol_last_seen_ms','upstream_protocol_http1_total','upstream_protocol_http2_total']) { + assert(rustPool.includes(`"${name}"`), name); +} +const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations; +const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations; +for(const key of ['vhBackendProtocolRecent','vhBackendProtocolUnobserved']) assert(en[key] && pl[key]); +const badge = (vhost, backend) => ({dataset:{upstreamProtocolVhost:vhost,upstreamProtocolBackend:backend},textContent:'',title:''}); +const visible = [badge('api','auto-backend'),badge('api','h1-backend'),badge('other','auto-backend')]; +const envelope = {config:{vhosts:[ + {id:'api',backends:[{id:'auto-backend',protocol:'auto'},{id:'h1-backend',protocol:'http1'}]}, + {id:'other',backends:[{id:'auto-backend',protocol:'auto'}]}, +]}}; +const ctx = {Date, Number, String, Map, envelope, document:{querySelectorAll:()=>visible},t:key=>en[key]}; +vm.createContext(ctx); +vm.runInContext(block, ctx); +const recent = Date.now() - 2000; +let status = {backend_pools:[{id:'api',members:[ + {id:'auto-backend',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:recent,upstream_protocol_http1_total:5,upstream_protocol_http2_total:9}, + {id:'h1-backend',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:recent,upstream_protocol_http1_total:12}, +]}]}; +ctx.renderUpstreamProtocolBadges(status); +assert.equal(visible[0].textContent,'auto-backend · AUTO → H2'); +assert.equal(visible[0].dataset.observed,'true'); +assert(visible[0].title.includes('H1: 5; H2: 9')); +assert.equal(visible[1].textContent,'h1-backend · H1 → H1'); +assert.equal(visible[2].textContent,'auto-backend · AUTO → ?'); +assert.equal(visible[2].dataset.observed,'false'); +// Old/stale response must not masquerade as a recent ALPN observation. +status.backend_pools[0].members[0].upstream_protocol_last_seen_ms = Date.now() - 3600000; +ctx.renderUpstreamProtocolBadges(status); +assert.equal(visible[0].textContent,'auto-backend · AUTO → ?'); +assert.equal(visible[0].dataset.observed,'false'); +assert(visible[0].title.startsWith(en.vhBackendProtocolUnobserved)); +// Fresh data for a similarly named backend from another vhost must not leak. +ctx.renderUpstreamProtocolBadges({backend_pools:[{id:'other',members:[ + {id:'auto-backend',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:recent} +]}]}); +assert.equal(visible[0].textContent,'auto-backend · AUTO → ?'); +assert.equal(visible[2].textContent,'auto-backend · AUTO → H1'); +ctx.renderUpstreamProtocolBadges({backend_pools:[]}); +assert.equal(visible[2].textContent,'auto-backend · AUTO → ?'); +console.log('PASS: observed Auto/H1/H2 badges, freshness, cross-vhost isolation and unknown data'); diff --git a/tools/checks/certificate_api_0319.py b/tools/checks/certificate_api_0319.py index 3a42337..3533dd8 100755 --- a/tools/checks/certificate_api_0319.py +++ b/tools/checks/certificate_api_0319.py @@ -7,7 +7,7 @@ import tomllib root = Path(__file__).resolve().parents[2] read = lambda path: (root / path).read_text(encoding='utf-8') version = tomllib.loads(read('Cargo.toml'))['package']['version'] -assert version == '0.32.5', version +assert version == '0.32.6', version spec = json.loads(read('src/admin/openapi.json')) assert spec['info']['version'] == version paths = spec['paths'] @@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'): for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate', 'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'): assert strings.get(key), (language, key) -assert '0.32.5' in read('docs/TEST_REPORT.md') +assert '0.32.6' in read('docs/TEST_REPORT.md') print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).') diff --git a/tools/checks/check_source.py b/tools/checks/check_source.py index 69b957b..96a4c81 100755 --- a/tools/checks/check_source.py +++ b/tools/checks/check_source.py @@ -24,7 +24,7 @@ def read_docker_agent_source() -> str: preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.32.5" +assert manifest["package"]["version"] == "0.32.6" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" h3dep = manifest["dependencies"]["h3"] assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"] @@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"] lock_path = root / "Cargo.lock" if lock_path.exists(): lock = lock_path.read_text() - assert 'name = "rust-proxy"\nversion = "0.32.5"' in lock + assert 'name = "rust-proxy"\nversion = "0.32.6"' in lock assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries" for release_doc, marker in { - "README.md": "# RustProxy 0.32.5", - "CHANGELOG.md": "## 0.32.5", - "ROADMAP.md": "## 0.32.5", - "AGENTS.md": "Current source version: 0.32.5.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.5", + "README.md": "# RustProxy 0.32.6", + "CHANGELOG.md": "## 0.32.6", + "ROADMAP.md": "## 0.32.6", + "AGENTS.md": "Current source version: 0.32.6.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.6", }.items(): assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable" @@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi assert '"StreamMetrics"' in openapi and '"streams"' in openapi -for marker in ['"version": "0.32.5"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: +for marker in ['"version": "0.32.6"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: assert marker in openapi, marker for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: assert marker in openapi, marker @@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log" assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241 assert 'location.pathname === "/logging"' in settings_js_0241 -assert '"RuntimeInformation"' in openapi and '"version": "0.32.5"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.32.6"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. tls_0241 = rust_source(root, "src/tls.rs") diff --git a/tools/checks/dashboard_appearance_ribbon_0326_browser.py b/tools/checks/dashboard_appearance_ribbon_0326_browser.py new file mode 100755 index 0000000..e82890a --- /dev/null +++ b/tools/checks/dashboard_appearance_ribbon_0326_browser.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Optional real-CSS/JS Chromium regression for the horizontal Overview card ribbon. + +Mocks authenticated preferences; no server or physical iOS device involved. +Run with: python tools/checks/dashboard_appearance_ribbon_0326_browser.py +""" +from pathlib import Path +from playwright.sync_api import sync_playwright +import shutil +import json +root=Path(__file__).resolve().parents[2] +html=(root/'src/admin/web/index.html').read_text() +css=(root/'src/admin/web/style.css').read_text() +prefs=(root/'src/admin/web/app/preferences.js').read_text() +registry=json.loads((root/'src/admin/web/dashboard-widgets.json').read_text()) +translations=json.loads((root/'langs/pl_pl.json').read_text())['translations'] +heading=html.index('data-i18n="dashboardLayout"') +section_start=html.rfind('
',0,heading) +section_end=html.index('
',heading)+len('') +assert section_start >= 0 +source=html[section_start:section_end] +start=prefs.index('function renderDashboardWidgetSettings() {') +end=prefs.index('function refreshChartStylePalette(',start) +code=prefs[start:end] +stub=''' +const $ = id => document.getElementById(id); +const el = (tag, txt, classes) => { let node=document.createElement(tag); if (txt!==undefined) node.textContent=String(txt); if (classes) node.className=classes; return node; }; +const labels = __TRANSLATIONS__; +const registry = __REGISTRY__; +const ADMIN_DASHBOARD_BY_ID = new Map(registry.map(def=>[def.id,def])); +const order = registry.map(def=>def.id); +let adminPreferences = {dashboard_widgets:order, dashboard_hidden_widgets:registry.filter(def=>def.default_hidden).map(def=>def.id)}; +const ADMIN_APPEARANCE_DEFAULTS = adminPreferences; +function t(key){ return labels[key] || key; } +function dashboardWidgetLabel(id) { return t(ADMIN_DASHBOARD_BY_ID.get(id)?.label_key || id); } +function normalizeDashboardWidgets(value){return Array.isArray(value)?value:order;} +function normalizeHiddenDashboardWidgets(value){return Array.isArray(value)?value:[];} +function applyDashboardLayout(order,hidden){ window.__state={order,hidden}; } +async function saveAppearancePreference(next){ Object.assign(adminPreferences,next);return {}; } +'''.replace('__TRANSLATIONS__',json.dumps(translations,ensure_ascii=False)).replace('__REGISTRY__',json.dumps(registry)) +with sync_playwright() as p: + browser=p.chromium.launch(executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),args=['--no-sandbox'],headless=True) + for width,height in [(1500,950),(393,850)]: + page=browser.new_page(viewport={'width':width,'height':height},device_scale_factor=1) + page.set_content('
'+source+'
',wait_until='domcontentloaded') + page.add_script_tag(content=stub+code+'\nrenderDashboardWidgetSettings();') + page.wait_for_timeout(90) + metrics=page.evaluate('''() => { + const list=document.getElementById('dashboard-widget-list'), section=document.querySelector('.appearance-section'); + const parent=list.closest('.dashboard-widget-settings'); + return { total: list.children.length, listHeight:list.getBoundingClientRect().height, + listWidth:list.getBoundingClientRect().width, contentWidth:list.scrollWidth, + sectionHeight:section.getBoundingClientRect().height, + overflowX:getComputedStyle(list).overflowX, + firstWidth:list.firstElementChild.getBoundingClientRect().width, + density: document.querySelector('.dashboard-density-setting').getBoundingClientRect().width, + widget:parent.getBoundingClientRect().width, + viewportOverflow:document.documentElement.scrollWidth>window.innerWidth, + nextDisabled:document.getElementById('dashboard-widget-scroll-next').disabled}; + }''') + print(width,'COMPACT',metrics) + assert metrics['total']==len(registry) + assert metrics['listHeight']<66,(width,metrics) + assert metrics['contentWidth']>metrics['listWidth'],(width,metrics) + assert metrics['sectionHeight'] < (320 if width>700 else 450), (width,metrics) + assert not metrics['viewportOverflow'] + page.locator('#dashboard-widget-scroll-next').click() + page.wait_for_timeout(450) + pos=page.evaluate('document.getElementById("dashboard-widget-list").scrollLeft') + print(width,'scroll after next:',pos) + assert pos>10 + page.locator('.dashboard-widget-setting').nth(7).locator('button.dashboard-widget-toggle').click(force=True) + page.wait_for_timeout(60) + state=page.evaluate('window.__state') + assert state and len(state['order'])==len(registry) + retained=page.evaluate('document.getElementById("dashboard-widget-list").scrollLeft') + assert retained>=pos-15,(pos,retained) + page.locator('#dashboard-widget-expand').click() + page.wait_for_timeout(60) + expanded=page.evaluate('''() => { const list = document.getElementById("dashboard-widget-list"); return { height:list.getBoundingClientRect().height, mode:getComputedStyle(list).display, count:list.children.length, expanded:document.getElementById('dashboard-widget-expand').getAttribute('aria-expanded'), prevHidden:document.getElementById('dashboard-widget-scroll-prev').hidden } }''') + print(width,'EXPANDED',expanded) + assert expanded['mode']=='grid' + assert expanded['expanded']=='true' and expanded['prevHidden'] + assert expanded['height']>metrics['listHeight']*3 + page.locator('#dashboard-widget-expand').click() + assert page.locator('#dashboard-widget-expand').get_attribute('aria-expanded')=='false' + outfile=str(root.parent / f'dashboard_appearance_0326_preview_{width}.png') + page.locator('.appearance-section').screenshot(path=outfile) + print('screenshot',outfile) + page.close() + browser.close() diff --git a/tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs b/tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs new file mode 100644 index 0000000..ad23d52 --- /dev/null +++ b/tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs @@ -0,0 +1,36 @@ +// Appearance ribbon: compact by default, with full-width expansion and the same +// persisted visibility/reorder controls. No browser or Rust service required. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const html = fs.readFileSync('src/admin/web/index.html', 'utf8'); +const css = fs.readFileSync('src/admin/web/style.css', 'utf8'); +const js = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8'); +const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8')); +const locales = ['en_US', 'pl_pl'].map(id => JSON.parse(fs.readFileSync(`langs/${id}.json`, 'utf8')).translations); +for (const id of ['dashboard-widget-list', 'dashboard-widget-expand', 'dashboard-widget-scroll-prev', + 'dashboard-widget-scroll-next', 'overview-layout-open-settings', 'ui-dashboard-density']) { + assert(html.includes(`id="${id}"`), `missing ${id}`); +} +assert.match(html, /class="appearance-settings-grid dashboard-appearance-grid"/); +assert.match(css, /\.appearance-settings-grid\.dashboard-appearance-grid\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s, + 'dashboard setting layout must use the full available width'); +assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*overflow-x:\s*auto;/s, + 'the default card selector must be a horizontal ribbon, not a tall two-column list'); +assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\.is-expanded\s*\{[^}]*display:\s*grid;/s); +assert.match(css, /\.dashboard-widget-settings-controls > \.dashboard-widget-scroll-button/); +assert(js.includes('list.scrollLeft = previousScroll'), 'rebuilding settings must preserve horizontal scroll position'); +assert(js.includes('focusedId') && js.includes('preventScroll: true'), 'keyboard focus must survive updates'); +assert(js.includes('saveAppearancePreference({ dashboard_hidden_widgets:') && + js.includes('saveAppearancePreference({ dashboard_widgets:'), 'both APIs must retain persistence'); +assert(js.includes('const up = el("button", "←"') && js.includes('const down = el("button", "→"'), + 'horizontal order controls must align with the ribbon'); +assert.equal(registry.length, 62, 'all configured Overview cards must remain available'); +for (const translation of locales) { + for (const key of ['dashboardWidgetsShowAll', 'dashboardWidgetsCompact', 'dashboardWidgetsScrollBack', + 'dashboardWidgetsScrollNext', 'dashboardWidgetsScrollHelp', + 'dashboardWidgetsMoveEarlier', 'dashboardWidgetsMoveLater']) { + assert(translation[key], `missing ${key}`); + } +} +console.log('PASS: full-width compact Appearance ribbon with expandable 62-card layout, keyboard focus and preference persistence'); diff --git a/tools/checks/dependency_upgrade_0312.py b/tools/checks/dependency_upgrade_0312.py index db59991..3f6d7ea 100755 --- a/tools/checks/dependency_upgrade_0312.py +++ b/tools/checks/dependency_upgrade_0312.py @@ -14,7 +14,7 @@ parser = argparse.ArgumentParser() parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update') args = parser.parse_args() manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text()) -assert manifest['package']['version'] == '0.32.5' +assert manifest['package']['version'] == '0.32.6' assert manifest['package']['rust-version'] == '1.99' deps = manifest['dependencies'] expected_requirements = { diff --git a/tools/checks/docs_check.py b/tools/checks/docs_check.py index d3041dd..e1c7b43 100755 --- a/tools/checks/docs_check.py +++ b/tools/checks/docs_check.py @@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin readme = (ROOT / "README.md").read_text(encoding="utf-8") roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8") changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") -assert readme.startswith("# RustProxy 0.32.5") +assert readme.startswith("# RustProxy 0.32.6") assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive" assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme diff --git a/tools/checks/dynamic_acl_0325.py b/tools/checks/dynamic_acl_0325.py index 6f8d923..22c654c 100755 --- a/tools/checks/dynamic_acl_0325.py +++ b/tools/checks/dynamic_acl_0325.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Static source contract for DDNS ACL 0.32.5; NOT a Rust test.""" +"""Static source contract for DDNS ACL 0.32.6; NOT a Rust test.""" from pathlib import Path import json import tomllib @@ -25,8 +25,8 @@ checks = { 'visual selector and disabled DNS deny': '.acl-profile-rule-type' in js and 'aclDnsAllowOnly' in js, 'old network field persisted': 'network: normalizeNetwork(' in js, 'PL/EN translations': all(k in json.loads(read(f'langs/{loc}.json'))['translations'] for loc in ('en_US','pl_pl') for k in ('aclRuleType','aclRuleDns','aclDnsHelp','aclDnsAllowOnly')), - 'package version': tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.5', + 'package version': tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.6', } for name, ok in checks.items(): if not ok: raise AssertionError(name) -print(f'PASS: {len(checks)} DNS ACL 0.32.5 static source checks (not compiled or network-tested)') +print(f'PASS: {len(checks)} DNS ACL 0.32.6 static source checks (not compiled or network-tested)') diff --git a/tools/checks/h3_navigation_0316.py b/tools/checks/h3_navigation_0316.py index a2f43db..6859574 100755 --- a/tools/checks/h3_navigation_0316.py +++ b/tools/checks/h3_navigation_0316.py @@ -4,7 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] html = (ROOT / 'src/admin/web/index.html').read_text() -assert '0.32.5' in (ROOT / "Cargo.toml").read_text() +assert '0.32.6' in (ROOT / "Cargo.toml").read_text() assert '',1)[0] body = (ROOT / 'src/proxy/cache_helpers.rs').read_text() diff --git a/tools/checks/ios_scroll_0326_browser.py b/tools/checks/ios_scroll_0326_browser.py new file mode 100755 index 0000000..c72592c --- /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(live.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/live_diagnostics_0326_browser.py b/tools/checks/live_diagnostics_0326_browser.py new file mode 100755 index 0000000..4e30b93 --- /dev/null +++ b/tools/checks/live_diagnostics_0326_browser.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Real diagnostic renderer/CSS in Chromium mobile emulation (not iOS Safari).""" +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' +source = (WEB / 'app/runtime.js').read_text() +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 +# Run the real renderDiagnostics and interface component as shipped, without +# creating a fake SSE server or depending on Rust/Cargo in this test environment. +start = source.index('let systemSamples =') +end = source.index('function renderHeaderRules(', start) +renderer = source[start:end] +html = ('' + '
Earlier dashboard cards
' + '
' + + str(process) + str(network) + '
' + '
H1' + 'AUTO → H2
' + '
Footer
') +stubs = ''' +const dummy = new Map(); +const $ = id => document.getElementById(id) || (() => { + if (!dummy.has(id)) dummy.set(id, { textContent:'-', value:'', hidden:false, + classList:{ toggle(){} }, setAttribute(){}, removeAttribute(){}, append(){}, + replaceChildren(){}, closest(){ return {hidden:false}; } }); + return dummy.get(id); +})(); +const envelope = {config:{cache:{enabled:true,cold_enabled:false}}}; +const t = key => ({networkInterfacesVisibleCount:'{visible} / {total}', + networkInterfaceRate:'Rate',networkInterfacesHistory:'Traffic',hideMetric:'Hide', + showMetric:'Show',hiddenInterfaces:'Hidden',networkNoInterfaces:'None', + networkNoVisibleInterfaces:'None visible',notEnabled:'Disabled', + activeListeners:'Listeners',restartPending:'Restart',realSample:'Sampled', + threads:'threads',unavailable:'Unavailable',unlimited:'Unlimited'})[key] || key; +const bytes = value => `${Number(value)} B`; +const locale = () => 'en-US'; +const uptime = seconds => `${seconds}s`; +const formatDateTime = seconds => String(seconds); +const action = (text, fn) => { const node=document.createElement('button'); node.textContent=text; + node.addEventListener('click',fn); return node; }; +const el = (tag, text, cls) => {const node=document.createElement(tag); + if(text!==undefined) node.textContent=text; if(cls) node.className=cls; return node;}; +const updateCacheMemorySizing = () => {}; +const rustProxyRateSampling = {cadence:()=>3,difference:(previous,values,at)=>({next:{values,at}, + deltas:previous?values.map((v,i)=>v-previous.values[i]):null,seconds:3})}; +''' + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox']) + ctx = browser.new_context(viewport={'width':393,'height':844}, device_scale_factor=3, + is_mobile=True, has_touch=True) + page = ctx.new_page() + page.set_content(html, wait_until='domcontentloaded') + page.on('pageerror', lambda error: print('BROWSER ERROR:', error)) + page.add_script_tag(content=stubs + renderer) + result = page.evaluate('''() => { + const system = {source:'sysinfo', platform:'Linux', process_threads:5, + process_cpu_percent:5,process_rss_bytes:100,cpu_percent:15, + network_rx_bytes_per_second:20, network_tx_bytes_per_second:10, + sampled_at_ms:Date.now(),network_interfaces:[ + {name:'eth0',rx_bytes_per_second:10,tx_bytes_per_second:5}]}; + const s = {system,cache:{hits:50,misses:30,bypasses:10,resident_bytes:4096}, + metrics:{http3_requests:2,mean_duration_ms:0.5},uptime_seconds:100, + listeners:{active_endpoints:[]},proxy_listen:null,tls_listen:null}; + renderDiagnostics(s,true,true); + const process = document.querySelector('[data-dashboard-widget="process_diagnostics"]'); + const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]'); + const target = document.getElementById('network-interface-charts'); + const firstRow = target.firstElementChild; + const initialDetail = document.getElementById('process-cpu').textContent; + const initialHistory = processCpuSamples.length; + window.scrollTo(0, Math.round(network.getBoundingClientRect().top + window.scrollY + 125)); + const y = window.scrollY, height = process.getBoundingClientRect().height; + for (let i=0; i<35; i++) { + s.live_system = {sampled_at_ms:Date.now(),process_cpu_percent:7+i, + process_rss_bytes:150+i, cpu_percent:22+i, + network_rx_bytes_per_second:100+i*20,network_tx_bytes_per_second:10+i, + network_interfaces:[{name:'eth0',rx_bytes_per_second:100+i*20, + tx_bytes_per_second:10+i}]}; + renderDiagnostics(s,true,false); + } + const unchangedDetails = document.getElementById('process-cpu').textContent === initialDetail; + const recentChart = document.getElementById('process-cpu-chart-value').textContent; + const iface = target.querySelector('.network-interface-rate').textContent; + const beforeSystem = processCpuSamples.length; + const y2 = window.scrollY; + s.system = {...system, process_cpu_percent:93,process_rss_bytes:3000, + network_rx_bytes_per_second:9000, network_tx_bytes_per_second:900}; + renderDiagnostics(s,false,true); + const slowlyUpdated = document.getElementById('process-cpu').textContent; + const pill = document.querySelector('.pill'); + const origin = document.querySelector('.vhost-backend-protocol-badge'); + const roundedOrigin = getComputedStyle(origin).borderTopLeftRadius; + document.documentElement.dataset.cornerStyle = 'default'; + const defaultPillRadius = getComputedStyle(pill).borderTopLeftRadius; + const defaultOriginRadius = getComputedStyle(origin).borderTopLeftRadius; + document.documentElement.dataset.cornerStyle = 'rounded'; + return {initialDetail,initialHistory,unchangedDetails,recentChart,iface, + beforeSystem,afterSystem:processCpuSamples.length,slowlyUpdated, + rowStable:target.firstElementChild===firstRow, + heightDelta:process.getBoundingClientRect().height-height, + scrollDelta:y2-y,postSystemScrollDelta:window.scrollY-y, + nestedScroll:getComputedStyle(target).overflowY, + nestedMaxHeight:getComputedStyle(target).maxHeight, + pillRadius:getComputedStyle(pill).borderTopLeftRadius, + originRadius:roundedOrigin,defaultPillRadius,defaultOriginRadius, + originColor:getComputedStyle(origin).color}; + }''') + assert result['initialDetail'] == '5%', result + assert result['initialHistory'] == 1, result + assert result['unchangedDetails'] and result['recentChart'] == '41%', result + assert result['beforeSystem'] == 36 == result['afterSystem'], result + assert result['slowlyUpdated'] == '93%', result + assert result['rowStable'] and '780 B' in result['iface'], result + assert abs(result['heightDelta']) < 2 and abs(result['scrollDelta']) < 2, result + assert abs(result['postSystemScrollDelta']) < 2, result + assert result['nestedScroll'] == 'visible' and result['nestedMaxHeight'] == 'none', result + assert result['pillRadius'] == '8px' and result['originRadius'] == '8px', result + assert result['defaultPillRadius'] == result['defaultOriginRadius'], result + assert result['originColor'] != '', result + print('PASS: 35 live CPU/RSS/network samples, slow Process metadata, stable mobile layout/scroll, badge radii', result) + browser.close() diff --git a/tools/checks/mobile_protocol_0326.py b/tools/checks/mobile_protocol_0326.py new file mode 100755 index 0000000..584bb24 --- /dev/null +++ b/tools/checks/mobile_protocol_0326.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Source-only mobile/observed-backend UI invariants, no browser required.""" +from pathlib import Path +root=Path(__file__).resolve().parents[2] +css=(root/'src/admin/web/style.css').read_text() +preferences=(root/'src/admin/web/app/preferences.js').read_text() +masonry=(root/'src/admin/web/app/overview-masonry.js').read_text() +html=(root/'src/admin/web/index.html').read_text() +render=(root/'src/admin/web/vhosts/render.js').read_text() +client=(root/'src/upstream/client.rs').read_text() +pool=(root/'src/balance/pool_view.rs').read_text() +assert 'touch-action: pan-y pinch-zoom' in css +assert 'overflow-anchor: none' in css +assert 'grid-template-columns: repeat(2, minmax(0, 1fr))' in css +assert '@container overview-canvas (max-width: 700px)' in css +assert 'overview-canvas (max-width: 440px)' not in css +assert '.vhost-backend-protocol-badge' in css +assert '.vhost-card' in css[css.index('/* Global shape preference.'):] +assert '.draft-dock,' not in css[css.index('/* Global shape preference.'):] +assert 'grid.append(...orderedNodes)' in preferences +assert 'orderedNodes.some((node, index) => node !== currentNodes[index])' in preferences +assert 'if (ordered.some((node, i) => node !== previous[i])) grid.append(...ordered);' in masonry +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/mobile_protocol_0326_browser.py b/tools/checks/mobile_protocol_0326_browser.py new file mode 100755 index 0000000..7b7bf7d --- /dev/null +++ b/tools/checks/mobile_protocol_0326_browser.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Mobile Dashboard scroll/layout and appearance browser regression. + +Uses the shipped static web assets in a Chromium touch-device emulation, not +physical Safari, the authenticated server or a running Rust backend. +""" +from pathlib import Path +import json +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') +widgets = soup.select('#dashboard-overview-grid > [data-dashboard-widget]') +assert len(widgets) == 62 +widget_ids = [widget['data-dashboard-widget'] for widget in widgets] +css = (WEB / 'style.css').read_text() +source = (WEB / 'app/preferences.js').read_text() +layout_code = source.split('function applyDashboardLayout(order, hidden) {')[1].split('function dashboardWidgetLabel(')[0] +html = ('' + '
Mobile navigation
' + '
' + '
' + + ''.join(map(str, widgets)) + '
' + '
Vhost
' + '
Entity
' + 'AUTO → H2
' + '') + +with sync_playwright() as browser_tool: + chromium = browser_tool.chromium.launch(headless=True, + executable_path=shutil.which('chromium') or shutil.which('chromium-browser'), + args=['--no-sandbox']) + context = chromium.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=(WEB / 'app/overview-masonry.js').read_text()) + preference_fixture = ( + 'const ADMIN_DASHBOARD_WIDGETS=' + json.dumps(widget_ids) + ';' + + 'const ADMIN_DEFAULT_HIDDEN_WIDGETS=[];' + + 'const latestStatus=null; const currentPanel="overview";' + + 'const normalizeDashboardWidgets=(ids)=>ids;' + + 'const normalizeHiddenDashboardWidgets=(ids)=>ids;' + + 'function applyDashboardLayout(order, hidden) {' + layout_code + ) + page.add_script_tag(content=preference_fixture) + for width in (320, 375, 393, 402, 430, 550): + page.set_viewport_size({'width': width, 'height': 844}) + snapshot = page.evaluate('''() => { + const grid=document.getElementById('dashboard-overview-grid'); + const cards=[...grid.querySelectorAll('[data-dashboard-widget].stat')] + .filter(node => !node.hidden).slice(0,6).map(node => { + const r=node.getBoundingClientRect(); + return {left:r.left,top:r.top,right:r.right,bottom:r.bottom,width:r.width}; + }); + return {columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length, + cards,gridWidth:grid.clientWidth,gridScroll:grid.scrollWidth, + screenScroll:document.documentElement.scrollWidth, + screen:innerWidth, + touch:getComputedStyle(grid.querySelector('.stat')).touchAction}; + }''') + a,b,c,d,e,f = snapshot['cards'] + assert snapshot['columns'] == 2, (width, snapshot) + assert abs(a['top']-b['top']) < 1 and abs(c['top']-d['top']) < 1 + assert abs(e['top']-f['top']) < 1 and c['top'] > a['top'] and e['top'] > c['top'] + assert b['left'] >= a['right'] and d['left'] >= c['right'] + assert a['width'] > 50 and b['width'] > 50 + assert snapshot['gridScroll'] <= snapshot['gridWidth']+2, (width,snapshot) + assert snapshot['screenScroll'] <= snapshot['screen']+2, (width,snapshot) + assert 'pan-y' in snapshot['touch'] + action = page.evaluate('''() => { + const tile=document.querySelector('.stat-with-action'); + const label=tile.querySelector(':scope > span').getBoundingClientRect(); + const button=tile.querySelector('.stat-corner-action').getBoundingClientRect(); + const value=tile.querySelector('strong').getBoundingClientRect(); + const card=tile.getBoundingClientRect(); + return {labelBottom:label.bottom,buttonTop:button.top, + buttonBottom:button.bottom,valueTop:value.top, + buttonRight:button.right,cardRight:card.right}; + }''') + assert action['buttonTop'] >= action['labelBottom'] - 2, (width,action) + assert action['valueTop'] >= action['buttonBottom'] - 2, (width,action) + assert action['buttonRight'] <= action['cardRight'] + 1, (width,action) + page.evaluate('window.scrollTo(0,350)') + before = page.evaluate('window.scrollY') + assert before > 0, width + page.evaluate('''() => { + const grid=document.getElementById('dashboard-overview-grid'); + window.rustProxyMasonry.refresh(); + for(const [index,node] of [...grid.querySelectorAll('.stat strong')].entries()) { + node.textContent=index%2 ? '26216' : '49,750'; + } + }''') + after = page.evaluate('window.scrollY') + assert abs(after - before) <= 2, (width, before, after) + # Reapplying an unchanged preference must NOT reparent the grid children. + moves = page.evaluate('''() => { + const grid=document.getElementById('dashboard-overview-grid'); + const order=[...grid.children].map(n=>n.dataset.dashboardWidget); + let moves=0; + const observer=new MutationObserver(items => { moves+=items.filter(i=>i.type==='childList').length; }); + observer.observe(grid,{childList:true}); + applyDashboardLayout(order,[]); + observer.takeRecords().forEach(i=>{if(i.type==='childList')moves++}); + observer.disconnect(); + return moves; + }''') + assert moves == 0, (width, moves) + assert abs(page.evaluate('window.scrollY')-before) <= 2 + print(f'PASS: {width}px touch emulation, two metric columns, no overflow or scroll jumps') + for style,surface,control in (('square','0px','0px'), ('subtle','5px','4px'), + ('rounded','12px','8px'), ('extra','18px','12px')): + page.evaluate('(s) => document.documentElement.dataset.cornerStyle=s', style) + for selector in ('#vhost-test','#entity-test','.stat'): + radius = page.locator(selector).first.evaluate('node=>getComputedStyle(node).borderTopLeftRadius') + assert radius == surface, (style,selector,radius) + radius = page.locator('#badge-test').evaluate('node=>getComputedStyle(node).borderTopLeftRadius') + assert radius == control, (style,'origin protocol badge',radius) + print('PASS: vhost, entity, badge/control and metric corner presets') + chromium.close() diff --git a/tools/checks/overview_extra_metrics_0323_ui_test.mjs b/tools/checks/overview_extra_metrics_0323_ui_test.mjs index 3b65f61..4c27437 100644 --- a/tools/checks/overview_extra_metrics_0323_ui_test.mjs +++ b/tools/checks/overview_extra_metrics_0323_ui_test.mjs @@ -21,6 +21,7 @@ const context = { document: { querySelector: selector => widgets.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null }, }; vm.createContext(context); +vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context); vm.runInContext(source, context); const render = context.window.rustProxyExtraOverview.render; const text = id => field(`overview-extra-${id}`).textContent; diff --git a/tools/checks/overview_grid_spacing_0323_browser.py b/tools/checks/overview_grid_spacing_0323_browser.py index a92d78a..be020dd 100755 --- a/tools/checks/overview_grid_spacing_0323_browser.py +++ b/tools/checks/overview_grid_spacing_0323_browser.py @@ -147,7 +147,7 @@ with sync_playwright() as playwright: cases=[(1720,0,1,'normal',12),(1350,180,1,'compact',12), (1200,260,1,'normal',6),(900,0,1,'compact',6), (900,190,1,'normal',6),(720,190,1,'ultra',2), - (620,0,1,'compact',2),(430,0,1,'ultra',1), + (620,0,1,'compact',2),(430,0,1,'ultra',2), (1140,200,.9,'compact',6),(1140,200,1.1,'normal',6), (1140,200,1.3,'normal',6)] for width,sidebar,zoom,density,max_columns in cases: diff --git a/tools/checks/overview_wide_masonry_0323_ui_test.mjs b/tools/checks/overview_wide_masonry_0323_ui_test.mjs index 4bf2e0a..52fe60d 100644 --- a/tools/checks/overview_wide_masonry_0323_ui_test.mjs +++ b/tools/checks/overview_wide_masonry_0323_ui_test.mjs @@ -16,7 +16,8 @@ function $(id) { let now = 1000; const ctx = {window:{},currentPanel:'overview',performance:{now:()=>now},fmt:(n,places=0)=>Number(n).toFixed(places),fmtBytes:n=>`${n} B`,$, document:{querySelector:selector=>nodes.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1])||null}}; -vm.createContext(ctx);vm.runInContext(source,ctx); +vm.createContext(ctx);vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx); +vm.runInContext(source,ctx); const render=ctx.window.rustProxyWideOverview.render; const text=id=>$(id).textContent; const baseline = { revision:1, diff --git a/tools/checks/preserve_host_0324.py b/tools/checks/preserve_host_0324.py index 6d5f16d..7093fd1 100755 --- a/tools/checks/preserve_host_0324.py +++ b/tools/checks/preserve_host_0324.py @@ -51,6 +51,6 @@ for marker in ['margin: 0;', '--overview-gap: max(3px, calc(var(--ui-gap) - 1px) retry = re.search(r'fn retry_request\([\s\S]*?\n}\n', proxy) assert retry and '.uri(uri.clone())' in retry[0] assert 'upstream_request_target(&uri, request.headers_mut()' in retry[0] -assert json.loads(read('src/admin/openapi.json'))['info']['version']=='0.32.5' -assert 'version = "0.32.5"' in read('Cargo.toml') -print('PASS: 0.32.5 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)') +assert json.loads(read('src/admin/openapi.json'))['info']['version']=='0.32.6' +assert 'version = "0.32.6"' in read('Cargo.toml') +print('PASS: 0.32.6 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)') diff --git a/tools/checks/rate_pause_0326_ui_test.mjs b/tools/checks/rate_pause_0326_ui_test.mjs new file mode 100644 index 0000000..a877e03 --- /dev/null +++ b/tools/checks/rate_pause_0326_ui_test.mjs @@ -0,0 +1,126 @@ +// Counter-derived chart regression: pauses must not become fake 0/s samples. +// Real Cache / Transform chart functions run against simulated DOM and clock. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const samplingSource = fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'); +const panels = { + cache: { + path: 'src/admin/web/cache-panel.js', stop: ' function configure()', + counters: (hit, miss) => ({ hits: hit, misses: miss, bypasses: 0 }), + graph: 'cache-live-hit-line', rate: 'cache-live-hit-rate', + keys: ['hits', 'misses', 'bypasses'], seconds: 5, + }, + transform: { + path: 'src/admin/web/transform-panel.js', stop: ' function countersFor(data)', + counters: (hit, miss) => ({ applied: hit, cache_hits: miss, skipped: 0, errors: 0 }), + graph: 'transform-live-applied-line', rate: 'transform-live-applied-rate', + keys: ['applied', 'cache_hits', 'skipped', 'errors'], seconds: 3, + }, +}; + +function fixture(name, config) { + const source = fs.readFileSync(config.path, 'utf8'); + const end = source.indexOf(config.stop); + assert(end > 0, `unable to isolate ${name} live chart`); + const documentListeners = new Map(), windowListeners = new Map(), nodes = new Map(); + let now = 1000; + const node = id => { + if (!nodes.has(id)) nodes.set(id, { + value: id.endsWith('-scope') ? '*' : '', textContent: '', + attrs: {}, setAttribute(key, value) { this.attrs[key] = value; }, + }); + return nodes.get(id); + }; + const document = { + hidden: false, + addEventListener(name, listener) { documentListeners.set(name, listener); }, + }; + const window = { + addEventListener(name, listener) { windowListeners.set(name, listener); }, + rustProxyRefresh: { current: () => ({ overview_seconds: 2, cache_seconds: config.seconds }) }, + }; + const ctx = { + document, window, $: node, performance: { now: () => now }, + sparklinePoints: samples => samples.join(','), + }; + vm.createContext(ctx); + vm.runInContext(samplingSource, ctx); + vm.runInContext(source.slice(0, end) + '\nwindow.liveChartTest = { renderLiveChart, resetLiveChart, liveChartSamples };\n})();', ctx); + const chart = window.liveChartTest; + const line = () => node(config.graph).attrs.points; + const rate = () => node(config.rate).textContent; + const length = () => chart.liveChartSamples[config.keys[0]].length; + const sample = (stamp, hits, misses) => { + now = stamp; + chart.renderLiveChart(config.counters(hits, misses)); + }; + return { ctx, chart, line, rate, length, sample, document, documentListeners, windowListeners }; +} + +for (const [name, config] of Object.entries(panels)) { + const f = fixture(name, config); + f.sample(1000, 100, 50); // no deltas exist before the first observation + assert.equal(f.length(), 0, `${name}: first reading is a baseline, not 0/s`); + assert.equal(f.rate(), '-'); + f.sample(1000 + config.seconds * 1000, 130, 65); + assert.equal(f.rate(), `${(30 / config.seconds).toFixed(1)}/s`); + assert.equal(f.length(), 1); + f.sample(1000 + config.seconds * 2000, 130, 65); + assert.equal(f.rate(), '0.0/s', `${name}: real idle interval is still zero`); + assert.equal(f.length(), 2); + + const graphBefore = f.line(); + const delayed = 1000 + config.seconds * 2000 + 120000; + f.sample(delayed, 500, 200); + assert.equal(f.rate(), '-', `${name}: long pause cannot be averaged into a misleading rate`); + assert.equal(f.length(), 2, `${name}: do not append fake drop`); + assert.equal(f.line(), graphBefore, `${name}: keep old chart path`); + f.sample(delayed + config.seconds * 1000, 530, 215); + assert.equal(f.rate(), `${(30 / config.seconds).toFixed(1)}/s`, `${name}: next fresh interval resumes`); + assert.equal(f.length(), 3); + + f.document.hidden = true; + f.documentListeners.get('visibilitychange')?.(); + f.document.hidden = false; + f.documentListeners.get('visibilitychange')?.(); + const afterBackground = delayed + config.seconds * 1000 + 200; + f.sample(afterBackground, 533, 219); + assert.equal(f.length(), 3, `${name}: even a short background hide must not append a rate`); + assert.equal(f.rate(), '-'); + f.sample(afterBackground + config.seconds * 1000, 533, 219); + assert.equal(f.rate(), '0.0/s', `${name}: genuine zero after resume`); + assert.equal(f.length(), 4); + + f.windowListeners.get('pagehide')?.(); + f.sample(afterBackground + config.seconds * 2000, 540, 220); + assert.equal(f.length(), 4, `${name}: pagehide / BFCache must rebase`); + f.sample(afterBackground + config.seconds * 3000, 550, 230); + assert.equal(f.length(), 5); + f.sample(afterBackground + config.seconds * 4000, 1, 1); + assert.equal(f.length(), 5, `${name}: counters reset must not be interpreted as 0/s`); + f.sample(afterBackground + config.seconds * 5000, 3, 3); + assert.equal(f.length(), 6, `${name}: counter reset recovery`); +} + +// All remaining rate charts share this exact continuity check, including +// configured 30-second Cache refresh (not falsely treated as a pause). +{ + const listeners = new Map(), document = { hidden: false, addEventListener(key, fn) { listeners.set(key, fn); } }; + const context = { window: {}, document }; + vm.createContext(context); + vm.runInContext(samplingSource, context); + const sample = vm.runInContext('rustProxyRateSampling', context); + let baseline = sample.difference(null, [100], 1000, 30).next; + let value = sample.difference(baseline, [130], 31000, 30); + assert.equal(value.deltas[0], 30, '30-second chart interval is valid'); + baseline = value.next; + value = sample.difference(baseline, [200], 124000, 30); + assert.equal(value.deltas, null, 'large delay is a gap'); + document.hidden = true; + listeners.get('visibilitychange')?.(); + document.hidden = false; + assert.equal(sample.continuous(value.next, 124001, 30), false, 'hide invalidates even a short elapsed period'); +} +console.log('PASS: Cache/Transform history survives hidden tabs, long pauses, BFCache and counter resets; real 0/s and configured cadence remain intact.'); diff --git a/tools/checks/refresh_0310_ui_test.mjs b/tools/checks/refresh_0310_ui_test.mjs index 9f05e59..cef8615 100644 --- a/tools/checks/refresh_0310_ui_test.mjs +++ b/tools/checks/refresh_0310_ui_test.mjs @@ -95,7 +95,7 @@ function fixture({ withoutSse = false } = {}) { const h = fixture(); h.registerViews(); -assert.equal(h.refresh.current().overview_seconds, 2); +assert.equal(h.refresh.current().overview_seconds, 3); assert.equal(h.refresh.current().system_info_seconds, 60); assert.equal(h.refresh.current().traffic_seconds, 5); assert.equal(h.refresh.current().cache_seconds, 5); @@ -103,7 +103,7 @@ assert.equal(h.refresh.current().live_enabled, true); assert.equal(h.streams.length, 0); h.refresh.start(); await h.advance(); assert.equal(h.streams.length, 1, "one shared SSE connection"); -assert.equal(h.streams[0].url, "/api/events?interval=2"); +assert.equal(h.streams[0].url, "/api/events?interval=3"); assert.equal(h.count("/api/traffic"), 0, "no Traffic on Overview"); assert.equal(h.count("/api/cache"), 0, "no Cache on Overview"); assert.equal(h.count("/api/system-info"), 1, "Overview uses slow diagnostics"); @@ -231,7 +231,7 @@ const invalid = runInNewContext(scheduler + ";normalizeRefreshPreferences({ live liveFallback: false, stopLive() {}, startLive() {}, $: () => null }); assert.equal(invalid.live_enabled, true); -assert.equal(invalid.overview_seconds, 2); +assert.equal(invalid.overview_seconds, 3); assert.equal(invalid.traffic_seconds, 5); assert.equal(invalid.cache_seconds, 0); assert.equal(invalid.system_info_seconds, 300); diff --git a/tools/checks/release_0318.py b/tools/checks/release_0318.py index ae6daee..d3ef79a 100755 --- a/tools/checks/release_0318.py +++ b/tools/checks/release_0318.py @@ -8,9 +8,9 @@ from html.parser import HTMLParser ROOT = Path(__file__).resolve().parents[2] def read(rel): return (ROOT / rel).read_text(encoding='utf-8') -assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.5' -assert 'name = "rust-proxy"\nversion = "0.32.5"' in read('Cargo.lock') -assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.5' +assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.6' +assert 'name = "rust-proxy"\nversion = "0.32.6"' in read('Cargo.lock') +assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.6' pages = read('src/pages.rs') for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504): diff --git a/tools/checks/release_smoke.py b/tools/checks/release_smoke.py index 37a9493..1d2006b 100755 --- a/tools/checks/release_smoke.py +++ b/tools/checks/release_smoke.py @@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None: assert backup.is_dir() assert (backup / "src/obsolete.rs").is_file() assert not (target / "src/obsolete.rs").exists() - assert 'version = "0.32.5"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.32.6"' in (target / "Cargo.toml").read_text(encoding="utf-8") assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n" assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n" assert (target / "langs/site.json").is_file() @@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None: assert (target / "docker-agent/certificate.go").is_file() assert (target / "docker-agent/server.go").is_file() manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) - assert manifest["to_version"] == "0.32.5" + assert manifest["to_version"] == "0.32.6" assert manifest["removed_obsolete_runtime_state"] is True assert not legacy.exists() assert not (backup / "legacy-data").exists() @@ -617,7 +617,7 @@ def source_contract_smoke() -> None: assert marker in admin, marker anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0] assert '"/api/status"' not in anonymous_block - assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.5"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.6"' in openapi_0241 assert 'id="panel-logging"' not in html for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']: assert marker in html, marker @@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None: assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8") assert '"/api/config/history/restore"' in openapi - for marker in ['"version": "0.32.5"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: + for marker in ['"version": "0.32.6"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: assert marker in openapi, marker assert '"software_update"' in openapi and '"update_available"' in openapi for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']: diff --git a/tools/checks/security_logs_0315_ui_test.mjs b/tools/checks/security_logs_0315_ui_test.mjs index 0ba1b6b..1f729e5 100644 --- a/tools/checks/security_logs_0315_ui_test.mjs +++ b/tools/checks/security_logs_0315_ui_test.mjs @@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json'); const en = json('langs/en_US.json').translations; const pl = json('langs/pl_pl.json').translations; -assert.match(manifest, /version = "0\.32\.5"/); +assert.match(manifest, /version = "0\.32\.6"/); assert.match(lib, /pub mod user_agent;/); assert.match(model, /user_agent_blocking: crate::user_agent::Policy/); assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/); @@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/); assert.match(fileLogs, /aggregate_min_count/); assert.match(fileLogs, /file-log-aggregate-copy/); -assert.equal(openapi.info.version, '0.32.5'); +assert.equal(openapi.info.version, '0.32.6'); assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking); assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking); assert.ok(openapi.components.schemas.FileLogSource.properties.group_id); diff --git a/tools/checks/stream_0320_ui_test.mjs b/tools/checks/stream_0320_ui_test.mjs index da8c29b..bd12a2a 100644 --- a/tools/checks/stream_0320_ui_test.mjs +++ b/tools/checks/stream_0320_ui_test.mjs @@ -26,7 +26,7 @@ const scriptContext = { window, document: { querySelector(selector) { ] } }, latestStatus: null, currentPanel: 'overview', performance: { now: () => clock }, t: key => key, fmt: n => `${n}`, bytes: n => `${n} B`, Math, }; -vm.runInNewContext(src, scriptContext); +vm.runInNewContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8') + '\n' + src, scriptContext); const widget = window.rustProxyStreamDashboard; const status = { streams: { active: [ { id: 'tcp', enabled: true, protocol: 'tcp' }, diff --git a/tools/checks/stream_0320_visibility_regression.mjs b/tools/checks/stream_0320_visibility_regression.mjs index ebd395b..b9448c9 100644 --- a/tools/checks/stream_0320_visibility_regression.mjs +++ b/tools/checks/stream_0320_visibility_regression.mjs @@ -65,6 +65,7 @@ const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboa const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme(')); vm.runInContext(`${registryPrelude}\n${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context); window.rustProxyPreferences = { current: () => context.getPrefs() }; +vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context); vm.runInContext(streamSource, context); // First load (or page reload): defaults hide the new widgets. @@ -99,8 +100,10 @@ assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work'); assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility'); // Native title tooltips do not participate in layout or create horizontal scroll. assert.doesNotMatch(css, /dashboard-widget-(?:description|setting-label-block)/, 'obsolete custom tooltip styles must be removed'); -assert.match(css, /grid-template-columns: repeat\(auto-fit, minmax\(min\(260px, 100%\), 1fr\)\)/, - 'effective Appearance card columns must preserve a readable name and right-aligned arrows'); +assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*overflow-x: auto;/s, + 'Appearance must use a compact horizontal ribbon instead of long half-width columns'); +assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list > \.dashboard-widget-setting\s*\{[^}]*min-width: 226px;/s, + 'Appearance ribbon must retain readable labels and right-aligned reorder buttons'); assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/, 'old popover DOM and associations must be removed'); const widgetList = { children: [], replaceChildren(...children) { this.children = children; } }; diff --git a/tools/checks/upstream_protocols_0323.py b/tools/checks/upstream_protocols_0323.py index f3da1e4..6e24872 100755 --- a/tools/checks/upstream_protocols_0323.py +++ b/tools/checks/upstream_protocols_0323.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Offline source contracts for RustProxy 0.32.5 upstream protocols and card registry. +"""Offline source contracts for RustProxy 0.32.6 upstream protocols and card registry. These assertions supplement, rather than replace, Rust ALPN, H2C and websocket runtime tests. No code execution against a RustProxy server is implied. @@ -141,7 +141,7 @@ for field in ('dashboard_widgets', 'dashboard_hidden_widgets'): -assert '"version": "0.32.5"' in read('src/admin/openapi.json') -assert 'version = "0.32.5"' in read('Cargo.toml') -assert 'version = "0.32.5"' in read('Cargo.lock') -print(f'PASS: 0.32.5 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards') +assert '"version": "0.32.6"' in read('src/admin/openapi.json') +assert 'version = "0.32.6"' in read('Cargo.toml') +assert 'version = "0.32.6"' in read('Cargo.lock') +print(f'PASS: 0.32.6 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards') 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() diff --git a/tools/checks/web_assets.py b/tools/checks/web_assets.py index 7953e47..045bb0a 100755 --- a/tools/checks/web_assets.py +++ b/tools/checks/web_assets.py @@ -1,6 +1,6 @@ from pathlib import Path -APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "preferences.js", "dashboard-layout-editor.js", "dashboard-inline-layout.js", "stream-dashboard.js", "backend-dashboard.js", "config-history.js", "runtime.js", "settings.js") +APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "rate-sampling.js", "preferences.js", "dashboard-layout-editor.js", "dashboard-inline-layout.js", "stream-dashboard.js", "backend-dashboard.js", "config-history.js", "runtime.js", "settings.js") PANEL_PARTS = ( "management.js", "drafts.js", "integrations.js", "vhosts.js", "web-server-profiles.js", "web-server.js", "docker.js", "streams.js",