V0.32.6 #1

Merged
gru merged 4 commits from v0.32.6 into master 2026-10-14 13:05:10 +02:00
68 changed files with 3415 additions and 1301 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. 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 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 ## Delivery
+60 -9
View File
@@ -3,10 +3,68 @@
> Operator-facing release history. Historical engineering notes are kept in > Operator-facing release history. Historical engineering notes are kept in
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). > [`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 ## 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 - Add bounded DNS/DDNS hostnames as **allow-only** client ACL rules in reusable
profiles, host policies, vhosts, locations and Docker ACL assignments. profiles, host policies, vhosts, locations and Docker ACL assignments.
- Refresh A/AAAA answers asynchronously every 60 seconds and after config - Refresh A/AAAA answers asynchronously every 60 seconds and after config
@@ -19,13 +77,6 @@
## 0.32.4 ## 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 - 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 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 `:authority` while keeping DNS/TCP pinned to the configured origin URL and
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]] [[package]]
name = "rust-proxy" name = "rust-proxy"
version = "0.32.5" version = "0.32.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rust-proxy" name = "rust-proxy"
version = "0.32.5" version = "0.32.6"
edition = "2021" edition = "2021"
rust-version = "1.99" rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration" description = "RustProxy - independent reverse proxy with local administration"
+55 -4
View File
@@ -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). **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.** **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 - **ACL editor startup hotfix:** fix the JavaScript `i18n` initialization order
that could prevent the entire administration UI from loading. This does not 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). - **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. - **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. - 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 ## 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. - 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. - **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. - **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. - 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. - 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.
+31
View File
@@ -1,5 +1,36 @@
# RustProxy roadmap # 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) ## 0.32.5 - Dynamic DNS client ACLs (runtime acceptance pending)
- [x] Preserve CIDR/IP rules and introduce allow-only FQDNs for client ACLs. - [x] Preserve CIDR/IP rules and introduce allow-only FQDNs for client ACLs.
+67 -57
View File
@@ -1,18 +1,18 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
ad0382153576411fb19861072635c02bdf33de740d6f122d8f27bd9c52ce112a AGENTS.md a402ee07826dfab1b1d63b98c4a9f75809f056b908c92abacf0bba816d7ad133 AGENTS.md
4a58695f0ef64344228cb43caf2a78aeba724cf0cc573a6a8745da847681aeed CHANGELOG.md e4a565f39f4ecca622f0eb27822c7399e45b4c4d1a2fd1f669185d53196a969b CHANGELOG.md
2f1de87bae58761fc8690ec7225520e3a16213c77cbbb5981e34f12a95810668 Cargo.lock 934ec0e7f8a307b00319606a9c5d7e91dac6449a23dd1f3abcefe4304eb4de06 Cargo.lock
8c76ffaf4adba93df26e30f3dd163e0e5e201d069d1ae69c071ca42d7fc7264e Cargo.toml 8a7606650ce03f1faf36c893a17ccf1c9e8da4259e0248ac2c9bf030a64872d1 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
4ee3c626d1e5da5079fe65c6343f085cbb8bb53c2f2fa97e354f9633e8d415b3 README.md 0f8cedf595f57f96644e8d4dc608b43e6ebd9a73b31157a210d624492a6c5206 README.md
fc03a8d19055642313f0b34bcd22e1cce17efc4d91be7fe7e4819e33b91c531d ROADMAP.md da519345b2bce492e747c268df2730971543d1bdb198c3e854671a7d107e5609 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
4d9baf35c70d9f5cfb10f8bd4caab543772fdda1e979ff74c17f1e3ebc8e3cd0 dev.sh 26a607151fd343a59c6f147e70897c4b1bfeab376348f3270eb8a373503d7be5 dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
@@ -27,7 +27,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md 875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
1dc71311873be1372ae2383487b6c5924e288f5774496e315edf24faa35cf08a docs/API.md 627f1d8727321ef8961b153aabab7448f239fa383177bb9ef115cfeef48452da docs/API.md
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md 6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md
fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md
@@ -61,9 +61,9 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
25a06e7ddbb95d286e849b2fc5ba4adeedbddba0bad948e55bd91d962fd606de docs/SETTINGS.md a41cef4a4da5e098fe3e9dfa0fffb3625f2e09792eacdf5dbf8d8fd532146d26 docs/SETTINGS.md
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
97299efb6288ee29493eb7df243055d1ff76e8d0faf04bd195e184bce2e8bb67 docs/TEST_REPORT.md 130befc0f8216214567dd4b2df2574ea751b56b22c8f2cc3ba48e8d0aaccc3b5 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
@@ -95,8 +95,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md 71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
1c3bdb963bc4fa4eef48439721d7fee54375458f3a1d44f9e31c0c4ed7aca4f0 langs/en_US.json a1f873bfe87d090bac46962598aeb90ddfa62ae257b3b76ec1af011c74197bac langs/en_US.json
70d8ad7d503118356721ff7953b829049da793934dd73f4abbbbd7c7f0b4fc63 langs/pl_pl.json ca95ffe6919c07699155d1c49fa711916f91f0fbe928e5a36be23f05519ffbbb langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -154,9 +154,9 @@ b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/cert
ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs
c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.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 33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
c1b8ab15455a8199fbe011c6a7dd203572c4d4772c107d30531a6fb38a92ace2 src/admin/openapi.json 9426fba372656cb5abcc76e88e3885d1e9abc7ecf04653abc8c31803aeece019 src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -165,30 +165,31 @@ cd0fda2b5a7b86a6619d6fa7cf5cef5a2598f033a8e9304475c4a05a169c958c src/admin/pref
c17ccba5202d375d4cc593f7e20979f2b796362d87660b898feca0af5a135bfc src/admin/preferences_tests.rs c17ccba5202d375d4cc593f7e20979f2b796362d87660b898feca0af5a135bfc src/admin/preferences_tests.rs
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs 055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs 9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
ab8d92d79d0c4d595fdb2558ea877fa8cfa17bf07708375fef4668c4367950fe src/admin/router.rs a9a8b9cce86990d5759eba2f9b29dce885fef056d4f81775bcbd429f4c00bba3 src/admin/router.rs
a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs 479eca6162df5152ce536727b1090dd8e2e262775f2c8cf5bb2c8fa40cb2a600 src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
7be070a259d534fd6d1069207a464434602d94143f8f463b1dddb1c45f3dc797 src/admin/tests.rs 18b778c2c4e723e2fe5cbaaef20effa6ea50fcc35d507ebde2361c9b6eda62b2 src/admin/tests.rs
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 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 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 dec0b875086e3567486191231056bfc6c1edef006f7aad7c88f19ef37c034570 src/admin/web/app/dashboard-inline-layout.js
dc097b9c6ec8ebd4fa53ed8b0a641c9446fe44184c74246b521cfe49cf5a32b3 src/admin/web/app/dashboard-layout-editor.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 fb0ba435beaa91e518537606f5fe55b0eea63c70efd290798c8d9754fa6771b2 src/admin/web/app/overview-masonry.js
79d31e2002f8cf334f87285f6f2e6dbf69f3647de784ae26efadb92c126bc7a0 src/admin/web/app/overview-wide-metrics.js c4d59e129837db854f2851ac69130325f6d2f25fd1b67a009354b83da1dfbf02 src/admin/web/app/overview-wide-metrics.js
947c53fe10d97f5d3aa64ae3b819d7bcdb2a83a792747826acbc3750e313014f src/admin/web/app/preferences.js 7e0a4dc7274a1d1e4cc928e360a6b77c48a71d586b585a9e28c3bb2eeb17d46a src/admin/web/app/preferences.js
6a544baaf40763b4b612e53845b3bc33396b9bb6518565fd6b7c128c278b8d5d src/admin/web/app/refresh.js 8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js
41f635a510d6f08b37484f0e260929926b93a02a792bdfa05b31ed7ebd682fef src/admin/web/app/runtime.js 43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js
7eee9fd6f930288ee3f87dd7a2c64bddb24ae5326cf69600accc376fb015fc87 src/admin/web/app/runtime.js
31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
cc566f8293cc935f5080ebfb8076293c256bb6a87e1e6a69be03512448871ec9 src/admin/web/app/stream-dashboard.js fbd2c402828088695f5f5f4b210d1ea8da24ae6425f61248c82c5b07b14afd56 src/admin/web/app/stream-dashboard.js
a854e08f947a4d13fc4b27286a096dcb2773dc0358a28b27703a2320ba7e8bf7 src/admin/web/cache-panel.js 0603dc8793a731a0e582cbbec53aa6d1cc6e007faafcec5d8843730b2cdc39e1 src/admin/web/cache-panel.js
02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js 02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js
da3683f3aa0cec4f86b0ff0fb105959caac4ce65c41a9391f5adb3c289e8ff1c src/admin/web/dashboard-widgets.json da3683f3aa0cec4f86b0ff0fb105959caac4ce65c41a9391f5adb3c289e8ff1c src/admin/web/dashboard-widgets.json
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
@@ -212,7 +213,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.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 03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
@@ -221,16 +222,16 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
e3075af59644e42fa5735574d8e9caa1bcebfe8de184b78aff9f4026b91915f1 src/admin/web/streams.js c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
065a377512c146254a19ce03c299f9d07f54e4f2cb0bccae965ac63e3a96063f src/admin/web/style.css cd89785fc8f27353983846c799f9256a1a893d775cb4853fb62b76c29e42f8c7 src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js 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 d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js 8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.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 4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
355684980d1caf8918d1773ce17934b51e3a85a60f38491217d10035bf68ce9f src/admin/web/web-server.js 355684980d1caf8918d1773ce17934b51e3a85a60f38491217d10035bf68ce9f src/admin/web/web-server.js
53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs 53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs
@@ -250,7 +251,7 @@ a1e5d43f5417d71f3974812cb539c0932f206e2290090917f067494f3587fc72 src/balance/le
4d3aa415af2fbb42a4e19f2ca10be8b1d1ad42f366a013cd9c33806abc1fbea2 src/balance/model.rs 4d3aa415af2fbb42a4e19f2ca10be8b1d1ad42f366a013cd9c33806abc1fbea2 src/balance/model.rs
41c3c020a60c372dbf965bd277c330dd9cf2ab079f6608b472adf72a656f4cb3 src/balance/pool_core.rs 41c3c020a60c372dbf965bd277c330dd9cf2ab079f6608b472adf72a656f4cb3 src/balance/pool_core.rs
477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs 477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs
8f73e8fe7f57d8d720a8ce59ae94fc55ab03f6da59d50bfe6ae929815f4f3bfa src/balance/pool_view.rs 8abb6048303ee8c022546a528d5693167766e7450efd8e20f37ec6fd0fa9ef5e src/balance/pool_view.rs
656a6ab3c9855f92b7e3e0dd6de1bcc408da003db8bcfcda52b71c5234c56693 src/balance/tests.rs 656a6ab3c9855f92b7e3e0dd6de1bcc408da003db8bcfcda52b71c5234c56693 src/balance/tests.rs
5049d2bd01a9c866f3bad2288d8d594df96bba6045dfe15cd22b4173562b7388 src/basic_auth.rs 5049d2bd01a9c866f3bad2288d8d594df96bba6045dfe15cd22b4173562b7388 src/basic_auth.rs
9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs 9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs
@@ -436,7 +437,7 @@ c2f802d95da4332fb8480e83f0c7502334457b3408141c1c4516c9ba320a0463 src/traffic.rs
36301bcbd20b557dd3d58e77a9175f3581947f577c0bb0b972899cec6786b03f src/traffic/tests.rs 36301bcbd20b557dd3d58e77a9175f3581947f577c0bb0b972899cec6786b03f src/traffic/tests.rs
d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs
87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs 87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs
38dfd1005b1cd9ae21c1e2964a8b464368d8cdff5e525089b3132e0126f211ea src/transform/css.rs b2c5236589093454ec60984b785ad6a4af6149d47f6db37d8e08c17fec7a2c1b src/transform/css.rs
5478670cef87777b14a2dab308f5a9182df56d7ea2f5625bdc94aa7e3d661503 src/transform/html.rs 5478670cef87777b14a2dab308f5a9182df56d7ea2f5625bdc94aa7e3d661503 src/transform/html.rs
aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs
a1d2fec07617c8a8dc425bfaeaf77c844152a0a174568a88a5baf4c317cc6c5c src/transform/js.rs a1d2fec07617c8a8dc425bfaeaf77c844152a0a174568a88a5baf4c317cc6c5c src/transform/js.rs
@@ -447,12 +448,12 @@ cf5195ab6bacccae0082da7b3f1bd0382b73fdeb3607f4ae569ca6daab9d10d0 src/transform/
fde2fa3aa94b30d8536afec1c11348cfbd922d0b5ff89e91d34266e8e3863e26 src/transform/model.rs fde2fa3aa94b30d8536afec1c11348cfbd922d0b5ff89e91d34266e8e3863e26 src/transform/model.rs
a4e1d2aa2913587c250f251356431097b76995aee6e8bf9e35c4978a95491de1 src/transform/svg.rs a4e1d2aa2913587c250f251356431097b76995aee6e8bf9e35c4978a95491de1 src/transform/svg.rs
0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs 0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs
76b3cb4ebdba9cbac8e388d3a9d74bd2055db564d8e31869a60e7a439b46f7b3 src/transform/tests.rs 8ed4baeaf79edfa2a84912cc00f1d13c564e1b997efe6888d2e4b5a16aaa8905 src/transform/tests.rs
6146c59f36cf419623747d497b3de685da6c84964df43317bebdcb21deb9ee09 src/update_check.rs 6146c59f36cf419623747d497b3de685da6c84964df43317bebdcb21deb9ee09 src/update_check.rs
c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs
9c897664a8b3b288b761b84a724b212c3f6a82b856ceebf3b4ecd6eab1846a35 src/upstream.rs 9c897664a8b3b288b761b84a724b212c3f6a82b856ceebf3b4ecd6eab1846a35 src/upstream.rs
6d7a1798911add0905e316d5ef682b0977a42b8bf9e4c1976733aaf393a027bd src/upstream/client.rs 9f05552d0cdc523e37bc73214e35e472f271250fd4f95ddbbefda4c39f96ebd9 src/upstream/client.rs
4aa2420a92d33c50f7027f57ac4eccdc050bdb7e9ec8fa6e0eaa809228b71cd8 src/upstream/client_tests.rs ad29f3fc15c9b627b5d752af5adc9f99ee1364fec2df63f1e4509cb223988efb src/upstream/client_tests.rs
75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs 75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs
8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs 8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs
a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs
@@ -497,63 +498,72 @@ e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/a
0060f8217640403c6af2d5091fdc9087d0fb2fc30cd545a3c58277a396d8121c tools/checks/admin_public_origin_0314_sim.mjs 0060f8217640403c6af2d5091fdc9087d0fb2fc30cd545a3c58277a396d8121c tools/checks/admin_public_origin_0314_sim.mjs
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
c9d0bef4b8ed7a2f49b0fad3e116cace62d3e6e1ca31e02a3073d1fdde5157d8 tools/checks/admin_themes_0323_ui_test.mjs 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 8b6e1a355cae58901118b399fa2d165f7e3fd8465c89cac82776906b8b23f0a6 tools/checks/appearance_ux_0323_ui_test.mjs
f3dba206c60cd2e6fcc5f4a2c8d652ccf47362694448312cc1f8dc4112d93e7e tools/checks/backend_cache_0297_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 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 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 dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
7508f232a9e48049002dd4c9f34f9737bc92d304c51219f50430578dbc721855 tools/checks/dashboard_inline_layout_0323_ui_test.mjs 7508f232a9e48049002dd4c9f34f9737bc92d304c51219f50430578dbc721855 tools/checks/dashboard_inline_layout_0323_ui_test.mjs
dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/dashboard_layout_editor_0323_ui_test.mjs dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/dashboard_layout_editor_0323_ui_test.mjs
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_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 38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
3396e5a510787e70e2ad8d1fad90356af461a88fd84429525d56fa82113da0f3 tools/checks/docs_check.py 6ca752ed5e767dc1eed4e1d7b60270391e1dd0bd5c887de8fd0f9dcaf219d7bd tools/checks/docs_check.py
3fc765a7c2723ec783f269cfebc29ad2c09a90f33ea4af58285f883a026c4fa2 tools/checks/dynamic_acl_0325.py 48f0901a709f415f518d2a5eeb779180ac18c879e18f1ad7af748b1be9ac92e1 tools/checks/dynamic_acl_0325.py
9c77764561b6ebfeecbc228f43ea29a0dd9f44121ad773c2e4299b92758e45c2 tools/checks/dynamic_acl_startup_0325_ui_test.mjs 9c77764561b6ebfeecbc228f43ea29a0dd9f44121ad773c2e4299b92758e45c2 tools/checks/dynamic_acl_startup_0325_ui_test.mjs
5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs 5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs
72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_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 e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
fb8619444edf81afa604194f750dc76335394a73c74324eda59e392874f29ee8 tools/checks/ios_scroll_0326_browser.py
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
c56a7763250f8cf1ac011feb0c90a06309bd680aeb6da6e1d1f825fbb2a573aa tools/checks/live_diagnostics_0326_browser.py
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.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 af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs
0387a9e29244b58cf37a6673a88ae2b7fea3aa965c032423f3783a84eab4ba05 tools/checks/overview_0321_layout_regression.mjs 0387a9e29244b58cf37a6673a88ae2b7fea3aa965c032423f3783a84eab4ba05 tools/checks/overview_0321_layout_regression.mjs
53917376134d73fff32a68239b513c4eedf98bb65224c1c41c0e5cfc8fcb119b tools/checks/overview_extra_metrics_0323_ui_test.mjs 7fda61ae66a6e3f1ada1304440c09e1f43d0e577b3a5eac8331afe86fc5eebcb tools/checks/overview_extra_metrics_0323_ui_test.mjs
bc047e51ba17d7062a8a46a273c871c9b0b0ace16ef738a86e1517d0792d4c0a tools/checks/overview_grid_spacing_0323_browser.py 48dd1e13b83dc897c735028c93e17200cf4ed9ce175475dc5641ec3236569c38 tools/checks/overview_grid_spacing_0323_browser.py
d530cb9b783d6e7ea3afd400a35a64865d16e11800bbf8910cb7edb9648f5510 tools/checks/overview_gutters_0324_browser.py d530cb9b783d6e7ea3afd400a35a64865d16e11800bbf8910cb7edb9648f5510 tools/checks/overview_gutters_0324_browser.py
b565aef4594265350240c24067a554f91fd605d0e920c03eef0b5b62c3934aad tools/checks/overview_registry_0323_ui_test.mjs b565aef4594265350240c24067a554f91fd605d0e920c03eef0b5b62c3934aad tools/checks/overview_registry_0323_ui_test.mjs
f450e97dccf75c24e228e696f21ed4ae826a823ea4edde2429894a6b6b36eaab tools/checks/overview_uniform_grid_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 1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs
eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/preflight.py 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 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 c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs 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 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 31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_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 011bbe65dae072999a3951aba3d74a520926abf836b784cdb2395023d2942867 tools/checks/source_module_contracts.py
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py 37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
bb862620104f925488e36904e258b7c934a097362ef879ecb275c64008cebb83 tools/checks/stream_0320_ui_test.mjs be78df1e438d76be83e15398b310c9843ae5965b0ec6b41279b3a9650c313d8a tools/checks/stream_0320_ui_test.mjs
19cb347c87b0fa462fc2f3f2f5bef62e93628334ab4b8a1776df417e47c91ef0 tools/checks/stream_0320_visibility_regression.mjs 6c7d20fa2abb55de997c19abd07ec1afa8d5121c91e07f231976bcdfbfbba778 tools/checks/stream_0320_visibility_regression.mjs
8542f5c668155aa9f70de11e6fdaf64c5a80629a15ecc5f1d8c40f3d655f9b55 tools/checks/system_info_0301_ui_test.mjs 8542f5c668155aa9f70de11e6fdaf64c5a80629a15ecc5f1d8c40f3d655f9b55 tools/checks/system_info_0301_ui_test.mjs
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py 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 a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs 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 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 8d2e1837f94302d09a704aca452cc367e6892f289aca3dc2d82c086a2e7de26d tools/checks/web_cache_revalidation_0322.py
8ec055a16198701dd4fa35eac42a71b666d2b9bbe90edb1c797c7ed759615487 tools/checks/web_server_contracts.py 8ec055a16198701dd4fa35eac42a71b666d2b9bbe90edb1c797c7ed759615487 tools/checks/web_server_contracts.py
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs 35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
+4
View File
@@ -101,6 +101,7 @@ if [[ "$mode" == "test" ]]; then
python3 tools/checks/upstream_protocols_0323.py python3 tools/checks/upstream_protocols_0323.py
python3 tools/checks/preserve_host_0324.py python3 tools/checks/preserve_host_0324.py
python3 tools/checks/dynamic_acl_0325.py python3 tools/checks/dynamic_acl_0325.py
python3 tools/checks/mobile_protocol_0326.py
python3 tools/checks/check_source.py python3 tools/checks/check_source.py
python3 tools/checks/release_smoke.py python3 tools/checks/release_smoke.py
python3 tools/checks/memory_contracts.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/dashboard_inline_layout_0323_ui_test.mjs
node tools/checks/admin_themes_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/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/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_wide_masonry_0323_ui_test.mjs
node tools/checks/overview_uniform_grid_0323_ui_test.mjs node tools/checks/overview_uniform_grid_0323_ui_test.mjs
node tools/checks/overview_0321_layout_regression.mjs node tools/checks/overview_0321_layout_regression.mjs
+2 -2
View File
@@ -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. `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/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/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 /metrics` | Prometheus text exposition |
| `GET /api/metrics` | Alias of `/metrics` | | `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 session. These administrative previews are not anonymous. The separate configurable
exporter uses its own CIDRs and optional bearer token; see the scrape section below. 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 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 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. idle activity, not the absolute session deadline.
+28 -2
View File
@@ -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 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. 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. 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). 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) ## 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: **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 | | Preference | Default | Allowed seconds |
|---|---:|---| |---|---:|---|
| `refresh.live_enabled` | `true` | `true`, `false` | | `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.traffic_seconds` | 5 | 0, 3, 5, 10, 30 |
| `refresh.cache_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 | | `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. `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) ## System information (0.30.1)
+147 -2
View File
@@ -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 - **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 `src/admin/web/management.js` is now initialized before the DDNS hint uses
+9
View File
@@ -353,6 +353,8 @@
"vhDrain": "Drain new sessions", "vhDrain": "Drain new sessions",
"vhPrivate": "Allow private origin IP", "vhPrivate": "Allow private origin IP",
"vhPreserveHost": "Preserve original client Host / HTTP/2 authority", "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", "vhProtocol": "Origin protocol",
"vhProtocolHttp1": "HTTP/1.1 (TLS or cleartext)", "vhProtocolHttp1": "HTTP/1.1 (TLS or cleartext)",
"vhProtocolAuto": "Auto (HTTPS ALPN: H2 + H1; HTTP: H1)", "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.", "dashboardDensityHelp": "Controls Overview telemetry card size without changing the rest of the interface.",
"dashboardWidgets": "Overview cards", "dashboardWidgets": "Overview cards",
"dashboardWidgetsHelp": "Show, hide and reorder all Overview blocks, including charts, diagnostics and the backend table.", "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", "dashboardWidgetVisible": "Visible",
"moveUp": "Move up", "moveUp": "Move up",
"moveDown": "Move down", "moveDown": "Move down",
+9
View File
@@ -353,6 +353,8 @@
"vhDrain": "Wstrzymaj nowe sesje", "vhDrain": "Wstrzymaj nowe sesje",
"vhPrivate": "Zezwól na prywatny IP backendu", "vhPrivate": "Zezwól na prywatny IP backendu",
"vhPreserveHost": "Zachowaj Host klienta / HTTP/2 :authority", "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", "vhProtocol": "Protokół backendu",
"vhProtocolHttp1": "HTTP/1.1 (TLS lub bez TLS)", "vhProtocolHttp1": "HTTP/1.1 (TLS lub bez TLS)",
"vhProtocolAuto": "Auto (HTTPS ALPN: H2 + H1; HTTP: H1)", "vhProtocolAuto": "Auto (HTTPS ALPN: H2 + H1; HTTP: H1)",
@@ -2208,6 +2210,13 @@
"dashboardDensityHelp": "Steruje rozmiarem kafelków telemetrii w Overview bez zmiany reszty interfejsu.", "dashboardDensityHelp": "Steruje rozmiarem kafelków telemetrii w Overview bez zmiany reszty interfejsu.",
"dashboardWidgets": "Kafelki Overview", "dashboardWidgets": "Kafelki Overview",
"dashboardWidgetsHelp": "Pokazuj, ukrywaj i układaj wszystkie bloki Overview, także wykresy, diagnostykę i tabelę backendów.", "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", "dashboardWidgetVisible": "Widoczny",
"moveUp": "Przenieś wyżej", "moveUp": "Przenieś wyżej",
"moveDown": "Przenieś niżej", "moveDown": "Przenieś niżej",
+2 -2
View File
@@ -309,8 +309,8 @@ async fn events(
headers: HeaderMap, headers: HeaderMap,
Query(query): Query<EventsQuery>, Query(query): Query<EventsQuery>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let interval = query.interval.unwrap_or(2); let interval = query.interval.unwrap_or(3);
if ![1, 2, 5, 10].contains(&interval) { if ![1, 2, 3, 5, 10].contains(&interval) {
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "invalid SSE interval".into())); return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "invalid SSE interval".into()));
} }
let secret = auth::cookie_secret(&headers) let secret = auth::cookie_secret(&headers)
+23 -4
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
"title": "RustProxy Administration API", "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." "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": [ "servers": [
@@ -1237,6 +1237,23 @@
"properties": {}, "properties": {},
"additionalProperties": true "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": { "backend_pools": {
"type": "array", "type": "array",
"items": { "items": {
@@ -3826,10 +3843,11 @@
"enum": [ "enum": [
1, 1,
2, 2,
3,
5, 5,
10 10
], ],
"default": 2 "default": 3
}, },
"traffic_seconds": { "traffic_seconds": {
"type": "integer", "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": [ "parameters": [
{ {
"name": "interval", "name": "interval",
@@ -9049,10 +9067,11 @@
"enum": [ "enum": [
1, 1,
2, 2,
3,
5, 5,
10 10
], ],
"default": 2 "default": 3
} }
} }
] ]
+2 -2
View File
@@ -13,7 +13,7 @@ impl Default for RefreshPreferences {
fn default() -> Self { fn default() -> Self {
Self { Self {
live_enabled: true, live_enabled: true,
overview_seconds: 2, overview_seconds: 3,
traffic_seconds: 5, traffic_seconds: 5,
cache_seconds: 5, cache_seconds: 5,
system_info_seconds: 60, system_info_seconds: 60,
@@ -22,7 +22,7 @@ impl Default for RefreshPreferences {
} }
fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiError> { 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.traffic_seconds)
|| ![0, 3, 5, 10, 30].contains(&refresh.cache_seconds) || ![0, 3, 5, 10, 30].contains(&refresh.cache_seconds)
|| ![0, 15, 30, 60, 300].contains(&refresh.system_info_seconds) || ![0, 15, 30, 60, 300].contains(&refresh.system_info_seconds)
+2 -1
View File
@@ -56,6 +56,7 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/app/core.js"), include_str!("web/app/core.js"),
include_str!("web/app/session-resume.js"), include_str!("web/app/session-resume.js"),
include_str!("web/app/refresh.js"), include_str!("web/app/refresh.js"),
include_str!("web/app/rate-sampling.js"),
"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(", "const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(",
include_str!("web/dashboard-widgets.json"), include_str!("web/dashboard-widgets.json"),
");", ");",
@@ -277,7 +278,7 @@ fn secure(mut response: Response) -> Response {
let h = response.headers_mut(); let h = response.headers_mut();
h.insert( h.insert(
"cache-control", "cache-control",
HeaderValue::from_static("private, no-store, no-cache"), HeaderValue::from_static("private, no-store, no-cache, no-transform"),
); );
h.insert( h.insert(
"x-content-type-options", "x-content-type-options",
+17
View File
@@ -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 { fn live_status_payload(state: &App) -> Value {
let snapshot = state.config.load_full(); let snapshot = state.config.load_full();
let stream_metrics = state.active_streams.iter().filter_map(|stream| { 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 "restart_required": snapshot.listeners != state.active_listeners
}, },
"metrics": state.metrics.view(), "metrics": state.metrics.view(),
"live_system": live_chart_system_payload(state),
"cache": state.cache.view(), "cache": state.cache.view(),
"transform_metrics": state.transform_metrics.total(), "transform_metrics": state.transform_metrics.total(),
"logs_dropped": state.logs.dropped.load(Ordering::Relaxed), "logs_dropped": state.logs.dropped.load(Ordering::Relaxed),
+13 -1
View File
@@ -1663,7 +1663,7 @@ mod tests {
assert_eq!( assert_eq!(
initial["refresh"], initial["refresh"],
json!({ 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 "cache_seconds":5, "system_info_seconds":60
}) })
); );
@@ -2914,6 +2914,13 @@ mod tests {
assert_eq!(live.status(), StatusCode::OK); assert_eq!(live.status(), StatusCode::OK);
let live = json_body(live).await; let live = json_body(live).await;
assert!(live.get("metrics").is_some()); 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.get("backend_pools").is_some());
assert!(live["listeners"].get("active_endpoints").is_some()); assert!(live["listeners"].get("active_endpoints").is_some());
assert!(live.get("system").is_none()); assert!(live.get("system").is_none());
@@ -2966,6 +2973,7 @@ mod tests {
assert_eq!(partial.traffic_seconds, 0); assert_eq!(partial.traffic_seconds, 0);
assert_eq!(partial.system_info_seconds, 60); assert_eq!(partial.system_info_seconds, 60);
assert!(validate_admin_preferences(&loaded).is_ok()); assert!(validate_admin_preferences(&loaded).is_ok());
assert_eq!(loaded.refresh.overview_seconds, 3);
} }
#[test] #[test]
@@ -2975,6 +2983,10 @@ mod tests {
value.overview_seconds = seconds; value.overview_seconds = seconds;
assert!(validate_refresh_preferences(&value).is_err()); 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; value.overview_seconds = 1;
for seconds in [1, 2, 4, 31] { for seconds in [1, 2, 4, 31] {
value.traffic_seconds = seconds; value.traffic_seconds = seconds;
+17 -14
View File
@@ -136,12 +136,14 @@ window.rustProxyBackendDashboard = (() => {
let inPerSecond = samples.inbound.at(-1) ?? 0; let inPerSecond = samples.inbound.at(-1) ?? 0;
let outPerSecond = samples.outbound.at(-1) ?? 0; let outPerSecond = samples.outbound.at(-1) ?? 0;
const additional = { http5xx: 0, timeouts: 0, network: 0, retries: 0, queue: 0 }; const additional = { http5xx: 0, timeouts: 0, network: 0, retries: 0, queue: 0 };
let fraction = null; let fraction = null, pausedRate = false;
if (recordSample) { if (recordSample) {
const now = performance.now(); const now = performance.now();
const next = memberCounters(members); const next = memberCounters(members);
const elapsed = previous ? (now - previous.when) / 1000 : 0; const valid = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds"));
if (elapsed > 0) { pausedRate = previous !== null && !valid;
if (valid) {
const elapsed = (now - previous.at) / 1000;
const diff = deltas(next, previous.counters); const diff = deltas(next, previous.counters);
requestsPerSecond = diff.requests / elapsed; requestsPerSecond = diff.requests / elapsed;
errorsPerSecond = diff.errors / elapsed; errorsPerSecond = diff.errors / elapsed;
@@ -150,23 +152,24 @@ window.rustProxyBackendDashboard = (() => {
fraction = diff.requests ? 100 * diff.errors / diff.requests : null; fraction = diff.requests ? 100 * diff.errors / diff.requests : null;
for (const name of Object.keys(additional)) additional[name] = diff[name] / elapsed; for (const name of Object.keys(additional)) additional[name] = diff[name] / elapsed;
} else { requestsPerSecond = 0; errorsPerSecond = 0; inPerSecond = 0; outPerSecond = 0; } } 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_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_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_latency") && latency !== null) push("latency", latency);
if (visible("backend_traffic")) { push("inbound", inPerSecond); push("outbound", outPerSecond); } if (!pausedRate && 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) 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; 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")) { if (visible("backend_activity")) {
set("backend-dashboard-activity", fmt(active)); set("backend-dashboard-activity", fmt(active));
set("backend-dashboard-activity-detail", t("backendWidgetInFlight")); set("backend-dashboard-activity-detail", t("backendWidgetInFlight"));
paintSeries("active", "backend-dashboard-active-line", Math.max(1, ...samples.active)); paintSeries("active", "backend-dashboard-active-line", Math.max(1, ...samples.active));
} }
if (visible("backend_requests")) { 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)}`); set("backend-dashboard-requests-detail", `${t("backendWidgetRequestsTotal")}: ${fmt(totalRequests)}`);
paintSeries("requests", "backend-dashboard-request-line", Math.max(1, ...samples.requests)); paintSeries("requests", "backend-dashboard-request-line", Math.max(1, ...samples.requests));
} }
@@ -178,7 +181,7 @@ window.rustProxyBackendDashboard = (() => {
} }
if (visible("backend_errors")) { if (visible("backend_errors")) {
set("backend-dashboard-errors", fmt(totalErrors)); 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)); paintSeries("errors", "backend-dashboard-error-line", Math.max(1, ...samples.errors));
} }
if (visible("backend_latency")) { if (visible("backend_latency")) {
@@ -187,9 +190,9 @@ window.rustProxyBackendDashboard = (() => {
paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency)); paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency));
} }
if (visible("backend_traffic")) { if (visible("backend_traffic")) {
set("backend-dashboard-traffic", `${bytes(Math.round(inPerSecond + outPerSecond))}/s`); set("backend-dashboard-traffic", pausedRate ? "-" : `${bytes(Math.round(inPerSecond + outPerSecond))}/s`);
set("backend-dashboard-in-rate", `${bytes(Math.round(inPerSecond))}/s`); set("backend-dashboard-in-rate", pausedRate ? "-" : `${bytes(Math.round(inPerSecond))}/s`);
set("backend-dashboard-out-rate", `${bytes(Math.round(outPerSecond))}/s`); set("backend-dashboard-out-rate", pausedRate ? "-" : `${bytes(Math.round(outPerSecond))}/s`);
const peak = Math.max(1, ...samples.inbound, ...samples.outbound); const peak = Math.max(1, ...samples.inbound, ...samples.outbound);
paintSeries("inbound", "backend-dashboard-in-line", peak); paintSeries("inbound", "backend-dashboard-in-line", peak);
paintSeries("outbound", "backend-dashboard-out-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) : 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); counter(member.queue_timeouts_total) + counter(member.capacity_rejections_total)), 0);
set(field, fmt(total)); 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 === "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 === "queue") detail += ` · ${t("backendWidgetQueueRejected")}: ${fmt(members.reduce((n, { member }) => n + counter(member.capacity_rejections_total), 0))}`;
if (name === "network") detail += ` · ${t("backendWidgetNetworkBreakdown")}`; if (name === "network") detail += ` · ${t("backendWidgetNetworkBreakdown")}`;
+13 -15
View File
@@ -230,18 +230,17 @@ function renderStatus(s, systemSample = false) {
const now = performance.now(); const now = performance.now();
const transformed = s.transform_metrics || {}; 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 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 }; const keys = ["total", "h1", "h2", "h3", "transform"]; const rateSample = rustProxyRateSampling.difference(previous, keys.map(key => counters[key]), now, rustProxyRateSampling.cadence("overview_seconds"));
if (previous) { const pausedRate = previous !== null && rateSample.deltas === null; previous = rateSample.next;
const elapsed = Math.max(.001, (now - previous.time) / 1000); const rates = Object.fromEntries(keys.map((key, i) => [key, rateSample.deltas ? rateSample.deltas[i] / rateSample.seconds : 0]));
for (const key of ["total", "h1", "h2", "h3", "transform"]) if (counters[key] >= previous[key]) rates[key] = (counters[key] - previous[key]) / elapsed; 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.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.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); 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 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(" "); 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)); $("sparkline").setAttribute("points", points(samples));
@@ -249,12 +248,11 @@ function renderStatus(s, systemSample = false) {
$("sparkline-h2").setAttribute("points", points(protocolSamples.h2)); $("sparkline-h2").setAttribute("points", points(protocolSamples.h2));
$("sparkline-h3").setAttribute("points", points(protocolSamples.h3)); $("sparkline-h3").setAttribute("points", points(protocolSamples.h3));
$("sparkline-transform").setAttribute("points", points(transformSamples)); $("sparkline-transform").setAttribute("points", points(transformSamples));
$("request-rate").textContent = `${rates.total.toFixed(1)} req/s`; const rateText = key => pausedRate ? "-" : `${rates[key].toFixed(1)}/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`; $("request-rate").textContent = pausedRate ? "- req/s" : `${rates.total.toFixed(1)} req/s`;
$("metric-http-proto-total").textContent = `${rates.total.toFixed(1)}/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-h1").textContent = `${rates.h1.toFixed(1)}/s`; $("metric-http-proto-total").textContent = rateText("total"); $("metric-http-proto-h1").textContent = rateText("h1");
$("metric-http-proto-h2").textContent = `${rates.h2.toFixed(1)}/s`; $("metric-http-proto-h2").textContent = rateText("h2"); $("metric-http-proto-h3").textContent = rateText("h3");
$("metric-http-proto-h3").textContent = `${rates.h3.toFixed(1)}/s`;
$("http-protocol-h1").setAttribute("points", sparklinePoints(protocolSamples.h1, 300, 60, 5)); $("http-protocol-h1").setAttribute("points", sparklinePoints(protocolSamples.h1, 300, 60, 5));
$("http-protocol-h2").setAttribute("points", sparklinePoints(protocolSamples.h2, 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)); $("http-protocol-h3").setAttribute("points", sparklinePoints(protocolSamples.h3, 300, 60, 5));
@@ -302,7 +300,7 @@ function stopLive() {
} }
function startLive() { function startLive() {
if (!authenticated || document.hidden || typeof EventSource === "undefined") return; 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; if (liveEvents && liveInterval === seconds) return;
stopLive(); stopLive();
liveInterval = seconds; liveInterval = seconds;
+6 -7
View File
@@ -16,16 +16,15 @@ window.rustProxyExtraOverview = (() => {
const node = id => document.querySelector(`[data-dashboard-widget="${id}"]`); const node = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
function elapsedDelta(id, counters, now) { function elapsedDelta(id, counters, now) {
const old = lastCounter.get(id); const old = lastCounter.get(id);
const next = { values: counters, at: now }; const result = rustProxyRateSampling.difference(old, counters, now, rustProxyRateSampling.cadence("overview_seconds"));
lastCounter.set(id, next); lastCounter.set(id, result.next);
if (!old || now <= old.at || counters.some((v, i) => v < old.values[i])) return null; return result.deltas ? result : null;
return { deltas: counters.map((v, i) => v - old.values[i]), seconds: (now - old.at) / 1000 };
} }
function read(id, status, now, record) { 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 = { const direct = {
extra_process_cpu: d.process_cpu_percent, extra_process_cpu: live.process_cpu_percent,
extra_process_rss: d.process_rss_bytes, extra_process_rss: live.process_rss_bytes,
extra_process_fds: d.open_fds, extra_process_fds: d.open_fds,
extra_open_connections: m.active_connections, extra_open_connections: m.active_connections,
extra_websocket_active: m.websocket_active, extra_websocket_active: m.websocket_active,
+5 -8
View File
@@ -7,11 +7,11 @@ window.rustProxyWideOverview = (() => {
}, },
wide_cpu: { wide_cpu: {
unit: "percent", names: ["system", "process"], values: s => 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: { wide_memory: {
unit: "bytes", names: ["process", "cache"], values: s => 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: { wide_cache_activity: {
unit: "rate", names: ["hits", "misses"], counters: s => unit: "rate", names: ["hits", "misses"], counters: s =>
@@ -51,12 +51,9 @@ window.rustProxyWideOverview = (() => {
const counters = values.map(finite); const counters = values.map(finite);
if (counters.some(n => n === null)) { baselines.delete(id); return counters.map(() => null); } if (counters.some(n => n === null)) { baselines.delete(id); return counters.map(() => null); }
if (!record) return last.get(id) || counters.map(() => null); if (!record) return last.get(id) || counters.map(() => null);
const previous = baselines.get(id); const result = rustProxyRateSampling.difference(baselines.get(id), counters, now, rustProxyRateSampling.cadence("overview_seconds"));
baselines.set(id, { counters, now }); baselines.set(id, result.next);
if (!previous || now <= previous.now || counters.some((n, i) => n < previous.counters[i])) return result.deltas ? result.deltas.map(delta => delta / result.seconds) : counters.map(() => null);
return counters.map(() => null);
const elapsed = (now - previous.now) / 1000;
return counters.map((n, i) => (n - previous.counters[i]) / elapsed);
} }
function path(values, peak) { function path(values, peak) {
const count = values.length; const count = values.length;
+51 -6
View File
@@ -96,7 +96,10 @@ function applyDashboardLayout(order, hidden) {
const normalizedOrder = normalizeDashboardWidgets(order), hiddenSet = new Set(normalizeHiddenDashboardWidgets(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 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])); 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.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
window.rustProxyBackendDashboard?.syncVisibility?.([...hiddenSet]); window.rustProxyBackendDashboard?.syncVisibility?.([...hiddenSet]);
window.rustProxyMasonry?.refresh?.(); window.rustProxyMasonry?.refresh?.();
@@ -124,23 +127,25 @@ function dashboardWidgetTypeBadge(id) {
} }
function renderDashboardWidgetSettings() { function renderDashboardWidgetSettings() {
const list = $("dashboard-widget-list"); if (!list) return; 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 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 rows = order.map((id, index) => {
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id; const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
const visible = !hidden.has(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; const descriptionKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
if (descriptionKey) { if (descriptionKey) {
const description = t(descriptionKey); const description = t(descriptionKey);
row.title = description; row.title = description;
toggle.setAttribute("aria-description", 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")); 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 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 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.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown")); 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", () => { toggle.addEventListener("click", () => {
const previous = [...hidden], next = new Set(hidden); if (visible) next.add(id); else next.delete(id); 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(); 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; up.addEventListener("click", () => move(-1)); down.addEventListener("click", () => move(1)); actions.append(up, down); row.append(toggle, actions); return row;
}); });
list.replaceChildren(...rows); 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") { function refreshChartStylePalette(active = adminPreferences?.chart_style || "default") {
const target = $("chart-style-palette"); if (!target || !languagePacks.size) return; const target = $("chart-style-palette"); if (!target || !languagePacks.size) return;
+32
View File
@@ -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 };
})();
+2 -2
View File
@@ -1,9 +1,9 @@
const REFRESH_DEFAULTS = Object.freeze({ 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 cache_seconds: 5, system_info_seconds: 60
}); });
const REFRESH_CHOICES = Object.freeze({ 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] cache_seconds: [0, 3, 5, 10, 30], system_info_seconds: [0, 15, 30, 60, 300]
}); });
function normalizeRefreshPreferences(input = {}) { function normalizeRefreshPreferences(input = {}) {
+159 -105
View File
@@ -1,4 +1,3 @@
function verifyAdminModules() { function verifyAdminModules() {
const required = [ const required = [
["rustProxyIntegrations", "bindings"], ["rustProxyIntegrations", "bindings"],
@@ -12,7 +11,6 @@ function verifyAdminModules() {
const missing = required.find(([module, method]) => typeof window[module]?.[method] !== "function"); const missing = required.find(([module, method]) => typeof window[module]?.[method] !== "function");
if (missing) throw new Error(`Panel resources incomplete (${missing[0]}). Reload to retry.`); if (missing) throw new Error(`Panel resources incomplete (${missing[0]}). Reload to retry.`);
} }
async function startAdminPanel() { async function startAdminPanel() {
try { try {
verifyAdminModules(); verifyAdminModules();
@@ -33,7 +31,6 @@ async function startAdminPanel() {
} }
if (document.readyState === "complete") startAdminPanel(); if (document.readyState === "complete") startAdminPanel();
else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true }); else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true });
function certificateUsesExternalFiles(cert) { function certificateUsesExternalFiles(cert) {
return String(cert?.cert_file || "").startsWith("/") && String(cert?.key_file || "").startsWith("/"); return String(cert?.cert_file || "").startsWith("/") && String(cert?.key_file || "").startsWith("/");
} }
@@ -53,7 +50,6 @@ function syncCertificateFileSource() {
$("certificate-external-acl-example").hidden = !external; $("certificate-external-acl-example").hidden = !external;
} }
$("certificate-file-source").addEventListener("change", syncCertificateFileSource); $("certificate-file-source").addEventListener("change", syncCertificateFileSource);
function renderCertificates() { function renderCertificates() {
if (!envelope) return; if (!envelope) return;
const settings = envelope.config.tls || {}; const settings = envelope.config.tls || {};
@@ -69,7 +65,6 @@ function renderCertificates() {
if (settings.default_certificate === cert.id) used.push(t("defaultLabel")); if (settings.default_certificate === cert.id) used.push(t("defaultLabel"));
const managed = !!cert.issuer, inUse = used.length > 0; const managed = !!cert.issuer, inUse = used.length > 0;
const box = el("div", undefined, "entity certificate-card"); const box = el("div", undefined, "entity certificate-card");
const head = el("div", undefined, "certificate-card-head"); const head = el("div", undefined, "certificate-card-head");
const identity = el("div", undefined, "certificate-card-identity"); const identity = el("div", undefined, "certificate-card-identity");
identity.append(el("span", cert.id, "entity-title")); 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(); }); $("reset-certificate").addEventListener("click", () => { $("certificate-form").reset(); $("certificate-file-source").value = "managed"; syncCertificateFileSource(); });
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(); processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
const hiddenNetworkInterfaces = (() => { const hiddenNetworkInterfaces = (() => {
try { try {
@@ -206,8 +201,20 @@ function svgNode(tag, attrs = {}) {
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value); for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
return node; 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) { function renderNetworkInterfaceCharts(interfaces, appendSample) {
const current = Array.isArray(interfaces) ? interfaces.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))) : []; 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)); const names = new Set(current.map(item => item.name));
for (const name of [...networkInterfaceSamples.keys()]) if (!names.has(name)) networkInterfaceSamples.delete(name); for (const name of [...networkInterfaceSamples.keys()]) if (!names.has(name)) networkInterfaceSamples.delete(name);
if (appendSample) { if (appendSample) {
@@ -222,64 +229,104 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
} }
const target = $("network-interface-charts"); const target = $("network-interface-charts");
if (!target) return; if (!target) return;
target.replaceChildren();
const visible = current.filter(item => item?.name && !hiddenNetworkInterfaces.has(item.name)); 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); setChartText($("network-interface-chart-value"), t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length));
if (!current.length) { target.append(el("p", t("networkNoInterfaces"), "empty network-interface-empty")); return; } const existing = new Map([...target.children].filter(node => node.classList.contains("network-interface-row"))
for (const item of visible) { .map(node => [node.dataset.interfaceName, node]));
const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] }; const children = [];
const peak = Math.max(1, ...history.rx, ...history.tx); if (!current.length) {
const row = el("div", undefined, "network-interface-row"); const empty = target.querySelector(":scope > .network-interface-empty") || el("p", undefined, "empty network-interface-empty");
const head = el("div", undefined, "mini-live-chart-head network-interface-row-head"); empty.textContent = t("networkNoInterfaces");
const rate = el("span", `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`, "fine"); children.push(empty);
const hide = action(t("hideMetric"), () => { hiddenNetworkInterfaces.add(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); }); } else {
hide.classList.add("network-interface-hide"); for (const item of visible) {
const actions = el("span", undefined, "network-interface-head-actions"); actions.append(rate, hide); const name = item.name;
head.append(el("b", item.name || "-", "mono"), actions); let row = existing.get(name);
const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${item.name} ${t("networkInterfacesHistory")}` }); if (!row) {
svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" })); row = el("div", undefined, "network-interface-row");
svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55" })); row.dataset.interfaceName = name;
svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55" })); const head = el("div", undefined, "mini-live-chart-head network-interface-row-head");
row.append(head, svg); target.append(row); const rate = el("span", undefined, "fine network-interface-rate");
} const hide = action(t("hideMetric"), () => {
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name)); hiddenNetworkInterfaces.add(name);
if (hidden.length) { saveHiddenNetworkInterfaces();
const hiddenRow = el("div", undefined, "network-interface-hidden"); renderNetworkInterfaceCharts(latestNetworkInterfaceSnapshot, false);
hiddenRow.append(el("span", t("hiddenInterfaces"), "fine")); });
for (const item of hidden) { hide.classList.add("network-interface-hide");
const show = action(`${t("showMetric")} ${item.name}`, () => { hiddenNetworkInterfaces.delete(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); }); const actions = el("span", undefined, "network-interface-head-actions");
hiddenRow.append(show); 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); const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
} else if (!visible.length) { if (hidden.length) {
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty")); 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) { 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-h3").textContent = fmt(m.http3_requests);
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`; $("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`; $("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); if (systemSample) {
$("process-summary").hidden = d.process_threads == null; if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes);
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`; $("process-summary").hidden = d.process_threads == null;
const systemCpuSupported = d.cpu_percent != null; if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
setOptionalMetric("system-cpu", systemCpuSupported, systemCpuSupported ? `${fmt(d.cpu_percent, 1)}%` : ""); setOptionalMetric("system-cpu", d.cpu_percent != null, `${fmt(d.cpu_percent, 1)}%`);
$("system-chart").hidden = !systemCpuSupported; const sourceSupported = !!d.source && d.source !== "unsupported";
const sourceSupported = !!d.source && d.source !== "unsupported"; $("system-source").hidden = !sourceSupported; if (sourceSupported) $("system-source").textContent = t("realSample");
$("system-source").hidden = !sourceSupported; const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
if (sourceSupported) $("system-source").textContent = t("realSample"); setOptionalMetric("system-memory", systemMemory != null, systemMemory);
if (systemSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); } $("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(" ")); $("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-state").textContent = t(envelope?.config.cache?.enabled ? "enabled" : "notEnabled");
$("cache-counters").textContent = [c.hits, c.misses, c.bypasses].map(n => fmt(n)).join(" / "); $("cache-counters").textContent = [c.hits, c.misses, c.bypasses].map(n => fmt(n)).join(" / ");
$("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`; $("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); } 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); const currentCounters = [c.hits, c.misses, c.bypasses].map(Number);
if (currentCounters.every(Number.isFinite)) { if (currentCounters.every(Number.isFinite)) {
const delta = lastCacheCounters ? currentCounters.map((value, index) => value >= lastCacheCounters[index] ? value - lastCacheCounters[index] : 0) : [0, 0, 0]; const delta = rustProxyRateSampling.difference(lastCacheCounters, currentCounters, performance.now(), rustProxyRateSampling.cadence("overview_seconds"));
cacheHitSamples.push(delta[0]); cacheMissSamples.push(delta[1]); cacheBypassSamples.push(delta[2]); const values = delta.deltas || lastCacheOutcomeRates || (!lastCacheCounters ? [0, 0, 0] : null);
cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60); if (values) {
lastCacheCounters = currentCounters; 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 (appendSample) {
if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); } if (live.process_cpu_percent != null) { processCpuSamples.push(Number(live.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 (live.process_rss_bytes != null) { processRssSamples.push(Number(live.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); }
} }
const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples); const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples);
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak)); $("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
$("cache-miss-sparkline").setAttribute("points", sparklinePoints(cacheMissSamples, 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)); $("cache-bypass-sparkline").setAttribute("points", sparklinePoints(cacheBypassSamples, 300, 60, 5, cacheOutcomePeak));
const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)]; 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; 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-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 transientLimit = Number(c.transient_limit_bytes);
const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0 const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0
? Math.max(transientLimit, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples) ? 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-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak));
$("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 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-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 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; 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-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak));
$("cache-cold-chart-value").textContent = coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"); setChartText($("cache-cold-chart-value"), coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"));
const processPlatform = [d.platform, d.environment].filter(Boolean).join(" / "); if (systemSample) {
setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", processPlatform); const processPlatform = [d.platform, d.environment].filter(Boolean).join(" / ");
setOptionalMetric("process-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`); setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", processPlatform);
const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited")); setOptionalMetric("process-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`);
setOptionalMetric("process-fds", fds != null, fds); const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited"));
setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads)); setOptionalMetric("process-fds", fds != null, fds);
const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited")); setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads));
setOptionalMetric("process-virtual-memory", virtualMemory != null, virtualMemory); const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
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-virtual-memory", virtualMemory != null, virtualMemory);
setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds)); 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-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes)); setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
const memory = status.memory_accounting || {}; setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value)); const memory = status.memory_accounting || {};
const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value)); const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value));
const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value)); const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value));
const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value)); const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value));
setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory); const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value));
setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory); setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory);
setOptionalMetric("memory-transform", transformMemory != null, transformMemory); setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory);
setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight); setOptionalMetric("memory-transform", transformMemory != null, transformMemory);
setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes)); setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight);
setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes)); setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value)); setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
setOptionalMetric("process-rss-file", rssFile != null, rssFile); const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value)); setOptionalMetric("process-rss-file", rssFile != null, rssFile);
setOptionalMetric("process-data-swap", dataSwap != null, dataSwap); const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value));
const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited")); setOptionalMetric("process-data-swap", dataSwap != null, dataSwap);
setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory); 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-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-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
$("process-rss-chart-value").textContent = d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes); setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = d.process_cpu_percent == null; $("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
$("process-rss-sparkline").closest(".mini-live-chart").hidden = d.process_rss_bytes == null; $("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
updateCacheMemorySizing(); updateCacheMemorySizing();
const networkRateSupported = d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null; const networkRateSupported = live.network_rx_bytes_per_second != null && live.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)}` : ""); 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`); setOptionalMetric("mean-latency", m.mean_duration_ms != null, m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`);
if (appendSample) { if (appendSample) {
if (d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null) { if (networkRateSupported) {
networkRxSamples.push(Number(d.network_rx_bytes_per_second)); networkTxSamples.push(Number(d.network_tx_bytes_per_second)); 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); 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); } 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); const networkPeak = Math.max(1, ...networkRxSamples, ...networkTxSamples);
$("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak)); $("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak));
$("network-tx-sparkline").setAttribute("points", sparklinePoints(networkTxSamples, 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; $("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-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; $("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(" / "); $("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; const age = d.sampled_at_ms ? Math.max(0, Math.floor((Date.now() - d.sampled_at_ms) / 1000)) : null;
+10 -10
View File
@@ -88,17 +88,17 @@ window.rustProxyStreamDashboard = (() => {
const rxTotal = views.reduce((sum, view) => sum + (Number(view.bytes_in) || 0), 0); 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 txTotal = views.reduce((sum, view) => sum + (Number(view.bytes_out) || 0), 0);
const now = performance.now(); const now = performance.now();
let rx = 0, tx = 0; const sample = rustProxyRateSampling.difference(history.last, [rxTotal, txTotal], now, rustProxyRateSampling.cadence("overview_seconds"));
if (history.last) { const pausedRate = history.last !== null && sample.deltas === null;
const seconds = Math.max(.001, (now - history.last.when) / 1000); history.last = sample.next;
if (rxTotal >= history.last.rx) rx = (rxTotal - history.last.rx) / seconds; const rx = sample.deltas ? sample.deltas[0] / sample.seconds : 0;
if (txTotal >= history.last.tx) tx = (txTotal - history.last.tx) / seconds; 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 }; set("stream-dashboard-traffic", pausedRate ? "-" : `${bytes(Math.round(rx))}/s`);
history.rx.push(rx); history.tx.push(tx); trafficRates(pausedRate ? "-" : `${bytes(Math.round(rx))}/s`, pausedRate ? "-" : `${bytes(Math.round(tx))}/s`);
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`);
$("stream-dashboard-rx")?.setAttribute("points", line(history.rx)); $("stream-dashboard-rx")?.setAttribute("points", line(history.rx));
$("stream-dashboard-tx")?.setAttribute("points", line(history.tx)); $("stream-dashboard-tx")?.setAttribute("points", line(history.tx));
} }
+15 -12
View File
@@ -1,7 +1,7 @@
"use strict"; "use strict";
(() => { (() => {
let loading = false, offset = 0, generation = 0; 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 liveChartSamples = { hits: [], misses: [], bypasses: [] };
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
const LIVE_CHART_STORAGE_KEY = "rustproxy.cache.liveChartSamples"; 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; }; const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; };
function resetLiveChart(selected = scope()) { 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 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-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"; 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), hits: Math.max(0, Number(counters.hits) || 0),
misses: Math.max(0, Number(counters.misses) || 0), misses: Math.max(0, Number(counters.misses) || 0),
bypasses: Math.max(0, Number(counters.bypasses) || 0) bypasses: Math.max(0, Number(counters.bypasses) || 0)
}, rates = { hits: 0, misses: 0, bypasses: 0 }; };
if (liveChartPrevious && liveChartPreviousAt != null) { const keys = Object.keys(current);
const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000); const previous = liveChartPrevious && liveChartPreviousAt != null
for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed; ? { 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(); redrawLiveChart();
$("cache-live-hit-rate").textContent = `${rates.hits.toFixed(1)}/s`; const rate = key => sample.deltas ? `${(sample.deltas[keys.indexOf(key)] / sample.seconds).toFixed(1)}/s` : "-";
$("cache-live-miss-rate").textContent = `${rates.misses.toFixed(1)}/s`; $("cache-live-hit-rate").textContent = rate("hits");
$("cache-live-bypass-rate").textContent = `${rates.bypasses.toFixed(1)}/s`; $("cache-live-miss-rate").textContent = rate("misses");
$("cache-live-chart-value").textContent = `Σ ${(rates.hits + rates.misses + rates.bypasses).toFixed(1)}/s`; $("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() { function configure() {
const selected = scope(); const items = [el("option", t("logAllVhosts"))]; items[0].value = "*"; const selected = scope(); const items = [el("option", t("logAllVhosts"))]; items[0].value = "*";
+37 -12
View File
@@ -2477,8 +2477,9 @@
data-appearance-reset="dashboard" data-i18n="resetSection" data-appearance-reset="dashboard" data-i18n="resetSection"
type="button">Reset section</button> type="button">Reset section</button>
</div> </div>
<div class="appearance-settings-grid"> <div class="appearance-settings-grid dashboard-appearance-grid">
<label><span data-i18n="dashboardDensity">Dashboard density</span><select <label class="dashboard-density-setting"><span
data-i18n="dashboardDensity">Dashboard density</span><select
id="ui-dashboard-density"> id="ui-dashboard-density">
<option data-i18n="densityNormal" value="normal">Normal</option> <option data-i18n="densityNormal" value="normal">Normal</option>
<option data-i18n="densityCompact" value="compact">Compact</option> <option data-i18n="densityCompact" value="compact">Compact</option>
@@ -2486,15 +2487,38 @@
</select><small class="fine" data-i18n="dashboardDensityHelp">Controls </select><small class="fine" data-i18n="dashboardDensityHelp">Controls
Overview telemetry card size without changing the rest of the Overview telemetry card size without changing the rest of the
interface.</small></label> interface.</small></label>
<div class="dashboard-widget-settings"><span <div class="dashboard-widget-settings">
data-i18n="dashboardWidgets">Overview cards</span><small class="fine" <div class="dashboard-widget-settings-head">
data-i18n="dashboardWidgetsHelp">Show, hide and reorder the telemetry <div class="dashboard-widget-settings-intro">
cards at the top of Overview.</small> <span data-i18n="dashboardWidgets">Overview cards</span>
<button class="outline dashboard-layout-open-settings" type="button" <small class="fine" data-i18n="dashboardWidgetsHelp">Show, hide and
id="overview-layout-open-settings" reorder all Overview blocks, including charts, diagnostics and
data-i18n="overviewLayoutEditorOpenSettings">Visual layout the backend table.</small>
editor</button> </div>
<div class="dashboard-widget-list" id="dashboard-widget-list"></div> <div class="dashboard-widget-settings-controls">
<button class="outline dashboard-layout-open-settings" type="button"
id="overview-layout-open-settings"
data-i18n="overviewLayoutEditorOpenSettings">Visual layout
editor</button>
<button class="quiet" id="dashboard-widget-expand" type="button"
aria-expanded="false" aria-controls="dashboard-widget-list"
data-i18n="dashboardWidgetsShowAll">Show all</button>
<button class="quiet dashboard-widget-scroll-button"
id="dashboard-widget-scroll-prev" type="button"
data-i18n-aria="dashboardWidgetsScrollBack"
aria-label="Scroll cards left">&larr;</button>
<button class="quiet dashboard-widget-scroll-button"
id="dashboard-widget-scroll-next" type="button"
data-i18n-aria="dashboardWidgetsScrollNext"
aria-label="Scroll cards right">&rarr;</button>
</div>
</div>
<div class="dashboard-widget-list" id="dashboard-widget-list" role="group"
data-i18n-aria="dashboardWidgets" aria-label="Overview cards"
tabindex="0"></div>
<small class="fine dashboard-widget-scroll-hint"
data-i18n="dashboardWidgetsScrollHelp">Scroll horizontally to browse
cards, or expand the list.</small>
<small class="fine" data-i18n="streamWidgetSettingsHelp">Stream cards are <small class="fine" data-i18n="streamWidgetSettingsHelp">Stream cards are
opt-in and only change Dashboard presentation, not Stream routing or opt-in and only change Dashboard presentation, not Stream routing or
monitoring.</small> monitoring.</small>
@@ -2520,7 +2544,8 @@
<select id="refresh-overview-seconds" <select id="refresh-overview-seconds"
data-refresh-preference="overview_seconds"> data-refresh-preference="overview_seconds">
<option value="1">1 s</option> <option value="1">1 s</option>
<option selected value="2">2 s</option> <option value="2">2 s</option>
<option selected value="3">3 s</option>
<option value="5">5 s</option> <option value="5">5 s</option>
<option value="10">10 s</option> <option value="10">10 s</option>
</select></label> </select></label>
+8 -5
View File
@@ -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); 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)) : "-"); 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; 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 history = chartHistory.get(id) || { last: null, 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 sample = rustProxyRateSampling.difference(history.last, [Number(data.bytes_in) || 0, Number(data.bytes_out) || 0], now, rustProxyRateSampling.cadence("overview_seconds"));
const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0; const pausedRate = history.last !== null && sample.deltas === null;
history.lastTime = now; history.lastIn = inBytes; history.lastOut = outBytes; addPoint(history, rx, tx); chartHistory.set(id, history); 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 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 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 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"); } 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"); }
} }
+1496 -944
View File
File diff suppressed because it is too large. Load diff
+16 -13
View File
@@ -1,7 +1,7 @@
"use strict"; "use strict";
(() => { (() => {
let loading = false, generation = 0, lastData = null; 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 liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] };
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
const LIVE_CHART_STORAGE_KEY = "rustproxy.transform.liveChartSamples"; 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: {} }); 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()) { 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 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-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"; 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), cache_hits: Math.max(0, Number(counters.cache_hits) || 0),
skipped: Math.max(0, Number(counters.skipped) || 0), skipped: Math.max(0, Number(counters.skipped) || 0),
errors: Math.max(0, Number(counters.errors) || 0) errors: Math.max(0, Number(counters.errors) || 0)
}, rates = { applied: 0, cache_hits: 0, skipped: 0, errors: 0 }; };
if (liveChartPrevious && liveChartPreviousAt != null) { const keys = Object.keys(current);
const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000); const previous = liveChartPrevious && liveChartPreviousAt != null
for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed; ? { 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(); redrawLiveChart();
$("transform-live-applied-rate").textContent = `${rates.applied.toFixed(1)}/s`; const rate = key => sample.deltas ? `${(sample.deltas[keys.indexOf(key)] / sample.seconds).toFixed(1)}/s` : "-";
$("transform-live-cache-rate").textContent = `${rates.cache_hits.toFixed(1)}/s`; $("transform-live-applied-rate").textContent = rate("applied");
$("transform-live-skipped-rate").textContent = `${rates.skipped.toFixed(1)}/s`; $("transform-live-cache-rate").textContent = rate("cache_hits");
$("transform-live-error-rate").textContent = `${rates.errors.toFixed(1)}/s`; $("transform-live-skipped-rate").textContent = rate("skipped");
$("transform-live-chart-value").textContent = `Σ ${(rates.applied + rates.cache_hits + rates.skipped + rates.errors).toFixed(1)}/s`; $("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) { function countersFor(data) {
if (scope() === "*") return data?.metrics?.total || emptyCounters(); if (scope() === "*") return data?.metrics?.total || emptyCounters();
+53 -12
View File
@@ -101,6 +101,30 @@ async function toggleVhostEnabled(vhost) {
if (await saveSection("vhosts", next.vhosts || [])) render(); 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() { function render() {
if (!envelope) return; if (!envelope) return;
if (!initialized) load(defaults(), [], false); 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); } 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); box.append(details);
const live = el("div", undefined, "vhost-live-grid"); 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); }; 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"); 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")); policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "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); if (policy.children.length) box.append(policy);
return box; 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 reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
const previous = backendTrafficHistory.get(key); const previous = backendTrafficHistory.get(key);
if (previous?.snapshot === snapshot) return previous.rate; if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds"));
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; const observed = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0;
const rate = reported > 0 ? reported : observed; 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; return rate;
} }
function vhostTrafficRate(id, metrics, snapshot, now, seen) { 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 total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
const previous = vhostTrafficHistory.get(id); const previous = vhostTrafficHistory.get(id);
if (previous?.snapshot === snapshot) return previous.rate; if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds"));
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; const rate = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0;
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate }); vhostTrafficHistory.set(id, { ...rustProxyRateSampling.stamp(now), total, snapshot, rate });
return rate; return rate;
} }
function renderStatus(status) { function renderStatus(status) {
renderUpstreamProtocolBadges(status);
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.()); form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map(); const rows = [], summaries = new Map(), aggregate = new Map();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set(); 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`); fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
const now = performance.now(); const now = performance.now();
for (const [id, data] of aggregate) { for (const [id, data] of aggregate) {
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 }; const history = chartHistory.get(id) || { last: null, 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; const sample = rustProxyRateSampling.difference(history.last, [data.requests], now, rustProxyRateSampling.cadence("overview_seconds"));
history.pendingSum += rate; history.pendingCount += 1; const pausedRate = history.last !== null && sample.deltas === null;
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; } 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; } 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); 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 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 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")); const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
+7
View File
@@ -85,6 +85,13 @@ impl Pool {
"upstream_client_rotations_total".into(), "upstream_client_rotations_total".into(),
json!(client_rotation.rotations), 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("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("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0));
item.insert("last_probe_ms".into(), json!(h.last_probe_ms)); item.insert("last_probe_ms".into(), json!(h.last_probe_ms));
+5 -2
View File
@@ -52,7 +52,9 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
last_char = Some(' '); last_char = Some(' ');
} }
pending_space = false; 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.pop();
} }
out.push(ch); out.push(ch);
@@ -71,5 +73,6 @@ fn needs_space(previous: Option<char>, next: Option<char>) -> bool {
return false; return false;
}; };
!matches!(previous, '{' | '}' | ':' | ';' | ',' | '>') !matches!(previous, '{' | '}' | ':' | ';' | ',' | '>')
&& !matches!(next, '{' | '}' | ':' | ';' | ',' | '>') // A ':' can introduce a pseudo-class after a descendant combinator.
&& !matches!(next, '{' | '}' | ';' | ',' | '>')
} }
+25
View File
@@ -405,4 +405,29 @@ mod tests {
let received = response.into_body().collect().await.unwrap().to_bytes(); let received = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&received[..], encoded.as_slice()); 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");
}
} }
+55 -2
View File
@@ -6,7 +6,7 @@ use crate::config::{Backend, UpstreamProtocol};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use axum::body::Body; use axum::body::Body;
use http::{Request, Response}; use http::{Request, Response, Version};
use hyper::body::Incoming; use hyper::body::Incoming;
use hyper_rustls::HttpsConnectorBuilder; use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::{ use hyper_util::{
@@ -50,6 +50,10 @@ struct RotatingClient {
generation_started_ms: AtomicU64, generation_started_ms: AtomicU64,
rotations: AtomicU64, rotations: AtomicU64,
rotating: AtomicBool, rotating: AtomicBool,
observed_version: AtomicU64,
observed_at_ms: AtomicU64,
observed_h1: AtomicU64,
observed_h2: AtomicU64,
} }
/// Backend HTTP client with bounded generation lifetime. /// Backend HTTP client with bounded generation lifetime.
@@ -71,6 +75,14 @@ pub struct ClientRotationStats {
pub rotations: u64, 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 { impl std::fmt::Debug for ProxyClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter formatter
@@ -91,13 +103,25 @@ impl ProxyClient {
generation_started_ms: AtomicU64::new(monotonic_millis()), generation_started_ms: AtomicU64::new(monotonic_millis()),
rotations: AtomicU64::new(0), rotations: AtomicU64::new(0),
rotating: AtomicBool::new(false), 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<Body>) -> Result<Response<Incoming>, ClientError> { pub async fn request(&self, request: Request<Body>) -> Result<Response<Incoming>, ClientError> {
let client = self.inner.client_for_request(); 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 { pub fn rotation_stats(&self) -> ClientRotationStats {
@@ -119,6 +143,35 @@ impl ProxyClient {
} }
impl RotatingClient { 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 { fn stats(&self) -> ClientRotationStats {
let now = monotonic_millis(); let now = monotonic_millis();
ClientRotationStats { ClientRotationStats {
+17
View File
@@ -11,6 +11,23 @@ mod tests {
.unwrap() .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] #[test]
fn generation_recycles_after_request_budget() { fn generation_recycles_after_request_budget() {
let backend = backend("rotation-test", 18081); let backend = backend("rotation-test", 18081);
@@ -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-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') 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"') 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') original_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
page.evaluate('document.documentElement.dataset.theme = "ultra_dark"') page.evaluate('document.documentElement.dataset.theme = "ultra_dark"')
black_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor') black_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
@@ -29,6 +29,7 @@ const ctx = {
bytes: v => `${v} B`, currentPanel: 'overview', performance: { now: () => now }, bytes: v => `${v} B`, currentPanel: 'overview', performance: { now: () => now },
}; };
vm.createContext(ctx); vm.createContext(ctx);
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx);
vm.runInContext(source, ctx); vm.runInContext(source, ctx);
const module = window.rustProxyBackendDashboard; const module = window.rustProxyBackendDashboard;
const data = { const data = {
@@ -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');
+2 -2
View File
@@ -7,7 +7,7 @@ import tomllib
root = Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parents[2]
read = lambda path: (root / path).read_text(encoding='utf-8') read = lambda path: (root / path).read_text(encoding='utf-8')
version = tomllib.loads(read('Cargo.toml'))['package']['version'] 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')) spec = json.loads(read('src/admin/openapi.json'))
assert spec['info']['version'] == version assert spec['info']['version'] == version
paths = spec['paths'] paths = spec['paths']
@@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'):
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate', for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'): 'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
assert strings.get(key), (language, key) 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).') print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
+9 -9
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root) preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text()) 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" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
h3dep = manifest["dependencies"]["h3"] h3dep = manifest["dependencies"]["h3"]
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"] 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" lock_path = root / "Cargo.lock"
if lock_path.exists(): if lock_path.exists():
lock = lock_path.read_text() 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" 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 { for release_doc, marker in {
"README.md": "# RustProxy 0.32.5", "README.md": "# RustProxy 0.32.6",
"CHANGELOG.md": "## 0.32.5", "CHANGELOG.md": "## 0.32.6",
"ROADMAP.md": "## 0.32.5", "ROADMAP.md": "## 0.32.6",
"AGENTS.md": "Current source version: 0.32.5.", "AGENTS.md": "Current source version: 0.32.6.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.5", "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.6",
}.items(): }.items():
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" 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" 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 '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
assert '"StreamMetrics"' in openapi and '"streams"' 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 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"']: 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 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 '"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 '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 '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. # 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs") tls_0241 = rust_source(root, "src/tls.rs")
+91
View File
@@ -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('<section class="appearance-section">',0,heading)
section_end=html.index('</section>',heading)+len('</section>')
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('<!doctype html><html data-theme="dark" lang="pl"><head><style>'+css+'</style></head><body><main style="margin:0; padding:28px 16px; width:100%; max-width:100%; box-sizing:border-box">'+source+'</main></body></html>',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()
@@ -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');
+1 -1
View File
@@ -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') parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
args = parser.parse_args() args = parser.parse_args()
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text()) 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' assert manifest['package']['rust-version'] == '1.99'
deps = manifest['dependencies'] deps = manifest['dependencies']
expected_requirements = { expected_requirements = {
+1 -1
View File
@@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin
readme = (ROOT / "README.md").read_text(encoding="utf-8") readme = (ROOT / "README.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8") roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
changelog = (ROOT / "CHANGELOG.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 "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 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 assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 from pathlib import Path
import json import json
import tomllib import tomllib
@@ -25,8 +25,8 @@ checks = {
'visual selector and disabled DNS deny': '.acl-profile-rule-type' in js and 'aclDnsAllowOnly' in js, 'visual selector and disabled DNS deny': '.acl-profile-rule-type' in js and 'aclDnsAllowOnly' in js,
'old network field persisted': 'network: normalizeNetwork(' 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')), '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(): for name, ok in checks.items():
if not ok: raise AssertionError(name) 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)')
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
html = (ROOT / 'src/admin/web/index.html').read_text() 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 '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation' assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0] assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text() body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Regression for live Network diagnostic DOM stability beside Process.
Runs the actual renderer and the shipped dashboard CSS with mocked sample data
in Chromium touch emulation. This does NOT certify Safari/iOS 27 behavior.
"""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
process = soup.select_one('[data-dashboard-widget="process_diagnostics"]')
network = soup.select_one('[data-dashboard-widget="network_diagnostics"]')
assert process and network
source = (WEB / 'app/runtime.js').read_text()
start = source.index('function renderNetworkInterfaceCharts(interfaces, appendSample) {')
start = source.rfind('let latestNetworkInterfaceSnapshot', 0, start)
assert start >= 0
renderer = source[start:source.index('function renderDiagnostics(', start)]
helper = source[source.index('function setChartText('):start]
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
'<head><meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>' + (WEB / 'style.css').read_text() + '</style></head>'
'<body style="margin:0"><div style="height:1020px">Overview metrics</div>'
'<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned" '
'id="dashboard-overview-grid">' + str(process) + str(network) +
'</div></section><div style="height:160px"></div></body></html>')
stubs = '''
const networkInterfaceSamples = new Map();
const hiddenNetworkInterfaces = new Set();
const $ = id => document.getElementById(id);
const t = s => ({networkInterfacesVisibleCount:'{visible} / {total}',
networkInterfaceRate:'Rate', networkInterfacesHistory:'Traffic',
hideMetric:'Hide', showMetric:'Show',hiddenInterfaces:'Hidden',
networkNoInterfaces:'No interfaces',networkNoVisibleInterfaces:'None visible'})[s] || s;
const fmtBytes = n => `${n} B`;
const el = (tag, text, cls) => {const node=document.createElement(tag);
if(text !== undefined) node.textContent=text;if(cls) node.className=cls;return node;};
const action = (label, fn) => {const node=el('button',label);node.addEventListener('click',fn);return node;};
const svgNode = (tag, attrs={}) => {const node=document.createElementNS('http://www.w3.org/2000/svg',tag);
Object.entries(attrs).forEach(([key,value])=>node.setAttribute(key,value));return node;};
const sparklinePoints = (values,width=300,height=60,padding=5,peakOverride=null) => {
const peak=Math.max(1,peakOverride??Math.max(0,...values));
return values.map((value,index)=>`${index*width/Math.max(1,values.length-1)},${height-padding-Math.max(0,value)/peak*(height-padding*2)}`).join(' ');
};
const saveHiddenNetworkInterfaces = () => {};
'''
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
context = browser.new_context(viewport={'width':393,'height':844},
device_scale_factor=3, is_mobile=True, has_touch=True)
page = context.new_page()
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=stubs + helper + renderer)
baseline = page.evaluate('''() => {
const items = [{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:20},
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}];
renderNetworkInterfaceCharts(items,true);
const panel = document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]');
const target = document.getElementById('network-interface-charts');
window.testNodes = {first:target.firstElementChild, svg:target.querySelector('svg'),
path:target.querySelector('.network-rx-line')};
window.testInterfaces = items;
window.scrollTo(0,Math.round(network.getBoundingClientRect().top+window.scrollY+180));
return {processHeight:panel.getBoundingClientRect().height, y:scrollY,
network:network.getBoundingClientRect().top, count:target.children.length,
scrollHeight:document.documentElement.scrollHeight};
}''')
assert baseline['y'] > 0 and baseline['count'] == 2, baseline
count = page.evaluate('''() => {
const target = document.getElementById('network-interface-charts');
let mutations=0;
const obs = new MutationObserver(list=>mutations+=list.filter(item=>item.type==='childList').length);
obs.observe(target,{childList:true,subtree:true});
const beforeY=scrollY;
const beforeHeight=document.documentElement.scrollHeight;
const beforeProcess=document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height;
for(let i=0;i<45;i++){
renderNetworkInterfaceCharts([{name:'ens18',rx_bytes_per_second:i*109,tx_bytes_per_second:12},
{name:'lo',rx_bytes_per_second:i*2,tx_bytes_per_second:3}],true);
if(target.firstElementChild!==window.testNodes.first ||
target.querySelector('svg')!==window.testNodes.svg ||
target.querySelector('.network-rx-line')!==window.testNodes.path) throw Error('Network DOM replaced');
}
obs.takeRecords().forEach(r=> {if(r.type==='childList')mutations++});obs.disconnect();
return {mutations, y:scrollY, beforeY, count:target.children.length,
scrollHeight:document.documentElement.scrollHeight, beforeHeight,
processHeight:document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height, beforeProcess,
changed:target.querySelector('.network-interface-rate').textContent.includes('4796')};
}''')
assert count['mutations'] == 0, count
assert abs(count['y'] - count['beforeY']) < 2, count
assert count['count'] == 2 and count['changed'], count
assert abs(count['scrollHeight'] - count['beforeHeight']) < 2, count
assert abs(count['processHeight'] - count['beforeProcess']) < 2, count
print('PASS: 45 updates preserve SVG nodes, Process height, document height and scroll')
# Interface charts advance at every Overview live tick like the aggregate
# network chart, repeating the last observed rate until system-info updates.
# A system-info-only refresh or Hide/Show redraw never adds history points.
history = page.evaluate('''() => {
networkInterfaceSamples.clear();
const one=[{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200}];
renderNetworkInterfaceCharts(one,true);
const before=networkInterfaceSamples.get('ens18').rx.length;
for(let i=0;i<45;i++) renderNetworkInterfaceCharts(one,true);
const live=networkInterfaceSamples.get('ens18').rx.length;
const measured=[{name:'ens18',rx_bytes_per_second:500,tx_bytes_per_second:250}];
renderNetworkInterfaceCharts(measured,false); // only new system-info data
const afterSystem=networkInterfaceSamples.get('ens18').rx.length;
const label=document.querySelector('.network-interface-rate').textContent;
renderNetworkInterfaceCharts(measured,true); // next live tick
const afterLive=networkInterfaceSamples.get('ens18').rx.length;
const newest=networkInterfaceSamples.get('ens18').rx.at(-1);
return {before,live,afterSystem,afterLive,label,newest};
}''')
assert history['before'] == 1 and history['live'] == 46, history
assert history['afterSystem'] == 46 and history['afterLive'] == 47, history
assert history['newest'] == 500 and '500 B' in history['label'], history
assert 'renderNetworkInterfaceCharts(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()
+133
View File
@@ -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 = ('<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded" '
'data-dashboard-density="compact"><head><meta name="viewport" '
'content="width=device-width, initial-scale=1"><style>'
+ (WEB / 'style.css').read_text() + '</style></head>'
'<body style="margin:0"><div style="height:1100px">Earlier dashboard cards</div>'
'<section id="panel-overview"><div id="dashboard-overview-grid" '
'class="stats-grid dashboard-overview-grid is-aligned">'
+ str(process) + str(network) + '</div></section>'
'<div class="vhost-policy-pills"><small class="pill">H1</small>'
'<small class="vhost-backend-protocol-badge" data-observed="true">AUTO → H2</small></div>'
'<div style="height:900px">Footer</div></body></html>')
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()
+38
View File
@@ -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')
+126
View File
@@ -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 = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact" '
'data-corner-style="default"><head><meta name="viewport" '
'content="width=device-width,initial-scale=1"><style>' + css +
'</style></head><body style="margin:0">'
'<div style="height:80px">Mobile navigation</div>'
'<section id="panel-overview" style="width:auto;padding:0 10px;box-sizing:border-box">'
'<div id="dashboard-overview-grid" class="stats-grid dashboard-overview-grid">' +
''.join(map(str, widgets)) + '</div></section>'
'<div id="radii" style="padding:10px"><article class="vhost-card" id="vhost-test">Vhost</article>'
'<div class="entity" id="entity-test">Entity</div>'
'<span class="vhost-backend-protocol-badge" id="badge-test">AUTO → H2</span></div>'
'</body></html>')
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()
@@ -21,6 +21,7 @@ const context = {
document: { querySelector: selector => widgets.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null }, document: { querySelector: selector => widgets.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
}; };
vm.createContext(context); vm.createContext(context);
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
vm.runInContext(source, context); vm.runInContext(source, context);
const render = context.window.rustProxyExtraOverview.render; const render = context.window.rustProxyExtraOverview.render;
const text = id => field(`overview-extra-${id}`).textContent; const text = id => field(`overview-extra-${id}`).textContent;
@@ -147,7 +147,7 @@ with sync_playwright() as playwright:
cases=[(1720,0,1,'normal',12),(1350,180,1,'compact',12), cases=[(1720,0,1,'normal',12),(1350,180,1,'compact',12),
(1200,260,1,'normal',6),(900,0,1,'compact',6), (1200,260,1,'normal',6),(900,0,1,'compact',6),
(900,190,1,'normal',6),(720,190,1,'ultra',2), (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,.9,'compact',6),(1140,200,1.1,'normal',6),
(1140,200,1.3,'normal',6)] (1140,200,1.3,'normal',6)]
for width,sidebar,zoom,density,max_columns in cases: for width,sidebar,zoom,density,max_columns in cases:
@@ -16,7 +16,8 @@ function $(id) {
let now = 1000; let now = 1000;
const ctx = {window:{},currentPanel:'overview',performance:{now:()=>now},fmt:(n,places=0)=>Number(n).toFixed(places),fmtBytes:n=>`${n} B`,$, 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}}; 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 render=ctx.window.rustProxyWideOverview.render;
const text=id=>$(id).textContent; const text=id=>$(id).textContent;
const baseline = { revision:1, const baseline = { revision:1,
+3 -3
View File
@@ -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) retry = re.search(r'fn retry_request\([\s\S]*?\n}\n', proxy)
assert retry and '.uri(uri.clone())' in retry[0] assert retry and '.uri(uri.clone())' in retry[0]
assert 'upstream_request_target(&uri, request.headers_mut()' 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 json.loads(read('src/admin/openapi.json'))['info']['version']=='0.32.6'
assert 'version = "0.32.5"' in read('Cargo.toml') assert 'version = "0.32.6"' in read('Cargo.toml')
print('PASS: 0.32.5 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)') print('PASS: 0.32.6 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)')
+126
View File
@@ -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.');
+3 -3
View File
@@ -95,7 +95,7 @@ function fixture({ withoutSse = false } = {}) {
const h = fixture(); const h = fixture();
h.registerViews(); 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().system_info_seconds, 60);
assert.equal(h.refresh.current().traffic_seconds, 5); assert.equal(h.refresh.current().traffic_seconds, 5);
assert.equal(h.refresh.current().cache_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); assert.equal(h.streams.length, 0);
h.refresh.start(); await h.advance(); h.refresh.start(); await h.advance();
assert.equal(h.streams.length, 1, "one shared SSE connection"); 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/traffic"), 0, "no Traffic on Overview");
assert.equal(h.count("/api/cache"), 0, "no Cache 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"); 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 liveFallback: false, stopLive() {}, startLive() {}, $: () => null
}); });
assert.equal(invalid.live_enabled, true); 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.traffic_seconds, 5);
assert.equal(invalid.cache_seconds, 0); assert.equal(invalid.cache_seconds, 0);
assert.equal(invalid.system_info_seconds, 300); assert.equal(invalid.system_info_seconds, 300);
+3 -3
View File
@@ -8,9 +8,9 @@ from html.parser import HTMLParser
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
def read(rel): return (ROOT / rel).read_text(encoding='utf-8') def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.5' assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.6'
assert 'name = "rust-proxy"\nversion = "0.32.5"' in read('Cargo.lock') assert 'name = "rust-proxy"\nversion = "0.32.6"' in read('Cargo.lock')
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.5' assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.6'
pages = read('src/pages.rs') pages = read('src/pages.rs')
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504): for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
+4 -4
View File
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir() assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file() assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists() 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 / "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 / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
assert (target / "langs/site.json").is_file() 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/certificate.go").is_file()
assert (target / "docker-agent/server.go").is_file() assert (target / "docker-agent/server.go").is_file()
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) 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 manifest["removed_obsolete_runtime_state"] is True
assert not legacy.exists() assert not legacy.exists()
assert not (backup / "legacy-data").exists() assert not (backup / "legacy-data").exists()
@@ -617,7 +617,7 @@ def source_contract_smoke() -> None:
assert marker in admin, marker assert marker in admin, marker
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0] 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 '"/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 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"']: for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
assert marker in html, marker 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 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") openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
assert '"/api/config/history/restore"' in openapi 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 marker in openapi, marker
assert '"software_update"' in openapi and '"update_available"' in openapi assert '"software_update"' in openapi and '"update_available"' in openapi
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']: for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
+2 -2
View File
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
const en = json('langs/en_US.json').translations; const en = json('langs/en_US.json').translations;
const pl = json('langs/pl_pl.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(lib, /pub mod user_agent;/);
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/); assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
assert.match(vhost, /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, /aggregate_min_count/);
assert.match(fileLogs, /file-log-aggregate-copy/); 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.Config.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking); assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id); assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
+1 -1
View File
@@ -26,7 +26,7 @@ const scriptContext = { window, document: { querySelector(selector) {
] } }, latestStatus: null, currentPanel: 'overview', ] } }, latestStatus: null, currentPanel: 'overview',
performance: { now: () => clock }, t: key => key, fmt: n => `${n}`, bytes: n => `${n} B`, Math, 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 widget = window.rustProxyStreamDashboard;
const status = { streams: { active: [ const status = { streams: { active: [
{ id: 'tcp', enabled: true, protocol: 'tcp' }, { id: 'tcp', enabled: true, protocol: 'tcp' },
@@ -65,6 +65,7 @@ const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboa
const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme(')); 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); 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() }; window.rustProxyPreferences = { current: () => context.getPrefs() };
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
vm.runInContext(streamSource, context); vm.runInContext(streamSource, context);
// First load (or page reload): defaults hide the new widgets. // 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'); assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
// Native title tooltips do not participate in layout or create horizontal scroll. // 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.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\)\)/, assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*overflow-x: auto;/s,
'effective Appearance card columns must preserve a readable name and right-aligned arrows'); '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/, assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/,
'old popover DOM and associations must be removed'); 'old popover DOM and associations must be removed');
const widgetList = { children: [], replaceChildren(...children) { this.children = children; } }; const widgetList = { children: [], replaceChildren(...children) { this.children = children; } };
+5 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 These assertions supplement, rather than replace, Rust ALPN, H2C and websocket
runtime tests. No code execution against a RustProxy server is implied. 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.6"' in read('src/admin/openapi.json')
assert 'version = "0.32.5"' in read('Cargo.toml') assert 'version = "0.32.6"' in read('Cargo.toml')
assert 'version = "0.32.5"' in read('Cargo.lock') assert 'version = "0.32.6"' 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') print(f'PASS: 0.32.6 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards')
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Render real Vhost cards with mock config/status to verify badge placement/color.
Headless Chromium fixture only: does not verify authenticated Rust runtime/ALPN.
"""
from pathlib import Path
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
source = (WEB / 'vhosts/render.js').read_text()
start = source.index('const VHOST_PROTOCOL_FRESH_MS = ')
end = source.index('function backendTrafficRate(', start)
renderer = source[start:end]
css = (WEB / 'style.css').read_text()
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded">'
f'<head><meta name="viewport" content="width=device-width,initial-scale=1">'
f'<style>{css}</style></head><body><section id="panel-vhosts">'
f'<div id="backend-diagnostics-card"></div><input id="vhost-search">'
f'<div id="vhost-list"></div><span id="vhost-editing"></span>'
f'</section></body></html>')
stubs = r'''
const $ = id => document.getElementById(id);
const t = key => key;
const locale = () => 'en-US';
const el = (tag, value, cls) => {
const node=document.createElement(tag);
if (value !== undefined) node.textContent=value;
if (cls) node.className=cls;
return node;
};
const text = (tag, key, cls) => el(tag, t(key), cls);
const action = (name, fn) => {
const btn=el('button', name, 'quiet');
btn.addEventListener('click', fn);
return btn;
};
const button = (name, fn, cls) => {
const btn=action(name, fn);
if (cls) btn.className=cls;
return btn;
};
const initialized=true;
let editing=null, latestStatus=null;
const vhostListPrefs={sort:'id',descending:false};
const envelope={config:{listeners:{endpoints:[]},vhosts:[
{id:'proxy-test',enabled:true,frontend:{hosts:['proxy.example'],listener_ids:[]},
source:{type:'reverse_proxy'},force_ssl:true,websockets:true,
backends:[{id:'origin1',protocol:'auto'}, {id:'origin2',protocol:'http1'}]},
{id:'static-test',enabled:true,frontend:{hosts:['static.example'],listener_ids:[]},
source:{type:'web_server',web_service_id:'public'},
backends:[{id:'unused',protocol:'auto'}]},
]}};
'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
for width in (393, 1024):
page = browser.new_page(viewport={'width': width, 'height': 844})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=stubs + renderer + '\nrender();')
initial = page.evaluate('''() => {
const card=document.querySelector('[data-vhost-card="proxy-test"]');
const badge=card.querySelector('.vhost-backend-protocol-badge');
const group=card.querySelector('.vhost-policy-pills');
const staticCard=document.querySelector('[data-vhost-card="static-test"]');
return {badges:group.querySelectorAll('.vhost-backend-protocol-badge').length,
otherPills:group.querySelectorAll('.pill').length,
parent:badge.parentElement===group,
lastChild:card.lastElementChild===group,
separateRows:card.querySelectorAll('.vhost-backend-protocols').length,
staticBadges:staticCard.querySelectorAll('.vhost-backend-protocol-badge').length,
initialText:badge.textContent,
scroll:document.documentElement.scrollWidth,screen:innerWidth};
}''')
assert initial['badges'] == 2 and initial['otherPills'] == 2, initial
assert initial['parent'] and initial['lastChild'], initial
assert initial['separateRows'] == 0 and initial['staticBadges'] == 0, initial
assert initial['initialText'] == 'origin1 · AUTO → ?', initial
assert initial['scroll'] <= initial['screen'] + 2, initial
appearance = page.evaluate('''() => {
const badge=document.querySelector('[data-upstream-protocol-backend="origin1"]');
const expected=token=>{const n=document.createElement('span');n.style.color=`var(${token})`;
document.body.append(n);const c=getComputedStyle(n).color;n.remove();return c;};
const unknown=getComputedStyle(badge).color;
renderUpstreamProtocolBadges({backend_pools:[{id:'proxy-test',members:[
{id:'origin1',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:Date.now()-1000},
{id:'origin2',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:Date.now()-1000}
]}]});
const h2={text:badge.textContent,observed:badge.dataset.observed,
color:getComputedStyle(badge).color,expected:expected('--green')};
return {unknown, expectedUnknown:expected('--muted'), h2};
}''')
assert appearance['unknown'] == appearance['expectedUnknown'], (width, appearance)
assert appearance['h2']['color'] == appearance['h2']['expected'], (width, appearance)
assert appearance['h2']['observed'] == 'true' and appearance['h2']['text'] == 'origin1 · AUTO → H2', appearance
print(f'PASS: {width}px Vhost protocol badges share policy group and preserve green/neutral states')
page.close()
browser.close()
+1 -1
View File
@@ -1,6 +1,6 @@
from pathlib import Path 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 = ( PANEL_PARTS = (
"management.js", "drafts.js", "integrations.js", "vhosts.js", "management.js", "drafts.js", "integrations.js", "vhosts.js",
"web-server-profiles.js", "web-server.js", "docker.js", "streams.js", "web-server-profiles.js", "web-server.js", "docker.js", "streams.js",