This commit is contained in:
Mateusz Gruszczyński committed 2026-10-25 09:58:39 +01:00
1 parent 37c197c095
commit 5ac3617947
48 files changed
+1049 -255

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# RustProxy source and documentation maintenance
Current source version: **0.34.11**. The Cargo package and binary are named `rust-proxy`.
Current source version: **0.34.12**. The Cargo package and binary are named `rust-proxy`.
Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
+11 -1
View File
@@ -1,6 +1,16 @@
# Changelog
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.11.
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.12.
## 0.34.12 — cache diagnostics and optional chart axes (source candidate)
- Origin-provided `Cache-Status`, `X-Cache`, `X-Cache-Hits`, `X-Cache-Reason`, `X-Cache-Resident-Age` and `X-Cache-Status` are retained on forwarded and cached responses; the HOT/COLD cache and conditional `304` refresh no longer erase these values from stored metadata. Cache-disabled and HIT-only display modes leave origin headers untouched.
- `Cache-Status` follows RFC 9211's ordered member model: preserve upstream members and append the RustProxy member if enabled. RustProxy reports `hit; ttl=...` for a local cache hit, `fwd=miss`/`fwd=bypass` for forwarded requests, and `fwd=stale; fwd-status=304` when revalidation used a `304`. The existing fixed reason goes into `detail="..."`; transformation metadata applies to RustProxy's member only. No non-standard `backend=` parameter is generated.
- Existing `X-Cache*` RustProxy diagnostics remain available for compatibility. To avoid deleting an upstream value on a naming conflict, displaced origin values are exposed separately as `X-Upstream-Cache*`, one field value for each original value. These aliases are non-standard; clients should prefer `Cache-Status` when available.
- Added regression coverage for origin/proxy naming collisions, disable/HIT-only modes, HOT/COLD storage, RFC 9211 member order and metadata generated after revalidation.
- **Appearance → Charts:** optional account-scoped **Show X/Y scales** switch, off by default. On sampled line plots, HTML labels show the exact Y range used by the existing SVG renderer (including fixed percentage and adaptive Vhost/Docker ranges) and the X sample indices. Charts with separately normalized HTTP/WebSocket protocol lines identify each series' own Y maximum instead of inventing a common scale. Missing/compacted samples are not mislabeled as minutes; any clipped Vhost peak remains available in the existing legend. Disable the switch to restore original geometry. Donuts, gauges and categorical bars are unchanged.
- The new preference is included in API/Reader docs and both interface languages. Existing accounts retain their chart style and the new switch stays off until explicitly enabled.
- Source-only candidate: the target Rust toolchain and proxy integration suite must pass native compilation/tests before deployment.
## 0.34.11 — adaptive Vhost request-rate scaling
Generated
+1 -1
View File
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.34.11"
version = "0.34.12"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.34.11"
version = "0.34.12"
edition = "2021"
rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration"
+7 -2
View File
@@ -1,4 +1,4 @@
# RustProxy 0.34.11
# RustProxy 0.34.12
RustProxy combines a reverse proxy, static/PHP web server, certificate management and traffic monitoring in one service with a browser administration panel. It supports several domains and backends without requiring a separate proxy configuration language.
@@ -16,6 +16,11 @@ RustProxy combines a reverse proxy, static/PHP web server, certificate managemen
Features and default settings vary by component; configure only the capabilities you need. The [documentation index](docs/README.md) covers each workflow, including prerequisites, security boundaries, troubleshooting and API access.
## 0.34.12 — upstream cache diagnostics and RFC 9211
- **Cache and upstream headers:** retain origin-supplied `Cache-Status` and `X-Cache*` diagnostics on BYPASS, MISS and HOT/COLD HIT; no longer silently strip them when cache diagnostics are disabled or limited to HIT. Origin `Cache-Status` members remain in order; an enabled RustProxy `Cache-Status` member is appended with its own `hit`, `ttl`, `fwd` and `detail` parameters rather than overwriting earlier members. On collisions with the non-standard legacy `X-Cache*` headers, RustProxy keeps its existing names and preserves upstream values as `X-Upstream-Cache*`. These options affect only RustProxy's own diagnostic output. See [Cache](docs/CACHE.md) for examples and RFC 9211 semantics.
- **Appearance:** **Settings → Appearance → Charts → Show X/Y scales** adds optional, off-by-default numeric chart scales to live SVG plots. The existing lines, colors and dynamic Y limits (including adaptive Vhost/Docker rate scales) are unchanged; Y uses the exact plotted range and X shows sample numbers, not inferred minutes. The setting is account-scoped and reversible. See [Dashboard](docs/DASHBOARD.md).
## 0.34.11 — adaptive live request-rate charts
- **Vhosts and Docker Vhosts:** the lifetime request-rate sparkline now rescales with recent traffic. Isolated extreme peaks no longer flatten ordinary request activity; sustained traffic growth expands the scale and it shrinks again as traffic drops. Samples above the visible range are capped at the top of the plot, while the text legend retains the real recorded maximum. Hover the value to see the current scale range. Existing statistics collection, request counters and metrics APIs are unchanged. See [Vhosts](docs/VHOSTS.md) and [Docker discovery](docs/DOCKER_DISCOVERY.md).
@@ -123,4 +128,4 @@ ownership, cascade rules, duplicate checks and browser regression commands.
## Status of this source archive
Version **0.34.11** is provided as a **source-only release candidate**. It retains the experimental PQC implementation introduced in 0.34.9, whose dependencies were provisionally locked using published crate metadata. Native build and protocol acceptance remain required. Run `cargo metadata --locked`, `cargo check --locked --all-targets`, `cargo test --locked --all-targets` and protocol compatibility checks on your target system before production deployment. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md).
Version **0.34.12** is provided as a **source-only release candidate**. It retains the experimental PQC implementation introduced in 0.34.9, whose dependencies were provisionally locked using published crate metadata. Native build and protocol acceptance remain required. Run `cargo metadata --locked`, `cargo check --locked --all-targets`, `cargo test --locked --all-targets` and protocol compatibility checks on your target system before production deployment. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md).
+5
View File
@@ -1,3 +1,8 @@
# RustProxy roadmap
This file records **work still requiring acceptance or planned future consideration**, rather than repeating completed release history. Implemented functionality is described in the [documentation](docs/README.md), and operator-visible changes by release are in the [changelog](CHANGELOG.md).
## Chart axes acceptance follow-up
The opt-in X/Y sample-scale display is implemented in **0.34.12**, with current SVG ranges preserved and no elapsed-time inference. Before production acceptance, review live screenshots at small screen widths, all themes, dense/custom card layouts, charts with empty data and compacted rate histories; check the toggle with per-account stored preferences and the full Rust service. The original axis-free appearance remains the default.
+49 -49
View File
@@ -1,13 +1,13 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
8216a946a208da74532066574db3beb83b27e9d3d277237d02592fc162002bd8 AGENTS.md
eba2f6f89d0d4364892378f80f1184987b7b10bb97dff978cc8137659088cd3f CHANGELOG.md
4a8484f9a1e20f473ec502ce276b4086b6584946ed5e453696ceb33c7fe652b5 Cargo.lock
69b21d2305427bfa9bb589d2789244de1dcbc480c9d5199fb7cc8ce9467685f4 Cargo.toml
26115eda9fc03552f26f900865df1126ae252ddff39fe65a81e68650edc1372b AGENTS.md
9571c19750663ce2f764d15cb8c752fb092f009058264ef134776bf105173556 CHANGELOG.md
2b52395199055d2dbc09ecff16bf0ad238cbc15ac41d5022a6f94e65883a2e53 Cargo.lock
bdea624ad5b7e6f7cb749c0398af6846615bf641d59348bb092406383ca2a94c Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
fc884d9b37e69879ec50fcbbe70724e0847f4a161a139788e2149ab03f39a1f3 README.md
ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
1151dada0b9f3e85a2437f3eb7b7f3a2a40b56a14daca525fb593cf0be8a344f README.md
55d9e5a155bd2e164e81c949620518e2b86d5792165a3b571b964d2386c6cb8c ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs
2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md
@@ -32,11 +32,11 @@ dd186c91244be2d1dde47626d5022c2b24ce7a6834e0f08b1b401cec71017b4f docs/ACME_PROV
989293ade34917b0ab3718da3b6c84e897b45cd6fe22345757f151e1cbd510fd docs/API.md
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md
6dcfa83377d10169a3b6cd7e6f79c500f0707242874f2d21212be7ca6a81d3fa docs/CACHE.md
d71c36578d944f3fc64dc317937a8d0ca2360a6a1a15de18cb63223c8d6d0de7 docs/CACHE.md
f24767c2f9d4db4db5005b700d03323dd20f0a1d180ae939be4fe54a126aaf32 docs/CONFIGURATION.md
80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
302a9db0a5a4e5f6c49684ef0ea9ad9f1796fdbcb5e28bb5a98c75fffd4374f4 docs/DASHBOARD.md
15e1dd401e628e11ab26ba0de2f958ecb802d9195c4f259b878eb086d27217b8 docs/DASHBOARD.md
11a08bf2110bbeabc924eb049a30177a216af5607ccdaf8be9178e751f78a2c4 docs/DOCKER_DISCOVERY.md
c891ee5272e2e47e1a8f97133a413e2330844103d543fb5cbcc4d0724d59a1a5 docs/INSTALL.md
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
@@ -44,11 +44,11 @@ c891ee5272e2e47e1a8f97133a413e2330844103d543fb5cbcc4d0724d59a1a5 docs/INSTALL.m
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
27ba3ed337963d9cd05ba382666eed9c414f58c52e5897b4c1c45603f412b456 docs/MONITORING.md
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
005ce62922a1d9bf08ff7cf39c0da7440c5d39f115b5f6b41b5c4464bab1ee0a docs/README.md
81b14192310c59ae020dada4910b1984303ce2cbc8b4dfde933abcde7bf5dd51 docs/README.md
490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md
e2b36152c13d65621a367c69dd400118dae1e4426a4d2b2139d2f63fa30442d6 docs/SECURITY.md
bbef60ff1771da9f48af812987766e1e479408873ae2f079b13f1d5c62ecc6ff docs/STREAMS.md
a44bdb81483ba02ccb33303044302d963849e6489b7987a9e7be9079f97f3eab docs/TLS_POST_QUANTUM.md
d7e27ebdaf10cd6067d52ff9ded3ae3dca12f084a8473bf1ef79c7b1f68a2d57 docs/TLS_POST_QUANTUM.md
43a2c6230f95f81bf4efd1575a4f7cdc8b02244df38e19b1ec9c3cd736d3afe6 docs/TRANSPORT.md
8542c4a5c9d5dfe474459099546ae590b4c4d3f363f0bba9721c6107643c2d3a docs/TROUBLESHOOTING.md
e14c02f2c95daf5779d1aa5300751162b63fdd968832a1f0c2f191fa00ace6dc docs/VHOSTS.md
@@ -77,8 +77,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
1b2ab86636cf47938b0b55de9ac41227c643f23693cb5f7ff82a71fe73f8a36c langs/en_US.json
08fac446a02ebd027a5de519b090178fc35ef1d4dd03bee7455452d0a7cc979c langs/pl_pl.json
4de6c67c2ba4f7b6806d5747ec6402b058eb1f2050eeb031dcb61abb21e8288b langs/en_US.json
43dfc96953ef84a0d9fb04df728e3dea6ff70e6f8c8e7b4f0bf6de61f6fbc4a7 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -97,7 +97,7 @@ ee50f38c037765fab742e888d4b9fc4d874c31c785c6064d4eca3d2c4d24d944 nagios/check_r
1465396cf1358067e9fe48b3d61f28fd4ac65c577b67c502950ce6b42ecbe3b3 nagios/check_rustproxy_latency
6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues
7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml
fd1c34599f725ad5f7c3c0cc89e08b11917baef02c83af054ac8528b2e01dd8e scripts/README.md
40f3cdab547833ff7575205ef8b2c29b4c2a6e373a56d4aa64aaf3bd834a4ac8 scripts/README.md
1d63e9cea61a744f1bf2085cbb7c3d8755b3eda1569c8532574dd4fb6a840678 scripts/__init__.py
1843422d648101bb675d8bfcdb109ef350bfda273999ad7395d5f6ceb923eaa1 scripts/audit_css_usage.py
5fc5c7f274aa5a6c07970f7b2e548acabae3dc2a24d598308fd768a8d9de2510 scripts/build/__init__.py
@@ -115,6 +115,7 @@ f040ab366fb138a48debf764c71538c2471235353e2426316264c5d3c2d59762 scripts/prefli
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
d645f76ff8a9905cc419eab18cf9f8828f00e768721b8236d474ef9a5ade1981 scripts/test_chart_axes.cjs
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
@@ -148,13 +149,13 @@ d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file
282352eda7957477e7da44f16b90984b26a4a67a8755833bc0a6b34eaf93e3af src/admin/file_logs_query.rs
f66ff718f82a326f80808b0a5088bd05a48625be802e5ba305fff3c344b59948 src/admin/logging_api.rs
1ba0520c6348246cbb35b60e596e99dc600137c0696de86508cf06587bd86563 src/admin/mod.rs
9e3eb61074ba72fbd1d7c8a38fb1cc4a1683fe797a4a3ea5a96f19a649b9a526 src/admin/openapi.json
5d00cd59bb9ce85fe0d2762757645592eec5a1bef803e0692edda687dd7966a8 src/admin/openapi.json
7cfd6593ac9bcea3d1b9e61bbcb7ed98d65ed3190f54f94c1e104541f4fbb4f5 src/admin/operations.rs
257d93a29d0bda96462bcf695aba8d0ec0f9412c379e066ec46ae8ee034e3a53 src/admin/panel_assets.rs
f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs
730c559afaa8a50affc3eb6dc6fbde7005c0356237352fcf8c5423b65725a8fe src/admin/preferences.rs
b3c3c2d33c150eb42ce210c6074e5eff6a8af9391118012e413bc9323d3ae50c src/admin/preferences.rs
35a7f99c7448c90b8637f4b3a7becca138f1f378fe5dc3f87122151525bb20f9 src/admin/preferences_columns.rs
51dbfdf8b228cd4a381aa720b19146418d23763670fca74ff729bd88b13d6782 src/admin/preferences_tests.rs
334095b28a7f9e523dfa820a3499d93e209a5bad23b776f6d21b80bc82aa2942 src/admin/preferences_tests.rs
62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs
@@ -164,39 +165,39 @@ e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/sess
2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs
8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
db52f794a732bf644499b2243694e7e04096cd4e269e35e89ce0b53a89fc0dd2 src/admin/tests.rs
6548710af7070f2e73e09c2cce1054d85edbccd4e3d042e5cc3951303ce31d9f src/admin/tests.rs
c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js
6fc8eb253cbdafbf58dc5d55cdecf5122ed14d85ac815c67a0ee7e03ac6e28c1 src/admin/web/app/backend-dashboard.js
aed3aea4eef0011c6b51d39736782e7d1ec52662e422f40e0c7b9ddfc19d0378 src/admin/web/app/certificate-inspection.js
7e00a5a127a79887f3b450bc86f43ad00d87b391bb7c8a84fdf0ca7bbd89d2bb src/admin/web/app/chart-scales.js
1efc9fbe0e43bbe2bd9ca2469d7c3d017da9a89b93597de74e65892985e86269 src/admin/web/app/chart-scales.js
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
c8e1d31a7bf77d9e4f4be979bf6a7474369aae3e1e46e6b30d1a76677fa3d762 src/admin/web/app/core.js
fece68287c2c58b813b9313fdad1ef6617c55230b5c5322d8ba7b9fda32455b2 src/admin/web/app/core.js
622d34e1616fee9a480d208192fc5a84d62d84ef4233d6a7d95e2bfb8cef415b src/admin/web/app/dashboard-inline-layout.js
851a4337127f332db91f6954f689363ee227fcddde2dda82e55f645940d0ce3b src/admin/web/app/dashboard-layout-editor.js
a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/app/dashboard-presets.js
bfb3fbac62f9ff762a298d9c06808ae81ae55de7a41e0bb35ce5c167f333ffe2 src/admin/web/app/overview-extra-metrics.js
69ec8cce4a8458e1ea34cbafc32946197fe78b5360a7803f25911d825b7036bb src/admin/web/app/overview-extra-metrics.js
5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js
b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.js
d997f62e8bd7ac9d4f16bb302800c98ad943afab663bb0b212ea26c35b58de11 src/admin/web/app/overview-wide-metrics.js
5ce04e4c2456ebd0541d2f852358aeb4eac62c048d44021673b628ca23873397 src/admin/web/app/overview-wide-metrics.js
717820edf68b51af47b94ce18367635da99b36ce7331f44a0ec9f06e6f59ca4b src/admin/web/app/path-permissions.js
5bde5f0881320464eb133cd44de9eff5211e6816e79f7f7e9574b16784fab126 src/admin/web/app/preferences.js
cb0cff2de92aef3f91ae426b1ffe3041318a66af596ec3acdafae4066a95d681 src/admin/web/app/preferences.js
8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js
43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js
f0edfb4541dc85cf048aa99f9db0e21be8d87108d02beaad3a298b3a5fbd94a3 src/admin/web/app/runtime.js
47933380b0a6aff465654c46d8ee4709ca89a502b2ae4dc9aec53f9c43e420c2 src/admin/web/app/runtime.js
31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js
fa00bce2651308a4e0b1303cc0146be17ed4794b67b2f0cebfaf861558d8c78c src/admin/web/app/settings.js
8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js
7f89bfae66e2a08a05a112733864e908f036bb1ee3d8b5b9ff23783b870acd96 src/admin/web/app/web-overview.js
849b2ca9d183bdf6cb1e04651cda52da55b8a631e11ab1d827a052c8436bbd76 src/admin/web/cache-panel.js
6785d2bf2bb16dabecb92f01dfbd959a582fcd7741252f718ed7cf90533fbfcd src/admin/web/app/stream-dashboard.js
e8621ebbbe8d7ff5064d4bf833433155edca0782528ea0573b1f51690eae8c00 src/admin/web/app/web-overview.js
596205feca534302699120b84848cb0f3e9ef2ad7ba24eac18d5751532a40194 src/admin/web/cache-panel.js
e9788a9d1e0bce8775f612a5c842afea8a0dcf01301acdef4b69ff6a52d2ef45 src/admin/web/certificate-api.js
9d000e98497b3387641500d5273d23eda45d532c714f22e187572928da152a97 src/admin/web/css/00-tokens.css
b86b530c86ff7996f41644dcff7c26145f1fa8bf0d9476dc34cdb73a67188a9a src/admin/web/css/10-base.css
4aae1657e8f5faa755cab0399769e6179364148f80243e742fc7a5e6e237b3e6 src/admin/web/css/20-shell.css
31834acc1fc234ed314b0ca069658bfab175d782bcfaa5e6ee07d46fef689457 src/admin/web/css/30-overview.css
707d08b7b8505c7346d5a98f3a8058f10577ddac7b9e6a5a9894285bde9e300a src/admin/web/css/30-overview.css
ce5ffb911d7e4cd2317ca047534921132b6f93efd70a26673f0c428e55320a4d src/admin/web/css/40-editors.css
4fed2f6376ad85f767c98d1fc953dec90c965a722cca152e62027f638665f962 src/admin/web/css/50-settings.css
a263cfa89fd435ea4bd0e72b2770b91cba909722199b34dd9d927dbd63622aa3 src/admin/web/css/50-settings.css
d019d9741951c9d652fab9e520693e0dd3f00b395e32169e5c7ca3ccaf04132c src/admin/web/css/60-monitoring.css
6afa10207938787013f5a4731910630b19bec4a22682a07c3e67707d17cfd97e src/admin/web/css/70-responsive.css
f2a2b0476cc55d8dadb4fd52c2c589d64785ad400ccc6cac5a2d10cfca729928 src/admin/web/css/README.md
@@ -204,7 +205,7 @@ f2a2b0476cc55d8dadb4fd52c2c589d64785ad400ccc6cac5a2d10cfca729928 src/admin/web/
560a8e047182fbcee08aa4f8d999400a8a5ddca0b6a4640efd46f6b5c34fde91 src/admin/web/dashboard-defaults.json
14213011cfb6ac334c8711b76bbdde97588a7b7ba4eb2592a1e387f877d6cbe0 src/admin/web/dashboard-widgets.json
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
701ff696630a61451c24fb94c668a6d61a28309867cd330d18d7955f7a031173 src/admin/web/docker.js
448a4e00e78ca368f9307cc84f87e893e5225dc87fdeadfbb227fd4b1faf1702 src/admin/web/docker.js
1d5f8fbf81394a6a8c3c2eff78b48408ef7790c0613905c2c7f584f1548862c6 src/admin/web/docs.css
aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html
24b6d5676c2e0cd3862851a395c753dfd7e6fb022f7c69267c1e02b728f1d06f src/admin/web/docs.js
@@ -224,7 +225,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
85bbe657a9a190f29788e61bd98cb8d2f8d4eef967947ddff86020eb7116eb02 src/admin/web/index.html
9ab06865cc7b805dface467594e536ffc623de11139b91ade105bf03ae9b62e6 src/admin/web/index.html
7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
@@ -236,17 +237,17 @@ a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
3ee4508cdda0b0b0e0f880ecac4b085368098b46240d24f2e6efed21c96bd5e9 src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
6feb1996b1caa9d19e0dc56eae7ca5b48b7e7cdf0a93bb2e4b527fb8f13a2fe5 src/admin/web/streams.js
1ea78a6e5b5d813febf2359ecec7fde42dc1278531053e91428f4d72b8a1d62b src/admin/web/streams.js
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
c7e0ca45c19498d40ef6f4122de23f0e73094e905d39f798942f70d15b9cf86e src/admin/web/transform-panel.js
a55f45dad5b540def419f8487161fb506b7e52744c76a5e661c7a5fa0a98a426 src/admin/web/vhosts/core.js
d88d89c8071d2458aef278498a5a7863fb45c374fca801b0b3a9de402c455b4c src/admin/web/vhosts/editor.js
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
8c3da49973c62333559b0e3588c02beb0b425c025eac5cfa82a1bb968f70d9dd src/admin/web/vhosts/rates.js
510562ac8aa0a2558743172156a6797fb10d91ccedb7d619208821ac5b6b6afc src/admin/web/vhosts/render.js
03f99423df8b32aed674478afa24f6b366fb3128d9bab56218dcef71456bedc4 src/admin/web/vhosts/render.js
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
a9466c7b291a1f436c0531709458b22a453ec2251f554ed09ea8065df02411a8 src/admin/web/web-server.js
dafce3a59a6007e59d7a4018fce96a14cd8e8cedb3ce77f02ee229a280c122d8 src/admin/web/web-server.js
2d40c2cb32d7365df0c9cd6c8cb6f0483c7a799786c648f4836a0d9740c6c5ef src/auth/files/mod.rs
b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs
50ef648351271d78310e00caa6d246e0009171ed036a3e4e1144737df8c064c2 src/auth/mod.rs
@@ -271,14 +272,14 @@ d789bf40cf9433e2fd16409d112210fe93154588b1ac9bddc9c7f78e1db14426 src/balance/po
76b7db53f38c95e128fee503381efd3fdcc13340c124ef277faf0fe5c1c25c39 src/cache/cache_regression_tests.rs
7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs
b75ff512a1c6344483e00d2c67578ceea6b5472c279da0246f3622daa68d7e16 src/cache/cold/cleanup.rs
af83946c4df0bb8101bcd3ece96a3193eac625a40a9f087dd2772c82fafdf019 src/cache/cold/cold_tier_tests.rs
207538a177b432cabaabfc6bff60990b503f8b92d251d25834aaf03a29ce5b6e src/cache/cold/cold_tier_tests.rs
3c3897980c31a69d96f9295689d1ae19f2190da758c69ee7a0541f4171288bb8 src/cache/cold/demotion.rs
e80f84449412c4ae7bc753672a554160519bf1f186b5707252c3286cbe13f02e src/cache/cold/startup.rs
83095bc93132f306c537b31ba929669fedac99af3c4f133d0533b5ed7ac4e6ed src/cache/cold/worker.rs
a36a4a255a0fd2c38449bbc462b9c0e21488eae108c70326ab2a0e4c7cee0f97 src/cache/collapse.rs
346fdb40d85627b583a01fa17c65e45c822dc7216198cdeadbb3d07904286fa3 src/cache/dashboard.rs
4b8bea7aadae500df9a6223fb73a832820c56b47362c298499c3cc995f75f68f src/cache/dashboard_tests.rs
4c4c3a70b0e40f9bc806c97939b7d08820e37e5359c87d8f39710b626a869bac src/cache/fill.rs
881a9f977530b4a5b0ff50b4e9079b15425eb4f0eb6a5fd73c0ff3962afeec08 src/cache/dashboard_tests.rs
4605b7bdfb3cabbc00f53347360ad4bc31f71027a60276a06270eebe5aee0ff5 src/cache/fill.rs
675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs
c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs
bbd9f372648d89b5e14d5b58029582d37f66a89752caf2c14eb1cbf8f24bb7a0 src/cache/invalidation/http_invalidation_tests.rs
@@ -288,12 +289,12 @@ bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/look
9cfb83f414bdf4ccbedc3daf2eeeea4cb2ef83ab7c6aaef6063a331c9fbf5608 src/cache/lookup/hot.rs
4148106b9baf70e41eeba24233b1edbe8dd6f927c11f2c7647e89fbbfb60555e src/cache/lookup/hot_hit_034_tests.rs
e9c6ab0c2be9f96573f826e6a1b9e4c2b8b526f779e72e95a6463ae07e45ab55 src/cache/lookup/insert.rs
5303b3031d3ad97d7c4e75e5667d16775672ac1aeaca91c00f79f76ecb286cbe src/cache/lookup/tiered.rs
ac3907c9635bc61eb1a10633549794bf6c7404c510d5b2cf015fe257399c60d2 src/cache/lookup/tiered.rs
639d5be6258699ec7e9f260ec33bd2c366203b6655bf0e98f30517c2e12ceeeb src/cache/mod.rs
0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs
c409174a9aaf20c69dc7b329108d1c26b5672a7341236cf639480df48800d262 src/cache/model/cache.rs
0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs
60b186c980afec29fc867a32b07b1360d9b68ba471ad1f9b8ada29ad309b1414 src/cache/model/entry.rs
f0c9de4f40548df9b68f59dd4125b86291322fa4fcc2b0d45bd6501cd9dc3828 src/cache/model/entry.rs
ff1c3a1a3bc0d26218dcc943ef951c2a212c180bbf6e4bf1c542ffbf12a9f150 src/cache/model/http_invalidation.rs
dc1153685671be70c28bef814d72d8ef09afe070598f528ea62260c54d43b26f src/cache/model/maintenance.rs
19aaf66abe2ad30b973131a020b7ca8ec6d0646a51df5c604c5f8914db898416 src/cache/model/settings.rs
@@ -385,24 +386,24 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus
f1b0254f53b1a4336bd8170ad441ba072b49a114e497b985cb20699b4e375044 src/prometheus/web_php.rs
569eda61453affd053fbe24fa59652289cb12d22b77e64caa38325bbea48f7df src/proxy/body/mod.rs
57a71c9c6e4e0deba5d05963b9870af8f4c4d8dbd321ed4e52cee88746c997f0 src/proxy/body/tests.rs
dff5d29ad6d126fde26f2b5e770c3b35275a82b6d59e41dd85edf28ac5eb2740 src/proxy/cache_helpers.rs
31d70f8be178f9213159bb030af448f37cf32ddb40e3142068a3b335df8749f7 src/proxy/cache_helpers.rs
7e3837d2f6ba7d07ca2cbabda57b315f5a7f4d75a13e5eddf6861a150d0d2ed6 src/proxy/cache_http_invalidation.rs
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/cache_http_invalidation_tests.rs
a03b1735daa8fa8e40cf5bd9c6e200d211baf19c30f62522d4237ed56642bce7 src/proxy/cache_plan/mod.rs
3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs
a0b9e2e9ab0bc89c7b64bd19365198234b1c5c13f455ea47be122a5660639b17 src/proxy/error_fallback.rs
60438601eea0888cd53c75cd94a3656f1eae5eaa700ebae171db2fe4f34eba13 src/proxy/forward/cache_fast_path_tests.rs
6b1ae807b0efa480207d9c5baf6e86b1f9dc6becb52600f14e4d1a84e8efcda1 src/proxy/forward/cache_lookup.rs
ea4f82860404927ce43980a4323ce20ae2fb83c02f3a481ab0fe54307a9e0f8a src/proxy/forward/cache_fast_path_tests.rs
e0f3c0265450694978a1ad6d344d133e44c958679ff98a0364643396ac719496 src/proxy/forward/cache_lookup.rs
287012c81144d8f15af4a148ce3ca911d431bc59adff44e697567561aa372bb3 src/proxy/forward/h3_navigation_tests.rs
ce97cfcb07d90415937f0520ee7175502b22e60694c1ea8e3d2b47678d190e5f src/proxy/forward/response.rs
1a7322aadbe90e2a6940a27882c13e274b1c92f14de3ebad717054a9614d4ba8 src/proxy/forward/route_cache.rs
3f91a40a01378d1fbac56ec376df6626e0c4fb6c3b8c8d42583b9c398d368ad9 src/proxy/forward/success_finish.rs
ac449238bdc9b3c2c693b773c023d34c0844a40d9472154e4526a46540004a35 src/proxy/forward/success_finish.rs
aa30766a5d584c2143488f16caef19c1d369a2c7c00b5ff960eaf2ccf4045c31 src/proxy/forward/success_prepare.rs
e9ac9be64f6f83c4ca2cd5d299649f24dd16197712083c6dc10e55c662c65b80 src/proxy/forward/upstream.rs
b7b46ef0c530a0e9d541238e50ec848d80c799b363fed30e0acbccdd459b0ffa src/proxy/forward/web_source.rs
1bf132dee1d89d1e9b8eeb0c5b6f4fcc9e2a1d741b73876300221d0264696aa5 src/proxy/forward/web_source.rs
3340583d395fcc859cf3421f4e33cf8347915ee4c468f7d0e4abebce8156fdca src/proxy/forward_entry.rs
9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs
5b07b98d855453d5a4272f5fc98a75973c3e6032416e0fe4f7f151c1bf239fd8 src/proxy/headers/authority_tests.rs
982ab159c0f01cfe29d7830859d6323227722fdaa523973e2c160014a7c2ad5f src/proxy/headers/authority_tests.rs
66efc52d64d34667ba7419f8a2da89d2efb1af1f426684f4fff8caccfdf19996 src/proxy/headers/mod.rs
0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs
c04451ac8abebf4662ccc3ee4610d5342ad5382da2098f7af5db274d079770b6 src/proxy/identity.rs
@@ -411,8 +412,8 @@ a08ee061e146cb7a08311106f73a04468c89f77b6ad05a2c623621a2ae9faf2a src/proxy/mod.
de1763b89d98607e20f0f6e3c9b546483927cbe5450efc2e5634fe691fd52ad6 src/proxy/request_id/mod.rs
b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs
580fefdc7dfb27da73f86f4d2067d46a1f856d2f398b0598d37a743e880d3dac src/proxy/types.rs
2ed96bc94003a881ab60f6871e3215598a3a519dc9f2480137f57292b70dc888 src/proxy/upstream_helpers.rs
4f2280d25eb8c08c08ad42832a12362d4fc399e8feedebe78112827d92399c95 src/proxy/web_cache_revalidation/mod.rs
4ceccf761577ee2434d8e06d9601b64f99723401fef6c6bb07425d54d406318f src/proxy/upstream_helpers.rs
94a61d71f96c0ef444c5e6a9533aa8e3238fa8592da8dd21fcae02a83edd915c src/proxy/web_cache_revalidation/mod.rs
b9e2c34cfe0ac8627aca64b234e42024103b1bad6efc2f1bcc4347d929aee8f3 src/proxy/web_cache_revalidation/tests.rs
3d591201595a3b1e928ded20a8736477a47e5acc3bcbde158f7c17547ce155ba src/proxy/websocket.rs
f8972bedb1a487fac86177774cd98cf2619fb25af97e57cfac2853db5cbf91a9 src/proxy/websocket/handshake.rs
@@ -525,7 +526,6 @@ f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server
b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs
df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
0eb7bf4cf8a5007a760725630b57612d9bbd7aaf96b85f24d636277f3f77bd4d tests/web/vhost_live_rate.test.cjs
eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
+38
View File
@@ -80,6 +80,44 @@ done
Look for cache reason labels including `request_cookie`, `request_authorization`, `response_cache_control`, `route_disabled` and `cache_hit` when diagnostic headers are enabled. A `MISS` alone does not prove the object was admitted to HOT/COLD.
## Cache response headers and upstream diagnostics (0.34.12)
**RFC 9211:** `Cache-Status` is a structured list of cache hops, ordered from the origin-side cache toward the client. RustProxy **does not replace or suppress** an upstream `Cache-Status`. When **Settings → Cache → Expose Cache-Status** is enabled and RustProxy makes a cache decision, it appends its own `RustProxy` member to the existing list; multiple HTTP header fields are an equivalent wire representation of the same list. There is no `backend=hit/miss` parameter: the upstream's own members already describe its cache decisions.
For example, with an upstream HIT and a RustProxy HIT:
```http
Cache-Status: BackendCache; hit; ttl=500
Cache-Status: RustProxy; hit; ttl=482; detail="cache_hit"
X-Cache: HIT
```
On first fill, RustProxy uses `RustProxy; fwd=miss; detail="cache_miss"` (unless another forwarding reason applies). A cache policy BYPASS uses `fwd=bypass`, and reuse of a stored body following origin `304` validation is reported as `fwd=stale; fwd-status=304`, **not** a fresh unforwarded RFC 9211 HIT. The existing `X-Cache: HIT` for that validation remains for legacy monitoring compatibility. The `ttl` parameter counts remaining seconds for a RustProxy HIT, not the upstream's TTL, and `detail` is a fixed RustProxy reason code. The optional `transformed` extension on RustProxy's member is applied only to its own entry.
**Legacy collisions:** `X-Cache`, `X-Cache-Hits`, `X-Cache-Reason` and `X-Cache-Resident-Age` are non-standard response fields. They do not define interoperable append/list semantics. When RustProxy is configured to emit a particular field, it owns that field and copies all origin values (if any) into `X-Upstream-Cache`, `X-Upstream-Cache-Hits`, `X-Upstream-Cache-Reason`, or `X-Upstream-Cache-Resident-Age` respectively. For example:
```http
X-Cache: HIT
X-Upstream-Cache: MISS
Cache-Status: BackendCache; fwd=miss
Cache-Status: RustProxy; hit; ttl=482; detail="cache_hit"
```
Here, the upstream MISS is the **original backend state when RustProxy filled the object**; it is not a fresh backend measurement on every RustProxy HIT. `X-Cache-Status` is forwarded unchanged because RustProxy does not generate that field. The `X-Upstream-*` aliases are a compatibility convenience, **not RFC 9211**; for new integrations use `Cache-Status` instead. Upstream headers are relayed as provided, without verifying the upstream's diagnostic accuracy.
**Display switches:** `Expose cache status`, `Expose cache hits`, `Expose cache resident age`, `Expose Cache-Status` and `Only on HIT` control **only RustProxy-generated headers**, not origin-supplied headers. Even when RustProxy caching is disabled, or a MISS/BYPASS is hidden by the Only on HIT option, backend-provided cache diagnostics remain visible. The `Via` header is governed separately by its configured cache policy. Backend diagnostics persist with eligible cached objects in HOT and COLD; responses which were never cacheable are still forwarded with their upstream fields intact.
To verify on a cacheable URL, use **GET** twice, without forced reload:
```bash
URL=https://example.com/public.css
for n in 1 2; do
curl -sS -D - -o /dev/null "$URL" | grep -iE '^(HTTP/|age:|cache-status:|x-cache:|x-upstream-cache:|x-cache-hits:|x-upstream-cache-hits:)'
done
```
The initial request can show `RustProxy; fwd=miss`; an eligible repeat should show `RustProxy; hit; ttl=...`. If the origin sends no `Cache-Status`, there is no upstream member to display. If RustProxy diagnostic output is off, any visible `Cache-Status` is from upstream; absence of a RustProxy member is expected. The actual cache outcome should also be checked in server request logs.
## Advanced behavior
- **Budget limits:** HOT RAM capacity, object count/size, transient fill memory and optional COLD byte/object limits. Configure based on real workload and monitor disk/RSS.
+10
View File
@@ -32,6 +32,16 @@ Unfinished editors may be minimized into a draft dock; this is a UI convenience
**Configuration history** stores retained validated snapshots. Restoring a snapshot creates a new configuration revision. It does not restore every filesystem file, external certificate, Secret Store key or accumulated cache/log history. For disaster recovery, use a full encrypted backup; see [Backup](BACKUP.md).
## Optional X/Y scales
Under **Settings → Appearance → Charts**, turn on **Show X/Y scales on charts**. It is **off by default**, stored per administrator through `chart_axes: true|false` in `/api/preferences`, and can be reset with **Reset section** or **Reset appearance**. No routing or persistent metric collection changes are required. The optional labels cover sampled SVG line charts in Overview (including backend, cache, Web Server, Stream and optional extra panels), Vhosts, Docker Vhosts, Streams, Web Server live metrics, Cache and Transform activity. Donuts, progress meters and categorical breakdowns have no X/Y sample axes.
- **Y:** `0` through the exact range used to plot the series, using suitable units such as `%`, `ms`, `B`, `B/s` or `/s`. Existing auto-ranging, fixed 0–100% scales and adaptive Vhost/Docker percentile scales are **not recalculated or replaced**. If an isolated Vhost peak is clipped, its actual maximum remains in the legend and the scale annotation identifies the clipped range. On HTTP and WebSocket protocol mini charts where each line is independently normalized, separate Y ranges for H1/H2/H3 are shown rather than an incorrect shared axis.
- **X:** numbers **1 ... N** identify samples in the displayed history, not elapsed seconds or minutes. Sampling can pause and Vhost/Docker/Stream lifetime histories can compact older readings into buckets. The X axis never pretends that sample spacing is uniform time.
- Labels are HTML overlays/footers rather than SVG text so they remain legible with `preserveAspectRatio="none"`. The original SVG path geometry, visible data, styling and refresh rates are unchanged. Switching axes off unwraps the existing SVGs; charts return to their previous appearance without restarting the panel.
If axis labels look crowded in a small card or on mobile, disable the setting; the default presentation is unchanged.
## Interpreting charts
- The **Cache hit ratio** donut shows cumulative `HIT / (HIT + MISS)`: BYPASS and requests outside cache are deliberately excluded from its denominator.
+3 -3
View File
@@ -1,6 +1,6 @@
# RustProxy documentation
Practical guides for **RustProxy 0.34.11**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive.
Practical guides for **RustProxy 0.34.12**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive.
## Getting started
@@ -16,13 +16,13 @@ Practical guides for **RustProxy 0.34.11**. This index and the listed pages are
- [Access and authentication](ACCESS.md) — ACL, country/DDNS, Basic Auth, Tinyauth and rate limits.
- [Static Web Server](WEB_SERVER.md) — document roots, generated filesystem permission commands, downloads, rules, built-in error pages and file delivery.
- [PHP-FPM](PHP_FPM.md) — PHP service profiles, security and application examples.
- [Cache](CACHE.md) — HOT/COLD storage, privacy, TTL and invalidation.
- [Cache](CACHE.md) — HOT/COLD storage, privacy, TTL, RFC 9211 `Cache-Status`, origin diagnostic headers and invalidation.
- [Response delivery](RESPONSE_DELIVERY.md) — compression, static transformations and response headers.
- [HTTP transport](TRANSPORT.md) — HTTPS/H2/H3, WebSockets, origin protocols and timeouts.
- [Experimental TLS post-quantum key exchange](TLS_POST_QUANTUM.md) — opt-in X25519MLKEM768, compatibility, rollback and validation.
- [TCP/UDP Streams](STREAMS.md) — non-HTTP forwarding and its security boundaries.
The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11, including how high peaks are displayed without hiding ordinary traffic.
The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11. Version 0.34.12 preserves that adaptive range and adds a disabled-by-default X/Y scale display option under **Settings → Appearance → Charts**. See [Dashboard](DASHBOARD.md).
## Integrations and operations
-2
View File
@@ -1,7 +1,5 @@
# Experimental post-quantum TLS key exchange (PQC)
**Version:** introduced in RustProxy 0.34.9; included in the 0.34.11 source candidate. **Default: off.** This is an experimental opt-in feature and not a production compatibility guarantee.
## What it does
Post-quantum cryptography (PQC) aims to protect key exchange from attackers with future quantum computing capabilities. **X25519MLKEM768** combines a conventional X25519 ephemeral key agreement with post-quantum **ML-KEM-768** in one TLS 1.3 *hybrid key-exchange group*. Both contributions protect the derived traffic secret. This is **not a TLS cipher suite**: AES-GCM or ChaCha20-Poly1305 and ordinary X.509 RSA/ECDSA certificates remain in use. It does not change certificate signatures to post-quantum algorithms, and does not make all parts of TLS quantum-safe.
+5 -1
View File
@@ -2268,6 +2268,10 @@
"timeFormat24": "24-hour",
"timeFormat12": "12-hour",
"chartStyle": "Chart style",
"chartAxes": "Show X/Y scales on charts",
"chartAxesHelp": "Keep adaptive scaling and all existing chart lines. Y shows the plotted range; X shows sample indices, not elapsed time. Off by default.",
"chartAxisSamples": "samples",
"chartAxisSamplesHint": "X values are sample indices; gaps and aggregated buckets do not indicate elapsed time.",
"chartStyleDefault": "Default",
"chartStyleSoft": "Soft",
"chartStyleTechnical": "Technical",
@@ -2439,7 +2443,7 @@
"backendHealthNoEnabled": "No monitored backends. Enable “Monitor backend health” on at least one backend in the Backends tab.",
"backendHealthOverride": "Custom health check for this backend",
"backendHealthOverridesHelp": "When unchecked, inherit the vhost health policy. Disabling monitoring keeps the backend available for explicitly pinned locations, without active probes or passive ejection. Use a custom check for an individual backend.",
"cacheOnlyHits": "Expose cache diagnostics only on HIT (hide MISS and BYPASS)",
"cacheOnlyHits": "Show RustProxy cache diagnostics only on HIT (retain origin headers)",
"phpHintEnabled": "Run configured PHP file extensions through an already-running PHP-FPM pool. RustProxy does not start PHP; non-PHP files remain static.",
"phpHintPathInfo": "Split /static.php/skins/file.css into SCRIPT_FILENAME=static.php and PATH_INFO=/skins/file.css. Useful for Roundcube; usually unnecessary for WordPress. Off by default.",
"phpHintPassAuthorization": "Pass the incoming Authorization header as HTTP_AUTHORIZATION to PHP (API/Bearer/Basic authentication). Disable if not needed.",
+5 -1
View File
@@ -2268,6 +2268,10 @@
"timeFormat24": "24-godzinny",
"timeFormat12": "12-godzinny",
"chartStyle": "Styl wykresów",
"chartAxes": "Pokaż skale X/Y na wykresach",
"chartAxesHelp": "Zachowuje automatyczne skalowanie i przebieg linii. Y pokazuje zakres użyty do rysowania, X numer próbki, nie czas. Domyślnie wyłączone.",
"chartAxisSamples": "próbki",
"chartAxisSamplesHint": "Oś X pokazuje indeksy próbek; przerwy i zagregowane okresy nie oznaczają czasu, jaki upłynął.",
"chartStyleDefault": "Domyślny",
"chartStyleSoft": "Łagodny",
"chartStyleTechnical": "Techniczny",
@@ -2439,7 +2443,7 @@
"backendHealthNoEnabled": "Brak monitorowanych backendów. Włącz monitorowanie stanu przynajmniej jednego backendu w zakładce Backendy.",
"backendHealthOverride": "Osobny health check dla tego backendu",
"backendHealthOverridesHelp": "Bez nadpisania backend dziedziczy ustawienia vhosta. Wylaczenie monitorowania nie blokuje lokalizacji przypisanych do backendu, ale wylacza probe aktywna i pasywne wykluczanie.",
"cacheOnlyHits": "Pokazuj naglowki diagnostyki cache tylko dla HIT (ukryj MISS i BYPASS)",
"cacheOnlyHits": "Pokazuj naglowki RustProxy tylko dla HIT (zachowaj naglowki backendu)",
"phpHintEnabled": "Uruchamia pliki o wskazanych rozszerzeniach przez już działającą pulę PHP-FPM. RustProxy nie uruchamia PHP; inne pliki nadal są statyczne.",
"phpHintPathInfo": "Rozdziela /static.php/skins/file.css na SCRIPT_FILENAME=static.php i PATH_INFO=/skins/file.css. Potrzebne np. w Roundcube; zwykle zbędne w WordPressie. Domyślnie wyłączone.",
"phpHintPassAuthorization": "Przekazuje nagłówek Authorization do PHP jako HTTP_AUTHORIZATION (np. API/Bearer/Basic). Wyłącz, gdy aplikacja go nie używa.",
+6 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.11",
"version": "0.34.12",
"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. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
@@ -3594,6 +3594,11 @@
"default": "default",
"description": "Account-scoped visual presentation preset applied consistently to administration charts."
},
"chart_axes": {
"type": "boolean",
"default": false,
"description": "Account-scoped optional X/Y scales on sampled time-series charts. Shows the same Y range used to draw each SVG and X sample indices rather than inferred elapsed time; does not change adaptive scaling."
},
"button_style": {
"type": "string",
"enum": [
+3
View File
@@ -116,6 +116,8 @@ struct AdminPreferences {
time_format: String,
#[serde(default = "default_admin_chart_style")]
chart_style: String,
#[serde(default)]
chart_axes: bool,
#[serde(default = "default_admin_button_style")]
button_style: String,
#[serde(default = "default_admin_corner_style")]
@@ -177,6 +179,7 @@ impl Default for AdminPreferences {
ui_scale: default_admin_ui_scale(),
time_format: default_admin_time_format(),
chart_style: default_admin_chart_style(),
chart_axes: false,
button_style: default_admin_button_style(),
corner_style: default_admin_corner_style(),
table_density: default_admin_table_density(),
+14
View File
@@ -40,6 +40,20 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
}
assert!(hidden.iter().all(|id| order.contains(id)));
assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok());
assert!(!AdminPreferences::default().chart_axes);
}
#[test]
fn optional_chart_axes_preference_is_backward_compatible() {
let legacy: AdminPreferences = serde_json::from_str(r#"{"chart_style":"neon"}"#).unwrap();
assert!(!legacy.chart_axes);
let mut enabled = legacy.clone();
enabled.chart_axes = true;
assert!(validate_admin_preferences(&enabled).is_ok());
let stored = serde_json::to_string(&enabled).unwrap();
assert!(stored.contains("\"chart_axes\":true"));
let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap();
assert!(recovered.chart_axes);
}
#[test]
+3
View File
@@ -1820,6 +1820,7 @@ mod tests {
assert_eq!(initial["ui_scale"], 100);
assert_eq!(initial["time_format"], "24h");
assert_eq!(initial["chart_style"], "default");
assert_eq!(initial["chart_axes"], false);
assert_eq!(initial["button_style"], "normal");
assert_eq!(initial["corner_style"], "default");
assert_eq!(
@@ -1856,6 +1857,7 @@ mod tests {
assert_eq!(legacy["ui_scale"], 100);
assert_eq!(legacy["time_format"], "24h");
assert_eq!(legacy["chart_style"], "default");
assert_eq!(legacy["chart_axes"], false);
assert_eq!(legacy["button_style"], "normal");
assert_eq!(legacy["corner_style"], "default");
assert_eq!(
@@ -1877,6 +1879,7 @@ mod tests {
"ui_scale":70,
"time_format":"12h",
"chart_style":"neon",
"chart_axes":true,
"button_style":"compact",
"corner_style":"rounded",
"table_density":"ultra",
+9 -5
View File
@@ -18,7 +18,6 @@ window.rustProxyBackendDashboard = (() => {
}
function syncVisibility(hiddenOverride = null) {
const prefs = window.rustProxyPreferences?.current?.();
// While unlocked, currentDraft is authoritative until Save or Cancel.
const hidden = new Set(hiddenOverride ?? window.rustProxyInlineLayout?.currentDraft?.()?.hidden
?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
@@ -30,7 +29,13 @@ window.rustProxyBackendDashboard = (() => {
}
function paintSeries(name, id, scale) {
const node = $(id);
if (node) node.setAttribute("points", line(samples[name], scale));
if (node) {
node.setAttribute("points", line(samples[name], scale));
const unit = name === "latency" ? "ms" : ["inbound", "outbound"].includes(name) ? "bytes/s"
: name === "active" ? "count" : "rate";
window.rustProxyChartScale.axes(node, scale, unit, samples[name].length,
{ top: 5 / 60, bottom: 55 / 60 });
}
}
function push(name, value) {
samples[name].push(Math.max(0, value));
@@ -50,8 +55,7 @@ window.rustProxyBackendDashboard = (() => {
window.rustProxyExtraOverview?.meter?.("backend-dashboard-capacity", null);
reset();
}
// Counter deltas are matched to stable (pool, member) IDs, not aggregate
// totals: removed/rebuilt pools must never become negative or spike rates.
function memberCounters(members) {
const values = new Map();
for (const { pool, member } of members) {
@@ -73,7 +77,7 @@ window.rustProxyBackendDashboard = (() => {
for (const [id, next] of current) {
const old = earlier.get(id);
if (!old) continue;
// A generation reset on a member invalidates its entire interval.
if (tracked.some(field => next[field] < old[field])) continue;
for (const field of tracked) total[field] += next[field] - old[field];
}
+105 -9
View File
@@ -1,5 +1,4 @@
// Overview charts retain stable, expanding ranges (and fixed 0-100% axes).
// Vhost request-rate sparklines use a separate, adaptive plotting policy.
window.rustProxyChartScale = (() => {
const floors = { rate: 100, count: 10, ms: 100, bytes: 64 * 1024 * 1024, "bytes/s": 64 * 1024 };
const ceilings = new Map();
@@ -17,17 +16,12 @@ window.rustProxyChartScale = (() => {
ceilings.set(key, result);
return result;
}
// Vhost live charts show the entire session history, but their Y range
// follows the recent typical rate. A one-off surge must not flatten the
// subsequent request activity; the real (unclipped) peak stays in the legend.
// The recent quantile also prevents a range that only grows over time.
function liveRate(values) {
const samples = (Array.isArray(values) ? values : []).map(value =>
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0);
const maximum = Math.max(0, ...samples);
const recent = samples.slice(-24).sort((a, b) => a - b);
// Interpolation would allow a single extreme point to dominate small
// histories. Nearest lower rank excludes that isolated maximum.
const typical = recent.length ? recent[Math.floor((recent.length - 1) * 0.85)] : 0;
const scale = Math.max(1, typical * 1.25);
const points = samples.map((value, index) => {
@@ -40,5 +34,107 @@ window.rustProxyChartScale = (() => {
function reset(prefix) {
for (const key of ceilings.keys()) if (key.startsWith(prefix)) ceilings.delete(key);
}
return { peak, liveRate, reset };
function axisValue(value, unit) {
if (value === 0) return "0";
const n = Number(value);
const format = v => v.toLocaleString(typeof locale === "function" ? locale() : undefined,
{ maximumFractionDigits: v < 1 ? 3 : v < 10 ? 2 : 1 });
if (unit === "bytes" || unit === "bytes/s") {
let magnitude = n, suffix = "B";
for (const next of ["KiB", "MiB", "GiB", "TiB"]) {
if (magnitude < 1024) break;
magnitude /= 1024; suffix = next;
}
return `${format(magnitude)} ${suffix}${unit === "bytes/s" ? "/s" : ""}`;
}
return `${format(n)}${unit === "percent" ? "%" : unit === "ms" ? " ms" : unit === "rate" ? "/s" : ""}`;
}
function setAxisText(node, value) {
if (node.textContent !== value) node.textContent = value;
}
function renderAxes(svg) {
if (!svg?.isConnected || !svg.rustProxyAxisData) return;
let host = svg.parentElement;
if (!host?.classList.contains("rp-chart-axis-host")) {
host = document.createElement("div");
host.className = "rp-chart-axis-host";
svg.before(host);
host.append(svg);
const overlay = document.createElement("div");
overlay.className = "rp-chart-axis-y";
overlay.setAttribute("aria-hidden", "true");
for (const name of ["top", "middle", "bottom"]) {
const tick = document.createElement("span");
tick.className = `rp-chart-axis-${name}`;
overlay.append(tick);
}
const perSeries = document.createElement("div");
perSeries.className = "rp-chart-axis-series";
perSeries.hidden = true;
const x = document.createElement("div");
x.className = "rp-chart-axis-x";
x.setAttribute("aria-label", "X");
for (const name of ["start", "caption", "end"]) {
const tick = document.createElement("span");
tick.className = `rp-chart-axis-${name}`;
x.append(tick);
}
host.append(overlay, perSeries, x);
}
const { peak: maximum, unit, count, top, bottom, clipped, seriesScales } = svg.rustProxyAxisData;
const compact = svg.closest(".stat-chart") !== null;
host.classList.toggle("rp-chart-axis-compact", compact);
const multi = Array.isArray(seriesScales) && seriesScales.length > 0;
host.classList.toggle("rp-chart-axis-multiple", multi);
const perSeries = host.querySelector(".rp-chart-axis-series");
perSeries.hidden = !multi;
if (multi) {
const values = seriesScales.map(([name, value]) => `${name}: 0–${axisValue(value, unit)}`);
if (perSeries.dataset.values !== values.join("|")) {
perSeries.replaceChildren(...values.map(value => {
const label = document.createElement("span");
label.textContent = value;
return label;
}));
perSeries.dataset.values = values.join("|");
}
}
host.style.setProperty("--rp-axis-top", `${Math.round(top * 1000) / 10}%`);
host.style.setProperty("--rp-axis-bottom", `${Math.round(bottom * 1000) / 10}%`);
setAxisText(host.querySelector(".rp-chart-axis-top"), `Y ${axisValue(maximum, unit)}${clipped ? " ↑" : ""}`);
setAxisText(host.querySelector(".rp-chart-axis-middle"), axisValue(maximum / 2, unit));
setAxisText(host.querySelector(".rp-chart-axis-bottom"), "0");
setAxisText(host.querySelector(".rp-chart-axis-start"), compact && !multi
? `Y 0–${axisValue(maximum, unit)}` : count ? "1" : "–");
setAxisText(host.querySelector(".rp-chart-axis-caption"), `X · ${typeof t === "function" ? t("chartAxisSamples") : "samples"}`);
setAxisText(host.querySelector(".rp-chart-axis-end"), count ? (compact ? `1–${count}` : String(count)) : "–");
host.querySelector(".rp-chart-axis-x").title = typeof t === "function" ? t("chartAxisSamplesHint") : "Sample indices, not elapsed time";
}
function axes(target, peakValue, unit, count,
{ top = 0.08, bottom = 0.92, clipped = false, seriesScales = null } = {}) {
const node = typeof target === "string" ? document.getElementById(target) : target;
const svg = node?.tagName?.toLowerCase() === "svg" ? node : node?.ownerSVGElement;
if (!svg) return;
svg.rustProxyAxisData = {
peak: Number.isFinite(peakValue) && peakValue > 0 ? peakValue : 1,
unit, count: Number.isInteger(count) && count > 0 ? count : 0,
top: Math.min(1, Math.max(0, top)), bottom: Math.min(1, Math.max(0, bottom)), clipped: clipped === true,
seriesScales: Array.isArray(seriesScales) ? seriesScales.filter(row =>
Array.isArray(row) && typeof row[0] === "string" && Number.isFinite(row[1]) && row[1] >= 0) : null,
};
if (document.documentElement.dataset.chartAxes === "true") renderAxes(svg);
}
function refreshAxes() {
const show = document.documentElement.dataset.chartAxes === "true";
for (const svg of document.querySelectorAll("svg")) {
if (show && svg.rustProxyAxisData) renderAxes(svg);
else if (!show && svg.parentElement?.classList.contains("rp-chart-axis-host")) {
const host = svg.parentElement;
host.before(svg);
host.remove();
}
}
}
return { peak, liveRate, reset, axes, refreshAxes, axisValue };
})();
+11
View File
@@ -201,6 +201,8 @@ function redrawOverviewHistoryCharts() {
const connectionPoints = values => values.map((value, index) => `${index * 300 / Math.max(1, values.length - 1)},${95 - value / connectionPeak * 85}`).join(" ");
$("connection-open-sparkline").setAttribute("points", connectionPoints(connectionOpenSamples));
$("connection-exchange-sparkline").setAttribute("points", connectionPoints(connectionExchangeSamples));
window.rustProxyChartScale.axes("connection-chart", connectionPeak, "count", connectionOpenSamples.length,
{ top: 10 / 100, bottom: 95 / 100 });
const peak = Math.max(1, ...samples, ...transformSamples);
const points = values => values.map((n, i) => `${i * 600 / Math.max(1, values.length - 1)},${120 - n / peak * 105}`).join(" ");
$("sparkline").setAttribute("points", points(samples));
@@ -208,10 +210,19 @@ function redrawOverviewHistoryCharts() {
$("sparkline-h2").setAttribute("points", points(protocolSamples.h2));
$("sparkline-h3").setAttribute("points", points(protocolSamples.h3));
$("sparkline-transform").setAttribute("points", points(transformSamples));
window.rustProxyChartScale.axes("traffic-chart", peak, "rate", samples.length,
{ top: 15 / 130, bottom: 120 / 130 });
for (const key of ["h1", "h2", "h3"]) {
$("http-protocol-" + key).setAttribute("points", sparklinePoints(protocolSamples[key], 300, 60, 5));
$("ws-protocol-" + key).setAttribute("points", sparklinePoints(websocketProtocolSamples[key], 300, 60, 5));
}
const httpValues = Object.values(protocolSamples), websocketValues = Object.values(websocketProtocolSamples);
window.rustProxyChartScale.axes("http-protocol-chart", Math.max(1, ...httpValues.flat()), "rate",
protocolSamples.h1.length, { seriesScales: Object.entries(protocolSamples).map(([name, values]) =>
[name.toUpperCase(), Math.max(1, ...values)]) });
window.rustProxyChartScale.axes("ws-protocol-chart", Math.max(1, ...websocketValues.flat()), "count",
websocketProtocolSamples.h1.length, { seriesScales: Object.entries(websocketProtocolSamples).map(([name, values]) =>
[name.toUpperCase(), Math.max(1, ...values)]) });
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
}
window.rustProxyOverviewSamples.onChange(() => {
+17 -10
View File
@@ -1,5 +1,4 @@
// Opt-in compact metrics and bounded visualizations. All readings reuse the
// existing Overview live-status payload (no additional requests or timers).
window.rustProxyExtraOverview = (() => {
const limit = () => window.rustProxyOverviewSamples?.getLimit?.() ?? 60;
const names = [
@@ -87,13 +86,17 @@ window.rustProxyExtraOverview = (() => {
}
if (id === "extra_backend_active") {
const members = Array.isArray(status.backend_pools) ? status.backend_pools.flatMap(pool => pool.members || []) : null;
return { value: members ? members.reduce((sum, member) => sum + (positive(member.active) ?? 0), 0) : null,
unit: "count", detail: "" };
return {
value: members ? members.reduce((sum, member) => sum + (positive(member.active) ?? 0), 0) : null,
unit: "count", detail: ""
};
}
if (id === "extra_backend_unhealthy") {
return { value: Array.isArray(status.backend_pools) ? status.backend_pools.reduce((sum, pool) =>
sum + (pool.members || []).filter(member => member.status === "down").length, 0) : null,
unit: "count", detail: "" };
return {
value: Array.isArray(status.backend_pools) ? status.backend_pools.reduce((sum, pool) =>
sum + (pool.members || []).filter(member => member.status === "down").length, 0) : null,
unit: "count", detail: ""
};
}
if (id === "extra_cache_memory") return ramBudget(c.resident_bytes, c.hot_limit_bytes);
if (id === "extra_cold_queue_memory") return ramBudget(c.cold_queue_bytes, c.cold_queue_limit_bytes);
@@ -123,8 +126,10 @@ window.rustProxyExtraOverview = (() => {
const { deltas, seconds } = result;
if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" };
const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1];
return { value: sum > 0 ? Math.min(100, deltas[0] / sum * 100) : null, unit, detail: "",
slices: id === "extra_cache_hit_rate" ? [deltas[0], deltas[1]] : null };
return {
value: sum > 0 ? Math.min(100, deltas[0] / sum * 100) : null, unit, detail: "",
slices: id === "extra_cache_hit_rate" ? [deltas[0], deltas[1]] : null
};
}
function format(value, unit) {
if (value == null) return "-";
@@ -145,7 +150,6 @@ window.rustProxyExtraOverview = (() => {
for (const id of names) {
const tile = node(id);
if (!tile) { lastCounter.delete(id); continue; }
// Visibility is only a presentation concern; retain live history.
const metric = read(id, status, now, record);
const value = metric.value;
if (lastUnits.get(id) !== metric.unit) {
@@ -161,6 +165,9 @@ window.rustProxyExtraOverview = (() => {
histories.set(id, history);
}
$(`overview-extra-${id}-line`)?.setAttribute("points", points(id, history, metric.unit));
window.rustProxyChartScale.axes(`overview-extra-${id}-line`,
window.rustProxyChartScale.peak(`extra:${id}`, metric.unit, [history]), metric.unit,
history.length, { top: 5 / 60, bottom: 55 / 60 });
}
if (record) {
lastValue.set(id, value);
+2 -1
View File
@@ -72,7 +72,6 @@ window.rustProxyWideOverview = (() => {
const value = values[i];
if (value === null) { drawing = false; continue; }
const x = Math.round(600 * i / Math.max(1, count - 1));
// Fixed-range charts stop at the upper bound; the legend keeps the real reading.
const y = Math.round(137 - 120 * Math.min(value, peak) / peak);
result += `${drawing ? " L" : " M"}${x} ${y}`;
drawing = true;
@@ -106,6 +105,8 @@ window.rustProxyWideOverview = (() => {
if (field) field.textContent = format(value, config.unit);
$(`overview-wide-${id}-${key}-line`)?.setAttribute("d", path(series[i], peak));
});
window.rustProxyChartScale.axes(`overview-wide-${id}-${config.names[0]}-line`,
peak, config.unit, series[0].length, { top: 17 / 150, bottom: 137 / 150 });
const summary = $(`overview-wide-${id}-summary`);
if (summary) summary.textContent = config.total
? format(values.every(value => value !== null) ? values.reduce((sum, value) => sum + value, 0) : null, config.unit)
+21 -9
View File
@@ -13,6 +13,7 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([
])]);
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
chart_axes: false,
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null,
});
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
@@ -35,14 +36,12 @@ function normalizeDashboardWidgets(value) {
function normalizeHiddenDashboardWidgets(value, savedOrder = ADMIN_DASHBOARD_WIDGETS) {
const seen = new Set();
const hidden = (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
// Also protect preferences written by 0.31.x with an explicit empty hidden list.
for (const id of ADMIN_DEFAULT_HIDDEN_WIDGETS) {
if (!savedOrder?.includes(id) && !seen.has(id)) { hidden.push(id); seen.add(id); }
}
return hidden;
}
// An absent map preserves the previous grouped visual order after upgrading.
// An explicit object (even empty) opts into free ordering and card resizing.
const DASHBOARD_WIDTHS = Object.freeze([2, 3, 4, 6, 12]);
function dashboardAllowedWidths(id) {
switch (dashboardWidgetType(id)) {
@@ -76,7 +75,6 @@ function dashboardWidgetWidth(id, sizes) {
function dashboardVisualOrder(order, sizes = null) {
const normalized = normalizeDashboardWidgets(order);
if (sizes !== null) return normalized;
// Preserve the old grouped appearance until the first intentional edit.
return normalized.map((id, index) => ({ id, index }))
.sort((a, b) => (window.rustProxyMasonry?.groupFor?.(a.id) ?? 0) -
(window.rustProxyMasonry?.groupFor?.(b.id) ?? 0) || a.index - b.index)
@@ -118,6 +116,13 @@ function applyAdminChartStyle(value) {
const style = normalizeAdminChartStyle(value); document.documentElement.dataset.chartStyle = style;
const select = $("ui-chart-style"); if (select) select.value = style; refreshChartStylePalette(style); return style;
}
function applyAdminChartAxes(value) {
const enabled = value === true;
document.documentElement.dataset.chartAxes = String(enabled);
const input = $("ui-chart-axes"); if (input) input.checked = enabled;
window.rustProxyChartScale?.refreshAxes?.();
return enabled;
}
function applyAdminButtonStyle(value) {
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
const select = $("ui-button-style"); if (select) select.value = style; return style;
@@ -271,8 +276,7 @@ function formatDateTime(value) { const date = value instanceof Date ? value : ne
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
function applyAdminPreferences(value, cache = true) {
if (!value) return value;
// A live inline edit is a draft, not a saved preference. Background updates
// (including an unrelated preference save) must not repaint the old layout.
const editing = window.rustProxyInlineLayout?.currentDraft?.();
const savedOrder = normalizeDashboardWidgets(value.dashboard_widgets);
const savedHidden = normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets);
@@ -283,7 +287,7 @@ function applyAdminPreferences(value, cache = true) {
...value,
refresh: normalizeRefreshPreferences(value.refresh),
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes,
};
adminPreferences = normalized;
@@ -331,7 +335,7 @@ function updateAdminPreferences(patch) {
const next = { ...current, ...patch };
next.refresh = normalizeRefreshPreferences(next.refresh);
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes);
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
if (!authenticated || epoch !== authEpoch) return adminPreferences;
@@ -407,6 +411,14 @@ $("ui-chart-style")?.addEventListener("change", () => {
const control = $("ui-chart-style"), previous = adminPreferences?.chart_style || "default", next = normalizeAdminChartStyle(control.value);
applyAdminChartStyle(next); if (adminPreferences) adminPreferences.chart_style = next; void saveAppearancePreference({ chart_style: next }, control, () => { if (adminPreferences) adminPreferences.chart_style = previous; applyAdminChartStyle(previous); });
});
$("ui-chart-axes")?.addEventListener("change", () => {
const control = $("ui-chart-axes"), previous = adminPreferences?.chart_axes === true, next = control.checked;
applyAdminChartAxes(next); if (adminPreferences) adminPreferences.chart_axes = next;
void saveAppearancePreference({ chart_axes: next }, control, () => {
if (adminPreferences) adminPreferences.chart_axes = previous;
applyAdminChartAxes(previous);
});
});
$("ui-button-style")?.addEventListener("change", () => {
const control = $("ui-button-style"), previous = adminPreferences?.button_style || "normal", next = normalizeAdminButtonStyle(control.value);
applyAdminButtonStyle(next); if (adminPreferences) adminPreferences.button_style = next; void saveAppearancePreference({ button_style: next }, control, () => { if (adminPreferences) adminPreferences.button_style = previous; applyAdminButtonStyle(previous); });
@@ -438,7 +450,7 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
}
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
const patches = {
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null },
};
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
+22
View File
@@ -346,6 +346,8 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
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");
window.rustProxyChartScale.axes(row.querySelector("svg"), peak, "bytes/s", history.rx.length,
{ top: 5 / 60, bottom: 55 / 60 });
row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
children.push(row);
}
@@ -379,6 +381,8 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
if (children.length !== target.children.length || children.some((node, index) => node !== target.children[index])) {
target.replaceChildren(...children);
if (document.documentElement.dataset.chartAxes === "true") window.rustProxyChartScale.refreshAxes();
}
}
@@ -457,6 +461,8 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
}
$("system-chart").hidden = live.cpu_percent == null; if (appendSample && live.cpu_percent != null) { systemSamples.push(Number(live.cpu_percent)); systemSamples = systemSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
$("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(" "));
window.rustProxyChartScale.axes("system-chart", 100, "percent", systemSamples.length,
{ top: 5 / 100, bottom: 95 / 100 });
$("cache-state").textContent = t(envelope?.config.cache?.enabled ? "enabled" : "notEnabled");
$("cache-counters").textContent = [c.hits, c.misses, c.bypasses].map(n => fmt(n)).join(" / ");
$("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`;
@@ -496,10 +502,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
$("cache-miss-sparkline").setAttribute("points", sparklinePoints(cacheMissSamples, 300, 60, 5, cacheOutcomePeak));
$("cache-bypass-sparkline").setAttribute("points", sparklinePoints(cacheBypassSamples, 300, 60, 5, cacheOutcomePeak));
window.rustProxyChartScale.axes("cache-hit-sparkline", cacheOutcomePeak, "count", cacheHitSamples.length,
{ top: 5 / 60, bottom: 55 / 60 });
const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)];
setChartText($("cache-outcome-chart-value"), latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-");
const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null;
$("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak));
window.rustProxyChartScale.axes("cache-memory-sparkline", Math.max(1, hotPeak ?? Math.max(0, ...cacheMemorySamples)),
"bytes", cacheMemorySamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("cache-chart-value"), occupancyText(c.resident_bytes, c.hot_limit_bytes));
const transientLimit = Number(c.transient_limit_bytes);
const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0
@@ -509,10 +519,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak));
$("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak));
$("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak));
window.rustProxyChartScale.axes("cache-transient-sparkline", transientPeak, "bytes", cacheTransientSamples.length,
{ top: 5 / 60, bottom: 55 / 60 });
setChartText($("cache-transient-chart-value"), occupancyText(c.transient_bytes, c.transient_limit_bytes));
const coldEnabled = envelope?.config.cache?.cold_enabled === true;
const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null;
$("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak));
window.rustProxyChartScale.axes("cache-cold-sparkline", Math.max(1, coldPeak ?? Math.max(0, ...cacheColdStorageSamples)),
"bytes", cacheColdStorageSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("cache-cold-chart-value"), coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"));
if (systemSample) {
const processPlatform = [d.platform, d.environment].filter(Boolean).join(" / ");
@@ -545,8 +559,12 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory);
}
$("process-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
window.rustProxyChartScale.axes("process-cpu-sparkline", Math.max(1, ...processCpuSamples), "percent",
processCpuSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("process-cpu-chart-value"), live.process_cpu_percent == null ? "" : `${fmt(live.process_cpu_percent, 1)}%`);
$("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
window.rustProxyChartScale.axes("process-rss-sparkline", Math.max(1, ...processRssSamples), "bytes",
processRssSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
$("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
@@ -564,10 +582,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
const networkPeak = Math.max(1, ...networkRxSamples, ...networkTxSamples);
$("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak));
$("network-tx-sparkline").setAttribute("points", sparklinePoints(networkTxSamples, 300, 60, 5, networkPeak));
window.rustProxyChartScale.axes("network-rx-sparkline", networkPeak, "bytes/s", networkRxSamples.length,
{ top: 5 / 60, bottom: 55 / 60 });
setChartText($("network-chart-value"), networkRateSupported ? `${fmtBytes(live.network_rx_bytes_per_second)} / ${fmtBytes(live.network_tx_bytes_per_second)}` : "");
$("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported;
renderNetworkInterfaceCharts(live.network_interfaces || [], appendSample);
$("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples));
window.rustProxyChartScale.axes("latency-sparkline", Math.max(1, ...responseLatencySamples), "ms",
responseLatencySamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("latency-chart-value"), m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`);
$("latency-sparkline").closest(".mini-live-chart").hidden = m.mean_duration_ms == null;
$("compression-counters").textContent = [m.compression_gzip, m.compression_br, m.compression_zstd, m.compression_deflate].map(n => fmt(n)).join(" / ");
+2 -1
View File
@@ -16,7 +16,6 @@ window.rustProxyStreamDashboard = (() => {
}
function syncVisibility(hiddenOverride = null) {
const prefs = window.rustProxyPreferences?.current?.();
// While unlocked, currentDraft is authoritative until Save or Cancel.
const hidden = new Set(hiddenOverride ?? window.rustProxyInlineLayout?.currentDraft?.()?.hidden
?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
@@ -103,6 +102,8 @@ window.rustProxyStreamDashboard = (() => {
trafficRates(pausedRate ? "-" : `${bytes(Math.round(rx))}/s`, pausedRate ? "-" : `${bytes(Math.round(tx))}/s`);
$("stream-dashboard-rx")?.setAttribute("points", line(history.rx));
$("stream-dashboard-tx")?.setAttribute("points", line(history.tx));
window.rustProxyChartScale.axes("stream-dashboard-rx", Math.max(1, ...history.rx, ...history.tx),
"bytes/s", history.rx.length, { top: 5 / 60, bottom: 55 / 60 });
}
function reset() { clearTraffic(); wasVisible = false; }
window.rustProxyOverviewSamples.onChange(() => {
+7 -3
View File
@@ -160,15 +160,17 @@ window.rustProxyWebOverview = (() => {
histories.set("www_php_requests", values);
}
$(`www_php_requests-line`)?.setAttribute("points", smallPath(histories.get("www_php_requests") || []));
const phpSamples = histories.get("www_php_requests") || [];
window.rustProxyChartScale.axes("www_php_requests-line",
window.rustProxyChartScale.peak("web:php-small", "rate", [phpSamples]), "rate",
phpSamples.filter(value => value !== null).length, { top: 5 / 60, bottom: 55 / 60 });
const set = {
www_requests: r?.rps, www_latency: r?.latency, www_failures: r?.failures,
www_traffic: r?.traffic, www_status_trend: r?.status,
};
for (const [id, names] of Object.entries(seriesNames)) {
const series = histories.get(id) || names.map(() => []);
// A resumed/overdue interval has no rate; skipping that entire
// snapshot keeps existing paths connected without inventing 0/s.
// A genuinely unavailable series inside a valid interval stays null.
if (record && r) {
names.forEach((_, i) => { series[i].push(set[id]?.[i] ?? null); window.rustProxyOverviewSamples.trim(series[i]); });
histories.set(id, series);
@@ -182,6 +184,8 @@ window.rustProxyWebOverview = (() => {
show(`${id}-${name}-value`, label);
$(`${id}-${name}-line`)?.setAttribute("d", chartPath(series[i], peak));
});
window.rustProxyChartScale.axes(`${id}-${names[0]}-line`, peak, unit,
series[0].length, { top: 17 / 150, bottom: 137 / 150 });
const summary = id === "www_latency" ? (set[id]?.[0] == null ? "-" : `${fmt(set[id][0], 1)} ms`)
: id === "www_traffic" ? (set[id] ? `${fmtBytes(set[id][0] + set[id][1])}/s` : "-")
: fmtRate(set[id]?.[0]);
+2
View File
@@ -80,6 +80,8 @@
$("cache-live-hit-line")?.setAttribute("points", sparklinePoints(liveChartSamples.hits, 900, 180, 10, peak));
$("cache-live-miss-line")?.setAttribute("points", sparklinePoints(liveChartSamples.misses, 900, 180, 10, peak));
$("cache-live-bypass-line")?.setAttribute("points", sparklinePoints(liveChartSamples.bypasses, 900, 180, 10, peak));
window.rustProxyChartScale.axes("cache-live-activity-chart", peak, "rate", liveChartSamples.hits.length,
{ top: 10 / 180, bottom: 170 / 180 });
}
function setLiveChartLimit(value, persist = true) {
const parsed = Number(value);
+69
View File
@@ -76,6 +76,75 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
stroke-dasharray: var(--chart-grid-dash, 2 5);
}
/* Optional X/Y scale readouts do not change SVG coordinates or chart ranges.
* Labels are HTML, not distorted SVG text under preserveAspectRatio="none". */
.rp-chart-axis-host {
position: relative;
display: flow-root;
width: 100%;
min-width: 0;
}
.rp-chart-axis-host>svg { display: block; vertical-align: top; }
.rp-chart-axis-y {
position: absolute;
inset: 0 0 15px;
pointer-events: none;
font: 9px/1.15 var(--mono);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.rp-chart-axis-y span {
position: absolute;
left: 3px;
max-width: calc(100% - 6px);
padding: 1px 3px;
border-radius: 2px;
white-space: nowrap;
background: color-mix(in srgb, var(--panel) 82%, transparent);
}
.rp-chart-axis-top { top: var(--rp-axis-top, 8%); transform: translateY(-30%); }
.rp-chart-axis-middle { top: 50%; transform: translateY(-50%); }
.rp-chart-axis-bottom { top: var(--rp-axis-bottom, 92%); transform: translateY(-70%); }
.rp-chart-axis-x {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 5px;
min-height: 15px;
padding: 2px 3px 0;
border-top: 1px solid var(--line);
color: var(--muted);
font: 9px/1.2 var(--mono);
font-variant-numeric: tabular-nums;
white-space: nowrap;
align-items: center;
}
.rp-chart-axis-x .rp-chart-axis-start { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.rp-chart-axis-x .rp-chart-axis-end { min-width: 0; text-align: right; }
.rp-chart-axis-compact .rp-chart-axis-y,
.rp-chart-axis-multiple .rp-chart-axis-y,
.rp-chart-axis-series[hidden] { display: none; visibility: hidden; }
.rp-chart-axis-compact .rp-chart-axis-x { font-size: 8px; gap: 2px; }
.rp-chart-axis-series {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
padding: 2px 3px;
font: 8px/1.2 var(--mono);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
@media (max-width: 560px) {
.rp-chart-axis-y .rp-chart-axis-middle { display: none; }
.rp-chart-axis-x { font-size: 8px; }
}
.chart-footer {
display: flex;
justify-content: space-between;
+21
View File
@@ -83,6 +83,27 @@
max-width: none;
}
.appearance-chart-axes-setting {
align-self: center;
min-width: 0;
}
.appearance-chart-axes-setting>span {
display: grid;
gap: 4px;
min-width: 0;
}
.appearance-chart-axes-setting>span b {
font-weight: 600;
}
.appearance-chart-axes-setting>span small {
color: var(--muted);
font-size: 10px;
line-height: 1.45;
}
.settings-page>.edit-form+.edit-form {
margin-top: var(--ui-gap);
}
+2
View File
@@ -286,6 +286,8 @@
const data = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points;
const plot = window.rustProxyChartScale.liveRate(data);
view.line.setAttribute("points", plot.points);
window.rustProxyChartScale.axes(view.line, plot.scale, "rate", data.length,
{ top: 6 / 54, bottom: 50 / 54, clipped: plot.clipped });
const scaleHint = `${t("vhChartAdaptiveScale")}: 0–${plot.scale.toFixed(1)} ${t("vhReqPerSecond")}${plot.clipped ? ` · ${t("vhChartClippedPeak")}` : ""}`;
view.line.parentNode?.setAttribute("aria-label", `${t("vhLifetimeChart")} · ${scaleHint}`);
view.chartValue.textContent = `${history.paused ? "-" : history.rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${plot.peak.toFixed(1)}`;
+12 -5
View File
@@ -1101,8 +1101,10 @@
placeholder="app.key" required="" />
<div hidden id="certificate-external-acl-example"></div>
<div class="form-actions"><button type="button" class="quiet"
id="certificate-inspect" data-i18n="certificateReadNames"></button></div>
<p class="fine" id="certificate-inspect-status" role="status" aria-live="polite"></p>
id="certificate-inspect" data-i18n="certificateReadNames"></button>
</div>
<p class="fine" id="certificate-inspect-status" role="status"
aria-live="polite"></p>
<label class="check"><input id="certificate-default" type="checkbox" /><span
data-i18n="defaultCertificate"></span></label>
<div class="form-actions"><button class="primary"
@@ -1826,6 +1828,11 @@
<option data-i18n="chartStyleWarm" value="warm">Warm</option>
<option data-i18n="chartStyleCool" value="cool">Cool</option>
</select></label>
<label class="check appearance-chart-axes-setting"><input id="ui-chart-axes"
type="checkbox" /><span><b data-i18n="chartAxes">Show X/Y scales on
charts</b><small data-i18n="chartAxesHelp">Keep automatic scaling;
show Y values and sample indices on X (not elapsed
time).</small></span></label>
<div class="appearance-chart-preview" aria-hidden="true">
<svg viewBox="0 0 300 74" preserveAspectRatio="none">
<path class="chart-grid" d="M0 18H300 M0 37H300 M0 56H300"></path>
@@ -2451,8 +2458,8 @@
class="check"><input id="cache-resident-age-header"
type="checkbox" /><span>X-Cache-Resident-Age</span></label></div>
<label class="check"><input id="cache-only-hits" type="checkbox" /><span
data-i18n="cacheOnlyHits">Expose cache diagnostics only on HIT (hide
MISS/BYPASS)</span></label>
data-i18n="cacheOnlyHits">Show RustProxy cache diagnostics only on HIT
(retain origin headers)</span></label>
<div class="cache-via-setting">
<label class="check"><input id="cache-via-enabled" type="checkbox" /><span
data-i18n="cacheViaEnable">Add Via response header</span></label>
@@ -4297,4 +4304,4 @@ log_errors = On</code></pre>
</dialog>
</body>
</html>
</html>
+2
View File
@@ -277,6 +277,8 @@
const points = history.pendingCount ? [...history.points, [history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]] : history.points;
const peak = Math.max(1, ...points.flat()); const toPoints = index => points.map((pair, pointIndex) => `${pointIndex * 320 / Math.max(1, points.length - 1)},${50 - pair[index] / peak * 44}`).join(" ");
const svg = document.querySelector(`[data-stream-chart="${CSS.escape(id)}"]`); svg?.querySelector(".stream-chart-rx")?.setAttribute("points", toPoints(0)); svg?.querySelector(".stream-chart-tx")?.setAttribute("points", toPoints(1));
window.rustProxyChartScale.axes(svg, peak, "bytes/s", points.length,
{ top: 6 / 54, bottom: 50 / 54 });
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"); }
+2
View File
@@ -33,6 +33,8 @@
$("transform-live-cache-line")?.setAttribute("points", sparklinePoints(liveChartSamples.cache_hits, 900, 180, 10, peak));
$("transform-live-skipped-line")?.setAttribute("points", sparklinePoints(liveChartSamples.skipped, 900, 180, 10, peak));
$("transform-live-error-line")?.setAttribute("points", sparklinePoints(liveChartSamples.errors, 900, 180, 10, peak));
window.rustProxyChartScale.axes("transform-live-activity-chart", peak, "rate", liveChartSamples.applied.length,
{ top: 10 / 180, bottom: 170 / 180 });
}
function setLiveChartLimit(value, persist = true) {
const parsed = Number(value);
+2
View File
@@ -540,6 +540,8 @@ function renderStatus(status) {
const plot = window.rustProxyChartScale.liveRate(displayPoints);
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`);
svg?.querySelector("polyline")?.setAttribute("points", plot.points);
window.rustProxyChartScale.axes(svg, plot.scale, "rate", displayPoints.length,
{ top: 6 / 54, bottom: 50 / 54, clipped: plot.clipped });
const scaleHint = `${t("vhChartAdaptiveScale")}: 0–${plot.scale.toFixed(1)} ${t("vhReqPerSecond")}${plot.clipped ? ` · ${t("vhChartClippedPeak")}` : ""}`;
svg?.setAttribute("aria-label", `${t("vhLifetimeChart")} · ${scaleHint}`);
const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`);
+2
View File
@@ -449,6 +449,8 @@
const points = previous.length === 1 ? `0,${y(previous[0])} 320,${y(previous[0])}` :
previous.map((value, i) => `${320 * i / Math.max(1, previous.length - 1)},${y(value)}`).join(" ");
panel.querySelector("polyline")?.setAttribute("points", points || "0,50 320,50");
window.rustProxyChartScale.axes(panel.querySelector(".vhost-chart svg"), peak, "rate", previous.length,
{ top: 4 / 54, bottom: 50 / 54 });
set("requests", fmt(total)); set("rate", rate == null ? "-" : `${fmt(rate, 1)}/s`);
set("errors", fmt(n(web.status_5xx)));
set("latency", deltas?.[0] > 0 ? `${fmt(deltas[1] / deltas[0] / 1000, 1)} ms` : "-");
+84
View File
@@ -62,6 +62,90 @@ mod cold_tier_tests {
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn upstream_cache_headers_survive_cold_demotion_and_hit_annotation() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/origin-cache",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("cached-origin-body"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response.headers_mut().append(
"cache-status",
HeaderValue::from_static("OriginCache; hit; ttl=300"),
);
response
.headers_mut()
.insert("x-cache", HeaderValue::from_static("Origin-HIT"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
fill(&cache, &settings, "/second", "other-cached-body").await;
wait_for_cold(&cache, 1).await;
let mut hit = cache
.get_tiered(&ticket, &settings)
.await
.expect("COLD hit");
assert_eq!(hit.response.headers()["x-cache"], "Origin-HIT");
assert_eq!(
hit.response.headers()["cache-status"],
"OriginCache; hit; ttl=300"
);
let display = Settings {
expose_status: true,
expose_cache_status: true,
..settings.clone()
};
display.annotate_reason_transformed(
hit.response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
"cache_hit",
false,
);
assert_eq!(hit.response.headers()["x-cache"], "HIT");
assert_eq!(hit.response.headers()["x-upstream-cache"], "Origin-HIT");
assert_eq!(
hit.response
.headers()
.get_all("cache-status")
.iter()
.count(),
2
);
shutdown.cancel();
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
let dir = tempfile::tempdir().unwrap();
+200 -31
View File
@@ -103,7 +103,10 @@ mod dashboard_tests {
}
#[test]
fn bypass_reasons_are_fixed_and_private_inputs_are_not_echoed() {
let settings = Settings { varnish_compatible: false, ..Settings::default() };
let settings = Settings {
varnish_compatible: false,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.insert("cookie", HeaderValue::from_static("session=very-private"));
assert_eq!(
@@ -125,13 +128,15 @@ mod dashboard_tests {
expose_cache_status: true,
..settings
};
diagnostics.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert_eq!(headers["x-cache-reason"], "request_cookie");
assert_eq!(
headers["cache-status"],
"RustProxy; fwd=bypass; detail=\"request_cookie\""
diagnostics.annotate_reason_transformed(
&mut headers,
"BYPASS",
0,
0,
"request_cookie",
true,
);
diagnostics.annotate_transformed(&mut headers, true);
assert_eq!(headers["x-cache-reason"], "request_cookie");
assert_eq!(
headers["cache-status"],
"RustProxy; fwd=bypass; detail=\"request_cookie\"; transformed"
@@ -149,29 +154,39 @@ mod dashboard_tests {
};
let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream"));
headers.insert(
"cache-status",
HeaderValue::from_static("untrusted-upstream"),
headers.insert("cache-status", HeaderValue::from_static("Origin; hit"));
global.annotate_reason_transformed(
&mut headers,
"MISS",
0,
0,
"response_headers_policy",
true,
);
global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy");
global.annotate_transformed(&mut headers, true);
for name in [
"x-cache",
"x-cache-hits",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
assert!(!headers.contains_key(name), "{name} leaked on MISS");
}
assert_eq!(headers["x-cache"], "untrusted-upstream");
assert_eq!(headers["cache-status"], "Origin; hit");
assert!(!headers.contains_key("x-cache-hits"));
assert!(!headers.contains_key("x-cache-reason"));
assert_eq!(headers["via"], "RustProxy");
global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert!(!headers.contains_key("cache-status"));
assert_eq!(headers.get_all("cache-status").iter().count(), 1);
global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit");
global.annotate_resident_age(&mut headers, 10);
assert_eq!(headers["x-cache"], "HIT");
assert_eq!(headers["x-cache-hits"], "4");
assert!(headers["cache-status"].to_str().unwrap().contains("; hit;"));
let chain: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(
chain,
vec![
"Origin; hit",
"RustProxy; hit; ttl=60; detail=\"cache_hit\""
]
);
assert_eq!(headers["x-upstream-cache"], "untrusted-upstream");
assert_eq!(headers["x-cache-resident-age"], "10");
let mut override_on = Overrides {
expose_only_hits: Some(false),
@@ -210,7 +225,7 @@ mod dashboard_tests {
}
#[test]
fn disabled_cache_strips_diagnostics_without_exposing_bypass_headers() {
fn disabled_cache_forwards_origin_diagnostics_unchanged() {
let settings = Settings {
enabled: false,
expose_status: true,
@@ -228,16 +243,170 @@ mod dashboard_tests {
headers.insert("x-cache-resident-age", HeaderValue::from_static("5"));
settings.annotate_reason(&mut headers, "BYPASS", 0, 0, "cache_disabled");
settings.annotate_resident_age(&mut headers, 9);
for name in [
"x-cache",
"x-cache-hits",
"x-cache-status",
assert_eq!(headers["x-cache"], "origin");
assert_eq!(headers["x-cache-hits"], "99");
assert_eq!(headers["x-cache-status"], "origin");
assert_eq!(headers["cache-status"], "Origin; hit");
assert_eq!(headers["x-cache-reason"], "origin");
assert_eq!(headers["x-cache-resident-age"], "5");
assert!(!headers.contains_key("x-upstream-cache"));
assert!(!headers.contains_key("via"));
}
#[test]
fn chain_and_legacy_header_collisions_keep_origin_values() {
let settings = Settings {
expose_status: true,
expose_hits: true,
expose_cache_status: true,
expose_resident_age: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.append(
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
HeaderValue::from_static("Inner; hit; ttl=100"),
);
headers.append("cache-status", HeaderValue::from_static("Shield; fwd=miss"));
for (name, value) in [
("x-cache", "BACKEND-MISS"),
("x-cache-hits", "21"),
("x-cache-reason", "origin_reason"),
("x-cache-resident-age", "17"),
("x-cache-status", "backend-status"),
] {
assert!(!headers.contains_key(name), "{name}");
headers.insert(name, value.parse().unwrap());
}
settings.annotate_reason_transformed(&mut headers, "HIT", 3, 482, "cache_hit", true);
settings.annotate_resident_age(&mut headers, 9);
let cache_chain: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(
cache_chain,
vec![
"Inner; hit; ttl=100",
"Shield; fwd=miss",
"RustProxy; hit; ttl=482; detail=\"cache_hit\"; transformed",
]
);
assert_eq!(headers["x-cache"], "HIT");
assert_eq!(headers["x-upstream-cache"], "BACKEND-MISS");
assert_eq!(headers["x-cache-hits"], "3");
assert_eq!(headers["x-upstream-cache-hits"], "21");
assert_eq!(headers["x-cache-reason"], "cache_hit");
assert_eq!(headers["x-upstream-cache-reason"], "origin_reason");
assert_eq!(headers["x-cache-resident-age"], "9");
assert_eq!(headers["x-upstream-cache-resident-age"], "17");
assert_eq!(headers["x-cache-status"], "backend-status");
}
#[test]
fn diagnostic_options_do_not_hide_backend_cache_status() {
for (enabled, status, only_hits) in [
(true, false, false),
(true, true, true),
(false, true, false),
] {
let settings = Settings {
enabled,
expose_status: status,
expose_cache_status: status,
expose_only_hits: only_hits,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("Backend; MISS"));
headers.insert("cache-status", HeaderValue::from_static("Origin; fwd=miss"));
settings.annotate_reason_transformed(&mut headers, "MISS", 0, 0, "cache_miss", true);
assert_eq!(headers["x-cache"], "Backend; MISS");
assert_eq!(headers["cache-status"], "Origin; fwd=miss");
assert_eq!(headers.get_all("cache-status").iter().count(), 1);
assert!(!headers.contains_key("x-upstream-cache"));
}
}
#[test]
fn repeated_upstream_legacy_values_are_not_collapsed_or_discarded() {
let settings = Settings {
expose_status: true,
expose_cache_status: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.append("x-cache", HeaderValue::from_static("inner-HIT"));
headers.append("x-cache", HeaderValue::from_static("outer-MISS"));
headers.append("cache-status", HeaderValue::from_static("Inner; hit"));
headers.append("cache-status", HeaderValue::from_static("Outer; fwd=miss"));
settings.annotate_reason(&mut headers, "MISS", 0, 0, "cache_miss");
let upstream_values: Vec<_> = headers
.get_all("x-upstream-cache")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(upstream_values, vec!["inner-HIT", "outer-MISS"]);
assert_eq!(headers["x-cache"], "MISS");
let status_chain: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(
status_chain,
vec![
"Inner; hit",
"Outer; fwd=miss",
"RustProxy; fwd=miss; detail=\"cache_miss\"",
]
);
}
#[test]
fn client_no_cache_miss_does_not_claim_a_fresh_candidate() {
let settings = Settings {
expose_cache_status: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
settings.annotate_reason(&mut headers, "MISS", 0, 0, "request_no_cache");
assert_eq!(
headers["cache-status"],
"RustProxy; fwd=miss; detail=\"request_no_cache\"",
);
}
#[test]
fn revalidated_304_is_forwarded_under_rfc_9211_even_if_legacy_x_cache_is_hit() {
let settings = Settings {
expose_status: true,
expose_cache_status: true,
..Settings::default()
};
let mut headers = HeaderMap::new();
headers.insert("cache-status", HeaderValue::from_static("Origin; hit"));
settings.annotate_reason_transformed(
&mut headers,
"HIT",
2,
50,
"cache_revalidated",
false,
);
assert_eq!(headers["x-cache"], "HIT");
let chain: Vec<_> = headers
.get_all("cache-status")
.iter()
.map(|v| v.to_str().unwrap())
.collect();
assert_eq!(
chain,
vec![
"Origin; hit",
"RustProxy; fwd=stale; fwd-status=304; detail=\"cache_revalidated\"",
]
);
}
#[test]
+1 -8
View File
@@ -97,14 +97,7 @@ impl Candidate {
self.headers.insert("date", value);
}
}
for name in [
"x-request-id",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-reason",
"cache-status",
] {
for name in ["x-request-id"] {
self.headers.remove(name);
}
let vary = std::mem::take(&mut self.plan.vary);
+1 -8
View File
@@ -27,14 +27,7 @@ impl Cache {
headers.insert("date", value);
}
}
for name in [
"x-request-id",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-reason",
"cache-status",
] {
for name in ["x-request-id"] {
headers.remove(name);
}
let hits = cached.hits.saturating_add(1);
+87 -74
View File
@@ -171,16 +171,41 @@ impl Settings {
.any(|item| item.eq_ignore_ascii_case(extension))
}
pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) {
for name in [
"x-cache",
"x-cache-hits",
"x-cache-status",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
headers.remove(name);
}
self.annotate_internal(headers, outcome, hits, ttl, None, false);
}
pub fn annotate_reason(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: &'static str,
) {
self.annotate_internal(headers, outcome, hits, ttl, Some(reason), false);
}
pub fn annotate_reason_transformed(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: &'static str,
transformed: bool,
) {
self.annotate_internal(headers, outcome, hits, ttl, Some(reason), transformed);
}
fn annotate_internal(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: Option<&'static str>,
transformed: bool,
) {
if !self.enabled {
return;
}
@@ -196,89 +221,77 @@ impl Settings {
return;
}
if self.expose_status {
headers.insert("x-cache", HeaderValue::from_static(outcome));
insert_cache_diagnostic(
headers,
"x-cache",
"x-upstream-cache",
HeaderValue::from_static(outcome),
);
if let Some(reason) = reason {
insert_cache_diagnostic(
headers,
"x-cache-reason",
"x-upstream-cache-reason",
HeaderValue::from_static(reason),
);
}
}
if self.expose_hits {
headers.insert(
insert_cache_diagnostic(
headers,
"x-cache-hits",
"x-upstream-cache-hits",
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
);
}
if self.expose_cache_status {
let value = match outcome {
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
"MISS" => "RustProxy; fwd=uri-miss".into(),
_ => "RustProxy; fwd=bypass".into(),
let mut value = if matches!(
reason,
Some("cache_revalidated" | "cache_revalidated_uncached")
) {
"RustProxy; fwd=stale; fwd-status=304".to_owned()
} else {
match outcome {
"MISS" => "RustProxy; fwd=miss".to_owned(),
_ => "RustProxy; fwd=bypass".to_owned(),
}
};
headers.insert(
if let Some(reason) = reason {
value.push_str(&format!("; detail=\"{reason}\""));
}
if transformed {
value.push_str("; transformed");
}
headers.append(
"cache-status",
HeaderValue::from_str(&value).expect("generated cache status"),
HeaderValue::from_str(&value).expect("generated Cache-Status member"),
);
}
}
pub fn annotate_resident_age(&self, headers: &mut HeaderMap, resident_age: u64) {
headers.remove("x-cache-resident-age");
if !self.enabled {
return;
}
if self.expose_resident_age {
headers.insert(
if self.enabled && self.expose_resident_age {
insert_cache_diagnostic(
headers,
"x-cache-resident-age",
"x-upstream-cache-resident-age",
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
);
}
}
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
if !self.enabled
|| !self.expose_cache_status
|| !transformed
|| (self.expose_only_hits
&& !headers
.get("cache-status")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("RustProxy; hit")))
{
return;
}
let base = headers
.get("cache-status")
.and_then(|value| value.to_str().ok())
.unwrap_or("RustProxy");
if base
.split(';')
.any(|part| part.trim().eq_ignore_ascii_case("transformed"))
{
return;
}
if let Ok(value) = format!("{base}; transformed").parse() {
headers.insert("cache-status", value);
}
}
pub fn annotate_reason(
&self,
headers: &mut HeaderMap,
outcome: &'static str,
hits: u64,
ttl: u64,
reason: &'static str,
) {
self.annotate(headers, outcome, hits, ttl);
if !self.enabled || (self.expose_only_hits && outcome != "HIT") {
return;
}
if self.expose_status {
headers.insert("x-cache-reason", HeaderValue::from_static(reason));
}
if self.expose_cache_status {
let base = headers
.get("cache-status")
.and_then(|v| v.to_str().ok())
.unwrap_or("RustProxy");
if let Ok(value) = format!("{base}; detail=\"{reason}\"").parse() {
headers.insert("cache-status", value);
}
}
}
fn insert_cache_diagnostic(
headers: &mut HeaderMap,
name: &'static str,
upstream_name: &'static str,
value: HeaderValue,
) {
let upstream_values: Vec<HeaderValue> = headers.get_all(name).iter().cloned().collect();
for upstream_value in upstream_values {
headers.append(upstream_name, upstream_value);
}
headers.insert(name, value);
}
fn valid_relative_cache_path(value: &str) -> bool {
if value.is_empty() || value.len() > 256 {
+4
View File
@@ -87,6 +87,10 @@ fn enforce_local_page_no_cache(response: &mut Response) {
"x-cache-status",
"x-cache-resident-age",
"x-cache-reason",
"x-upstream-cache",
"x-upstream-cache-hits",
"x-upstream-cache-reason",
"x-upstream-cache-resident-age",
"cache-status",
] {
response.headers_mut().remove(name);
+144 -1
View File
@@ -472,7 +472,9 @@ mod cache_fast_path_tests {
"cache-control",
HeaderValue::from_static(match parts.uri.path() {
"/private" => "private, max-age=600",
"/backend-no-cache" => "public, no-cache, max-age=600",
"/backend-no-cache" | "/cache-headers-revalidate" => {
"public, no-cache, max-age=600"
}
"/backend-zero-stale" => "public, max-age=0, stale-if-error=120",
"/grace" => "public, max-age=1",
_ => "public, max-age=600",
@@ -484,6 +486,28 @@ mod cache_fast_path_tests {
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/plain"));
if matches!(
parts.uri.path(),
"/cache-headers" | "/cache-headers-revalidate"
) {
for (name, value) in [
("x-cache", "backend-MISS"),
("x-cache-hits", "14"),
("x-cache-status", "origin-status"),
("x-cache-reason", "origin_reason"),
("x-cache-resident-age", "16"),
] {
response.headers_mut().insert(name, value.parse().unwrap());
}
response.headers_mut().append(
"cache-status",
HeaderValue::from_static("InnerCache; hit; ttl=120"),
);
response.headers_mut().append(
"cache-status",
HeaderValue::from_static("OriginCache; fwd=miss"),
);
}
for name in [
"range",
"if-range",
@@ -577,6 +601,124 @@ mod cache_fast_path_tests {
fixture.stop().await;
}
fn cache_chain(headers: &HeaderMap) -> Vec<&str> {
headers
.get_all("cache-status")
.iter()
.map(|value| value.to_str().unwrap())
.collect()
}
#[tokio::test]
async fn origin_cache_headers_survive_bypass_and_hot_storage_when_rustproxy_debug_is_off() {
for cache_enabled in [false, true] {
let origin = origin().await;
let mut config = config_for(origin.addr, cache_enabled);
config.cache.expose_status = false;
config.cache.expose_hits = false;
config.cache.expose_cache_status = false;
config.cache.expose_resident_age = false;
let fixture = Fixture::new(config).await;
for _ in 0..2 {
let response = fixture.get("/cache-headers", &[]).await;
assert_eq!(response.status, StatusCode::OK);
assert_eq!(response.headers["x-cache"], "backend-MISS");
assert_eq!(response.headers["x-cache-hits"], "14");
assert_eq!(response.headers["x-cache-status"], "origin-status");
assert_eq!(response.headers["x-cache-reason"], "origin_reason");
assert_eq!(response.headers["x-cache-resident-age"], "16");
assert_eq!(
cache_chain(&response.headers),
vec!["InnerCache; hit; ttl=120", "OriginCache; fwd=miss",]
);
}
assert_eq!(
origin.requests.load(Ordering::Relaxed),
if cache_enabled { 1 } else { 2 }
);
fixture.stop().await;
}
}
#[tokio::test]
async fn origin_and_rustproxy_diagnostics_coexist_on_miss_hit_and_hit_only() {
for hit_only in [false, true] {
let origin = origin().await;
let mut config = config_for(origin.addr, true);
config.cache.expose_only_hits = hit_only;
config.cache.expose_resident_age = true;
let fixture = Fixture::new(config).await;
let miss = fixture.get("/cache-headers", &[]).await;
assert_eq!(miss.status, StatusCode::OK);
assert_eq!(
miss.headers["x-cache"],
if hit_only { "backend-MISS" } else { "MISS" }
);
assert_eq!(miss.headers["x-cache-status"], "origin-status");
let miss_chain = cache_chain(&miss.headers);
assert_eq!(
&miss_chain[..2],
&["InnerCache; hit; ttl=120", "OriginCache; fwd=miss"][..]
);
if hit_only {
assert_eq!(miss_chain.len(), 2);
assert!(!miss.headers.contains_key("x-upstream-cache"));
} else {
assert_eq!(miss.headers["x-upstream-cache"], "backend-MISS");
assert_eq!(miss.headers["x-upstream-cache-hits"], "14");
assert!(miss_chain[2].starts_with("RustProxy; fwd=miss; detail="));
}
let hit = fixture.get("/cache-headers", &[]).await;
assert_eq!(hit.status, StatusCode::OK);
assert_eq!(hit.headers["x-cache"], "HIT");
assert_eq!(hit.headers["x-upstream-cache"], "backend-MISS");
assert_eq!(hit.headers["x-upstream-cache-hits"], "14");
assert_eq!(hit.headers["x-upstream-cache-reason"], "origin_reason");
assert_eq!(hit.headers["x-upstream-cache-resident-age"], "16");
assert_eq!(hit.headers["x-cache-status"], "origin-status");
let hit_chain = cache_chain(&hit.headers);
assert_eq!(
&hit_chain[..2],
&["InnerCache; hit; ttl=120", "OriginCache; fwd=miss"][..]
);
assert_eq!(hit_chain.len(), 3);
assert!(hit_chain[2].starts_with("RustProxy; hit; ttl="));
assert!(hit_chain[2].ends_with("; detail=\"cache_hit\""));
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
fixture.stop().await;
}
}
#[tokio::test]
async fn origin_cache_status_survives_conditional_304_and_hot_refresh() {
let origin = origin().await;
let mut config = config_for(origin.addr, true);
config.cache.default_ttl_seconds = 0;
let fixture = Fixture::new(config).await;
let first = fixture.get("/cache-headers-revalidate", &[]).await;
assert_eq!(first.headers["x-cache"], "MISS");
assert_eq!(first.headers["x-upstream-cache"], "backend-MISS");
assert_eq!(cache_chain(&first.headers).len(), 3);
let validated = fixture.get("/cache-headers-revalidate", &[]).await;
assert_eq!(validated.status, StatusCode::OK);
assert_eq!(validated.headers["x-cache"], "HIT");
assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated");
assert_eq!(validated.headers["x-upstream-cache"], "backend-MISS");
assert_eq!(validated.headers["x-cache-status"], "origin-status");
let chain = cache_chain(&validated.headers);
assert_eq!(
&chain[..2],
&["InnerCache; hit; ttl=120", "OriginCache; fwd=miss"][..]
);
assert_eq!(chain.len(), 3);
assert_eq!(
chain[2],
"RustProxy; fwd=stale; fwd-status=304; detail=\"cache_revalidated\""
);
assert_eq!(origin.requests.load(Ordering::Relaxed), 2);
fixture.stop().await;
}
#[tokio::test]
async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() {
let origin = origin().await;
@@ -648,6 +790,7 @@ mod cache_fast_path_tests {
.get("/missing", &[("cache-control", "no-cache, only-if-cached")])
.await;
assert_eq!(miss.status, StatusCode::GATEWAY_TIMEOUT);
assert!(!miss.headers.contains_key("cache-status"));
assert_eq!(
origin.requests.load(Ordering::Relaxed),
0,
+8 -9
View File
@@ -26,8 +26,7 @@ if let Some(ticket) = &cache_ticket {
} else {
"cache_hit"
};
// only-if-cached forbids any origin communication, including
// asynchronous grace validation and proactive refresh.
if hit.background_revalidate && !only_cached {
if let (Some(cached), Some(guard)) = (
cached_revalidation.clone(),
@@ -108,14 +107,14 @@ if let Some(ticket) = &cache_ticket {
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
hit.response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
reason,
hit.transformed,
);
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
@@ -165,14 +164,14 @@ let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str
if let crate::cache::RangeSelection::Single(range) = range_selection {
hit = hit.apply_range(range, if_range.as_ref());
}
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
hit.response.headers_mut(),
"HIT",
hit.hits,
0,
reason,
hit.transformed,
);
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
@@ -198,6 +197,7 @@ if only_cached {
"RustProxy: no reusable cached response\n",
);
cache_settings.annotate(response.headers_mut(), "MISS", 0, 0);
response.headers_mut().remove("cache-status");
return response;
}
@@ -254,15 +254,14 @@ if handshake.is_none()
if conditional {
hit.response = crate::cache::make_not_modified(hit.response);
}
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
hit.response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
reason,
hit.transformed,
);
cache_settings
.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
+7 -8
View File
@@ -21,15 +21,14 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
}
let resident_age = hit.resident_age_seconds;
let mut response = hit.response;
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
"cache_revalidated",
hit.transformed,
);
cache_settings
.annotate_transformed(response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
@@ -46,15 +45,14 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
} else {
cached.response(merged)
};
cache_settings.annotate_reason(
cache_settings.annotate_reason_transformed(
response.headers_mut(),
"HIT",
0,
0,
"cache_revalidated_uncached",
cached.transformed(),
);
cache_settings
.annotate_transformed(response.headers_mut(), cached.transformed());
if cached.transformed() {
state.transform_metrics.record_cache_hit(scope);
}
@@ -184,7 +182,8 @@ let transformed = response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some();
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
cache_settings.annotate_transformed(response.headers_mut(), transformed);
cache_settings.annotate_reason_transformed(
response.headers_mut(), outcome, 0, 0, reason, transformed,
);
response
}
+5 -5
View File
@@ -96,9 +96,8 @@
if request_method == Method::HEAD {
*response.body_mut() = Body::empty();
}
cache_settings.annotate_reason(response.headers_mut(), "HIT", hit.hits,
hit.remaining_ttl, "cache_hit");
cache_settings.annotate_transformed(response.headers_mut(), hit.transformed);
cache_settings.annotate_reason_transformed(response.headers_mut(), "HIT", hit.hits,
hit.remaining_ttl, "cache_hit", hit.transformed);
cache_settings.annotate_resident_age(response.headers_mut(), hit.resident_age_seconds);
state.cache.record(scope, &host, "HIT", "cache_hit");
if hit.transformed { state.transform_metrics.record_cache_hit(scope); }
@@ -257,9 +256,10 @@
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason;
}
state.cache.record(scope, &host, outcome, reason);
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
cache_settings.annotate_transformed(response.headers_mut(), transformed);
cache_settings.annotate_reason_transformed(
response.headers_mut(), outcome, 0, 0, reason, transformed,
);
#[cfg(target_os = "linux")]
if !enabled && identity_request && request_method == Method::GET
&& !transform_settings.is_some_and(|settings| settings.enabled)
+10 -1
View File
@@ -322,13 +322,22 @@ mod authority_tests {
("etag", "\"old\""),
("x-cache", "HIT"),
("x-cache-reason", "origin"),
("x-upstream-cache", "origin-MISS"),
("x-upstream-cache-hits", "3"),
("cache-status", "Origin; hit"),
] {
response.headers_mut().insert(name, value.parse().unwrap());
}
enforce_local_page_no_cache(&mut response);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
for name in ["etag", "x-cache", "x-cache-reason", "cache-status"] {
for name in [
"etag",
"x-cache",
"x-cache-reason",
"x-upstream-cache",
"x-upstream-cache-hits",
"cache-status",
] {
assert!(!response.headers().contains_key(name), "{name}");
}
assert!(response
+18 -2
View File
@@ -258,6 +258,12 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
"x-cache-hits",
"x-cache-status",
"x-cache-resident-age",
"x-cache-reason",
"x-upstream-cache",
"x-upstream-cache-hits",
"x-upstream-cache-reason",
"x-upstream-cache-resident-age",
"cache-status",
"content-encoding",
"vary",
"strict-transport-security",
@@ -395,7 +401,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner());
cache.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
let transformed = response
.extensions()
.get::<crate::transform::Applied>()
@@ -404,7 +409,14 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some();
cache.annotate_transformed(response.headers_mut(), transformed);
cache.annotate_reason_transformed(
response.headers_mut(),
outcome,
0,
0,
reason,
transformed,
);
}
}
if !minimal_welcome {
@@ -441,6 +453,10 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
"x-cache-status",
"x-cache-resident-age",
"x-cache-reason",
"x-upstream-cache",
"x-upstream-cache-hits",
"x-upstream-cache-reason",
"x-upstream-cache-resident-age",
"cache-status",
] {
response.headers_mut().remove(name);
+8 -2
View File
@@ -129,8 +129,14 @@ pub(super) fn finish_web_revalidation(
if *method == Method::HEAD {
*response.body_mut() = Body::empty();
}
settings.annotate_reason(response.headers_mut(), "HIT", hits, ttl, reason);
settings.annotate_transformed(response.headers_mut(), transformed);
settings.annotate_reason_transformed(
response.headers_mut(),
"HIT",
hits,
ttl,
reason,
transformed,
);
settings.annotate_resident_age(response.headers_mut(), resident_age);
Some(WebRevalidated {
response,