0.32.1
This commit is contained in:
1 parent
81ab39e70a
commit
101df02a5f
45 files changed
+1148
-2277
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
|
||||
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
|
||||
compatibility. Current source version: 0.32.0.
|
||||
compatibility. Current source version: 0.32.1.
|
||||
|
||||
## Delivery
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
> Operator-facing release history. Historical engineering notes are kept in
|
||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||
|
||||
## 0.32.1
|
||||
|
||||
- Restore Overview proportions from 0.31.9 by removing 0.32.0's desktop minimum heights. Increase compact metric typography so labels, values and captions remain readable at 90% UI scaling without large empty bottoms.
|
||||
- Unify Overview inter-card gaps with the same `--ui-gap` density spacing used by the rest of the Dashboard, instead of distinct gaps for dashboard density options.
|
||||
- Retain the six desktop columns, responsive layouts, permanent chart legends, optional Stream cards and saved Appearance preferences. Add focused layout regression coverage. No changes to proxy transport, persisted data, API or saved settings schema.
|
||||
- Replace oversized Appearance Stream card help bubbles with localized native `title` tooltips, retain assistive descriptions on the buttons, remove tooltip-specific CSS and prevent widget-grid overflow on narrow screens. No preference or API changes.
|
||||
|
||||
## 0.32.0
|
||||
|
||||
- Extend Stream TCP/UDP listeners with explicit per-host backend pools (up to 32), legacy single-upstream fallback, stable backend IDs, weight, enabled, drain, session caps and weighted Round Robin / Least Connections or Sessions selection. Keep TCP/UDP affinity for each session and release backend gauges via RAII.
|
||||
|
||||
Generated
+1
-1
@@ -1881,7 +1881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.32.0"
|
||||
version = "0.32.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.32.0"
|
||||
version = "0.32.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.99"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# RustProxy 0.32.0
|
||||
# RustProxy 0.32.1
|
||||
|
||||
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
|
||||
|
||||
@@ -7,7 +7,14 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos
|
||||
|
||||
**0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.**
|
||||
|
||||
## What changed in 0.32.0
|
||||
## What changed in 0.32.1
|
||||
|
||||
- **Overview readability at 90% UI scale:** compare the supplied 0.31.9 source with 0.32.0, remove the latter's forced desktop card heights and restore content-sized rows. The six-column desktop layout and visible chart legends remain intact. Increase compact Dashboard headings (10 → 11px), values (22 → 25px), secondary text (8.5 → 10px) and the Payload breakdown (9 → 10px).
|
||||
- **Consistent tile spacing:** Overview now uses `--ui-gap`, the same density-aware spacing variable as other Dashboard sections, instead of its own fixed 10/8/6px spacing in normal/compact/ultra Dashboard modes. UI scale (including 90%) affects all gaps equally.
|
||||
- **Appearance hints without horizontal scrolling:** Dashboard layout Stream card descriptions now use native, localized browser tooltips and accessible button descriptions instead of absolute-positioned custom bubbles. The settings grid fits narrower containers and obsolete tooltip CSS has been removed.
|
||||
- Preserve Dashboard visibility/order preferences, density choices, responsive column breakpoints and opt-in Stream cards. No runtime, API, SQLite, proxy routing or language-pack changes. The Rust-independent `./dev.sh test` checks passed, then stopped with code 127 because Cargo is unavailable; **Cargo/build and authenticated-browser validation remain open** until executed on a supported runtime.
|
||||
|
||||
## Previously in 0.32.0
|
||||
|
||||
**Dashboard follow-up:** saved opt-in cards show on Overview even without active Streams (neutral no-data state). Appearance card-setting explanations remain on hover/focus; Overview metrics and legends are permanently visible inside the original six-column desktop grid, with taller tiles and no hover popovers. Stream editor explains the detailed-metrics inheritance and omits the redundant enable hint. `scripts/start_stream_backends.sh`, `scripts/test_stream_tcp.py` and `scripts/test_stream_udp.py` provide runnable mock peers and separate black-box tests. See [STREAMS.md](docs/STREAMS.md).
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# RustProxy roadmap
|
||||
|
||||
## 0.32.1 - Dashboard Overview typography and tile height (runtime acceptance pending)
|
||||
|
||||
- Restore content-driven heights for desktop Overview tiles (six columns) seen in 0.31.9, removing 0.32.0's fixed-height whitespace at 90% scale.
|
||||
- Increase compact Dashboard headings, values and secondary descriptions, including the fixed-size Payload legend. Align tile gaps with the same `--ui-gap` as other dashboard blocks for all dashboard density settings.
|
||||
- Keep opt-in Streams, all responsive breakpoints and chart legends visible inline. Add layout-regression checks. No proxy or persistence changes. Rust/Cargo and authenticated-UI runtime acceptance are still outstanding.
|
||||
|
||||
## 0.32.0 - Stream backend pools and opt-in Dashboard cards (runtime acceptance pending)
|
||||
|
||||
- [x] Extend legacy TCP/UDP upstream with an optional validated backend pool, Round Robin/Least Sessions, session pinning, and bounded concurrency.
|
||||
|
||||
+40
-37
@@ -1,18 +1,18 @@
|
||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||
bf752089983e44302369c9f9e49d772fd30f332fb92a825a88aa0721aa55755b AGENTS.md
|
||||
f883914a769deebbee14dcdd56edffab73423f63b01d775d0479780b63d37b6c CHANGELOG.md
|
||||
ac5a5bbe018e66e9b26468fa4155c576d162ea6224ae3444c86560a2ed779311 Cargo.lock
|
||||
d292878efbedefe37a3ee39150a9988ba53dba18b09bc38a0045c7c30a4c21ea Cargo.toml
|
||||
5090b92a2b7a045f90da92c369c16ac5a9704d466f38650f9215124d5e8cf785 AGENTS.md
|
||||
a17c615a6a46227bc66118f6e557c18c40fc1db0fcbdfe3c23fe339480b66357 CHANGELOG.md
|
||||
ef41be4589ed16a84279f06232d3a7604beae512db47bd7c0d1be771c4446582 Cargo.lock
|
||||
5c50094ca7773fbe586db61007783d2c600c5481a03f446e99b862dd724a9dac Cargo.toml
|
||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||
c7ece696785cb815fd8b3b30f8ad99b87501daa82d59d4d7cd156412958a4b07 README.md
|
||||
9a7bd0c457cb6b198c6e8335b0cd9a311fa5c2decc1f9c1bdccbb727af628f85 ROADMAP.md
|
||||
bf10b4944a4cb45449eb3a9ec6a4cd91a9ca44c47a47e015320c2e03b6a67e59 README.md
|
||||
7a0aacd9db1ea4fa42a59614498fea140f17d2375d5cac4db8819b3eb43786e4 ROADMAP.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
||||
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
|
||||
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
|
||||
ce5dd515f2fdf93c27b7cd68940cea108b8a460f765cc68f26441c0958bb3a1f dev.sh
|
||||
3b7faf0c7f26387c908ed1f9d11bfd00e1fc042680826e73b01d7e01f2af4043 dev.sh
|
||||
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
||||
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
||||
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
||||
@@ -61,9 +61,9 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m
|
||||
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
|
||||
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
||||
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
||||
70ee2ba62d21345c6ea25db2c4a3de9cd5913cb12de530159527c3efc10a5893 docs/SETTINGS.md
|
||||
ffe962544cf09ef401465b1ef9d0666558490d664212b733a5ab205490ee3b9d docs/STREAMS.md
|
||||
06f7f5981fc3a285e1316aa025ce962f9253e77e949b4a77263b55b8996a2422 docs/TEST_REPORT.md
|
||||
919aed5a97f8b08aac1a0784672170b49b420012cd8f2159dd3e46e654787dbd docs/SETTINGS.md
|
||||
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
|
||||
22568f54908dfda2cf830351e9f3394d81564b13473dea8baa9bb7e36646dcdb docs/TEST_REPORT.md
|
||||
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
||||
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
||||
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
|
||||
@@ -128,6 +128,8 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim
|
||||
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
|
||||
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
|
||||
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
|
||||
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/tests/test_stream_tcp.py
|
||||
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/tests/test_stream_udp.py
|
||||
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
|
||||
ad62f73bb42906d617cc1b479f02d4648c7324cb89fc11d2c6aa6bf13774eec9 src/acl.rs
|
||||
ea272433e7f92e983509a8ab1aa54242440abef21df526c2f99944ef279e09b9 src/acl/tests.rs
|
||||
@@ -152,26 +154,26 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
|
||||
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
||||
7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs
|
||||
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
|
||||
b7af5b8be98904231ac08b6f8e9aaf4b3ec73ae3bbf965cc3f078b27d5948cce src/admin/openapi.json
|
||||
da852198f31d08e3e5f146683d149aef94d48343014a7074308c57aabeb1e3c4 src/admin/openapi.json
|
||||
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
|
||||
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
||||
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
||||
fce294c2af33845cd139fc7fe794f6ee7dea1b20b13fe597c5174cab0d3739d8 src/admin/preferences.rs
|
||||
78942d5e97e42c9de9379644ea1b73e595e48567d8f7820742cab6ec91851a73 src/admin/preferences_columns.rs
|
||||
c132a03c165293a7e248a3a232327aab1b061ff61b4752135927cb9fe1598dec src/admin/preferences.rs
|
||||
fd9846943a57f59e6db9e9d91de81eaa2688799a113102b28c9e93694505b3fe src/admin/preferences_columns.rs
|
||||
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
|
||||
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
|
||||
6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs
|
||||
afa1a677dbd7caf34dd5b81cf4de98cc83b58550f05937978421b7c5dc915a68 src/admin/router.rs
|
||||
fc0db7f27163ef107a9a03dc174eac35346b8d9742f4a83464398ee8b49768e7 src/admin/router.rs
|
||||
a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
|
||||
e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs
|
||||
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs
|
||||
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
|
||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||
bf403ed0db49fc7ead3b981d47604bb52d52c56b271a2e379dd878956989faf0 src/admin/tests.rs
|
||||
50da77927c0344e4eda0cd6a8d11c23780109c6d20c32585c2fad8cbc8369ffa src/admin/tests.rs
|
||||
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
|
||||
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
|
||||
cb9def393b7f57a6cfecbb8edaff4b7e20e7805231d08e354ad0b7deb294a103 src/admin/web/app/core.js
|
||||
f38cd0381be1f0ec00101dede69bf13fc7d77e71ede0aea5ef0aa0f2bc9e419c src/admin/web/app/preferences.js
|
||||
919019e3e71ae7dc2a7c9ba411dad2908ead42942c0702e7349b5ebe12908442 src/admin/web/app/preferences.js
|
||||
729ed152eb8471c67fb0e55e44fe0669c2708802ce83228df9cb4e53c14cee02 src/admin/web/app/refresh.js
|
||||
984d092961bfaaa19f982e54c7d4be474e8417b208d814ac88bda63f1ab19f4c src/admin/web/app/runtime.js
|
||||
db56370db60e514a2241d231cd949bac2c7aa2f413a2c14035a877029a8d864f src/admin/web/app/session-resume.js
|
||||
@@ -200,7 +202,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
|
||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||
a065af0a331a953dc51c72f0f589023cf8a917cda5baa5308dd0d8c105ec3d91 src/admin/web/file-logs.js
|
||||
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
||||
d38d416baf581453a23ab6a8e119272fb3b743119b0bd3ed7a8cfb35f1101d0b src/admin/web/index.html
|
||||
0ee5b6ac3cf2022c5b88166dc4816868161351471e81b424b53d0f174eca7ed1 src/admin/web/index.html
|
||||
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
|
||||
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
||||
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
|
||||
@@ -210,11 +212,11 @@ cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/
|
||||
2efa65f4112ea5523bbb5583c6e320c93f0cfed520a0bc5cf4c22fb51b123649 src/admin/web/settings.js
|
||||
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
||||
6e0036e3915de17529f14a7e47fa57d52ed3e03e65a3e4c0aa78cd6486342e96 src/admin/web/streams.js
|
||||
0795e65f07dfea1ae7871198115f83e877cdac3d62171c08b1afadd77a5050e4 src/admin/web/style.css
|
||||
39f5e489813ae397ab03bbfca0d3daca85244da93ec4e895a3fc75d40234b1e3 src/admin/web/style.css
|
||||
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
||||
a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js
|
||||
00f59a6f656e44c364ed3c8eed33ce1df878a488908b30a9523a87b64f50f9ef src/admin/web/vhosts/core.js
|
||||
2aaeacc1b71748bb01b5f02613be343df9f5b496be644662130b184c7c195177 src/admin/web/vhosts/editor.js
|
||||
f3aded010313a31e3f8599891be2f9bccfcf3d1e7a1649ccf81df95fbee3232f src/admin/web/vhosts/editor.js
|
||||
30a6344e76de77b60cd7247a33c68f1cb5d972aebfde56469f2ebd44440c7bf9 src/admin/web/vhosts/health.js
|
||||
137aeabed99cbdf9f122da6350e596e6a4e32244fa23101ccf6378305148d38d src/admin/web/vhosts/rates.js
|
||||
f2689283cfe43d6aae911b0df778a69335089be230fbfc6de5dcce1801c38427 src/admin/web/vhosts/render.js
|
||||
@@ -340,7 +342,7 @@ d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs
|
||||
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
|
||||
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs
|
||||
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
|
||||
902185d2abe011fda35b438621e73c0728ca230f2f0f7779640cd2136f2cd9ed src/prometheus/docker_misc.rs
|
||||
c46882ed26742d93806a976257674326e105b8ca1fc23f5ca81e317b268057aa src/prometheus/docker_misc.rs
|
||||
d3c6b08088df0003144544936a55e2a3661aac85292821b594f00adfd19a2951 src/prometheus/model.rs
|
||||
064b2be7fed2f6acf526b1cce070c1bb1b943d36f08fa50bbfcbdf3cf1c9318c src/prometheus/tests.rs
|
||||
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
|
||||
@@ -398,11 +400,11 @@ e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/ser
|
||||
240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs
|
||||
1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs
|
||||
9dbf814870b8636770f579c3d36d16f5b6afe8bc66adfad356c26d7ae7dbce45 src/stream.rs
|
||||
c29f19729ef93c55de5a4d9b448a0adc366e1f34655bd95471ad9100cef51ba1 src/stream/health.rs
|
||||
1ad99c5f5eea1e4aff0d9ba82186d36f41a87aafd2849f082652d2270f3166b2 src/stream/model.rs
|
||||
049cc0e13cbc78ccc8f19ab97986a40f95587235c359529293d7c90b2503447d src/stream/pool.rs
|
||||
254840126be2310426f6332f5efbe98147d9d8de1999731f40a3d13325c1dc6c src/stream/health.rs
|
||||
bdc56be7dae85a97a4d5b8d46ef31ec4177292b1cadd507fe423b6d4ee1403bd src/stream/model.rs
|
||||
de138a4ac68bcddba3a4f17ceba008bab643359d76a68726a755069f19334bed src/stream/pool.rs
|
||||
6ec42b8647c95a0c2025462e0be29c71481ce1c3b1f4d4193926471f1cf06be0 src/stream/runtime.rs
|
||||
2d8497f7486240c27fc0091dc22f3bfd71dc20d4ac839c10d5b57b2715a72fab src/stream/tests.rs
|
||||
9346cbd71e006e41a7188f28eb86f9b00bac822e4cb31c76c8d6d2ec5d4c4711 src/stream/tests.rs
|
||||
3070d78a7b88c2e47ed59960fa35e7d220a4cd7d78e48f6c6d2bcc5ff77f9620 src/system_stats/linux.rs
|
||||
d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_stats/linux/tests.rs
|
||||
751d59582fb92c84e0c0cc7c0855c9d0a545805c9ba3be61826e50fb96c297ed src/system_stats/mod.rs
|
||||
@@ -482,47 +484,48 @@ e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/a
|
||||
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
|
||||
412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs
|
||||
2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs
|
||||
92a2992e7d60ec7edd743bd5eced22d08fd2bfd765f87cd656c97e9691543cff tools/checks/certificate_api_0319.py
|
||||
b25381435bddb9d91ddf057bf5447877080b3bfb6444e8222bffc9034e3c5684 tools/checks/certificate_api_0319.py
|
||||
526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs
|
||||
5e88ddee66527c97b11a21b4cc3dfd73fffbaaec64061e6a9a59a3a6ff2b452e tools/checks/check_source.py
|
||||
49829119968828bfbb276ff408716ba6f2706a8463878af3d92ec963e7071b6b tools/checks/check_source.py
|
||||
dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
|
||||
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||
d3760705017644f1993c3a9adc9c308b29b54d55f523fc2dc95f8c4c19950b23 tools/checks/dependency_upgrade_0312.py
|
||||
a6f8b7ee2cf0cc212032c2b76588e360143e9b2e20ff87caa8f29675595cd885 tools/checks/dependency_upgrade_0312.py
|
||||
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
|
||||
aaef0dc6a56eb58eff94544f3eee20d15ae1c5b714ca5cd52ac3ec93f9dbf197 tools/checks/docs_check.py
|
||||
2f4fecdad966d9898f7a9168c3f6e216ffb3ea165d06b62fe5865f2c3314ed83 tools/checks/docs_check.py
|
||||
6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs
|
||||
83c74f0027a6d2f51f637d552593f9904e60ccd69665f6460136c48b159afb60 tools/checks/h3_cache_mobile_0315_test.mjs
|
||||
4f3a892139ea81e195866215ed9d8e22449b1876bc6d6310b561b1fc06586192 tools/checks/h3_navigation_0316.py
|
||||
72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs
|
||||
94396cf2fc2a2a683e1198c15d887ed878730ee36101ba980a8b11c5b99db830 tools/checks/h3_navigation_0316.py
|
||||
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
|
||||
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
|
||||
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py
|
||||
af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs
|
||||
295bd8b9dc1f80cfaa74a44074887d0ccefc0e0a066ab4cdab44da82e66243e1 tools/checks/overview_0321_layout_regression.mjs
|
||||
1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs
|
||||
eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/preflight.py
|
||||
021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs
|
||||
c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
|
||||
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs
|
||||
a920b391463d69f37af8da36f3b19e991fc62b7483ae98469ab9c5a358800a37 tools/checks/release_0318.py
|
||||
4b4e9e84b21d78dcee9cb8ef4bc40155ebdb3dceb7e064715a93e89345a098ce tools/checks/release_0318.py
|
||||
e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs
|
||||
5a23bf1561c8f8850f011fff79e08074df1d4f41041a820c28d6a4ccb4b3c9ef tools/checks/release_smoke.py
|
||||
d7483d0456ccd9d8b328587b733598020a0a6fa4496be0527c603c90fbd380b4 tools/checks/release_smoke.py
|
||||
31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py
|
||||
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
||||
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
||||
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
||||
56ccfd5ea83f36a5afb291d4a8011ace842c8f9998184024e0803ead0053e247 tools/checks/security_logs_0315_ui_test.mjs
|
||||
1c747fd65f48a0ec7650c7b8144ea97d9de8d78014d5ddaef82792d18a026f2b tools/checks/security_logs_0315_ui_test.mjs
|
||||
70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py
|
||||
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
|
||||
e9f96a2a44712feb090ddb0eb273124ac780cb68e428292f7c7f106de11be050 tools/checks/stream_0320_ui_test.mjs
|
||||
a4d4a61b34840dd0971a4b5fdef33fee89d0e8fbc7f68b7cdbefb259d480f822 tools/checks/stream_0320_visibility_regression.mjs
|
||||
1218e3b847daf1850f2601fe62de63000e9f9891d2903b11b8e60b169c960d46 tools/checks/system_info_0301_ui_test.mjs
|
||||
4d066f8cae3162a5574b5523b775e6e7c351379f6bf3d63706d5fe082b6900bc tools/checks/stream_0320_visibility_regression.mjs
|
||||
8542f5c668155aa9f70de11e6fdaf64c5a80629a15ecc5f1d8c40f3d655f9b55 tools/checks/system_info_0301_ui_test.mjs
|
||||
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
|
||||
a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
|
||||
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||
0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs
|
||||
ea98e8ce2bdc841814e486ea4c13f94e284b2c50a5887598b7a288f75a14deaf tools/checks/vhost_www_ui_test.mjs
|
||||
7aeef339092ab07aa0d906fce223ff34ecc1ee8dbd6b3f173d94c521a79558ac tools/checks/web_assets.py
|
||||
618f9c6b20c043d151eac319d1712ec50358ae86b2e7fa23418c8e4bbcc45963 tools/checks/web_server_contracts.py
|
||||
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
|
||||
adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs
|
||||
4c541640a7edcdb69319770d6359e7715617df8f5443f82b29f867a5e820a08e tools/checks/www_redirect_ui_test.mjs
|
||||
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
|
||||
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
||||
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
|
||||
|
||||
@@ -115,6 +115,7 @@ if [[ "$mode" == "test" ]]; then
|
||||
node tools/checks/refresh_0310_ui_test.mjs
|
||||
node tools/checks/stream_0320_ui_test.mjs
|
||||
node tools/checks/stream_0320_visibility_regression.mjs
|
||||
node tools/checks/overview_0321_layout_regression.mjs
|
||||
node tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||
node tools/checks/dashboard_boot_0311_ui_test.mjs
|
||||
node tools/checks/origin_resume_0313_ui_test.mjs
|
||||
|
||||
+1
-1
@@ -539,4 +539,4 @@ PROMETHEUS.md for immediate revocation, restart rules and scrape configuration.
|
||||
|
||||
## Stream Dashboard cards (0.32.0)
|
||||
|
||||
Under **Settings → Appearance → Dashboard layout**, the existing Show/Hide and Up/Down card controls now include Stream overview, traffic, sessions and backend health. They are opt-in and account-scoped, saved through the existing `/api/preferences` flow. Legacy 12-card orders migrate in memory with all four Stream cards hidden. They use the existing status/SSE stream and stop computing their charts while hidden; no backend monitoring or listener settings are changed when toggled. An explicitly enabled Stream card stays on Overview and shows a neutral no-data state when no Stream is active. Overview details and chart legends stay visible inside the cards (without hover tooltips); the appearance editor continues to show its own descriptions on hover or keyboard focus.
|
||||
Under **Settings → Appearance → Dashboard layout**, the existing Show/Hide and Up/Down card controls now include Stream overview, traffic, sessions and backend health. They are opt-in and account-scoped, saved through the existing `/api/preferences` flow. Legacy 12-card orders migrate in memory with all four Stream cards hidden. They use the existing status/SSE stream and stop computing their charts while hidden; no backend monitoring or listener settings are changed when toggled. An explicitly enabled Stream card stays on Overview and shows a neutral no-data state when no Stream is active. Overview details and chart legends stay visible inside the cards (without hover tooltips). Appearance Stream card descriptions use browser-native `title` hints on hover, with the same localized descriptions available to assistive technology through `aria-description` on the visibility buttons; they no longer add floating boxes or horizontal scrolling.
|
||||
+1
-1
@@ -47,7 +47,7 @@ The existing `/api/status` response and `/api/status?view=live` SSE/fallback add
|
||||
|
||||
In the editor, **Stream → Detailed metrics → Default (disabled)** means the absence of a Stream override resolves to `false` (it does **not** inherit from Prometheus). At the backend level, **Use Stream setting** resolves to that Stream's value; backend **Enabled**/**Disabled** override it. Prometheus retains `include_streams` as the separate global export gate. `metrics_enabled` defaults to `false` for detailed per-backend accounting (no new overhead on legacy streams). Stream-level `true` enables the default for its backends; backend-level `true`/`false` explicitly overrides the Stream value, while `null` inherits. This choice never changes session routing or health checks. Basic per-Stream counters and backend active gauges stay available. Backend label cardinality is bounded to IDs declared in config, never client IPs. History is a limited browser-memory window only; metrics are not persisted as periodic SQLite samples.
|
||||
|
||||
Under **Settings → Appearance → Dashboard layout**, toggle/reorder Stream overview, traffic, sessions and backend-health cards alongside existing telemetry cards. All Stream cards default hidden for existing accounts, including migrated 0.31.x layouts. An explicitly enabled card stays visible even when no Stream is configured, enabled or running; it displays a neutral **No stream data** state in that case rather than ignoring the Appearance setting. They use the existing API status/SSE/fallback and one dashboard refresh scheduler. Hiding a card avoids its chart computations, and changing visibility does not alter Stream runtime, existing widgets or global Prometheus settings. Widget descriptions in Appearance appear on hover/keyboard focus rather than permanently beneath settings labels. On the main Overview page, secondary values and RX/TX chart legend are always inline under the metric/graph (not tooltips), with the original six-column desktop layout and taller tiles for readability.
|
||||
Under **Settings → Appearance → Dashboard layout**, toggle/reorder Stream overview, traffic, sessions and backend-health cards alongside existing telemetry cards. All Stream cards default hidden for existing accounts, including migrated 0.31.x layouts. An explicitly enabled card stays visible even when no Stream is configured, enabled or running; it displays a neutral **No stream data** state in that case rather than ignoring the Appearance setting. They use the existing API status/SSE/fallback and one dashboard refresh scheduler. Hiding a card avoids its chart computations, and changing visibility does not alter Stream runtime, existing widgets or global Prometheus settings. Widget descriptions in Appearance use native `title` tooltips on hover and accessible button descriptions (`aria-description`) instead of floating elements that expand the layout. On the main Overview page, secondary values and RX/TX chart legend are always inline under the metric/graph (not tooltips), with the original six-column desktop layout and taller tiles for readability.
|
||||
|
||||
## Safety and operational limits
|
||||
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
# Verification report - RustProxy 0.32.0
|
||||
|
||||
## 0.32.0 — Overview original six-column layout (2026-10-12)
|
||||
|
||||
- **Fix:** remove the Hotfix 3 four-column desktop override; restore the existing six-column Overview grid above 1200px, retaining the original 3-column/2-column responsive breakpoints and dashboard density settings. Use taller desktop cards so secondary labels and colored graph legends remain visible inline, without hover/focus behavior. No changes to Dashboard preferences, widgets, API, refresh scheduler or Vhosts behavior.
|
||||
- **PASS — regression & Chromium static fixture (not a live RustProxy panel):** `stream_0320_visibility_regression.mjs` rejects the four-column override and asserts the six-column desktop rule. Chromium computed six columns at 1280px (126px-tall compact cards), three at 1024px and two at 390px, with all 16 original+Stream cards present in fixture, captions and RX/TX legend visible, and no horizontal page overflow. This is not an authenticated application or a Rust runtime test.
|
||||
|
||||
## 0.32.0 — Overview inline legends and Vhosts edit-hint fix (2026-10-12)
|
||||
|
||||
- **Overview:** remove the hover/focus tooltip CSS previously affecting every Overview `small` caption on pointer devices; restore their permanent placement below numbers and graphs on desktop and touch. Remove `tabindex=0` from non-interactive stat articles (inner buttons remain keyboard-accessible). Keep the 0.32.0 Stream opt-in/empty-state behavior and unchanged status/SSE cadence.
|
||||
- **Layout:** Overview uses four wider columns above 1200px and the original responsive grid below that breakpoint; allow chart legends to wrap. Stream RX and TX share the already collected traffic rates and existing graph and now render as a two-color inline legend (no API/timer added). **Settings -> Appearance** description popovers remain unchanged.
|
||||
- **Vhosts:** the add/edit enabled checkbox still uses the shared enabled-state control, but its redundant `vhEnableHint` helper node is removed locally, as already done for Streams. This does not change how `enabled` is saved or processed.
|
||||
- **PASS — full Rust-independent `./dev.sh test`:** production-module/source contracts, SQLite/schema/upgrade checks, security and documentation checks, locale parity, and all bundled Node UI regressions. Includes modified Stream visibility and in-card RX/TX legend tests and the Vhost hint source test. The command ultimately stops at **127** because Cargo/Rust is not installed; no Rust tests ran.
|
||||
- **PASS — Chromium static-layout fixture (not a live authenticated panel):** desktop 1280px Overview has four columns (Stream traffic card 268px wide), labels remain visible and in place after hover, and colored RX/TX legend fits within the card. At 390px the layout has two columns with wrapped chart legends; both viewports have no horizontal overflow. Appearance help is hidden at rest and still appears on hover. Screenshots were checked manually.
|
||||
- **OPEN:** Cargo format/check/tests, a rebuilt running RustProxy panel, and authenticated browser/live TCP/UDP regressions on the operator host. No change was made to Rust Stream routing, system widgets, preference persistence or refresh scheduler in this patch.
|
||||
|
||||
## 0.32.0 — Stream Dashboard/Appearance hotfix 2 (2026-10-12)
|
||||
|
||||
- **Root cause addressed:** `stream-dashboard.js` unconditionally hid opted-in Stream cards whenever no enabled/running listener appeared in the current config/status. Visibility now follows existing Appearance preferences exclusively. When there is no running/configured Stream, explicitly selected cards show the translated neutral no-data state without extra polling or history samples. Disabled cards remain entirely hidden; legacy cards and layout controls are unchanged. Regression cases include saved opt-in, toggling, disabled/no-data and restart-pending Streams.
|
||||
- **Appearance UX:** descriptions of the four Stream widget controls are popovers on pointer hover or keyboard focus (`role=tooltip`, `aria-describedby`), rather than permanent second lines. The shared Overview metric tips remain unchanged. In the Stream editor, the redundant generic active-control hint is removed locally; `metrics_enabled=null` is labeled **Default (disabled)** on a Stream and **Use Stream setting** on a backend, consistent with existing runtime inheritance. The `include_streams` Prometheus export switch remains independent.
|
||||
- **Tools:** moved the separate Python TCP and UDP test clients from `scripts/tests/` to `scripts/`, added `scripts/start_stream_backends.sh` alongside them, and updated command examples and README. The launcher starts/stops both TCP peers (ports 9101, 9102) and both UDP peers (9201, 9202) in one group; no service is exposed beyond loopback unless explicitly requested.
|
||||
- **PASS — full Rust-independent `./dev.sh test` run:** Python/SQL/schema/security/docs/i18n checks and all bundled Node UI/regression tests passed, including both new Stream visibility cases; terminated **127** because Cargo is missing. Source line-count contract (including JS <=500 lines) passed after moving a local Stream UI change out of core.js.
|
||||
- **PASS — Chromium static Appearance fixture, not authenticated RustProxy:** 1280px and 390px viewports; Stream description tooltip hidden at rest, shown on hover or focus, 34px row height and no horizontal overflow.
|
||||
- **PASS — actual four-peer launcher test (mock-only):** direct Python client connections to TCP 9101/9102 and UDP 9201/9202 exchanged 4 messages per port over two sessions. Python TCP/UDP standalone selftests also passed. Neither path went through the RustProxy binary; it is not a real proxy routing/failover acceptance test.
|
||||
- **PASS:** `node --check` for modified browser modules, script syntax, locale/doc checks, `GOPROXY=off GOSUMDB=off go test ./...` for the Docker agent.
|
||||
- **Still pending on a Rust-equipped host:** Cargo build/check/test, actual admin session/browser UI, and TCP/UDP routing + health/failover/load tests through running RustProxy. This source hotfix is not runtime-production validated here.
|
||||
|
||||
## 0.32.0 — Dashboard follow-up and separate TCP/UDP test tools (2026-10-12)
|
||||
|
||||
- **Reported on a Rust-enabled host:** `src/stream/pool.rs` emitted one deprecated `AtomicU64::fetch_update` warning. The source now calls `AtomicU64::try_update` with the same memory orderings and session-capacity closure. The modified Rust source was **not compiled here**.
|
||||
- **Fixed in source:** Appearance layout now supplies the newly selected `dashboard_hidden_widgets` to Stream visibility immediately, instead of consulting stale `adminPreferences` during an initial load or a persisted switch. Cached Stream totals repaint on enabling or returning to Overview without an extra API request, timer or synthetic traffic sample. Disabled/nonexistent Streams remain absent.
|
||||
- **UX:** secondary Overview values (the `small` text below metrics) float above cards only on pointer hover/keyboard focus; card names and primary values remain visible. For touch-only devices, details retain their original inline presentation. Keyboard focus outlines are preserved.
|
||||
- **PASS — actual Chromium static layout check:** desktop 1280px hover and keyboard focus changed tooltip opacity from 0 to 1, mobile touch 390px retained inline detail opacity 1 and no horizontal overflow. This is **not** an authenticated RustProxy browser test.
|
||||
- **PASS — full Rust-independent `./dev.sh test` suite:** all Python/SQLite/security/docs/locale and Node UI regressions, including `stream_0320_visibility_regression.mjs`, completed. The tool terminated **127** at the expected missing-Cargo check. New navigation code was additionally checked against the existing Docker VHosts simulated regression. No Rust tests ran.
|
||||
- **PASS — standalone harness-only selftests:** `scripts/test_stream_tcp.py selftest` (8 local mock TCP sessions/24 messages) and `scripts/test_stream_udp.py selftest` (8 local mock UDP client sockets/24 datagrams). Both verify echo and same-session affinity against the Python mock **directly**, not through RustProxy.
|
||||
- **PASS:** `GOPROXY=off GOSUMDB=off go test ./...` for the bundled Docker Agent; `bash -n dev.sh`, `python3 -m py_compile` for the new Python scripts, Node source syntax, docs and i18n checks.
|
||||
- **PENDING on operator's Rust toolchain and live proxy:** `cargo fmt --check`, `cargo check --all-targets`, `cargo test`, compilation without warnings, and running both `client` scripts through actual configured TCP/UDP Stream listeners with mock upstreams, followed by drain/health/failover verification. No claim of production-ready runtime is made here.
|
||||
|
||||
## 0.32.0 — Stream pools and Appearance/Dashboard integration
|
||||
|
||||
- **Implemented, source inspection:** legacy single-upstream compatibility, bounded TCP/UDP backend pool with RAII assignments, weighted Round Robin and least-load selection, health modes and probe hysteresis, optional per-backend counters and Prometheus labels.
|
||||
- **Implemented, source inspection:** four hidden-by-default Stream Dashboard widgets and Appearance controls in the existing per-user preference mechanism, using the existing status stream/polling (no new timer or HTTP route).
|
||||
- **PASS — offline source/UI regressions:** `./dev.sh test` completed all Python checks, all browser-side Node.js tests (including `tools/checks/stream_0320_ui_test.mjs`), security and documentation checks, and language-pack checks. It then stopped at the Cargo prerequisite with exit code **127** because the Rust toolchain is absent; this is **not** a passed Rust test run.
|
||||
- **PASS — syntax/docs:** `node --check` on all 31 first-party browser JavaScript modules; `python3 tools/checks/docs_check.py`; `python3 tools/checks/langs_check.py`; JSON parsing for the OpenAPI specification.
|
||||
- **PASS — independent Go agent:** `GOPROXY=off GOSUMDB=off go test ./...` from `docker-agent/`.
|
||||
- **NEW, NOT EXECUTED (Rust):** legacy configuration parse/validation, duplicate IDs/self-loops, weighted RR/least sessions, cap/drain/lease accounting, TCP rise/fall, UDP Unverified, real TCP/UDP probe replies, real socket forwarding and UDP session pinning. These tests were added to `src/stream/tests.rs` but require the Rust 1.99+ toolchain.
|
||||
- **OPEN:** `cargo fmt --check`, `cargo check --locked --all-targets`, `cargo test --locked`, staging TCP/UDP health/recovery/load tests and authenticated Dashboard browser acceptance. No production-readiness claim is made without these checks.
|
||||
|
||||
|
||||
## 0.31.9 — Certificate API credential management (runtime acceptance open)
|
||||
|
||||
- **Implemented:** read-only detail for a Key ID, metadata-only edit retaining the current bearer hash and usage/creation timestamps, and explicit per-Key-ID regeneration replacing the hash and resetting last-used time while retaining permissions and comment. Existing scoped reload/deploy and session/CSRF rules remain unchanged. API responses from GET/PATCH never expose existing tokens or hashes; the new secret is returned only after regeneration.
|
||||
- **Added verification:** Rust admin route regression covers status, edit vs. rotation, authorization, invalid scopes, old/new token authentication and audit actions; a focused Node panel/API simulation and OpenAPI/locale/source contract test cover UI/API wiring. Rust compilation and real runtime acceptance remain pending until the Rust 1.99+ toolchain is available.
|
||||
- **Compatibility:** no SQLite schema migration, no existing token changes on upgrade, no public proxy/listener changes. Legacy wildcard grants remain valid for reads and regeneration; editing requires a new explicit certificate ID allowlist.
|
||||
- **Checks executed:** focused Certificate API Python source/OpenAPI/locale and Node actual-module/UI-simulation checks **PASS**; Node browser module syntax, OpenAPI JSON parsing, shell syntax, `docs_check.py`, `langs_check.py` and offline Go agent tests **PASS**. The `./dev.sh test` pre-Cargo source/schema/upgrade/security/memory/load/Node stages printed **PASS** through documentation checks, but the enclosing execution timed out before its final result. The language check passed independently. No live RustProxy binary was run.
|
||||
- **Open release gate:** this environment has neither `cargo` nor `rustc`; compile/check/unit tests and authenticated live browser/HTTP verification remain necessary on a Rust-equipped host.
|
||||
|
||||
## 0.31.8 — implementation pending runtime acceptance
|
||||
|
||||
- **Test-source consolidation:** merge all 28 numbered `part_XX.rs` fragments across seven Rust test suites into existing `tests.rs` files, preserving test bodies and scopes. Update relative `login-keys.json` fixture path, compiler-independent source checks and archive smoke to exempt test-only Rust files from the 500-line production limit. Delete the seven now-empty fragment directories.
|
||||
- **PASS — retained tests:** all 28 fragments were concatenated in their original order into seven `tests.rs` suites. Exact body comparison (apart from the required `login-keys.json` relative path) confirms all 251 `#[test]`/`#[tokio::test]` markers are retained. All seven obsolete directories are absent; source include references resolve.
|
||||
- **PASS — Rust-independent 0.31.8 release suite:** `./dev.sh test` passed its pre-Cargo Python/SQL/schema/memory/source, JavaScript DOM/API simulation, documentation and translation checks, including Docker VHosts startup regression. The command exited **127** only at the missing Cargo guard. `GOPROXY=off go test ./...` passed in `docker-agent`.
|
||||
- **Unverified:** Rust/Cargo compilation, formatting and unit-test execution are not available in this environment; no claim is made about compiled behavior or live runtime acceptance.
|
||||
- **Docker startup-state follow-up:** fixed the client rendering where a missing first Docker observation incorrectly displayed a connection failure; now shows WAITING until a server-side poll result exists. A visit to Docker VHosts reads GET `/api/docker` regardless of per-page Live view; cached-status retries stop after the first result unless Live view is enabled. A TLS certificate pin is not marked connected without a successful poll; genuine poll errors are still visible. No Docker agent, TLS verification, proxy route, SQLite schema or poll interval changes.
|
||||
- **Docker follow-up verification:** `node tools/checks/docker_startup_0318_ui_test.mjs` passed with the real dashboard JS in a simulated DOM/API: pending/failure/success transitions, TLS pin indicator, per-page Live-off one-shot/initial status reads, re-entry and disabled-agent handling. No live Docker agent or Rust binary was exercised.
|
||||
- **Scope:** Stored Logs server-side sorting + UI, built-in status templates, GeoIP packed/boxed ranges, transformed response single-frame cache allocation sharing, and version metadata.
|
||||
- **Compatibility:** API sorting query parameters and administrator preference fields are optional and default to descending chronological order. No SQLite schema or cold cache format changes.
|
||||
- **PASS — source/static checks (2026-10-12):** `release_0318.py`, `release_0318_ui_test.mjs`, `check_source.py`, `version_contracts.py`, `web_server_contracts.py`, `admin_surface_audit_0310.py`, `security_dependency_contracts.py`, `docs_check.py`, `langs_check.py`, `dependency_upgrade_0312.py`, `memory_contracts.py` and available focused UI tests; Node syntax check, JSON schema parsing and Bash syntax checks. These are not Rust compiler or runtime tests.
|
||||
- **Unverified:** `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test`, resolved lockfile verification, H1/H2/H3, browser behavior, and before/after RSS under matched traffic and HOT load on a Rust 1.99+ build host. The source-only checks cannot validate compilation or the zero-copy runtime optimization.
|
||||
|
||||
## 0.31.7 — pruned source package correction (2026-10-11)
|
||||
|
||||
- **Reported:** deleting 47 historical audit/implementation Markdown files made `dev.sh` fail at source preflight (`docs/V0.25.5_AUDIT.md`). They were not needed at runtime, but hardcoded documentation/source checks still demanded them.
|
||||
- **Additional build hazard fixed:** the embedded documentation Reader still contained seven `include_str!` registrations for deleted `IMPLEMENTATION_0.31.x.md` files. These would cause actual Rust compilation failures even if the Python preflight were bypassed.
|
||||
- **Changed:** remove obsolete audit/implementation requirements from preflight, source/release smoke tests and documentation checks; keep strict runtime source checks and validate the existence of every Markdown file still embedded in the Reader. Update Reader integration test, documentation index and broken relative links. No SQL, proxy runtime, UI or packaging policy changes.
|
||||
- **PASS:** `./dev.sh test` passed Python source/upgrade/SQLite/security/memory regressions, Node UI checks, docs/locale checks and the new source-pruning test. It terminated **127 only because Cargo is not installed here**. New regression exercises the expected failure when a live Reader document or source module is deleted. `GOPROXY=off go test ./...` passed in `docker-agent`; `bash -n` for `dev.sh`/`update.sh` passed.
|
||||
- **OPEN:** Rust compilation/linking and real update on the user's host remain unverified. After deploying this source archive, run `bash dev.sh release` and `bash update.sh` in the usual release workflow.
|
||||
|
||||
## 0.31.7 — revision history UI follow-up
|
||||
|
||||
- Move **Full JSON comparison** directly below **Restore revision** inside the existing action group, without additional separators. Preserve existing revision selection, restore handlers, diff logic and JSON display behavior.
|
||||
- Bound the revision history dialog to the viewport. Large change lists and day-revision buttons scroll inside their desktop panes; the calendar and the stacked mobile layout scroll without forcing page-level overflow. Make the full JSON dialog use the same base dialog border/background/padding as other windows, with a right-aligned close button and borderless JSON blocks.
|
||||
- **PASS — browser layout fixture:** headless Chromium with the actual HTML/CSS dialog fragments, 200 changed fields, 50 day revisions and six month calendars at viewport sizes 1280×800, 1024×600, 800×600, 390×760 and 320×568. Desktop change list has independently scrollable content (including the final change); narrow viewports have a scrollable modal body and do not expand the document width. Full JSON dialog shares the generic border rule. This is synthetic browser rendering, not a running Rust server or real Safari.
|
||||
- **OPEN:** end-to-end panel render with authenticated session, actual revision content and iPhone browser. Rust/Cargo compilation remains unverified in this environment.
|
||||
|
||||
## 0.31.7 — source, updater and UI regression verification (2026-10-11)
|
||||
|
||||
- **PASS:** `./dev.sh test` executed all bundled Python security/source/version/SQLite-9 migration/upgrade/memory checks, Node.js UI regressions, documentation validation and language-pack checks. It exits **127** only at the missing Rust/Cargo toolchain gate. Rust tests and release compilation did not run.
|
||||
- **PASS:** new `tools/checks/release_0317.py` exercised the actual Bash `update.sh` retention code using a sandboxed fake installer: successful, failed and `--plan` updates; release directory names with spaces; active symlink protection; exactly two installed releases and three backups; age-based Cargo incremental cleanup while preserving recent state, `deps`, `.fingerprint`, `build`, binaries and locks; and skip on symlinked Cargo targets. Systemd was not called.
|
||||
- **PASS:** new `tools/checks/release_0317_ui_test.mjs` executed the real configuration diff function against changed leaves, reordered ID-bearing arrays, created/deleted fields and the 200-entry bound. Validated Settings form relocation, listener spacing, collapsed ACL attachments, user-agent editor/OpenAPI and separate JSON dialog at the source level. This was not a live browser render.
|
||||
- **PASS:** `GOPROXY=off GOSUMDB=off go test ./...` in `docker-agent`, `node --check` for all browser JavaScript modules, OpenAPI JSON parsing, `bash -n dev.sh update.sh`, `docs_check.py` and `langs_check.py`.
|
||||
- **CHECKED IN SOURCE, NOT LIVE:** `scripts/deploy/deploy_runtime.py` creates `.managed-update` before `systemctl stop`; `src/main/admin_runtime.rs` checks the marker and calls forced HOT→COLD even with `cold_on_shutdown=false`; shutdown time budget is 90 seconds with a 120-second systemd stop budget. The cache persistence worker now returns a success boolean so `moved` counts only successfully inserted COLD objects. Existing Rust cold-tier unit tests cover enabled COLD with ordinary shutdown disabled, but could **not** run without Cargo.
|
||||
- **OPEN:** Rust type checking/formatting, cargo --locked consistency, compiled vhost 429/Retry-After behavior, actual update/rollback and persistence after restart, browser UI on a live server, H2/H3 performance and iPhone reload acceptance. No production-ready claim.
|
||||
|
||||
|
||||
## 0.31.6 - iPhone H3 full navigation and observability (2026-10-11)
|
||||
|
||||
- **Target:** iPhone Safari and Chrome when an existing dashboard session navigates through the unchanged sidebar logo link (`<a class="brand" href="/">`). It creates an H3 document GET while older SSE/API requests can be cancelled.
|
||||
- **Fix candidates implemented:** The H3 GET/HEAD cache and 404 preflight avoids unbounded waiting on a delayed request FIN; on an H3->HTTP/1.1 vhost, a truly empty GET or HEAD stream is normalized before forwarding (bounded 35 ms first-frame probe; nonempty data replayed; pending client keeps streaming). On an H3 accept error, no longer `abort_all` active responses; bounded drain still applies.
|
||||
- **Diagnostics:** Persist `http3_peer_cancelled_before_headers` events with status 0 (not 400). For interrupted responses write `http3_peer_cancelled_after_headers` or `http3_response_write_error`, including sanitized path without query, stream ID, response phase/status, bytes sent and redacted error. Normal process INFO/WARN logs also record the start, completion or interruption of root-document H3 GET `/` using its stream ID; no cookies or query strings. These records remain in vhost file logs and are subject to configured log sinks. They are distinct from application response access logs.
|
||||
- **Rust regression cases added:** GET/HEAD empty-body normalization, nonempty GET payload replay, bounded cache preflight and stalled GET; retained 0.31.5 H3 cancellation classification tests. Static release and documentation contract check extended.
|
||||
- **NOT VERIFIED:** Rust compilation, a live QUIC listener, reverse-proxy-to-panel traffic, iOS Safari/Chrome, frame captures, HTTP/3 stream integrity, TLS/QUIC delivery, persisted data upgrades. Do not describe root cause or fix as proven until captured on device. No WS-over-H3 is assumed; H1 websocket origins do not activate RFC 9220 by themselves.
|
||||
- **No changes:** Sidebar logo link, H3 on/off configuration, SQLite schema 9, cache format, frontend routes, authentication/session/CSRF or WebSocket settings.
|
||||
- **PASS — executed for 0.31.6:** `./dev.sh test` completed all pre-Cargo Python/SQLite/schema/upgrade/security/source checks, 300,000 simulated memory-model operations, 2,000 requests to a Python test peer, all Node UI regressions (including the 0.31.5 mobile/H3 suite updated for the new error path), documents and both language packs. `tools/checks/h3_navigation_0316.py` also passed. The script then exited **127** solely because Rust/Cargo is unavailable. `GOPROXY=off go test ./...` in `docker-agent`, `bash -n dev.sh` and OpenAPI JSON parsing passed. Source guards are not execution of Rust tests.
|
||||
|
||||
## 0.31.5 follow-up - mobile Stored Logs, H3 peer aborts, cache preservation (2026-10-11)
|
||||
|
||||
- **Problem 1, mobile `/logs`:** narrow pages grew horizontally under dynamically selected log columns and large JSON/error fields. CSS now scopes width to the Logs workspace, presents entries as two-column, keyboard-expandable cards at widths `<=700px`, preserves every user-selected field in its card, stacks dense filters, wraps long values, and retains independently horizontally scrollable code/aggregate data. Details and Raw JSON remain accessible. Existing row interaction and per-user column choices are unchanged.
|
||||
- **Problem 2, H3 synthetic status:** `resolver.resolve_request()` returned `StreamError::RemoteTerminate(H3_REQUEST_CANCELLED)` for a browser-cancelled stream before request headers; RustProxy previously called `log_protocol_error(..., 400, ...)`, making a *transport cancellation* appear to be an application HTTP 400 with no method/path. Now peer cancellations (`H3_REQUEST_CANCELLED` or peer `H3_NO_ERROR`) are low-level debug observations, while malformed messages still retain an attributed HTTP 400. Response send errors directly classified as peer cancellations no longer provoke a redundant `H3_INTERNAL_ERROR` reset. We deliberately retain the old `ReceiveBody::Drop` safeguards against h3/quinn pending-read panic.
|
||||
- **Important limit:** the supplied log cannot establish why the new navigation rendered a blank page; an old request may simply have been cancelled by F5. Missing HTTP method, path, authority, User-Agent, and 0 sent bytes prevent correlation to the failed top-level document. The *root cause of the blank page has not been reproduced or verified*. Validate Safari and Chrome with actual RustProxy QUIC/HTTPS, the reverse-proxy Host/Origin/`X-Forwarded-Proto` chain, browser Network waterfall (especially HTML and fingerprinted JS/CSS), and connection-level debug/qlog before accepting the fix as complete.
|
||||
- **Problem 3, cache:** `cache_revision()` used to hash complete vhost configuration, including `http2_enabled` and `http3_enabled`; an unrelated frontend protocol toggle rotated the global compatibility identity and triggered `CacheInvalidation::All`, flushing HOT and COLD. Normalize these two flags to the default `true` only for the cache-identity digest; live routing and serialized configuration remain unmodified. Rust tests cover H2, H3, combined toggles and a true cache-policy edit.
|
||||
- **Problem 4, public origin vs allowlist:** public HTTPS origin fixes scheme + host + port for security checks behind a *trusted* proxy (and implicitly allows its DNS host); extra allowed DNS hosts only expand allowed names. Retain both; improve Polish and English help text, no API/SQLite format changes.
|
||||
- **Verification, 2026-10-11:** `./dev.sh test` passed its entire available Python/SQL/schema/upgrade/security/security-dependency/Node/docs/language pre-Cargo suite, including new `h3_cache_mobile_0315_test.mjs`, then exited **127** because `cargo` is absent. `./dev.sh check` similarly exited **127** for missing Cargo. `GOPROXY=off go test ./...` in `docker-agent` passed. `bash -n dev.sh`, Node syntax, JSON syntax and focused source gates also passed. Rust unit tests were **not compiled or executed**.
|
||||
- **Browser layout smoke:** headless Chromium/Python Playwright rendered a synthetic fixture with the actual HTML/CSS log workspace at widths **320, 375, 430, 700, 768, 1024px**. In every case `body.scrollWidth === innerWidth`; cards were grid rows at `<=700px`, with desktop table rows at 768px+. Parsed fields, long unbroken values and raw JSON were included. This was a *static layout simulation*, **not** authenticated panel/live H3 acceptance.
|
||||
|
||||
## 0.31.5 follow-up - Stored Logs navigation polish
|
||||
|
||||
- Split Stored Logs into **Log entries** and **Repeated values** tabs, sharing source/search filters; the count query is only included while the analysis tab is active. Move the persisted consolidation toggle into the header. Verify via dedicated Node UI source regression. Rust compilation/browser acceptance remain outstanding.
|
||||
|
||||
## 0.31.5 follow-up - build-specific JS/CSS filenames
|
||||
|
||||
- `scripts/build/swagger_assets.py` creates a fresh random 64-bit hex identifier using Python `secrets` when Cargo invokes the build script. The identifier is embedded in the binary and substituted into dashboard, public login and Docs HTML. HTML retains its existing cache-header behavior. Matching fingerprint paths serve the existing embedded asset bytes without runtime disk access; unrecognized fingerprints return 404. Legacy URLs remain for compatibility. Package remains 0.31.5; rebuilding with unchanged source changes these asset URLs whenever the build script reruns.
|
||||
- Regression checks cover URL templates, version independence, build token generation, asset routing and anonymous allowlist. Source/UI checks run without Cargo; compilation and real browser/cache tests remain open.
|
||||
|
||||
## 0.31.5 follow-up - Stored Logs consolidation preference
|
||||
|
||||
- Stored Logs now restores the last selected consolidation mode from browser-local storage (`rp-file-logs-consolidate-vhosts`) on panel load; saves changes immediately; defaults to the existing checked mode if no preference is present or storage is blocked. Clearing browser storage restores the default. No API or SQLite change.
|
||||
- Verification: `security_logs_0315_ui_test.mjs` checks preference read/write integration. Rust compile and runtime acceptance remain open as described below.
|
||||
|
||||
## 0.31.5 - User-Agent blocking and consolidated log analysis (2026-10-11)
|
||||
|
||||
- **Scope:** global and per-vhost User-Agent blocklists, copyable Traffic values, Stored Logs repeated-value aggregation and optional per-vhost consolidation of active JSONL plus gzip rotations.
|
||||
- **Compatibility:** no SQLite migration; existing configurations load with User-Agent blocking disabled. Existing Stored Logs physical source IDs remain available when consolidation is disabled.
|
||||
- **Security:** User-Agent matching is bounded and runs before upstream contact; Stored Logs continue to accept only server-derived sources with existing symlink/gzip/scan limits. User-Agent is client-controlled and is not treated as an identity boundary.
|
||||
- **PASS — full Rust-independent release run:** `./dev.sh test` passed module/version/source contracts, SQLite schema-9 migration/upgrade smoke, security/dependency checks, the 300,000-PURGE memory model, 2,000 HTTP requests to the Python test peer, every bundled Node UI/simulation test (including `security_logs_0315_ui_test.mjs`), documentation and both language packs. It then exited **127** only at the missing Rust/Cargo guard; the Python peer is not a RustProxy binary.
|
||||
- **PASS — focused 0.31.5 regression:** `security_logs_0315_ui_test.mjs` verifies global/per-vhost UA wiring and pre-origin ordering, Traffic copy controls, Stored Logs aggregation/consolidation markers, OpenAPI and English/Polish locale keys.
|
||||
- **PASS — other available checks:** `go test ./...` in `docker-agent`; `bash -n dev.sh`; Node syntax checks for the modified UI files/test; OpenAPI JSON parsing; `docs_check.py`; `langs_check.py`; `check_source.py`; and `rust_module_contracts.py`.
|
||||
- **Rust release gate OPEN:** `./dev.sh check` exits **127** because this environment does not provide `rustc`, `cargo` or `rustfmt`; new Rust unit/regression tests are present but cannot be compiled/executed here.
|
||||
|
||||
## 0.31.4 reissue - origin protection and one-field administration public URL (2026-10-11)
|
||||
|
||||
- **Reissue verification (2026-10-11):** rebased the complete previously prepared simplified-config source onto release identifier `0.31.4` in the manifest, existing resolved-lock snapshot package entry, OpenAPI, source/version checks, documentation and deterministic ZIP metadata. Merged the panel Origin and one-field config release notes; no backend validation logic changes were introduced during this renumbering.
|
||||
- **PASS — executed for this reissue:** `version_contracts.py`, `docs_check.py`, `langs_check.py`, `check_source.py`, `release_smoke.py`, `security_contracts.py`, `dependency_upgrade_0312.py`, `rust_module_contracts.py`, `system_info_no_subprocess_contracts.py`, `source_module_contracts.py`, `memory_contracts.py`, `web_server_contracts.py`, `admin_surface_audit_0310.py`, `security_dependency_contracts.py`; 15 standalone Node frontend/simulation scripts (including the updated original 53-case and expanded 61-case administration simulations and 9 UI scenarios); `go test ./...` for `docker-agent`; `bash -n dev.sh` and OpenAPI JSON parsing.
|
||||
- **Not executed for this reissue:** full Rust compile/test/release (`cargo` and `rustc` absent), real mobile Safari/Chrome or live reverse-proxy verification. Passing Node simulations is **not** proof of compiled Rust behavior. The inherited Cargo.lock requires verification and/or re-resolution on the declared Rust 1.99+ toolchain.
|
||||
|
||||
- **Scope:** panel administration settings/config and narrow self-admin cookie opt-in only. `allowed_hosts` stores only additional DNS hostnames; the HTTPS pin contributes its hostname to the active/desired Host allowlist without persisting a duplicate.
|
||||
- **Compatibility:** older configurations with a duplicate hostname remain valid; validation canonicalizes the in-memory config and the next save omits the duplicate without an automatic SQLite migration; changing/removing the pin does not silently retain a stale implicit hostname.
|
||||
- **Security:** Host, single forwarded Host, trusted reverse proxy, first-hop HTTPS, CSRF and `Origin`/cross-site guard rules are unchanged. No SQLite schema migration, TLS listener or other vhost route changes.
|
||||
- **PASS — source/release gates:** `version_contracts.py`, `rust_module_contracts.py`, `check_source.py`, `release_smoke.py`, `docs_check.py`, `langs_check.py`, `security_contracts.py`, `admin_surface_audit_0310.py`, `security_dependency_contracts.py`, and `dependency_upgrade_0312.py`; SQLite schema-9/upgrade smoke and the source/HTML/Reader/translation checks also passed.
|
||||
- **PASS — focused simulated regressions:** `admin_public_origin_0314_config_sim.mjs`: **61/61 local Node HTTP guard-simulation assertions**, comprising 37 cases and 24 repeated session GETs. Invalid forwarded-proto on 3/12 unpinned GETs produced 403; with a pinned HTTPS public URL and **no duplicate DNS allowlist entry**, the same sequence produced 0/12. This simulation is not compiled RustProxy and cannot prove actual mobile/proxy headers.
|
||||
- **PASS — real frontend helper/UI checks:** `admin_panel_config_0314_ui_test.mjs`: **9/9 settings-normalization scenarios** plus form/HTML/locale/OpenAPI consistency; `panel_cache_http_0310_ui_test.mjs` also executes the updated real administration form callback with a duplicated legacy hostname and verifies it is omitted on save.
|
||||
- **PASS — other standalone checks:** all 14 bundled Node UI/simulation scripts, memory model (300,000 simulated PURGEs), 2,000 HTTP requests to a **Python mock peer** (not RustProxy), and `cd docker-agent && go test ./...`.
|
||||
- **Harness and compiler limitations:** an initial `./dev.sh test` run exposed a legacy UI-test harness that lacked the new form helper; the harness was corrected and passed separately. A subsequent monolithic run exceeded the execution time limit after the memory/load-model stage, so no complete `./dev.sh test` success is claimed. `./dev.sh check` exited **127** because `cargo`/`rustc` are not installed. New Rust tests in `src/settings/tests.rs` and `src/admin/tests.rs` were **not compiled/executed**. The inherited Cargo.lock still needs resolution on Rust 1.99+; live mobile and real reverse-proxy acceptance remain OPEN.
|
||||
|
||||
### Earlier 0.31.4 validation baseline (2026-10-11)
|
||||
|
||||
- **User scope:** admin panel only; public vhosts, proxy forwarding, streams and database migrations are unchanged. The 0.31.3 BFCache/session resume and visible retry protections are retained.
|
||||
- **Source correction:** optional `administration.public_origin` pins a browser-facing HTTPS panel origin to a configured DNS allowlist; it takes effect after restart and only with a trusted last-hop proxy. It accepts bounded `X-Forwarded-Proto: https, http` without relying on an ambiguous forwarded-proto element for the expected origin. Without the pin, 0.31.3's strict handling remains.
|
||||
- **Security:** actual Host is validated, forwarded Host must be single/matching, the protocol's first hop must be HTTPS, and Origin, cross-site and CSRF checks remain. Untrusted peers and credential-bearing certificate automation retain their previous scheme validation. The earlier build required explicit `allowed_hosts`; the reissue now derives the pinned hostname without wildcard trust.
|
||||
- **Node HTTP guard simulation (not compiled RustProxy): PASS, 53/53 assertions**, including 29 individual scenarios and 24 repeated session GETs: without the pin and with intentionally malformed XFP every fourth request, **3/12 GETs returned 403**; with an explicit pin and otherwise identical simulated headers, **0/12 returned 403**. `tools/checks/admin_public_origin_0314_sim.mjs` checks relevant code anchors and operates a local Node HTTP test peer. This does not confirm production headers or backend runtime behavior.
|
||||
- **PASS (2026-10-11):** `./dev.sh test` passed every available Rust-independent stage: source-module boundaries, version/contracts, SQLite schema-9 migrations and upgrade, security source/dependency checks, 300,000 PURGE-model iterations, 2,000 real HTTP requests to a **Python test peer**, all bundled Node/browser UI regressions (including BFCache/resume), 0.31.4 HTTP simulation, documentation and both language packs. Exit **127** at the missing Rust/Cargo toolchain guard only. The simulated peer is **not** a running RustProxy binary. `./dev.sh check` likewise exits 127 due to missing Cargo.
|
||||
- **PASS:** `(cd docker-agent && go test ./...)`, `node --check tools/checks/admin_public_origin_0314_sim.mjs`; source-package size/integrity checks are performed separately.
|
||||
- **Rust regression tests added, NOT EXECUTED:** `src/settings/tests.rs` and `src/admin/tests.rs` cover pin validation, restart, strict opt-out, session Secure cookie, CSRF, foreign Origin, cross-site, host spoofing and untrusted peers.
|
||||
- **Remaining release gate OPEN:** the sandbox has no Rust/Cargo/rustfmt and the inherited lockfile remains unresolved against 0.31.2+ dependencies. Before production use: resolve on Rust 1.99+, format, clippy, test/build, then capture actual mobile Safari/Chrome and reverse-proxy traffic and run the acceptance matrix in [SETTINGS.md](SETTINGS.md). No claim is made that the original field `auth.invalid_origin` was directly observed or eliminated.
|
||||
|
||||
## 0.31.3 - invalid-Origin resume and mobile panel recovery (2026-10-11)
|
||||
|
||||
- **Source:** the uploaded RustProxy 0.31.2 ZIP plus the user's case description. No remote repository/history or real mobile network capture was provided.
|
||||
- **Reproduced in the original source:** two overlapping session validations; a 403 during return-to-tab caused `location.reload()`; a concurrent successful validation cleared the recovery marker; another 403 could reload again. Separately, refresh tasks and SSE restarted before a resumed session was validated.
|
||||
- **Corrections:** single-flight session verification and coalesced visibility/BFCache events; suspend polling and SSE until verified; visible localized retry on 403/network errors; unchanged login handling on 401; clear the startup recovery marker only after full initialization; stop refresh on startup failure. Invalid-Origin top-level HTML navigation returns an explicit 403 error page rather than raw JSON. Server Origin/CSRF/cross-site enforcement is unchanged; structured diagnostic logging redacts credentials and untrusted forwarding data.
|
||||
- **Regression coverage:** `tools/checks/origin_resume_0313_ui_test.mjs` exercises actual JavaScript source against deterministic session API outcomes, repeated tab cycles, stale auth epoch, SSE suspension/resume, retry, 401 and startup marker. Rust unit/integration tests were added for blocked foreign origins, safe diagnostic values and readable 403 document handling, but cannot run here without Cargo.
|
||||
- **PASS (2026-10-11):** `tools/checks/origin_resume_0313_ui_test.mjs` and all 11 other bundled Node UI tests; actual assembled dashboard bundle parsed by Node; original-source Node reproduction recorded the duplicate request/reload race.
|
||||
- **PASS (2026-10-11):** source/module/version/security/dependency contracts, SQLite schema-9 migration and upgrade smoke (`release_smoke.py`), documentation/Reader (`docs_check.py`), language packs (`langs_check.py`), shell syntax and Go Docker Agent `go test ./...`. Memory workload simulated 300,000 PURGEs; 2,000 HTTP requests targeted a **Python test peer**, not the RustProxy executable.
|
||||
- **NOT VERIFIED:** `./dev.sh check` exits 127 because `cargo`/`rustc` are absent. `./dev.sh test` passed all Rust-independent stages through docs/locales and reached the same missing-Cargo gate; it did not run Rust unit tests. No compiled RustProxy, iOS/Android browser, trusted reverse-proxy trace or production rollout was exercised.
|
||||
- **Verification limit:** the new Rust tests are written but unexecuted; the lockfile is an inherited dependency snapshot, not a resolved 0.31.3 graph. The code has not passed a compiler-enabled release gate.
|
||||
- **Release gate remains OPEN:** the local environment has no rustc/cargo/rustfmt. The inherited lockfile is unresolved for 0.31.2/0.31.3 dependency families. Run Cargo resolution, fmt, clippy, Rust tests and real admin/reverse-proxy/mobile acceptance on a Rust 1.99+ host before production release.
|
||||
|
||||
## 0.31.2 — stable library upgrade candidate (2026-10-10)
|
||||
|
||||
- **Source basis:** shipped RustProxy 0.31.1 archive, without local Git history; no confirmation of current remote `master`.
|
||||
- **Changed dependency branches:** Rust 1.99 (manifest and Docker builders), SQLx 0.9, Argon2 0.6, Rand 0.10, rcgen 0.14, Tower HTTP 0.7, Brotli 9 and sysinfo 0.39; HTTP/3 hpx-h3 is pinned to reviewed 2.5.20. Existing SemVer-compatible crates remain range-defined for a full `cargo update`. Embedded Swagger UI is pinned to upstream 5.33.1.
|
||||
- **Code adaptations:** secure RNG API, Argon2 PHC generation/verification and TLS certificate signing key representation. No intended changes to JSON schema, SQLite schema 9, cache format, credential storage, TLS policy, or proxy traffic processing.
|
||||
- **Executed locally:** `./dev.sh test` passed all offline code/API/SQLite/upgrade/security/translation/UI/Node/documentation checks; then exited 127 because Rust/Cargo is absent. `cd docker-agent && go test ./...` passed. `python3 tools/checks/dependency_upgrade_0312.py` reports PENDING for the old lockfile. Python compilation and Bash syntax checks passed. This does not prove a Rust build.
|
||||
- **CRITICAL:** This sandbox has no Rust/Cargo/rustfmt and cannot reach the crates registry. The existing `Cargo.lock` carries the previous dependency graph: only the root package version has been updated, with all third-party resolved entries left untouched. It is **NOT the resolved lockfile for 0.31.2** and `--locked` is expected to reject it until `cargo update` runs. Do not treat this source package as a built or production-accepted release. Do not fake checksums or mark compilation/tests as passing.
|
||||
- **Required on a Rust 1.99 build host:** run `cargo update`, review and commit the newly resolved `Cargo.lock`, `cargo metadata --locked`, `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `./dev.sh check`, `./dev.sh test`, `./dev.sh release`, plus `cargo audit` when installed. Fix all compilation/API errors revealed by real Cargo before approving a release.
|
||||
- **Runtime regression gate:** real H1/H2/h2c/H3 QUIC, WebSocket, TLS and certificates, SQLite upgrade/backup/restore with preexisting data, sessions/Basic Auth/API security, SSE and panel on Safari behind reverse proxy, cache HOT/COLD and BAN/PURGE, and zstd/br/gzip negotiation. Compare load/RSS and avoid overwriting production data.
|
||||
|
||||
# Historical verification records (0.31.1 and earlier)
|
||||
|
||||
## 0.31.1 — mobile/reverse-proxy dashboard startup (2026-10-10)
|
||||
|
||||
- **Cause found in source:** dashboard HTML fetched 17 deferred JS resources, while the first bundle launched an async session/config bootstrap without waiting for all later scripts to finish. A fast API response could invoke the renderer before `window.rustProxyIntegrations.bindings` was registered. An interrupted script transfer could cause the same exception. The reported Safari error is consistent with this race, but the specific production network failure was not captured.
|
||||
- **Implemented:** one ordered embedded JS payload at `/assets/app.js?v=0.31.1`; CSS URL also versioned; DOMContentLoaded/module gating and explicit startup recovery; up to two bounded retries of initial configuration (400/800 ms). Old private asset URLs remain available. No API, authentication, saved preferences, database or proxy data path changes.
|
||||
- **PASS:** `./dev.sh test` completes all source checks, SQLite migration/backup and upgrade smoke tests, security and language checks, Node UI tests (including new startup-order and retry test), and documentation checks **before Cargo**. `python3 tools/checks/source_module_contracts.py` checks each JS module and the assembled 608 KB bundle with `node --check`. `cd docker-agent && go test ./...` passed. Shell syntax passed.
|
||||
- **Not verified:** `./dev.sh test` terminates **127** at Cargo; `./dev.sh check` also **127** (`cargo`/`rustc` absent). Browser smoke against a local simulated API was attempted, but Chromium navigation was blocked by sandbox policy (`ERR_BLOCKED_BY_ADMINISTRATOR`); no successful browser smoke run is claimed. Playwright WebKit browser binaries are not installed here. No real iPhone Safari or production reverse-proxy test was possible.
|
||||
- **Release-host checks:** `./dev.sh check && ./dev.sh test && ./dev.sh release`, followed by Safari/Chrome iPhone tests behind the reverse: 20+ full reloads from Overview/Cache/Settings, initial config API failures, SSE reconnect, browser console errors, and response encoding/version consistency for HTML, `/assets/app.js?v=0.31.1` and CSS. Review the detailed plan in [HTTP3.md](HTTP3.md).
|
||||
|
||||
## 0.31.0 Axum handler `Send` correction (2026-10-10)
|
||||
|
||||
- **Reported failure:** `error[E0277]` at `src/proxy/types.rs:53`, `.fallback(forward)`, on Axum 0.8.9. `forward` calls asynchronous public BAN/PURGE handling. That helper accepted `&Request<Body>` across `.await`, which requires a `Sync` body to produce the `Send` future that `Handler` expects.
|
||||
- **Source correction:** move `Request<Body>` into `cache_http_invalidation`, validate it synchronously, copy the method for audit logging and explicitly drop the body before the first `.await`. No cache selectors, IP permissions, status codes, API routes or session flows were changed. Strengthen `tools/checks/admin_surface_audit_0310.py` to reject a reintroduced borrowed request in the async handler.
|
||||
- **Executed:** `./dev.sh test` — all pre-Rust checks **PASS** (source/security/SQLite, UI Node, docs, language packs); command terminates **127** when Cargo is required. `./dev.sh check` — **127**, Cargo absent. `cd docker-agent && go test ./...` — **PASS**. `bash -n dev.sh`, Python source compile and manifest version probe — **PASS** (`0.31.0`).
|
||||
- **Not executed:** Rust `cargo check --locked --all-targets`, `cargo test --locked --all-targets`, `cargo build --locked --release`, rustfmt or live HTTP regression. No `cargo`/`rustc` or download access is available in this environment. This source correction is not claimed as a compiler-verified release.
|
||||
|
||||
## 0.31.0 BAN/PURGE compilation fix, security review and UI help (2026-10-10)
|
||||
|
||||
- Addressed reported Rust E0716 (`format!` temporary borrowed as `&'static str`) by returning a fixed 422 response and logging the specific rejection.
|
||||
- Restricted public HTTP maintenance to one external scheme and matched Host/vhost, rejected duplicate controls and invalid bodies, and added a shared two-slot limit plus `spawn_blocking` for heavy HTTP/admin cache invalidation work; audit removal before BAN-rule deletion.
|
||||
- Added localized EN/PL `curl` help popover beside the Cache allowlist; added protocol/host/scheme/headers Rust regression sources, admin anonymous/CSRF test sources and an offline source audit script. The detailed findings, attack-surface table and operator acceptance checklist are in [SECURITY_AUDIT_0.31.0.md](SECURITY_AUDIT_0.31.0.md).
|
||||
- **Executed this follow-up:** `./dev.sh test` passed every pre-Rust static/SQLite/security/JS/docs/translation check, then exited **127** on unavailable Cargo. `./dev.sh check` also exited **127**. `go test ./...` under `docker-agent/`, syntax checking all 27 browser JS files, OpenAPI JSON parse, shell syntax and separate source audits passed. A separate Chromium screenshot attempt timed out and does **not** count as verified UI rendering.
|
||||
- **Rust compiler and runtime tests NOT RUN HERE**: `cargo`, `rustc` and `rustfmt` are absent; the only safe conclusion about E0716 is that its offending lifetime pattern was removed in source. Live BAN/PURGE over H1/H2/H3, TLS, reverse trust, performance and regression gates remain open.
|
||||
|
||||
## 0.31.0 negotiated panel content compression and HTTP BAN/PURGE follow-up (2026-10-10)
|
||||
|
||||
- `administration.panel_compression` defaults to `{zstd:true,br:true,gzip:true}` and uses the **existing Administration form/config revisions**. Authenticated panel HTML and embedded `/assets/` CSS/JS/language bundles are precompressed only once per resource; per-request browser `Accept-Encoding`/`q` selects zstd, br, gzip or identity. The public login HTML remains identity (static login assets can negotiate compression). `Vary`, `Content-Encoding`, `Content-Length` are set per response. Compression for public proxied responses is unchanged. An upstream reverse proxy can override `Accept-Encoding` and therefore the negotiated result.
|
||||
- Empty-by-default `cache.http_invalidation_allowed_cidrs` restricts public-vhost `PURGE /path?query` exact removal and `BAN /prefix` lazy invalidation to allowed client IPv4/IPv6 ranges; `/0` rejected. Existing vhost/host ACL, rate limit and trusted forwarding logic remain in force. `BAN /` requires explicit confirmation; operation is audited, uses `spawn_blocking` around the existing cache engine and is never sent upstream. CIDR changes do not rotate cached objects.
|
||||
- System information Refresh button is next to the API badge, without another row. EN/PL locales, OpenAPI and settings/cache/API docs were updated; SQLite schema remains 9 and Cold format remains 3.
|
||||
- **PASS:** `./dev.sh test` completed all pre-Rust checks (SQLite migrations, release smoke, security, translations, Node UI, source contracts) then exited **127** because `cargo`/`rustc` are missing. `./dev.sh check` likewise exits **127**. Standalone `go test ./...` in `docker-agent/`, JavaScript syntax checks, OpenAPI JSON parse and Node form roundtrip passed. Chromium headless verified that API badge and System information Refresh button remain on the same row at viewport widths 320, 390, 1024 px.
|
||||
- **Source-byte measurements (not wire captures)**: authenticated HTML after version substitution **320,756 B**; Zstd level 3 around **50,526 B**, Brotli level 5 around **43,654 B**, gzip level 6 around **47,343 B** using local utilities. The actual wire choice follows browser `q` values, not necessarily the smallest encoded representation. FCP/LCP and mobile real-browser startup were not measured on a running RustProxy.
|
||||
- **Rust NOT RUN:** no Cargo/Rustc; Rust codec decoding, actual vhost BAN/PURGE authorization/HTTP/1-H2-H3, live proxy integration and exact wire metrics require a Rust-capable host. Production acceptance remains open.
|
||||
|
||||
## 0.31.0 mobile startup / HTML payload follow-up (2026-10-10)
|
||||
|
||||
- Removed `html.app-loading body { visibility: hidden }`. An initial CSS-driven overlay shows a loading spinner before the application code runs, a delayed (12 s) retry link when scripts or requests stall, and an explicit localized EN/PL failure state. No inline scripts or styles were introduced; authenticated CSP and the public login document are preserved. Reduced-motion preferences disable the spinner animation.
|
||||
- Dashboard shell and live SSE start once the authenticated session and language bundle are resolved, **without blocking on** preferences, configuration, certificate inventory or initial `/api/status`. Unavailable configuration shows a visible toast and does not blank the page. This improves perceived startup but is not a measured FCP improvement.
|
||||
- Moved the Overview **Refresh now** button into the title row, with no additional top-level row; the button remains hidden outside Overview.
|
||||
- Private HTML is gzip-compressed and cached once in memory (no per-request recompression); `Accept-Encoding: gzip` yields `Content-Encoding: gzip`, `Vary: Accept-Encoding` and compressed `Content-Length`. Without gzip or with `gzip;q=0`, the existing identity HTML remains. Public login is not compressed/altered by this handler; API, SSE and proxy paths are not changed.
|
||||
- **Measured from source bytes:** HTML source **318,905 B**, authenticated response after substituting two version placeholders **318,875 B** (previous packaged authenticated response **317,723 B**); Python gzip level 6 of the response **46,821 B** (about **85.3% less HTML transfer**). These are source-based estimates, **not HTTP wire captures from running RustProxy**, so browser-delivered Content-Length remains to be confirmed with a compiled server.
|
||||
- **PASS:** deterministic Node startup/placement test, Chromium 320/390/1024 px rendering and title/button alignment, existing static checks and Node coordinator regressions. **NOT RUN:** Rust compilation or Rust tests (`cargo`/`rustc` unavailable), live HTTP gzip/identity/auth, real mobile hardware reproduction and field measurements (FCP/LCP/white-screen rate). Acceptance remains open.
|
||||
|
||||
## 0.31.0 dashboard refresh implementation and source verification (2026-10-10)
|
||||
|
||||
- Source delivery based on the supplied `RustProxy-0.30.1.zip` (no `.git`; remote `master` not verifiable). Implemented one per-browser scheduler, per-account/validated preferences, per-client SSE interval and fallback isolation, active-view Traffic/Cache, separated `/api/system-info`, compatible full `/api/status`, version/docs and English/Polish UI.
|
||||
- **PASS:** source/version/module/security checks, SQLite schema-9/upgrade smoke, translations/docs, all bundled Node UI tests including deterministic refresh scheduler, Go agent unit tests, all admin JS syntax. Browser simulation checks 20 view transitions, no duplicated timers/in-flight requests, 0/manual, hidden tab, SSE failure/recovery, independent errors and logout.
|
||||
- **NOT RUN:** Rust compilation, Rust tests and release build because `cargo`/`rustc`/`rustfmt` are missing (`./dev.sh test`/`check`/`fmt`/`release` exit 127 at toolchain gate). Real RustProxy API/SSE/traffic/regression tests and SSE bandwidth/CPU measurement remain unverified. The stress suite's Python HTTP test peer is **not** a RustProxy runtime test.
|
||||
- See [current verification report](TEST_REPORT.md). No database schema changes, git commit or tag.
|
||||
|
||||
## 0.30.1 sysinfo compiler-feature fix (2026-10-10)
|
||||
|
||||
- The operator's Rust compiler reported four `E0433` errors because the pinned `sysinfo = 0.38.4` dependency enabled only `network`, but the new OS/kernel status methods require the `system` feature. Enabled both `network` and `system` without changing dependency versions or status API behavior.
|
||||
- Removed the reported unused Tokio `AsyncReadExt` import from `src/acme/runtime_env.rs`.
|
||||
- Added a guard for both `sysinfo` features to `system_info_no_subprocess_contracts.py`, wired that source check into `./dev.sh test`, and synchronized the older manifest assertions in `check_source.py` and `release_smoke.py`. Cargo compilation on the operator's machine is still required to verify the full Rust build.
|
||||
- Offline checks passed after the correction: system-info feature/source contracts, Rust and browser module contracts, version/source/release-smoke (SQLite/schema 9 and upgrade), memory, web server, security/dependency, simulated system-info UI, docs/translations, and Bash syntax. These static checks do not establish Rust compile success.
|
||||
|
||||
## 0.30.1 no-subprocess system information refinement (2026-10-10)
|
||||
|
||||
- Reused `sysinfo::System::{long_os_version, name, os_version, kernel_version}` for OS and kernel metadata on supported Linux/BSD/macOS/Windows targets; results remain cached. The status endpoint no longer reads Linux `/etc/os-release` or `/proc/sys/kernel/osrelease` directly.
|
||||
- Removed the extra Python, pip and Certbot version subprocesses. At ACME startup, bounded local reads of the selected interpreter's `pyvenv.cfg` and the selected installation's `pip-*.dist-info/METADATA` and `certbot-*.dist-info/METADATA` provide versions when possible; missing/ambiguous values remain `null`. System Python full patch versions cannot be guaranteed without an executable probe, so they may be unavailable. ACME operational dependency checks and actual issuance still require subprocesses; those are **not** system-information collection.
|
||||
- Added Windows `.exe` lookup for system Python/Certbot, metadata source unit regressions (not executed without Cargo) and compiler-independent source-contract regression `system_info_no_subprocess_contracts.py`. No new Rust dependencies, schema or API property changes.
|
||||
- Compiler-independent checks passed: `system_info_no_subprocess_contracts.py`, `rust_module_contracts.py`, `source_module_contracts.py`, `version_contracts.py`, `check_source.py`, `release_smoke.py` (SQLite/schema 9 and upgrade), `memory_contracts.py`, `web_server_contracts.py`, `security_contracts.py`, `security_dependency_contracts.py`, `docs_check.py`, `langs_check.py`, all shipped Node UI regressions (including no-metadata fallback) and `high_traffic_memory_model.py` (simulated PURGE + 2,000 requests against a Python peer, **not RustProxy**). Rust/Cargo are absent, so `cargo check`, Rust unit tests and live multi-OS acceptance were not performed; production acceptance remains open.
|
||||
|
||||
## 0.30.1 system information and legend change (2026-10-10)
|
||||
|
||||
- Corrected H1/H2/H3 Overview legend markers to use exactly the protocol series theme variables (HTTP and WebSocket cards).
|
||||
- Removed certificate runtime details from About, moved existing authenticated runtime metadata to Settings -> System information, added OS/version/kernel/architecture and existing process telemetry. Initial implementation used bounded ACME version subprocess probes for the selected binaries; the later refinement above replaces them with non-executing metadata reads, and leaves missing versions unavailable.
|
||||
- Updated locale packs (English/Polish), authenticated OpenAPI status schema and release identity. SQLite schema 9, Cold Cache format 3 and persisted configuration have not changed.
|
||||
- Passed offline: `rust_module_contracts.py`, `source_module_contracts.py`, `version_contracts.py`, `check_source.py`, `release_smoke.py` (including SQLite/schema 9 and source upgrade), `memory_contracts.py`, `high_traffic_memory_model.py` (simulated PURGE plus 2,000 requests against a Python peer, **not RustProxy**), `web_server_contracts.py`, `security_contracts.py`, `security_dependency_contracts.py`, all seven shipped Node UI regressions (including `system_info_0301_ui_test.mjs`), `docs_check.py` and `langs_check.py`. The full `./dev.sh test` pipeline exceeded this environment's execution limit after passing through the Web Server contracts; remaining checks were run individually. `./dev.sh check` exited 127 because Rust/Cargo are absent. No Rust compilation, Rust unit tests or real managed/system Certbot runtime checks were performed. Production acceptance remains open.
|
||||
|
||||
## 0.30.0 same-version sustained-memory audit (2026-10-10)
|
||||
|
||||
- Reviewed cache metadata, HOT/COLD admission and cleanup, file/remote JSON sinks, live log history, analytics, local directory listings, HTTP/2/HTTP/3, TCP/UDP sessions, DNS/health tasks, PHP-FPM streaming, transforms, rate-limit buckets, and periodic tasks. See [memory verification](MEMORY_STRESS.md) for audited invariants and the real-runtime acceptance procedure.
|
||||
- Confirmed a long-run retention issue: the selective PURGE path incremented `Store.key_generations` for affected base keys and never reclaimed those identities until an all-cache purge. Its existing global `generation` barrier already invalidates all older fills/tickets, so obsolete per-key metadata can be reset per selective PURGE. Also release large HashMap capacity after mass invalidations. Added a 512-distinct-PURGE Rust regression (not executed without Cargo).
|
||||
- Removed unnecessary repeated analytics-label allocations using borrowed map lookups and allocation on insertion only. Filtered local directory listings in place rather than building a second metadata vector and moved generated HTML into the response instead of cloning it. Reused the existing redacted JSONL serialization when sending the same access event to Loki (while preserving the Loki-only path).
|
||||
- Offline test: **300,000 simulated distinct PURGE identities** over six intervals. Legacy bookkeeping model retained **50k → 300k** keys, whereas the corrected epoch model retained **1** key after each operation. The load driver sent **10,000 real HTTP requests** using **24 persistent clients** and **1,000 distinct paths** to a **Python test peer**, with expected status/body counts. These tests validate model behavior and driver operation only: **they do not measure RustProxy RSS, heap, or leak freedom**.
|
||||
- `./dev.sh test`: all Rust-independent source, security, SQL/upgrade, Node/browser UI, documentation/translation and memory-replay checks passed (including its 2,000-request peer smoke); **exit 127 only at the missing Cargo toolchain guard**. `./dev.sh check` also exits 127 for missing Cargo. `go test ./...` in `docker-agent` passed.
|
||||
- Rust/Cargo unavailable in this environment. Actual `cargo check`, Rust unit tests, RustProxy GET/PURGE under load, production-like JSON sinks, HTTP/2/3 and TCP/UDP traffic, and long-duration RSS/heap plateau measurements **remain pending**. No API, SQLite schema, Cold Cache format or version changes.
|
||||
|
||||
## 0.30.0 source verification (2026-10-10)
|
||||
|
||||
- Moved per-backend health-check overrides into the **Health** tab: a monitored-only backend selector reveals one persistent editor at a time, without changing the JSON configuration schema. Newly enabled custom policies start from the current shared policy; previously customized backends keep their configured values. Disabling monitor hides the editor but retains the override. Preserving draft switching, validation routing, rename and removal is covered by the simulated DOM regression.
|
||||
- Memory audit: access-file JSONL and Logstash now share one serialization for each event. Existing bounded file/error sink queues, live history, open files, traffic analytics and cache/transform budgets were checked in source. Removed two avoidable directory-inventory memory spikes: per-vhost log scanning now retains at most 256 candidates while enumerating and gzip-rotation scanning at most 64. Added a source contract and a Rust unit regression for 10,000 candidate filenames and deterministic newest-first ties.
|
||||
- The Node/UI, source, security, upgrade, SQL migration, translation and documentation checks in `./dev.sh test` are run in this environment; the Rust suite and full `./dev.sh check` cannot complete because `cargo` and `rustc` are unavailable (exit 127). `go test ./...` in `docker-agent` is run separately. The Rust unit regression is **included but not executed**. No real request-load test or long-running RSS/heap profile was performed, so the review cannot prove complete absence of runtime leaks.
|
||||
- No SQLite schema migration or Cold Cache format change; root Cargo package/lock and OpenAPI version set to `0.30.0`. Use a Rust toolchain for `./dev.sh check` and `./dev.sh test`, and measure RSS during sustained file/JSON logging, before deploying.
|
||||
|
||||
## Same-version 0.29.7 PHP profile UI, Rust warning and error interception review (2026-10-09)
|
||||
|
||||
- Move the shared PHP-FPM **Syntax and safety** help button to the bottom of the profile-management dialog, below **Add profile**, with a visible horizontal separator. Keep the service-local help button where it was. The simulated Web Service UI test asserts this placement and that the help modal still opens.
|
||||
- Remove the unused `mut` in the `web_server::serve` binding (`src/proxy/forward/web_source.rs`).
|
||||
- Correct application-error interception semantics in both Reverse Proxy and Web Server/PHP-FPM: responses supplied by a backend/service (including cache hits) are explicitly marked and are not overwritten by global/vhost error pages when `pages.intercept_statuses` is disabled. RustProxy-generated routing/upstream failures remain eligible for the configured local error page. Interception is performed before cache admission; selected statuses receive `no-store` even after response-header rules and always retain the application's HTTP status, ignoring a standalone error page's status override. An internal named-location fallback remains terminal and has precedence.
|
||||
- Rust regression tests cover the opt-in behavior, the marker's effect in the response observer, precedence and preservation of status with a differently configured page status. Source/UI contracts verify both forwarding paths and correct ordering around HTTP/2 WebSocket body adaptation; these are **not** substitutes for live protocol tests.
|
||||
- `./dev.sh test`: all Rust-independent source, JS, SQL/upgrade, security, documentation and translation checks passed; then exited **127** due to unavailable `cargo`/`rustc`. Rust compilation, unit tests and runtime HTTP/FastCGI/WebSocket acceptance have **not** been performed here. No configuration schema change or version bump.
|
||||
|
||||
## 0.29.7 same-version compile error corrections (2026-10-09)
|
||||
|
||||
- Correct the three operator-reported `E0308` mismatches. Reverse Proxy origin interception now receives an `axum::body::Body` response only **after** adapting the upstream `Response<hyper::body::Incoming>`, preserving the original `Incoming` body expected by the HTTP/2 WebSocket tunnel wrapper. The operation remains before transformation and cache admission.
|
||||
- Reapply configured response-header rules to an intercepted page, then restore `Cache-Control: no-store` so the admin page cannot accidentally enter shared cache through a custom header policy. Internal named error fallbacks retain precedence.
|
||||
- Build owned `Arc<Member>` candidates for the active health-check supervisor and clone the `Arc` into each `JoinSet::spawn` task, rather than attempting to spawn a future carrying `&Arc<Member>`.
|
||||
- Static/UI/source/SQL/upgrade/security/documentation checks passed via `./dev.sh test` up to the Rust guard (exit 127). `./dev.sh check` exited 127 before compilation due to missing Rust/Cargo. **These are source-level corrections, not a verified Rust build.** Operator should run `./dev.sh check` and `./dev.sh test` on a Cargo-equipped host before deployment.
|
||||
|
||||
## 0.29.7 in-version PHP UX and origin error interception (2026-10-09)
|
||||
|
||||
- Both Web Service local PHP fields and shared PHP-FPM profiles now expose per-field examples, units, bounded syntax, PATH_INFO/Authorization/PHP enablement info tooltips and a common detailed help dialog; all localized through language packs. Configuration semantics remain unchanged.
|
||||
- New optional `vhosts[].pages.intercept_statuses`: supported unique HTTP error codes are validated; selected application-origin responses are replaced by vhost/global/built-in RustProxy pages before admission to cache, preserve the HTTP status by default, and carry `Cache-Control: no-store`. The existing internal error fallback runs first. Empty/default list leaves existing origin error responses intact.
|
||||
- Added Rust source-level regression tests for default preservation, replacement, headers, precedence and malformed codes plus JS form behavior checks. `./dev.sh test` ran all pre-Cargo contracts (including Web Service PHP profile editing, origin-interception roundtrip, offline migration and security checks) successfully but exited **127** at the missing `cargo` guard. `./dev.sh check` also exited **127** for that reason. Rust compilation, Rust unit execution, and live Web Server/FastCGI/backend routing remain **unverified**.
|
||||
|
||||
## 0.29.7 backend/cache/FPM policy review (2026-10-09)
|
||||
|
||||
- Backend monitoring now has an explicit per-member switch and optional policy; tests cover backwards-compatible defaults, serialization, inherited Host and validation. Membership in the balanced pool remains a separate setting.
|
||||
- `cache.expose_only_hits` defaults to false globally and may be overridden at a vhost. Tests cover HIT/MISS/BYPASS suppression, origin diagnostic removal, independently configured Via and cache identity stability. Actual wire behavior requires Cargo/runtime tests.
|
||||
- **PHP limitation:** user-supplied WordPress and Roundcube services both target `unix:/run/php/php_rustproxy.sock`. Only Roundcube passes a restrictive FastCGI `PHP_ADMIN_VALUE: open_basedir`; PHP-FPM may retain this value across worker reuse, causing WordPress to fail. The 302 to setup-config.php is a consequence of missing access to wp-config.php. Added conflict warning, inline guidance and pool-isolation instructions; code changes cannot alter an already-shared PHP-FPM worker's isolation. The operator must provision and select a dedicated Roundcube FPM pool and restart/recycle affected old workers.
|
||||
- Source changes keep SQLite schema 9/Cold Cache format 3. Compiler-independent tests and verification are reported below; Rust compilation/live HTTP/2/H3/PHP integration is not asserted in this environment.
|
||||
|
||||
### 0.29.7 verification summary
|
||||
|
||||
- `./dev.sh test`: all Rust-independent contracts, SQLite migration/backup/source-upgrade checks, JS vhost/Web Server regressions, source security/dependency audits, docs and translation checks passed. It exits with code 127 at the Rust/Cargo availability guard, because `rustc` and `cargo` are not installed. This is **not** a successful Rust test-suite run.
|
||||
- `node tools/checks/backend_cache_0297_ui_test.mjs`: passed; simulates the actual backend form toggles and verifies independent monitoring/pool-eligibility state, per-member health override and cache controls.
|
||||
- Rust-only regression tests were added but could not execute: effective active/passive member policies and validation, HTTP Host defaults, HIT-only cache headers, per-vhost policy resolution, and cache compatibility identity.
|
||||
- Operator acceptance remains open: Rust compilation, real PHP-FPM worker isolation on separate sockets, alternating WordPress/Roundcube requests, live 404 fallback, and actual wire-level cache HIT/MISS/BYPASS checks on HTTP/1.1, H2 and H3.
|
||||
|
||||
## 0.29.6 FastCGI EOF and error-log routing (2026-10-09)
|
||||
|
||||
- Operator reproduced: uncompressed `/static.php/...` returned HTTP 200 and 29,309 bytes with cache BYPASS; gzip returned HTTP 200 then TLS unexpected EOF and zero bytes. Repeated panics in `futures-util 0.3.34` `stream/unfold.rs:108` correlate with these failures. This is operator evidence, not a reproduced in-house integration test.
|
||||
- Cause identified in source: native Web Server FastCGI response bodies used an unfused `stream::unfold` over a Tokio MPSC receiver. The compression wrapper may poll the stream after it returns `None`; the unfused `Unfold` then panics. The Reverse Proxy source uses an HTTP client body rather than this FastCGI stream.
|
||||
- Change: wrap the FastCGI response stream in `.fuse()`. Add repeated-post-EOF polling test and mocked FastCGI-to-gzip completion/decode test. No changes to PHP-FPM parameters, vhost configuration or migrations.
|
||||
- Logging change: the existing error-file controller receives WARN and ERROR (including PHP-FPM stderr); the console tracing layer suppresses these only when the file is enabled, with fallback to stderr on file/queue failure. A Rust panic hook emits structured ERROR diagnostics instead of bypassing the sink via the default stderr hook.
|
||||
- Add asynchronous file routing and live enabled/disabled toggle tests. Actual Rust compilation, FastCGI compressor tests, panic runtime acceptance and live Roundcube regression require a Cargo-equipped environment and remain unverified here.
|
||||
- Release: Cargo package/lock root/OpenAPI and release checks moved to 0.29.6; retained SQLite schema 9 and Cold Cache format 3.
|
||||
- Static checks passed: `version_contracts.py`, `docs_check.py`, `langs_check.py`, `rust_module_contracts.py`, `web_server_contracts.py`, `check_source.py`, `release_smoke.py`, `web_service_ui_test.mjs`, and Node syntax/OpenAPI JSON parsing. The `./dev.sh test` run passed its source, browser UI, security, memory and upgrade checks before reaching the missing Rust toolchain; `./dev.sh check` exited 127 for the same reason.
|
||||
|
||||
## 0.29.5 Roundcube PHP PATH_INFO dispatch (2026-10-09)
|
||||
|
||||
- Cause confirmed by source: `serve_inner` originally ran `try_files` before recognizing `/static.php/skins/...` as a PHP script plus `PATH_INFO`. For an existing `static.php`, the last `try_files` candidate `/index.php` intercepted the asset request and could return HTML instead of the asset.
|
||||
- Fixed dispatch order following rewrites and path ACLs; execute existing PHP PATH_INFO targets before `try_files`. Access restrictions also apply to the resolved PHP script path; the other fallback path remains protected.
|
||||
- Added `roundcube_static_php_path_info_runs_before_front_controller_try_files` and `without_php_path_info_try_files_keeps_legacy_front_controller_fallback` using a local test FastCGI server and decoded FastCGI params. These are source tests until run with Cargo, **not** validated against live PHP-FPM or Roundcube.
|
||||
- Operator action: ensure PHP-FPM PATH_INFO splitting enabled on the chosen Web Service / shared profile; keep `try_files` for the main service. No extra special-case URL rewrite is needed for Roundcube `static.php` URLs.
|
||||
- Static checks passed: `version_contracts.py`, `web_server_contracts.py`, `rust_module_contracts.py`, `docs_check.py`, `langs_check.py`, `web_service_ui_test.mjs`, and `release_smoke.py`. `./dev.sh test` exceeded the available execution limit while running broader static `check_source.py`. `cargo`/`rustc` are unavailable; the new Rust FastCGI tests were not executed and neither was a live Roundcube integration.
|
||||
|
||||
## 0.29.4 PHP-FPM profile toolbar shortcut (2026-10-09)
|
||||
|
||||
The Web Server list toolbar now contains a **PHP-FPM profiles** button
|
||||
immediately before **New Web Service**. Both this shortcut and the PHP tab's
|
||||
existing profile button open the same shared profile-management dialog;
|
||||
no API or configuration schema changes were required. English/Polish button
|
||||
translations and a UI regression covering placement, both entry paths and
|
||||
closing the dialog were added.
|
||||
|
||||
`node --check` on the changed Web Server script and its profile helper,
|
||||
`node tools/checks/web_service_ui_test.mjs`, and the focused version,
|
||||
language, Web Server architecture and documentation checks passed.
|
||||
`./dev.sh test` was attempted but exceeded the execution time limit during
|
||||
the Rust-independent verification phase, so it did not complete in this run.
|
||||
Rust compilation and runtime tests remain unverified without Cargo.
|
||||
|
||||
## 0.29.4 version bump (2026-10-09)
|
||||
|
||||
The PHP-FPM profiles, Web Server cache/transforms and `HTTP_HOST` fixes have
|
||||
been assigned to a new 0.29.4 release. The source version, root Cargo.lock
|
||||
package entry, OpenAPI metadata, release checks and documentation match 0.29.4.
|
||||
The 0.29.3 internal-fallback changes remain included. This is a source release;
|
||||
no compiled artifacts are included.
|
||||
|
||||
Release verification: `python3 tools/checks/version_contracts.py`,
|
||||
`python3 tools/checks/check_source.py`, `python3 tools/checks/docs_check.py`,
|
||||
`python3 tools/checks/release_smoke.py` and the Node Web Service profile
|
||||
UI regression checks passed. `./dev.sh test` completed its Rust-independent
|
||||
source, migration/upgrade, security, browser and documentation checks, then
|
||||
exited 127 because Rust/Cargo is not installed. Compilation, Rust unit tests
|
||||
and live HTTP/FastCGI/cache/transform verification remain unperformed.
|
||||
|
||||
## 0.29.4 feature verification (2026-10-09)
|
||||
|
||||
The operator-requested follow-up adds reusable PHP-FPM profiles, fixes the
|
||||
Web Server cache/transform bypass and supplies `HTTP_HOST` for authority-only
|
||||
HTTP/2 and HTTP/3 FastCGI requests. Changes include Config serialization,
|
||||
revisioned administration section and OpenAPI, Web Server browser modal,
|
||||
route cache/transform pipeline and FastCGI parameter encoding. New Rust unit
|
||||
tests cover profile resolution, detach, validation and HTTP_HOST; the Node UI
|
||||
test exercises opening/editing a profile and attaching it to a service.
|
||||
|
||||
Rust compilation, proxy/PHP-FPM integration tests and performance acceptance
|
||||
remain unverified without Rust/Cargo. Static contracts and browser tests are
|
||||
run separately and do not substitute for production acceptance.
|
||||
|
||||
## Changes
|
||||
|
||||
0.29.3 fallback correction (operator report): the replay-body guard
|
||||
treated HTTP/2 and HTTP/3 GET/HEAD bodies with unknown size hints as non-replayable,
|
||||
even when the stream was empty. The fallback guard now performs the existing
|
||||
bounded empty-body probe for GET/HEAD; a true empty stream is replayable, while
|
||||
nonempty/unknown-length request bodies are forwarded only once. The internal
|
||||
target lookup now warns when the named location cannot be resolved. Added Rust
|
||||
async regression tests for empty H2/H3 GET/HEAD, unknown-length GET payload
|
||||
preservation, and unbounded POST non-consumption. The observer also preserves the
|
||||
final internal fallback response (including its 404 HTML) instead of applying a
|
||||
second global/vhost error page; a Rust test exercises this precedence. These Rust tests have not been
|
||||
executed in this environment because Cargo is unavailable.
|
||||
|
||||
Same-version 0.29.2 correction: Web Server main routes no longer inherit `transient_unavailable` from the vhost backend configuration. When the Web Server source has no enabled Reverse Proxy Custom Locations, backend definitions are not compiled. An enabled reverse-proxy Custom Location still compiles and uses the backend pool. Removed fallback-only Prometheus metrics, retained PHP/Web Server metrics.
|
||||
|
||||
Validated operator-controlled PHP_ADMIN_VALUE FastCGI configuration per Web Service,
|
||||
with distinct PHP_VALUE/ADMIN directive sets, explicit panel controls and OpenAPI
|
||||
schema. Prometheus PHP Web Service request/status counters, response header
|
||||
latency and available concurrency permit gauge, plus Web Server
|
||||
service-scoped response statuses and header latency.
|
||||
|
||||
## Acceptance limitations
|
||||
|
||||
The build environment has no Rust compiler (`cargo`/`rustc`). Rust compilation,
|
||||
unit tests, FastCGI integration with a live PHP-FPM pool and runtime load tests
|
||||
have not been performed here. Rust-independent checks are recorded separately.
|
||||
This source package is not certified for production deployment.
|
||||
|
||||
## Checks performed
|
||||
|
||||
- 0.29.3 release identity: `Cargo.toml`, the root `rust-proxy` entry in `Cargo.lock`, administration OpenAPI version, README, CHANGELOG, ROADMAP, AGENTS and verification-report heading were updated to 0.29.3; the fallback source changes from the tested 0.29.2 correction are retained.
|
||||
- 0.29.3 offline validation: `python3 tools/checks/version_contracts.py`, `python3 tools/checks/check_source.py`, `python3 tools/checks/release_smoke.py` passed; `bash ./dev.sh test` passed all listed non-Rust checks and stopped at missing Cargo with exit 127. This does not constitute compilation or successful runtime testing of RustProxy 0.29.3.
|
||||
|
||||
|
||||
- Fallback correction: `bash ./dev.sh test` passed Rust-independent module, source, upgrade/SQLite, security, browser UI and documentation checks. It exited 127 at the missing Rust/Cargo toolchain. `bash ./dev.sh check` also exited 127 because Rust/Cargo is unavailable. The added Rust regression tests and the live HTTP/2/HTTP/3 proxy behavior remain **unverified** pending a compiler-enabled runtime and an operator test against `origin-1`/`origin-2`.
|
||||
|
||||
- Same-version correction: `python3 tools/checks/check_source.py` and `python3 tools/checks/release_smoke.py` passed. `bash ./dev.sh test` passed the Rust-independent checks and stopped at missing Rust/Cargo (exit 127). No live 503 regression test or Rust compilation could be performed.
|
||||
|
||||
- `./dev.sh test`: all Rust-independent release, upgrade, security, JS, UI, documentation and source checks passed; exited 127 at the missing `cargo` toolchain guard.
|
||||
- `node --check src/admin/web/web-server.js`: passed.
|
||||
- `python -m json.tool src/admin/openapi.json`: passed.
|
||||
- `./dev.sh check`: exited 127 because Rust/Cargo is unavailable.
|
||||
|
||||
## Debian deployment maintenance (same 0.29.2)
|
||||
|
||||
Installer/update now installs /etc/logrotate.d/rustproxy using the recorded service user/group. Updates repair /etc/profile.d/rustproxy-path.sh rather than warning. logrotate uses copytruncate because RustProxy holds open log descriptors; some log records may be lost during truncate. Rust build and live Debian/systemd acceptance remain unverified in this environment.
|
||||
|
||||
## Version identity (0.29.7 source release)
|
||||
|
||||
`Cargo.toml` package version is the canonical release identity (now 0.29.7). Rust uses `CARGO_PKG_VERSION` at build time; `build.rs` substitutes the admin HTML placeholder. Packaging, upgrade and dev scripts read the manifest with `scripts/version.py`. Run `python3 tools/checks/version_contracts.py` to check that executable paths do not hardcode a version. Historical documentation and external dashboards may retain manually managed versions. Rust compilation/runtime verification is still pending.
|
||||
|
||||
## Shared PHP-FPM profiles and Web Server cache/transform/Host patch (0.29.4)
|
||||
|
||||
- Added a revisioned `php_fpm_profiles` collection with per-Web-Service `php_profile_id`, preserving the local PHP configuration when a shared profile is attached/detached. Added profile editor modal, schema, Polish/English messages and browser regression test.
|
||||
- Web Server vhost/location responses now pass the configured RustProxy cache, vhost response rules and transforms. Cache skips ineligible/private/`Set-Cookie` responses and handles cache hits, conditional requests and range requests. Filesystem/PHP requests are refreshed synchronously rather than using the reverse-proxy background revalidator.
|
||||
- FastCGI always supplies `HTTP_HOST` when HTTP/2 or HTTP/3 omitted the ordinary `Host` header; existing explicit Host with port is retained. Added Rust regression tests for both paths.
|
||||
- `bash ./dev.sh test`: all Rust-independent release, source, security, browser UI, documentation, migration and dependency checks passed; stopped at absent Rust/Cargo (exit 127). `bash ./dev.sh check` also stopped at absent Cargo (exit 127). Rust compilation, new Rust tests and live PHP-FPM/HTTP2/HTTP3 cache/transform verification remain unverified in this environment.
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.32.0",
|
||||
"version": "0.32.1",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -86,8 +86,6 @@ function applyDashboardLayout(order, hidden) {
|
||||
const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] };
|
||||
const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node]));
|
||||
for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); }
|
||||
// Apply the newly selected visibility, not the previous saved preferences.
|
||||
// During load, adminPreferences is assigned only after this function returns.
|
||||
window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
|
||||
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview")
|
||||
window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false);
|
||||
@@ -104,18 +102,13 @@ function renderDashboardWidgetSettings() {
|
||||
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
|
||||
const visible = !hidden.has(id);
|
||||
const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
|
||||
const labelBlock = el("span", undefined, "dashboard-widget-setting-label-block");
|
||||
labelBlock.append(el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
|
||||
const descriptionKey = ({ stream_overview: "streamWidgetOverviewDescription", stream_traffic: "streamWidgetTrafficDescription", stream_sessions: "streamWidgetSessionsDescription", stream_backend_health: "streamWidgetHealthDescription" })[id];
|
||||
if (descriptionKey) {
|
||||
const description = el("small", t(descriptionKey), "dashboard-widget-description");
|
||||
description.dataset.i18n = descriptionKey;
|
||||
description.id = `dashboard-widget-help-${id}`;
|
||||
description.setAttribute("role", "tooltip");
|
||||
toggle.setAttribute("aria-describedby", description.id);
|
||||
labelBlock.append(description);
|
||||
const description = t(descriptionKey);
|
||||
row.title = description;
|
||||
toggle.setAttribute("aria-description", description);
|
||||
}
|
||||
toggle.append(el("span", undefined, "dashboard-widget-visibility"), labelBlock);
|
||||
toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
|
||||
const actions = el("span", undefined, "dashboard-widget-setting-actions");
|
||||
const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp"));
|
||||
const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown"));
|
||||
@@ -183,7 +176,6 @@ async function loadAdminPreferences(force = false) {
|
||||
return applyAdminPreferences(value);
|
||||
} catch (error) {
|
||||
if (error.status === 401 || !authenticated || epoch !== authEpoch) throw error;
|
||||
// Preferences are optional for startup. Keep UI and live telemetry usable.
|
||||
console.warn("Preferences unavailable; using defaults", error);
|
||||
adminPreferencesUnavailable = true;
|
||||
return applyAdminPreferences({ ...ADMIN_APPEARANCE_DEFAULTS, refresh: REFRESH_DEFAULTS });
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Single scheduler for all authenticated dashboard polling. SSE is controlled here
|
||||
// but remains one native EventSource with its own browser reconnect behavior.
|
||||
const REFRESH_DEFAULTS = Object.freeze({
|
||||
live_enabled: true, overview_seconds: 2, traffic_seconds: 5,
|
||||
cache_seconds: 5, system_info_seconds: 60
|
||||
@@ -49,13 +47,11 @@ const rustProxyRefresh = (() => {
|
||||
const controller = new AbortController(), generation = task.generation, epoch = authEpoch;
|
||||
const panelAtStart = panel, tabAtStart = settingsTab;
|
||||
task.controller = controller;
|
||||
// A manual request remains valid with Live switched off, but not in a stale view.
|
||||
const isCurrent = () => authenticated && epoch === authEpoch && !controller.signal.aborted &&
|
||||
generation === task.generation && (manual ? panel === panelAtStart && settingsTab === tabAtStart : allowed(task));
|
||||
const promise = Promise.resolve().then(() => task.run({ signal: controller.signal, isCurrent }));
|
||||
task.inFlight = promise.catch(error => {
|
||||
if (error?.name !== "AbortError" && isCurrent()) {
|
||||
// A failure is isolated to this source; other timers continue.
|
||||
if (task.onError) task.onError(error);
|
||||
else console.warn(`Refresh ${task.id} failed:`, error);
|
||||
}
|
||||
@@ -161,19 +157,26 @@ const rustProxyRefresh = (() => {
|
||||
if (!task) return Promise.reject(new Error(`Unknown refresh task: ${id}`));
|
||||
return execute(task, true);
|
||||
}
|
||||
register("status-full", { interval: () => 0, run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status", { signal });
|
||||
if (isCurrent()) renderStatus(value, true);
|
||||
}});
|
||||
register("status-fallback", { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status?view=live", { signal });
|
||||
if (isCurrent()) renderLiveStatus(value);
|
||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }});
|
||||
register("logs-fallback", { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/logs", { signal });
|
||||
if (isCurrent()) renderLogs(value);
|
||||
}});
|
||||
register("system-info", { interval: () => options.system_info_seconds,
|
||||
register("status-full", {
|
||||
interval: () => 0, run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status", { signal });
|
||||
if (isCurrent()) renderStatus(value, true);
|
||||
}
|
||||
});
|
||||
register("status-fallback", {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status?view=live", { signal });
|
||||
if (isCurrent()) renderLiveStatus(value);
|
||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }
|
||||
});
|
||||
register("logs-fallback", {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/logs", { signal });
|
||||
if (isCurrent()) renderLogs(value);
|
||||
}
|
||||
});
|
||||
register("system-info", {
|
||||
interval: () => options.system_info_seconds,
|
||||
active: () => panel === "overview" || (panel === "settings" && ["system-info", "cache"].includes(settingsTab)),
|
||||
run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/system-info", { signal });
|
||||
@@ -183,11 +186,15 @@ const rustProxyRefresh = (() => {
|
||||
window.rustProxySettings?.render();
|
||||
}
|
||||
});
|
||||
register("tunnels", { interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
|
||||
run: async context => { await loadTunnels(context); } });
|
||||
return { register, manual, start, stop, sync, update, setPanel, setSettingsTab,
|
||||
register("tunnels", {
|
||||
interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
|
||||
run: async context => { await loadTunnels(context); }
|
||||
});
|
||||
return {
|
||||
register, manual, start, stop, sync, update, setPanel, setSettingsTab,
|
||||
suspendForSessionValidation, completeSessionValidation,
|
||||
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null })) };
|
||||
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null }))
|
||||
};
|
||||
})();
|
||||
window.rustProxyRefresh = rustProxyRefresh;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// All modules must be available before any asynchronous session/config work can
|
||||
// render the panel. DOMContentLoaded also waits for the complete deferred bundle.
|
||||
|
||||
function verifyAdminModules() {
|
||||
const required = [
|
||||
["rustProxyIntegrations", "bindings"],
|
||||
@@ -17,9 +16,6 @@ function verifyAdminModules() {
|
||||
async function startAdminPanel() {
|
||||
try {
|
||||
verifyAdminModules();
|
||||
// Revalidate the session first. The anonymous document has its own login.js;
|
||||
// app.js belongs to the authenticated shell and must not fall back to its
|
||||
// legacy embedded sign-in form when another startup step fails.
|
||||
const info = await api("/api/auth/session");
|
||||
await loadLanguages();
|
||||
setupRequired = false;
|
||||
@@ -35,9 +31,6 @@ async function startAdminPanel() {
|
||||
showStartupFailure(e);
|
||||
}
|
||||
}
|
||||
// The previous bootstrap ran while sibling deferred scripts were still loading.
|
||||
// Starting only after DOMContentLoaded prevents fast API responses from racing
|
||||
// against a slower or failed JS resource on mobile/reverse-proxied connections.
|
||||
if (document.readyState === "complete") startAdminPanel();
|
||||
else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true });
|
||||
|
||||
@@ -159,7 +152,6 @@ $("reload-certificates").addEventListener("click", async () => {
|
||||
$("reset-certificate").addEventListener("click", () => { $("certificate-form").reset(); $("certificate-file-source").value = "managed"; syncCertificateFileSource(); });
|
||||
syncCertificateFileSource();
|
||||
|
||||
// Dashboard metrics are actual server samples. Null means unavailable, not zero.
|
||||
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null,
|
||||
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
|
||||
const hiddenNetworkInterfaces = (() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// One in-flight validation per authenticated document. Neither BFCache restore nor
|
||||
// visibility resume may trigger a reload: a 403 is a recoverable, visible error.
|
||||
|
||||
function revalidateSession() {
|
||||
if (!authenticated) return Promise.resolve(false);
|
||||
if (sessionCheckPromise) return sessionCheckPromise;
|
||||
@@ -16,7 +15,6 @@ function revalidateSession() {
|
||||
window.rustProxyRefresh?.completeSessionValidation(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// api() locks and navigates to the login document on a 401.
|
||||
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
|
||||
window.rustProxyRefresh?.completeSessionValidation(false);
|
||||
showSessionValidationFailure(error);
|
||||
@@ -46,7 +44,6 @@ function displayStartupError(error, sessionResume = false) {
|
||||
$("app-session-retry").hidden = !sessionResume;
|
||||
}
|
||||
function showSessionValidationFailure(error) {
|
||||
// Keep the workspace inaccessible while its session cannot be verified.
|
||||
displayStartupError(error, true);
|
||||
}
|
||||
function hideSessionValidationFailure() {
|
||||
@@ -68,7 +65,6 @@ $("app-session-retry").addEventListener("click", () => {
|
||||
);
|
||||
});
|
||||
function showStartupFailure(error) {
|
||||
// All startup failure paths stop background tasks and show visible recovery.
|
||||
window.rustProxyRefresh?.stop();
|
||||
displayStartupError(error);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Four opt-in cards in the EXISTING dashboard widget order and status refresh.
|
||||
// No timers, fetches, or independent persistence are created here.
|
||||
|
||||
window.rustProxyStreamDashboard = (() => {
|
||||
const ids = ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"];
|
||||
const history = { last: null, rx: [], tx: [] };
|
||||
@@ -18,8 +17,6 @@ window.rustProxyStreamDashboard = (() => {
|
||||
function syncVisibility(hiddenOverride = null) {
|
||||
const prefs = window.rustProxyPreferences?.current?.();
|
||||
const hidden = new Set(hiddenOverride ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
|
||||
// Visibility belongs exclusively to Appearance. An absent, disabled or
|
||||
// restart-pending Stream must not silently override an explicit opt-in.
|
||||
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
|
||||
if (card("stream_traffic")?.hidden && wasVisible) clearTraffic();
|
||||
wasVisible = !card("stream_traffic")?.hidden;
|
||||
@@ -35,8 +32,6 @@ window.rustProxyStreamDashboard = (() => {
|
||||
const active = (status?.streams?.active || []).filter(item => item.enabled !== false);
|
||||
const metrics = status?.streams?.metrics || {};
|
||||
if (!configured.some(item => item.enabled !== false) && !active.length) {
|
||||
// A deliberately shown tile has a neutral empty state, not a
|
||||
// hidden DOM node. No history is sampled without running streams.
|
||||
for (const name of ["overview", "sessions", "health", "traffic"]) {
|
||||
if (!card(({ overview: "stream_overview", sessions: "stream_sessions", health: "stream_backend_health", traffic: "stream_traffic" })[name])?.hidden) {
|
||||
set(`stream-dashboard-${name}`, "-");
|
||||
@@ -79,8 +74,6 @@ window.rustProxyStreamDashboard = (() => {
|
||||
trafficRates("-", "-");
|
||||
return;
|
||||
}
|
||||
// Switching tabs or appearance preferences paints cached numbers without
|
||||
// generating an extra rate sample from the same status response.
|
||||
if (!recordTrafficSample) {
|
||||
if (history.rx.length) {
|
||||
const rx = history.rx.at(-1), tx = history.tx.at(-1);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
// Dedicated cache inspector. Entry bodies and request headers are never loaded.
|
||||
(() => {
|
||||
let loading = false, offset = 0, generation = 0;
|
||||
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
try {
|
||||
const saved = localStorage.getItem(CONSOLIDATION_STORAGE_KEY);
|
||||
if (consolidationToggle && (saved === "1" || saved === "0")) consolidationToggle.checked = saved === "1";
|
||||
} catch { /* Browser storage can be unavailable; use the HTML default. */ }
|
||||
} catch { /* html default */ }
|
||||
|
||||
const sourceSelect = () => $("file-log-source");
|
||||
function setView(view) {
|
||||
@@ -258,8 +258,6 @@
|
||||
row.setAttribute("aria-label", entrySummary(entry));
|
||||
for (const id of ids) {
|
||||
const value = columnValue(entry, id), cell = el("td", value); cell.title = value;
|
||||
// Mobile uses compact, expandable cards instead of a width-growing
|
||||
// table. All parsed fields remain accessible in the detail row.
|
||||
cell.dataset.mobileColumn = id;
|
||||
const label = columnLabel(id);
|
||||
cell.dataset.mobileLabel = label;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
// No operational API or dashboard module is loaded on this document.
|
||||
(() => {
|
||||
const node = id => document.getElementById(id);
|
||||
const themes = new Set(["dark", "light"]);
|
||||
@@ -53,9 +52,6 @@
|
||||
if (setupRequired) { if (password !== node("password-confirm").value) throw new Error(t("passwordMismatch")); await request("/api/auth/setup", { token: node("setup-token").value.trim(), username, password }); setupRequired = false; translate(); }
|
||||
await request("/api/auth/login", { username, password });
|
||||
node("password").value = ""; node("password-confirm").value = ""; node("setup-token").value = "";
|
||||
// Never accept arbitrary return URLs (including scheme-relative redirects).
|
||||
// A direct panel route is an exact local allowlist entry, so an expired
|
||||
// session can sign back in without losing the requested subpage.
|
||||
location.replace(panelDestination());
|
||||
} catch (error) { node("password").value = ""; node("password-confirm").value = ""; if (error.code === "auth.already_initialized") { setupRequired = false; translate(); } message(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
// Shared, safe controls: HTML is edited as text, never injected into the admin DOM.
|
||||
(() => {
|
||||
const label = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const option = (value, key) => { const node = label("option", key); node.value = value; return node; };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
// Persistent tab DOM: switching tabs never discards unsaved form values.
|
||||
(() => {
|
||||
const buttons = [...document.querySelectorAll("[data-settings-tab]")];
|
||||
const search = $("settings-search"), searchStatus = $("settings-search-status");
|
||||
@@ -8,8 +7,6 @@
|
||||
? "logging"
|
||||
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
|
||||
const tabAliases = { http2: "connections", headers: "policies", response: "policies" };
|
||||
// Keep the public panel hostname implicit. Legacy configurations may have
|
||||
// written it into allowed_hosts in 0.31.4; do not show/save it twice.
|
||||
function pinnedAdminHostname(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use strict";
|
||||
// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP
|
||||
// listeners separate from the HTTP policy stack.
|
||||
(() => {
|
||||
const sortColumns = ["name", "id", "protocol", "listen", "upstream", "status", "maxConnections", "idleTimeout"];
|
||||
let editing = null, revision = null, dirty = false, initialized = false, listPrefs = { sort: "name", descending: false };
|
||||
@@ -141,7 +139,6 @@
|
||||
field(g, "max-connections", "streamMaxConnections", value.max_connections ?? 1024, "number", { min: 1, max: 65535, required: true });
|
||||
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", () => {
|
||||
sync();
|
||||
// Explicitly reset incompatible health modes when switching transport.
|
||||
for (const row of backendList.children) {
|
||||
const mode = row.querySelector('[name="backend-health-mode"]');
|
||||
if (mode?.value !== "off") mode.value = "off";
|
||||
|
||||
+20
-774
File diff suppressed because it is too large.
Load diff
+469
-471
@@ -1,495 +1,493 @@
|
||||
"use strict";
|
||||
// Vhost UI: language content comes from langs/*.json, never a locale switch.
|
||||
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], uaRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
const dialog = $("vhost-dialog");
|
||||
const form = $("vhost-form"), container = $("vhost-fields");
|
||||
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const field = (parent, name, key, value, type = "text", options = {}) => {
|
||||
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
|
||||
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
|
||||
input.name = name;
|
||||
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
|
||||
else if (type !== "textarea") input.type = type;
|
||||
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
|
||||
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
|
||||
if (type === "textarea") input.spellcheck = false;
|
||||
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
|
||||
parent.append(label); return input;
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], uaRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
const dialog = $("vhost-dialog");
|
||||
const form = $("vhost-form"), container = $("vhost-fields");
|
||||
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const field = (parent, name, key, value, type = "text", options = {}) => {
|
||||
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
|
||||
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
|
||||
input.name = name;
|
||||
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
|
||||
else if (type !== "textarea") input.type = type;
|
||||
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
|
||||
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
|
||||
if (type === "textarea") input.spellcheck = false;
|
||||
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
|
||||
parent.append(label); return input;
|
||||
};
|
||||
const group = (key, advanced = false) => {
|
||||
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
|
||||
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhCompression: "compression", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
|
||||
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
|
||||
};
|
||||
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
|
||||
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
|
||||
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
|
||||
const str = (name, scope) => read(name, scope).value.trim();
|
||||
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
|
||||
const yes = (name, scope) => read(name, scope).checked;
|
||||
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
const nullable = (name, scope) => str(name, scope) || null;
|
||||
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
|
||||
const changed = () => { dirty = true; };
|
||||
const fillWebServices = (select, chosen = "") => {
|
||||
const first = el("option", t("webServiceSelect")); first.value = "";
|
||||
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
|
||||
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
|
||||
select.value = chosen;
|
||||
};
|
||||
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
|
||||
const defaults = () => ({
|
||||
id: "", enabled: true, force_ssl: false, www_redirect: false, www_redirect_target: null, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["compression", "vhCompression"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
|
||||
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), expose_only_hits: tri("vh-cache-only-hits"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; }
|
||||
function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; }
|
||||
function collectTransform() {
|
||||
const tri = name => str(name) === "" ? null : str(name) === "on";
|
||||
return {
|
||||
enabled: yes("vh-transform-enabled"),
|
||||
javascript: tri("vh-transform-javascript"),
|
||||
css: tri("vh-transform-css"),
|
||||
html: tri("vh-transform-html"),
|
||||
svg: tri("vh-transform-svg"),
|
||||
json: tri("vh-transform-json"),
|
||||
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
|
||||
};
|
||||
const group = (key, advanced = false) => {
|
||||
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
|
||||
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhCompression: "compression", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
|
||||
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
|
||||
}
|
||||
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
|
||||
function validateHeaders(value) {
|
||||
const fail = () => { throw new Error(t("headersInvalid")); };
|
||||
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
|
||||
for (const direction of ["request", "response"]) {
|
||||
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
|
||||
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
|
||||
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
|
||||
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
|
||||
}
|
||||
} return value;
|
||||
}
|
||||
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
|
||||
const holders = {}, adders = {}, emptyStates = {};
|
||||
const renumber = direction => {
|
||||
const rows = holders[direction];
|
||||
[...rows.children].forEach((row, index) => {
|
||||
const title = row.querySelector(".vh-header-rule-title");
|
||||
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
|
||||
});
|
||||
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
|
||||
};
|
||||
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
|
||||
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
|
||||
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
|
||||
const str = (name, scope) => read(name, scope).value.trim();
|
||||
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
|
||||
const yes = (name, scope) => read(name, scope).checked;
|
||||
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
const nullable = (name, scope) => str(name, scope) || null;
|
||||
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
|
||||
const changed = () => { dirty = true; };
|
||||
const fillWebServices = (select, chosen = "") => {
|
||||
const first = el("option", t("webServiceSelect")); first.value = "";
|
||||
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
|
||||
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
|
||||
select.value = chosen;
|
||||
const sync = () => {
|
||||
const result = {};
|
||||
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
|
||||
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
|
||||
return op === "remove" ? { op, name } : { op, name, value };
|
||||
});
|
||||
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
|
||||
};
|
||||
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
|
||||
const defaults = () => ({
|
||||
id: "", enabled: true, force_ssl: false, www_redirect: false, www_redirect_target: null, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
for (const direction of ["request", "response"]) {
|
||||
const block = el("section", undefined, "vh-wide location-header-direction location-header-editor"); block.dataset.headerDirection = direction;
|
||||
const heading = el("div", undefined, "location-header-direction-head");
|
||||
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
|
||||
block.append(heading);
|
||||
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
|
||||
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
|
||||
const add = (value = { op: "set", name: "", value: "" }) => {
|
||||
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-header-row");
|
||||
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
|
||||
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
|
||||
const fields = el("div", undefined, "vh-header-rule-fields");
|
||||
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
|
||||
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
|
||||
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
|
||||
row.append(fields);
|
||||
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
|
||||
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
|
||||
};
|
||||
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
|
||||
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
|
||||
}
|
||||
target.addEventListener("input", () => {
|
||||
try {
|
||||
const value = validateHeaders(JSON.parse(target.value));
|
||||
for (const direction of ["request", "response"]) {
|
||||
holders[direction].replaceChildren();
|
||||
for (const rule of value[direction] || []) adders[direction](rule);
|
||||
renumber(direction);
|
||||
}
|
||||
target.setCustomValidity(""); onChange();
|
||||
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["compression", "vhCompression"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
|
||||
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), expose_only_hits: tri("vh-cache-only-hits"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; }
|
||||
function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; }
|
||||
function collectTransform() {
|
||||
const tri = name => str(name) === "" ? null : str(name) === "on";
|
||||
return {
|
||||
enabled: yes("vh-transform-enabled"),
|
||||
javascript: tri("vh-transform-javascript"),
|
||||
css: tri("vh-transform-css"),
|
||||
html: tri("vh-transform-html"),
|
||||
svg: tri("vh-transform-svg"),
|
||||
json: tri("vh-transform-json"),
|
||||
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
|
||||
};
|
||||
}
|
||||
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
|
||||
function validateHeaders(value) {
|
||||
const fail = () => { throw new Error(t("headersInvalid")); };
|
||||
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
|
||||
for (const direction of ["request", "response"]) {
|
||||
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
|
||||
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
|
||||
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
|
||||
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
|
||||
}
|
||||
} return value;
|
||||
}
|
||||
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
|
||||
const holders = {}, adders = {}, emptyStates = {};
|
||||
const renumber = direction => {
|
||||
const rows = holders[direction];
|
||||
[...rows.children].forEach((row, index) => {
|
||||
const title = row.querySelector(".vh-header-rule-title");
|
||||
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
|
||||
});
|
||||
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
|
||||
};
|
||||
const sync = () => {
|
||||
const result = {};
|
||||
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
|
||||
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
|
||||
return op === "remove" ? { op, name } : { op, name, value };
|
||||
});
|
||||
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
|
||||
};
|
||||
for (const direction of ["request", "response"]) {
|
||||
const block = el("section", undefined, "vh-wide location-header-direction location-header-editor"); block.dataset.headerDirection = direction;
|
||||
const heading = el("div", undefined, "location-header-direction-head");
|
||||
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
|
||||
block.append(heading);
|
||||
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
|
||||
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
|
||||
const add = (value = { op: "set", name: "", value: "" }) => {
|
||||
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-header-row");
|
||||
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
|
||||
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
|
||||
const fields = el("div", undefined, "vh-header-rule-fields");
|
||||
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
|
||||
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
|
||||
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
|
||||
row.append(fields);
|
||||
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
|
||||
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
|
||||
};
|
||||
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
|
||||
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
|
||||
note(parent, "locationHeadersHelp");
|
||||
return { read: () => validateHeaders(JSON.parse(target.value)) };
|
||||
}
|
||||
function suspendDraft() {
|
||||
const state = {
|
||||
editing, revision, label: $("vhost-editing").textContent,
|
||||
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, uaRatesReader, pagesEditor, backendHealthEditorControl,
|
||||
stagedCertificates, uploading
|
||||
};
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled");
|
||||
draftEpoch++; editing = null; revision = null; dirty = false; stagedCertificates = []; uploading = false; dialog.close();
|
||||
return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
|
||||
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; uaRatesReader = state.uaRatesReader || (() => []); pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
|
||||
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
|
||||
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function closeEditor() {
|
||||
if (dirty) return window.rustProxyDrafts.dock("vhost");
|
||||
draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); if (dialog.open) dialog.close(); return true;
|
||||
}
|
||||
$("vhost-close").addEventListener("click", closeEditor); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
function administrationListenCandidates() {
|
||||
return [
|
||||
envelope?.config.administration?.listen,
|
||||
latestStatus?.administration?.locked_listen,
|
||||
latestStatus?.administration?.desired?.listen,
|
||||
latestStatus?.administration?.active?.listen,
|
||||
latestStatus?.admin_listen,
|
||||
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
|
||||
}
|
||||
function adminListenerBackendCandidate(raw) {
|
||||
const splitSocket = value => {
|
||||
const textValue = String(value || "").trim();
|
||||
if (!textValue) return null;
|
||||
if (textValue.startsWith("[")) {
|
||||
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
|
||||
}
|
||||
target.addEventListener("input", () => {
|
||||
try {
|
||||
const value = validateHeaders(JSON.parse(target.value));
|
||||
for (const direction of ["request", "response"]) {
|
||||
holders[direction].replaceChildren();
|
||||
for (const rule of value[direction] || []) adders[direction](rule);
|
||||
renumber(direction);
|
||||
}
|
||||
target.setCustomValidity(""); onChange();
|
||||
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
|
||||
});
|
||||
note(parent, "locationHeadersHelp");
|
||||
return { read: () => validateHeaders(JSON.parse(target.value)) };
|
||||
}
|
||||
function suspendDraft() {
|
||||
const state = {
|
||||
editing, revision, label: $("vhost-editing").textContent,
|
||||
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, uaRatesReader, pagesEditor, backendHealthEditorControl,
|
||||
stagedCertificates, uploading
|
||||
};
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled");
|
||||
draftEpoch++; editing = null; revision = null; dirty = false; stagedCertificates = []; uploading = false; dialog.close();
|
||||
return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
|
||||
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; uaRatesReader = state.uaRatesReader || (() => []); pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
|
||||
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
|
||||
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function closeEditor() {
|
||||
if (dirty) return window.rustProxyDrafts.dock("vhost");
|
||||
draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); if (dialog.open) dialog.close(); return true;
|
||||
}
|
||||
$("vhost-close").addEventListener("click", closeEditor); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
function administrationListenCandidates() {
|
||||
return [
|
||||
envelope?.config.administration?.listen,
|
||||
latestStatus?.administration?.locked_listen,
|
||||
latestStatus?.administration?.desired?.listen,
|
||||
latestStatus?.administration?.active?.listen,
|
||||
latestStatus?.admin_listen,
|
||||
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
|
||||
}
|
||||
function adminListenerBackendCandidate(raw) {
|
||||
const splitSocket = value => {
|
||||
const textValue = String(value || "").trim();
|
||||
if (!textValue) return null;
|
||||
if (textValue.startsWith("[")) {
|
||||
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
|
||||
}
|
||||
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
|
||||
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
|
||||
};
|
||||
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
|
||||
if (!adminListeners.length) return null;
|
||||
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
|
||||
return adminListeners.some(admin => {
|
||||
if (port !== admin.port) return false;
|
||||
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
|
||||
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
|
||||
});
|
||||
}
|
||||
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
|
||||
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
|
||||
};
|
||||
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
|
||||
if (!adminListeners.length) return null;
|
||||
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
|
||||
return adminListeners.some(admin => {
|
||||
if (port !== admin.port) return false;
|
||||
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
|
||||
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
|
||||
});
|
||||
}
|
||||
|
||||
function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`, url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }) {
|
||||
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
|
||||
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
|
||||
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
|
||||
function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`, url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }) {
|
||||
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
|
||||
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
|
||||
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
|
||||
|
||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
|
||||
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
|
||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
|
||||
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
|
||||
|
||||
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
|
||||
const controls = {};
|
||||
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain], ["member-health-enabled", "backendHealthEnabled", value.health_check_enabled !== false]]) controls[name] = field(routing, name, key, checked, "checkbox");
|
||||
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
|
||||
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
|
||||
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
|
||||
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
|
||||
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
|
||||
const controls = {};
|
||||
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain], ["member-health-enabled", "backendHealthEnabled", value.health_check_enabled !== false]]) controls[name] = field(routing, name, key, checked, "checkbox");
|
||||
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
|
||||
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
|
||||
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
|
||||
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
|
||||
|
||||
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
|
||||
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
|
||||
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
|
||||
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
|
||||
note(riskGate, "vhLocalListenerRiskHelp");
|
||||
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
|
||||
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
|
||||
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
|
||||
note(extra, "vhBackendCapacityHelp");
|
||||
// The per-member settings live in the Health tab. Keep one persistent form
|
||||
// per backend, so switching the selection never discards unsaved edits.
|
||||
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
|
||||
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
|
||||
const health = value.health_check || {};
|
||||
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); healthPanel.append(healthFields);
|
||||
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
|
||||
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
|
||||
field(healthFields, "member-health-interval", "vhInterval", health.interval_ms ?? 5000, "number", { min: 250, max: 300000, required: true });
|
||||
field(healthFields, "member-health-timeout", "vhTimeout", health.timeout_ms ?? 2000, "number", { min: 50, max: 60000, required: true });
|
||||
field(healthFields, "member-health-slow", "vhSlow", health.slow_response_ms ?? 1500, "number", { min: 0, max: 60000, required: true });
|
||||
field(healthFields, "member-health-fall", "vhFall", health.fall ?? 3, "number", { min: 1, max: 20, required: true });
|
||||
field(healthFields, "member-health-rise", "vhRise", health.rise ?? 2, "number", { min: 1, max: 20, required: true });
|
||||
field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true });
|
||||
field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true });
|
||||
const syncHealth = () => {
|
||||
const editable = controls["member-health-enabled"].checked && customHealth.checked;
|
||||
healthFields.hidden = !customHealth.checked;
|
||||
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !editable; });
|
||||
};
|
||||
let customInitialized = value.health_check != null;
|
||||
customHealth.addEventListener("change", () => {
|
||||
if (customHealth.checked && !customInitialized) { inheritBackendHealthInputs(healthPanel); customInitialized = true; }
|
||||
syncHealth();
|
||||
});
|
||||
controls["member-health-enabled"].addEventListener("change", () => { syncHealth(); backendHealthEditorControl?.refresh(); });
|
||||
row.memberHealthPanel = healthPanel;
|
||||
row.memberHealthEnabled = controls["member-health-enabled"];
|
||||
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
|
||||
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
|
||||
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
|
||||
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
|
||||
note(riskGate, "vhLocalListenerRiskHelp");
|
||||
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
|
||||
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
|
||||
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
|
||||
note(extra, "vhBackendCapacityHelp");
|
||||
// The per-member settings live in the Health tab. Keep one persistent form
|
||||
// per backend, so switching the selection never discards unsaved edits.
|
||||
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
|
||||
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
|
||||
const health = value.health_check || {};
|
||||
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); healthPanel.append(healthFields);
|
||||
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
|
||||
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
|
||||
field(healthFields, "member-health-interval", "vhInterval", health.interval_ms ?? 5000, "number", { min: 250, max: 300000, required: true });
|
||||
field(healthFields, "member-health-timeout", "vhTimeout", health.timeout_ms ?? 2000, "number", { min: 50, max: 60000, required: true });
|
||||
field(healthFields, "member-health-slow", "vhSlow", health.slow_response_ms ?? 1500, "number", { min: 0, max: 60000, required: true });
|
||||
field(healthFields, "member-health-fall", "vhFall", health.fall ?? 3, "number", { min: 1, max: 20, required: true });
|
||||
field(healthFields, "member-health-rise", "vhRise", health.rise ?? 2, "number", { min: 1, max: 20, required: true });
|
||||
field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true });
|
||||
field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true });
|
||||
const syncHealth = () => {
|
||||
const editable = controls["member-health-enabled"].checked && customHealth.checked;
|
||||
healthFields.hidden = !customHealth.checked;
|
||||
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !editable; });
|
||||
};
|
||||
let customInitialized = value.health_check != null;
|
||||
customHealth.addEventListener("change", () => {
|
||||
if (customHealth.checked && !customInitialized) { inheritBackendHealthInputs(healthPanel); customInitialized = true; }
|
||||
syncHealth();
|
||||
row.append(advanced);
|
||||
});
|
||||
controls["member-health-enabled"].addEventListener("change", () => { syncHealth(); backendHealthEditorControl?.refresh(); });
|
||||
row.memberHealthPanel = healthPanel;
|
||||
row.memberHealthEnabled = controls["member-health-enabled"];
|
||||
syncHealth();
|
||||
row.append(advanced);
|
||||
|
||||
const actions = el("div", undefined, "vh-backend-actions");
|
||||
actions.append(button("vhDebugOriginPreset", async () => {
|
||||
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
|
||||
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
|
||||
controls["member-pool"].checked = true; controls["member-private"].checked = true;
|
||||
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
|
||||
}));
|
||||
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
|
||||
const actions = el("div", undefined, "vh-backend-actions");
|
||||
actions.append(button("vhDebugOriginPreset", async () => {
|
||||
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
|
||||
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
|
||||
controls["member-pool"].checked = true; controls["member-private"].checked = true;
|
||||
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
|
||||
}));
|
||||
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
|
||||
|
||||
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
|
||||
const syncRisk = (clearWhenHidden = false) => {
|
||||
const show = adminListenerBackendCandidate(url.value);
|
||||
riskGate.hidden = show !== true;
|
||||
if (clearWhenHidden && show === false) localRisk.checked = false;
|
||||
};
|
||||
id.addEventListener("input", () => { syncIdentity(); backendHealthEditorControl?.refresh(); }); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); backendHealthEditorControl?.refresh(); }); url.addEventListener("change", () => syncRisk(true));
|
||||
row.syncLocalRisk = () => syncRisk(false);
|
||||
syncIdentity(); syncRisk(false); parent.append(row); backendHealthEditorControl?.refresh();
|
||||
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
|
||||
const syncRisk = (clearWhenHidden = false) => {
|
||||
const show = adminListenerBackendCandidate(url.value);
|
||||
riskGate.hidden = show !== true;
|
||||
if (clearWhenHidden && show === false) localRisk.checked = false;
|
||||
};
|
||||
id.addEventListener("input", () => { syncIdentity(); backendHealthEditorControl?.refresh(); }); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); backendHealthEditorControl?.refresh(); }); url.addEventListener("change", () => syncRisk(true));
|
||||
row.syncLocalRisk = () => syncRisk(false);
|
||||
syncIdentity(); syncRisk(false); parent.append(row); backendHealthEditorControl?.refresh();
|
||||
}
|
||||
|
||||
function countryRateEditor(parent, values = []) {
|
||||
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
|
||||
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
|
||||
return () => [...rows.children].map(row => ({
|
||||
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
|
||||
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
|
||||
}));
|
||||
}
|
||||
function debugOriginIssues(members) {
|
||||
return members.filter(member => {
|
||||
let parsed; try { parsed = new URL(member.url); } catch { return false; }
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
||||
if (parsed.protocol !== "https:" || !ipHost) return false;
|
||||
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
|
||||
});
|
||||
}
|
||||
function aclEditor(parent, initial, onChange = () => { }) {
|
||||
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
|
||||
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
|
||||
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
|
||||
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
|
||||
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
|
||||
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
|
||||
}
|
||||
function openLocationHelp(topic = "locations") {
|
||||
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
|
||||
if (!dialog || !title || !body) return;
|
||||
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
|
||||
const section = (headingKey, keys, cls = "") => {
|
||||
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
|
||||
if (headingKey) box.append(text("h3", headingKey));
|
||||
for (const key of keys) box.append(el("p", t(key)));
|
||||
return box;
|
||||
};
|
||||
if (topic === "headers") {
|
||||
body.replaceChildren(
|
||||
el("p", t("headersDocsIntro"), "location-doc-intro"),
|
||||
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
|
||||
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
|
||||
);
|
||||
} else {
|
||||
body.replaceChildren(
|
||||
el("p", t("locationDocsIntro"), "location-doc-intro"),
|
||||
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
|
||||
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
|
||||
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
|
||||
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
|
||||
);
|
||||
}
|
||||
|
||||
function countryRateEditor(parent, values = []) {
|
||||
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
|
||||
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
|
||||
return () => [...rows.children].map(row => ({
|
||||
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
|
||||
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
|
||||
}));
|
||||
}
|
||||
function debugOriginIssues(members) {
|
||||
return members.filter(member => {
|
||||
let parsed; try { parsed = new URL(member.url); } catch { return false; }
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
||||
if (parsed.protocol !== "https:" || !ipHost) return false;
|
||||
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
|
||||
dialog.showModal();
|
||||
}
|
||||
function locationsEditor(parent, values) {
|
||||
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
|
||||
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
|
||||
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
|
||||
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
|
||||
function readRows() {
|
||||
return [...rows.children].map(r => {
|
||||
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
|
||||
const handled = action === "proxy" || action === "web_server";
|
||||
const internal = yes("location-internal", r), fallbackTarget = str("location-error-target", r);
|
||||
const fallbackStatuses = lines(str("location-error-statuses", r)).map(value => Number(value));
|
||||
return {
|
||||
id: str("location-id", r), internal, path_prefix: internal ? "/" : str("location-path", r), match_type: internal ? "prefix" : str("location-match", r),
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
acl_profile_id: str("location-acl-profile", r) || null,
|
||||
acl: r.readAcl(),
|
||||
country_access: { mode: str("location-country-mode", r), countries: lines(str("location-country-list", r)).map(value => value.toUpperCase()) },
|
||||
method_policy: { mode: str("location-method-mode", r), methods: lines(str("location-method-list", r)).map(value => value.toUpperCase()) },
|
||||
headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null,
|
||||
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null,
|
||||
error_fallback: fallbackTarget ? { target: fallbackTarget, statuses: fallbackStatuses.length ? fallbackStatuses : [404] } : null
|
||||
};
|
||||
});
|
||||
}
|
||||
function aclEditor(parent, initial, onChange = () => { }) {
|
||||
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
|
||||
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
|
||||
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
|
||||
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
|
||||
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
|
||||
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
|
||||
}
|
||||
function openLocationHelp(topic = "locations") {
|
||||
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
|
||||
if (!dialog || !title || !body) return;
|
||||
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
|
||||
const section = (headingKey, keys, cls = "") => {
|
||||
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
|
||||
if (headingKey) box.append(text("h3", headingKey));
|
||||
for (const key of keys) box.append(el("p", t(key)));
|
||||
return box;
|
||||
};
|
||||
if (topic === "headers") {
|
||||
body.replaceChildren(
|
||||
el("p", t("headersDocsIntro"), "location-doc-intro"),
|
||||
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
|
||||
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
|
||||
);
|
||||
} else {
|
||||
body.replaceChildren(
|
||||
el("p", t("locationDocsIntro"), "location-doc-intro"),
|
||||
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
|
||||
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
|
||||
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
|
||||
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
|
||||
);
|
||||
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
|
||||
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
|
||||
function add(value = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
|
||||
const row = el("details", undefined, "vh-location"); row.open = true;
|
||||
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
||||
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
||||
const initialAction = value.response ? "return" : value.redirect ? "redirect" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["redirect", "locationRedirectAction"], ["return", "locationReturnAction"]] });
|
||||
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
|
||||
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
|
||||
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(handlingBody, "locationErrorFallbackHelp");
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
|
||||
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
|
||||
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
|
||||
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
|
||||
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
|
||||
const redirectBox = el("div", undefined, "vh-wide vh-grid location-redirect-box"); handlingBody.append(redirectBox);
|
||||
field(redirectBox, "location-redirect-status", "locationRedirectStatus", value.redirect?.status ?? 302, "select", { values: [["301", "locationRedirect301"], ["302", "locationRedirect302"], ["303", "locationRedirect303"], ["307", "locationRedirect307"], ["308", "locationRedirect308"]] });
|
||||
field(redirectBox, "location-redirect-target", "locationRedirectTarget", value.redirect?.target || "", "url", { maxLength: 2048 });
|
||||
field(redirectBox, "location-redirect-pattern", "locationRedirectPattern", value.redirect?.rewrite_pattern || "", "text", { maxLength: 1024 });
|
||||
field(redirectBox, "location-redirect-query", "locationRedirectQuery", value.redirect?.preserve_query !== false, "checkbox");
|
||||
note(redirectBox, "locationRedirectHelp");
|
||||
note(handlingBody, "locationRewriteHelp");
|
||||
|
||||
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra);
|
||||
const locationProfile = field(accessBody, "location-acl-profile", "locationAclProfile", value.acl_profile_id || "", "select");
|
||||
const locationNoProfile = text("option", "vhAclProfileNone"); locationNoProfile.value = "";
|
||||
locationProfile.append(locationNoProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; }));
|
||||
locationProfile.value = value.acl_profile_id || "";
|
||||
field(accessBody, "location-method-mode", "locationMethodMode", value.method_policy?.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
|
||||
field(accessBody, "location-method-list", "locationMethodList", (value.method_policy?.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationMethodHelp");
|
||||
field(accessBody, "location-country-mode", "locationCountryMode", value.country_access?.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(accessBody, "location-country-list", "locationCountryList", (value.country_access?.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationCountryHelp");
|
||||
const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
|
||||
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
|
||||
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
|
||||
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
|
||||
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
|
||||
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
|
||||
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
|
||||
|
||||
const preview = el("section", undefined, "vh-wide location-preview");
|
||||
preview.append(text("h4", "locationPreviewTitle"));
|
||||
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
|
||||
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
|
||||
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
|
||||
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
|
||||
}
|
||||
dialog.showModal();
|
||||
}
|
||||
function locationsEditor(parent, values) {
|
||||
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
|
||||
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
|
||||
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
|
||||
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
|
||||
function readRows() {
|
||||
return [...rows.children].map(r => {
|
||||
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
|
||||
const handled = action === "proxy" || action === "web_server";
|
||||
const internal = yes("location-internal", r), fallbackTarget = str("location-error-target", r);
|
||||
const fallbackStatuses = lines(str("location-error-statuses", r)).map(value => Number(value));
|
||||
return {
|
||||
id: str("location-id", r), internal, path_prefix: internal ? "/" : str("location-path", r), match_type: internal ? "prefix" : str("location-match", r),
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
acl_profile_id: str("location-acl-profile", r) || null,
|
||||
acl: r.readAcl(),
|
||||
country_access: { mode: str("location-country-mode", r), countries: lines(str("location-country-list", r)).map(value => value.toUpperCase()) },
|
||||
method_policy: { mode: str("location-method-mode", r), methods: lines(str("location-method-list", r)).map(value => value.toUpperCase()) },
|
||||
headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null,
|
||||
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null,
|
||||
error_fallback: fallbackTarget ? { target: fallbackTarget, statuses: fallbackStatuses.length ? fallbackStatuses : [404] } : null
|
||||
};
|
||||
});
|
||||
}
|
||||
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
|
||||
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
|
||||
function add(value = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
|
||||
const row = el("details", undefined, "vh-location"); row.open = true;
|
||||
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
||||
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
||||
const initialAction = value.response ? "return" : value.redirect ? "redirect" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["redirect", "locationRedirectAction"], ["return", "locationReturnAction"]] });
|
||||
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
|
||||
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
|
||||
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
preview.append(previewGrid); body.append(preview);
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(handlingBody, "locationErrorFallbackHelp");
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
|
||||
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
|
||||
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
|
||||
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
|
||||
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
|
||||
const redirectBox = el("div", undefined, "vh-wide vh-grid location-redirect-box"); handlingBody.append(redirectBox);
|
||||
field(redirectBox, "location-redirect-status", "locationRedirectStatus", value.redirect?.status ?? 302, "select", { values: [["301", "locationRedirect301"], ["302", "locationRedirect302"], ["303", "locationRedirect303"], ["307", "locationRedirect307"], ["308", "locationRedirect308"]] });
|
||||
field(redirectBox, "location-redirect-target", "locationRedirectTarget", value.redirect?.target || "", "url", { maxLength: 2048 });
|
||||
field(redirectBox, "location-redirect-pattern", "locationRedirectPattern", value.redirect?.rewrite_pattern || "", "text", { maxLength: 1024 });
|
||||
field(redirectBox, "location-redirect-query", "locationRedirectQuery", value.redirect?.preserve_query !== false, "checkbox");
|
||||
note(redirectBox, "locationRedirectHelp");
|
||||
note(handlingBody, "locationRewriteHelp");
|
||||
const actions = el("div", undefined, "vh-wide location-actions");
|
||||
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
|
||||
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
|
||||
|
||||
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra);
|
||||
const locationProfile = field(accessBody, "location-acl-profile", "locationAclProfile", value.acl_profile_id || "", "select");
|
||||
const locationNoProfile = text("option", "vhAclProfileNone"); locationNoProfile.value = "";
|
||||
locationProfile.append(locationNoProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; }));
|
||||
locationProfile.value = value.acl_profile_id || "";
|
||||
field(accessBody, "location-method-mode", "locationMethodMode", value.method_policy?.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
|
||||
field(accessBody, "location-method-list", "locationMethodList", (value.method_policy?.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationMethodHelp");
|
||||
field(accessBody, "location-country-mode", "locationCountryMode", value.country_access?.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(accessBody, "location-country-list", "locationCountryList", (value.country_access?.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationCountryHelp");
|
||||
const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
|
||||
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
|
||||
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
|
||||
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
|
||||
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
|
||||
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
|
||||
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
|
||||
const syncUi = () => {
|
||||
const isReturn = action.value === "return", isRedirect = action.value === "redirect", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
match.disabled = path.disabled = internal.checked;
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; redirectBox.hidden = !isRedirect; rewriteMode.disabled = isReturn || isRedirect; rewriteValue.disabled = isReturn || isRedirect || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
|
||||
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
|
||||
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
|
||||
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
|
||||
const id = str("location-id", row) || t("locationId"), pattern = internal.checked ? `@${id}` : (str("location-path", row) || "/");
|
||||
summary.textContent = `${id} · ${internal.checked ? t("locationInternal") : t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
|
||||
const preview = el("section", undefined, "vh-wide location-preview");
|
||||
preview.append(text("h4", "locationPreviewTitle"));
|
||||
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
|
||||
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
|
||||
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
|
||||
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
|
||||
}
|
||||
preview.append(previewGrid); body.append(preview);
|
||||
|
||||
const actions = el("div", undefined, "vh-wide location-actions");
|
||||
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
|
||||
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
|
||||
|
||||
const syncUi = () => {
|
||||
const isReturn = action.value === "return", isRedirect = action.value === "redirect", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
match.disabled = path.disabled = internal.checked;
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; redirectBox.hidden = !isRedirect; rewriteMode.disabled = isReturn || isRedirect; rewriteValue.disabled = isReturn || isRedirect || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
|
||||
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
|
||||
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
|
||||
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
|
||||
const id = str("location-id", row) || t("locationId"), pattern = internal.checked ? `@${id}` : (str("location-path", row) || "/");
|
||||
summary.textContent = `${id} · ${internal.checked ? t("locationInternal") : t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
|
||||
previewStatus.hidden = yes("location-enabled", row);
|
||||
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
|
||||
if (isReturn) {
|
||||
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
|
||||
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = "-";
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else if (isRedirect) {
|
||||
const status = str("location-redirect-status", row) || "302", target = str("location-redirect-target", row) || "-", capture = str("location-redirect-pattern", row);
|
||||
previewValues.handling.textContent = `${t("locationRedirectAction")}: HTTP ${status} · ${target} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = capture || "-";
|
||||
previewStatus.hidden = yes("location-enabled", row);
|
||||
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
|
||||
if (isReturn) {
|
||||
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
|
||||
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = "-";
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else if (isRedirect) {
|
||||
const status = str("location-redirect-status", row) || "302", target = str("location-redirect-target", row) || "-", capture = str("location-redirect-pattern", row);
|
||||
previewValues.handling.textContent = `${t("locationRedirectAction")}: HTTP ${status} · ${target} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = capture || "-";
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
if (isWeb) {
|
||||
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
|
||||
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
if (isWeb) {
|
||||
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
|
||||
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
|
||||
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
|
||||
}
|
||||
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
|
||||
const replacement = read("location-rewrite-value", row).value.trim();
|
||||
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
|
||||
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
|
||||
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
|
||||
}
|
||||
const accessParts = [];
|
||||
const profile = str("location-acl-profile", row); if (profile) accessParts.push(`${t("locationAclProfile")}: ${profile}`);
|
||||
const methodMode = str("location-method-mode", row); if (methodMode !== "off") accessParts.push(`${t("locationMethodMode")}: ${t(methodMode === "allow" ? "locationMethodAllow" : "locationMethodBlock")} ${lines(str("location-method-list", row)).join(", ")}`);
|
||||
const countryMode = str("location-country-mode", row); if (countryMode !== "off") accessParts.push(`${t("locationCountryMode")}: ${t(countryMode === "allow" ? "vhCountryAllow" : "vhCountryBlock")} ${lines(str("location-country-list", row)).join(", ")}`);
|
||||
if (yes("acl-use", row)) accessParts.push(t("locationPreviewCustomAcl"));
|
||||
previewValues.access.textContent = accessParts.length ? accessParts.join(" · ") : t("locationPreviewVhostAcl");
|
||||
try {
|
||||
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
|
||||
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
|
||||
} catch { previewValues.headers.textContent = t("headersInvalid"); }
|
||||
};
|
||||
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
|
||||
rows.append(row); syncUi();
|
||||
}
|
||||
// The main vhost backend is an internal fallback, not a Custom location.
|
||||
// An empty location list must stay empty so the editor cannot accidentally
|
||||
// delete or recreate the main route as user configuration.
|
||||
for (const value of values) add(value);
|
||||
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
|
||||
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
|
||||
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
|
||||
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
|
||||
sync(); return readRows;
|
||||
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
|
||||
const replacement = read("location-rewrite-value", row).value.trim();
|
||||
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
|
||||
}
|
||||
const accessParts = [];
|
||||
const profile = str("location-acl-profile", row); if (profile) accessParts.push(`${t("locationAclProfile")}: ${profile}`);
|
||||
const methodMode = str("location-method-mode", row); if (methodMode !== "off") accessParts.push(`${t("locationMethodMode")}: ${t(methodMode === "allow" ? "locationMethodAllow" : "locationMethodBlock")} ${lines(str("location-method-list", row)).join(", ")}`);
|
||||
const countryMode = str("location-country-mode", row); if (countryMode !== "off") accessParts.push(`${t("locationCountryMode")}: ${t(countryMode === "allow" ? "vhCountryAllow" : "vhCountryBlock")} ${lines(str("location-country-list", row)).join(", ")}`);
|
||||
if (yes("acl-use", row)) accessParts.push(t("locationPreviewCustomAcl"));
|
||||
previewValues.access.textContent = accessParts.length ? accessParts.join(" · ") : t("locationPreviewVhostAcl");
|
||||
try {
|
||||
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
|
||||
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
|
||||
} catch { previewValues.headers.textContent = t("headersInvalid"); }
|
||||
};
|
||||
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
|
||||
rows.append(row); syncUi();
|
||||
}
|
||||
// The main vhost backend is an internal fallback, not a Custom location.
|
||||
// An empty location list must stay empty so the editor cannot accidentally
|
||||
// delete or recreate the main route as user configuration.
|
||||
for (const value of values) add(value);
|
||||
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
|
||||
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
|
||||
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
|
||||
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
|
||||
sync(); return readRows;
|
||||
}
|
||||
@@ -55,8 +55,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(g, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
// Keep the list of names beside the WWW routing control. Every name in
|
||||
// frontend.hosts remains an independent alias of this vhost.
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
"use strict";
|
||||
// Per-member health editor shares the vhost form state from core.js.
|
||||
// Starting a custom override should copy the CURRENT shared policy once,
|
||||
// rather than surprising the operator with unrelated hardcoded defaults.
|
||||
function inheritBackendHealthInputs(panel) {
|
||||
for (const [local, shared] of [
|
||||
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
|
||||
["member-health-host", "health-host"], ["member-health-interval", "interval"],
|
||||
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
|
||||
["member-health-fall", "fall"], ["member-health-rise", "rise"],
|
||||
["member-health-min", "status-min"], ["member-health-max", "status-max"]
|
||||
]) {
|
||||
const source = read(shared), target = read(local, panel);
|
||||
if (source && target) target.value = source.value;
|
||||
}
|
||||
}
|
||||
function backendHealthEditor(parent, members) {
|
||||
const section = el("section", undefined, "vh-wide vh-backend-health-editor");
|
||||
section.append(text("h3", "backendHealthPerBackend"));
|
||||
note(section, "backendHealthSelectionHelp");
|
||||
const selection = field(section, "health-backend-select", "backendHealthSelect", "", "select");
|
||||
const empty = note(section, "backendHealthNoEnabled");
|
||||
const panels = el("div", undefined, "vh-health-member-panels"); section.append(panels); parent.append(section);
|
||||
let active = null, eligible = [];
|
||||
const refresh = () => {
|
||||
eligible = [...members.children].filter(row => row.memberHealthEnabled.checked);
|
||||
if (!eligible.includes(active)) active = eligible[0] || null;
|
||||
selection.replaceChildren(...eligible.map((row, index) => {
|
||||
const id = str("member-id", row) || t("backend");
|
||||
const address = str("member-url", row);
|
||||
const option = el("option", address ? `${id} — ${address}` : id);
|
||||
option.value = String(index); return option;
|
||||
}));
|
||||
selection.value = active ? String(eligible.indexOf(active)) : "";
|
||||
selection.disabled = eligible.length === 0;
|
||||
empty.hidden = eligible.length !== 0;
|
||||
panels.replaceChildren(...[...members.children].map(row => row.memberHealthPanel));
|
||||
for (const row of members.children) row.memberHealthPanel.hidden = row !== active;
|
||||
};
|
||||
selection.addEventListener("change", () => { active = eligible[Number(selection.value)] || null; refresh(); });
|
||||
refresh();
|
||||
return { refresh, reveal(row) { if (eligible.includes(row)) { active = row; refresh(); } } };
|
||||
function inheritBackendHealthInputs(panel) {
|
||||
for (const [local, shared] of [
|
||||
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
|
||||
["member-health-host", "health-host"], ["member-health-interval", "interval"],
|
||||
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
|
||||
["member-health-fall", "fall"], ["member-health-rise", "rise"],
|
||||
["member-health-min", "status-min"], ["member-health-max", "status-max"]
|
||||
]) {
|
||||
const source = read(shared), target = read(local, panel);
|
||||
if (source && target) target.value = source.value;
|
||||
}
|
||||
}
|
||||
function backendHealthEditor(parent, members) {
|
||||
const section = el("section", undefined, "vh-wide vh-backend-health-editor");
|
||||
section.append(text("h3", "backendHealthPerBackend"));
|
||||
note(section, "backendHealthSelectionHelp");
|
||||
const selection = field(section, "health-backend-select", "backendHealthSelect", "", "select");
|
||||
const empty = note(section, "backendHealthNoEnabled");
|
||||
const panels = el("div", undefined, "vh-health-member-panels"); section.append(panels); parent.append(section);
|
||||
let active = null, eligible = [];
|
||||
const refresh = () => {
|
||||
eligible = [...members.children].filter(row => row.memberHealthEnabled.checked);
|
||||
if (!eligible.includes(active)) active = eligible[0] || null;
|
||||
selection.replaceChildren(...eligible.map((row, index) => {
|
||||
const id = str("member-id", row) || t("backend");
|
||||
const address = str("member-url", row);
|
||||
const option = el("option", address ? `${id} — ${address}` : id);
|
||||
option.value = String(index); return option;
|
||||
}));
|
||||
selection.value = active ? String(eligible.indexOf(active)) : "";
|
||||
selection.disabled = eligible.length === 0;
|
||||
empty.hidden = eligible.length !== 0;
|
||||
panels.replaceChildren(...[...members.children].map(row => row.memberHealthPanel));
|
||||
for (const row of members.children) row.memberHealthPanel.hidden = row !== active;
|
||||
};
|
||||
selection.addEventListener("change", () => { active = eligible[Number(selection.value)] || null; refresh(); });
|
||||
refresh();
|
||||
return { refresh, reveal(row) { if (eligible.includes(row)) { active = row; refresh(); } } };
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
"use strict";
|
||||
// Per-vhost rate limit editors, shared in the bundled vhost closure.
|
||||
function userAgentRateEditor(parent, values = []) {
|
||||
parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide"));
|
||||
const rows = el("div", undefined, "vh-wide vh-ua-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhUaRateMax"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "ua-rate-pattern", "vhUaRatePattern", value.pattern || "", "text", { required: true, maxLength: 256 });
|
||||
field(row, "ua-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "ua-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "ua-rate-burst", "vhRateBurst", value.burst ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddUaRate", () => { add(); changed(); })); note(parent, "vhUaRateHelp");
|
||||
return () => [...rows.children].map(row => ({ pattern: str("ua-rate-pattern", row).trim(), requests: num("ua-rate-requests", row), window_seconds: num("ua-rate-window", row), burst: num("ua-rate-burst", row) }));
|
||||
}
|
||||
function userAgentRateEditor(parent, values = []) {
|
||||
parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide"));
|
||||
const rows = el("div", undefined, "vh-wide vh-ua-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhUaRateMax"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "ua-rate-pattern", "vhUaRatePattern", value.pattern || "", "text", { required: true, maxLength: 256 });
|
||||
field(row, "ua-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "ua-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "ua-rate-burst", "vhRateBurst", value.burst ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddUaRate", () => { add(); changed(); })); note(parent, "vhUaRateHelp");
|
||||
return () => [...rows.children].map(row => ({ pattern: str("ua-rate-pattern", row).trim(), requests: num("ua-rate-requests", row), window_seconds: num("ua-rate-window", row), burst: num("ua-rate-burst", row) }));
|
||||
}
|
||||
+374
-376
@@ -1,380 +1,378 @@
|
||||
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
|
||||
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
|
||||
const defaultVhostListPrefs = () => ({ sort: "id", descending: false });
|
||||
let poolTablePrefs = defaultPoolTablePrefs(), vhostListPrefs = defaultVhostListPrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
|
||||
function normalizePoolTablePrefs(preferences) {
|
||||
const selected = Array.isArray(preferences?.backend_table_columns) ? preferences.backend_table_columns.filter(key => poolColumns.includes(key)) : [];
|
||||
return {
|
||||
sort: poolColumns.includes(preferences?.backend_table_sort) ? preferences.backend_table_sort : "vhost",
|
||||
descending: preferences?.backend_table_descending === true,
|
||||
columns: selected.length ? selected : [...poolColumns],
|
||||
};
|
||||
}
|
||||
function normalizeVhostListPrefs(preferences) {
|
||||
return { sort: vhostSortColumns.includes(preferences?.vhost_list_sort) ? preferences.vhost_list_sort : "id", descending: preferences?.vhost_list_descending === true };
|
||||
}
|
||||
function syncVhostSortDirection() {
|
||||
const control = $("vhost-sort-direction"); if (!control) return;
|
||||
control.textContent = vhostListPrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(vhostListPrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
function applyPreferences(preferences) {
|
||||
poolTablePrefs = normalizePoolTablePrefs(preferences);
|
||||
vhostListPrefs = normalizeVhostListPrefs(preferences);
|
||||
const poolSort = $("pool-sort"); if (poolSort) poolSort.value = poolTablePrefs.sort;
|
||||
const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort;
|
||||
syncPoolColumns(); syncPoolSortDirection(); syncVhostSortDirection(); applyPoolTableView(); render();
|
||||
}
|
||||
async function saveVhostListPrefs() {
|
||||
if (!authenticated) return;
|
||||
try {
|
||||
const saved = await window.rustProxyPreferences.update({ vhost_list_sort: vhostListPrefs.sort, vhost_list_descending: vhostListPrefs.descending });
|
||||
vhostListPrefs = normalizeVhostListPrefs(saved); const sort = $("vhost-sort"); if (sort) sort.value = vhostListPrefs.sort; syncVhostSortDirection();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
}
|
||||
async function savePoolTablePrefs() {
|
||||
if (!authenticated) return;
|
||||
poolPreferenceSaveQueued = true;
|
||||
if (poolPreferenceSaveRunning) return;
|
||||
poolPreferenceSaveRunning = true;
|
||||
try {
|
||||
while (poolPreferenceSaveQueued && authenticated) {
|
||||
poolPreferenceSaveQueued = false;
|
||||
const saved = await window.rustProxyPreferences.update({
|
||||
backend_table_columns: poolColumns.filter(key => poolTablePrefs.columns.includes(key)),
|
||||
backend_table_sort: poolTablePrefs.sort,
|
||||
backend_table_descending: poolTablePrefs.descending,
|
||||
});
|
||||
if (!authenticated) return;
|
||||
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
|
||||
}
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
finally { poolPreferenceSaveRunning = false; }
|
||||
}
|
||||
function syncPoolColumns() {
|
||||
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
|
||||
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
|
||||
const defaultVhostListPrefs = () => ({ sort: "id", descending: false });
|
||||
let poolTablePrefs = defaultPoolTablePrefs(), vhostListPrefs = defaultVhostListPrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
|
||||
function normalizePoolTablePrefs(preferences) {
|
||||
const selected = Array.isArray(preferences?.backend_table_columns) ? preferences.backend_table_columns.filter(key => poolColumns.includes(key)) : [];
|
||||
return {
|
||||
sort: poolColumns.includes(preferences?.backend_table_sort) ? preferences.backend_table_sort : "vhost",
|
||||
descending: preferences?.backend_table_descending === true,
|
||||
columns: selected.length ? selected : [...poolColumns],
|
||||
};
|
||||
}
|
||||
function normalizeVhostListPrefs(preferences) {
|
||||
return { sort: vhostSortColumns.includes(preferences?.vhost_list_sort) ? preferences.vhost_list_sort : "id", descending: preferences?.vhost_list_descending === true };
|
||||
}
|
||||
function syncVhostSortDirection() {
|
||||
const control = $("vhost-sort-direction"); if (!control) return;
|
||||
control.textContent = vhostListPrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(vhostListPrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
function applyPreferences(preferences) {
|
||||
poolTablePrefs = normalizePoolTablePrefs(preferences);
|
||||
vhostListPrefs = normalizeVhostListPrefs(preferences);
|
||||
const poolSort = $("pool-sort"); if (poolSort) poolSort.value = poolTablePrefs.sort;
|
||||
const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort;
|
||||
syncPoolColumns(); syncPoolSortDirection(); syncVhostSortDirection(); applyPoolTableView(); render();
|
||||
}
|
||||
async function saveVhostListPrefs() {
|
||||
if (!authenticated) return;
|
||||
try {
|
||||
const saved = await window.rustProxyPreferences.update({ vhost_list_sort: vhostListPrefs.sort, vhost_list_descending: vhostListPrefs.descending });
|
||||
vhostListPrefs = normalizeVhostListPrefs(saved); const sort = $("vhost-sort"); if (sort) sort.value = vhostListPrefs.sort; syncVhostSortDirection();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
}
|
||||
async function savePoolTablePrefs() {
|
||||
if (!authenticated) return;
|
||||
poolPreferenceSaveQueued = true;
|
||||
if (poolPreferenceSaveRunning) return;
|
||||
poolPreferenceSaveRunning = true;
|
||||
try {
|
||||
while (poolPreferenceSaveQueued && authenticated) {
|
||||
poolPreferenceSaveQueued = false;
|
||||
const saved = await window.rustProxyPreferences.update({
|
||||
backend_table_columns: poolColumns.filter(key => poolTablePrefs.columns.includes(key)),
|
||||
backend_table_sort: poolTablePrefs.sort,
|
||||
backend_table_descending: poolTablePrefs.descending,
|
||||
});
|
||||
if (!authenticated) return;
|
||||
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
|
||||
}
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
finally { poolPreferenceSaveRunning = false; }
|
||||
}
|
||||
function syncPoolColumns() {
|
||||
const visible = new Set(poolTablePrefs.columns);
|
||||
document.querySelectorAll("[data-pool-column]").forEach(node => { node.hidden = !visible.has(node.dataset.poolColumn); });
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
|
||||
}
|
||||
function syncPoolSortDirection() {
|
||||
const control = $("pool-sort-direction"); if (!control) return;
|
||||
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
function applyPoolTableView() {
|
||||
const body = $("pool-rows"); if (!body) return;
|
||||
const query = ($("pool-search")?.value || "").trim().toLowerCase();
|
||||
const key = $("pool-sort")?.value || poolTablePrefs.sort;
|
||||
const rows = [...body.querySelectorAll("tr")];
|
||||
rows.sort((left, right) => {
|
||||
const a = left.poolSort?.[key] ?? "", b = right.poolSort?.[key] ?? "";
|
||||
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return poolTablePrefs.descending ? -result : result;
|
||||
});
|
||||
body.replaceChildren(...rows);
|
||||
let visible = 0;
|
||||
for (const row of rows) { const match = !query || (row.poolSearchValue || "").includes(query); row.hidden = !match; if (match) visible++; }
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
const empty = $("pool-empty");
|
||||
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
|
||||
}
|
||||
function initPoolTableTools() {
|
||||
const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search");
|
||||
if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); }
|
||||
direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||
search?.addEventListener("input", applyPoolTableView);
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
|
||||
const visible = new Set(poolTablePrefs.columns);
|
||||
document.querySelectorAll("[data-pool-column]").forEach(node => { node.hidden = !visible.has(node.dataset.poolColumn); });
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
|
||||
}
|
||||
function syncPoolSortDirection() {
|
||||
const control = $("pool-sort-direction"); if (!control) return;
|
||||
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
function applyPoolTableView() {
|
||||
const body = $("pool-rows"); if (!body) return;
|
||||
const query = ($("pool-search")?.value || "").trim().toLowerCase();
|
||||
const key = $("pool-sort")?.value || poolTablePrefs.sort;
|
||||
const rows = [...body.querySelectorAll("tr")];
|
||||
rows.sort((left, right) => {
|
||||
const a = left.poolSort?.[key] ?? "", b = right.poolSort?.[key] ?? "";
|
||||
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return poolTablePrefs.descending ? -result : result;
|
||||
});
|
||||
body.replaceChildren(...rows);
|
||||
let visible = 0;
|
||||
for (const row of rows) { const match = !query || (row.poolSearchValue || "").includes(query); row.hidden = !match; if (match) visible++; }
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
const empty = $("pool-empty");
|
||||
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
|
||||
}
|
||||
function initPoolTableTools() {
|
||||
const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search");
|
||||
if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); }
|
||||
direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||
search?.addEventListener("input", applyPoolTableView);
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
|
||||
const visible = new Set(poolTablePrefs.columns);
|
||||
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
|
||||
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
|
||||
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns();
|
||||
}));
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
}
|
||||
async function toggleVhostEnabled(vhost) {
|
||||
if (!envelope || busy) return;
|
||||
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
|
||||
if (!target) return;
|
||||
target.enabled = vhost.enabled === false;
|
||||
if (await saveSection("vhosts", next.vhosts || [])) render();
|
||||
}
|
||||
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
|
||||
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
|
||||
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns();
|
||||
}));
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
}
|
||||
async function toggleVhostEnabled(vhost) {
|
||||
if (!envelope || busy) return;
|
||||
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
|
||||
if (!target) return;
|
||||
target.enabled = vhost.enabled === false;
|
||||
if (await saveSection("vhosts", next.vhosts || [])) render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
if (!initialized) load(defaults(), [], false);
|
||||
const config = envelope.config;
|
||||
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
const diagnosticsCard = $("backend-diagnostics-card");
|
||||
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
|
||||
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
||||
const visibleVhosts = (config.vhosts || []).filter(v => {
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
v.id,
|
||||
...(v.frontend?.hosts || []),
|
||||
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
|
||||
v.source?.web_service_id,
|
||||
...(v.routes || []).map(route => route.source?.web_service_id),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
const sortValue = v => ({
|
||||
id: String(v.id || "").toLowerCase(),
|
||||
hostname: String(v.frontend?.hosts?.[0] || "").toLowerCase(),
|
||||
status: v.enabled === false ? 1 : 0,
|
||||
listeners: (v.frontend?.listener_ids || []).join(" ").toLowerCase(),
|
||||
certificate: String(v.certificate_id || "").toLowerCase(),
|
||||
backends: Number(v.backends?.length || 0),
|
||||
}[vhostListPrefs.sort]);
|
||||
visibleVhosts.sort((left, right) => {
|
||||
const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return vhostListPrefs.descending ? -result : result;
|
||||
});
|
||||
const cards = visibleVhosts.map(v => {
|
||||
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
|
||||
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
|
||||
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
|
||||
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
|
||||
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
|
||||
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
|
||||
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
|
||||
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
|
||||
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
|
||||
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
|
||||
openButton.disabled = !browseHost || v.enabled === false;
|
||||
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
|
||||
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
|
||||
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
||||
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
||||
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
|
||||
|
||||
const details = el("div", undefined, "vhost-detail-strip");
|
||||
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
|
||||
config.listeners?.endpoints?.find(e => e.id === id) || { id }
|
||||
);
|
||||
const listeners = selectedEndpoints.map(endpoint => {
|
||||
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
|
||||
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
|
||||
});
|
||||
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
|
||||
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
|
||||
const protocols = [
|
||||
"HTTP/1.1",
|
||||
v.http2_enabled !== false && supportsH2 ? "H2" : null,
|
||||
v.http3_enabled !== false && supportsH3 ? "H3" : null,
|
||||
].filter(Boolean).join(" + ");
|
||||
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
|
||||
const detailItems = [
|
||||
["vhListeners", listeners.join(" | ") || "-"],
|
||||
["vhProtocols", protocols],
|
||||
["tlsPolicy", tlsPolicy],
|
||||
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
|
||||
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
|
||||
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
|
||||
["vhBackendPool", v.source?.type === "web_server"
|
||||
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
|
||||
? `${t("webServer")} + ${(v.backends || []).length}`
|
||||
: t("webServer"))
|
||||
: `${(v.backends || []).length}`],
|
||||
];
|
||||
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
box.append(details);
|
||||
|
||||
const live = el("div", undefined, "vhost-live-grid");
|
||||
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
|
||||
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
|
||||
box.append(live);
|
||||
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
|
||||
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (hasProxySource) {
|
||||
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
|
||||
}
|
||||
const policy = el("div", undefined, "vhost-policy-pills");
|
||||
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
|
||||
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
|
||||
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
|
||||
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
|
||||
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
|
||||
if (v.www_redirect) {
|
||||
const destination = v.www_redirect_target || (v.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
|
||||
policy.append(el("small", `WWW 308 \u2192 ${destination}`, "pill"));
|
||||
}
|
||||
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
|
||||
if (v.websockets) {
|
||||
const transports = ["H1"];
|
||||
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
|
||||
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
|
||||
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
|
||||
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
|
||||
}
|
||||
if (policy.children.length) box.append(policy);
|
||||
return box;
|
||||
});
|
||||
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
|
||||
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
// SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it.
|
||||
// Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero.
|
||||
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
||||
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
|
||||
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
||||
const previous = backendTrafficHistory.get(key);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
const rate = reported > 0 ? reported : observed;
|
||||
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||
seen.add(id);
|
||||
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
||||
const previous = vhostTrafficHistory.get(id);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function renderStatus(status) {
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
||||
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
||||
for (const pool of status.backend_pools || []) {
|
||||
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
|
||||
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
|
||||
for (const member of members) {
|
||||
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
|
||||
const label = vhostDisabled ? t("vhDisabled") : (member.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
|
||||
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
const generationAgeRaw = member.upstream_client_generation_age_seconds;
|
||||
const generationAge = Math.max(0, Number(generationAgeRaw));
|
||||
const generationAgeText = generationAgeRaw == null || !Number.isFinite(generationAge) ? "-" : generationAge >= 3600 ? `${Math.floor(generationAge / 3600)}h ${Math.floor((generationAge % 3600) / 60)}m` : generationAge >= 60 ? `${Math.floor(generationAge / 60)}m ${Math.floor(generationAge % 60)}s` : `${Math.floor(generationAge)}s`;
|
||||
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery];
|
||||
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
|
||||
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||
}
|
||||
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
|
||||
summaries.set(pool.id, summary || t("vhHealthEmpty"));
|
||||
aggregate.set(pool.id, {
|
||||
disabled: vhostDisabled,
|
||||
healthMode: pool.health_mode,
|
||||
up,
|
||||
total: members.length,
|
||||
active,
|
||||
requests,
|
||||
errors,
|
||||
traffic,
|
||||
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
|
||||
});
|
||||
}
|
||||
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
|
||||
for (const [id, vhost] of configuredVhosts) {
|
||||
const existing = aggregate.get(id) || {};
|
||||
const metrics = status.vhost_metrics?.[id];
|
||||
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
const disabled = vhost.enabled === false;
|
||||
aggregate.set(id, {
|
||||
...existing,
|
||||
disabled,
|
||||
staticOnly: !hasProxySource,
|
||||
healthMode: existing.healthMode,
|
||||
up: existing.up ?? 0,
|
||||
total: existing.total ?? 0,
|
||||
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
|
||||
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
|
||||
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
|
||||
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
|
||||
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
|
||||
});
|
||||
}
|
||||
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
|
||||
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
$("pool-rows").replaceChildren(...rows);
|
||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
||||
applyPoolTableView();
|
||||
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
||||
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
||||
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
|
||||
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
|
||||
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
|
||||
const now = performance.now();
|
||||
for (const [id, data] of aggregate) {
|
||||
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
|
||||
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
|
||||
history.pendingSum += rate; history.pendingCount += 1;
|
||||
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
|
||||
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; }
|
||||
chartHistory.set(id, history);
|
||||
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
|
||||
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
|
||||
}
|
||||
const editingVhost = editing ? configuredVhosts.get(editing) : null;
|
||||
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
|
||||
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
|
||||
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
|
||||
}
|
||||
form.addEventListener("input", changed); form.addEventListener("change", changed);
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy || uploading) return;
|
||||
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) {
|
||||
const healthPanel = invalid.closest(".vh-backend-health-panel");
|
||||
if (healthPanel) backendHealthEditorControl?.reveal([...form.querySelector(".vh-members").children].find(row => row.memberHealthPanel === healthPanel));
|
||||
activateTab(invalid.closest(".vh-group")?.dataset.tab || "details");
|
||||
const details = invalid.closest("details"); if (details) details.open = true;
|
||||
invalid.reportValidity(); return;
|
||||
}
|
||||
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
|
||||
try {
|
||||
const epoch = draftEpoch; uploading = true;
|
||||
try { await basicEditor.prepare(); } finally { uploading = false; }
|
||||
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
|
||||
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
|
||||
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
|
||||
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
|
||||
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
|
||||
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
|
||||
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
|
||||
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
|
||||
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
|
||||
if (saved) { load(value, [], false); dialog.close(); render(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
if (!initialized) load(defaults(), [], false);
|
||||
const config = envelope.config;
|
||||
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
const diagnosticsCard = $("backend-diagnostics-card");
|
||||
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
|
||||
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
||||
const visibleVhosts = (config.vhosts || []).filter(v => {
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
v.id,
|
||||
...(v.frontend?.hosts || []),
|
||||
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
|
||||
v.source?.web_service_id,
|
||||
...(v.routes || []).map(route => route.source?.web_service_id),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
$("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
|
||||
$("vhost-search").addEventListener("input", render);
|
||||
$("vhost-sort")?.addEventListener("change", event => { vhostListPrefs.sort = event.target.value; void saveVhostListPrefs(); render(); });
|
||||
$("vhost-sort-direction")?.addEventListener("click", () => { vhostListPrefs.descending = !vhostListPrefs.descending; syncVhostSortDirection(); void saveVhostListPrefs(); render(); });
|
||||
$("vhost-reset").addEventListener("click", async () => {
|
||||
if (!(await allowDiscard())) return;
|
||||
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
|
||||
load(saved ? structuredClone(saved) : defaults());
|
||||
const sortValue = v => ({
|
||||
id: String(v.id || "").toLowerCase(),
|
||||
hostname: String(v.frontend?.hosts?.[0] || "").toLowerCase(),
|
||||
status: v.enabled === false ? 1 : 0,
|
||||
listeners: (v.frontend?.listener_ids || []).join(" ").toLowerCase(),
|
||||
certificate: String(v.certificate_id || "").toLowerCase(),
|
||||
backends: Number(v.backends?.length || 0),
|
||||
}[vhostListPrefs.sort]);
|
||||
visibleVhosts.sort((left, right) => {
|
||||
const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return vhostListPrefs.descending ? -result : result;
|
||||
});
|
||||
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
initPoolTableTools();
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
const cards = visibleVhosts.map(v => {
|
||||
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
|
||||
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
|
||||
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
|
||||
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
|
||||
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
|
||||
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
|
||||
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
|
||||
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
|
||||
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
|
||||
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
|
||||
openButton.disabled = !browseHost || v.enabled === false;
|
||||
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
|
||||
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
|
||||
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
||||
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
||||
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
|
||||
|
||||
const details = el("div", undefined, "vhost-detail-strip");
|
||||
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
|
||||
config.listeners?.endpoints?.find(e => e.id === id) || { id }
|
||||
);
|
||||
const listeners = selectedEndpoints.map(endpoint => {
|
||||
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
|
||||
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
|
||||
});
|
||||
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
|
||||
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
|
||||
const protocols = [
|
||||
"HTTP/1.1",
|
||||
v.http2_enabled !== false && supportsH2 ? "H2" : null,
|
||||
v.http3_enabled !== false && supportsH3 ? "H3" : null,
|
||||
].filter(Boolean).join(" + ");
|
||||
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
|
||||
const detailItems = [
|
||||
["vhListeners", listeners.join(" | ") || "-"],
|
||||
["vhProtocols", protocols],
|
||||
["tlsPolicy", tlsPolicy],
|
||||
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
|
||||
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
|
||||
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
|
||||
["vhBackendPool", v.source?.type === "web_server"
|
||||
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
|
||||
? `${t("webServer")} + ${(v.backends || []).length}`
|
||||
: t("webServer"))
|
||||
: `${(v.backends || []).length}`],
|
||||
];
|
||||
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
box.append(details);
|
||||
|
||||
const live = el("div", undefined, "vhost-live-grid");
|
||||
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
|
||||
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
|
||||
box.append(live);
|
||||
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
|
||||
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (hasProxySource) {
|
||||
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
|
||||
}
|
||||
const policy = el("div", undefined, "vhost-policy-pills");
|
||||
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
|
||||
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
|
||||
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
|
||||
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
|
||||
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
|
||||
if (v.www_redirect) {
|
||||
const destination = v.www_redirect_target || (v.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
|
||||
policy.append(el("small", `WWW 308 \u2192 ${destination}`, "pill"));
|
||||
}
|
||||
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
|
||||
if (v.websockets) {
|
||||
const transports = ["H1"];
|
||||
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
|
||||
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
|
||||
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
|
||||
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
|
||||
}
|
||||
if (policy.children.length) box.append(policy);
|
||||
return box;
|
||||
});
|
||||
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
|
||||
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
||||
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
|
||||
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
||||
const previous = backendTrafficHistory.get(key);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
const rate = reported > 0 ? reported : observed;
|
||||
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||
seen.add(id);
|
||||
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
||||
const previous = vhostTrafficHistory.get(id);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function renderStatus(status) {
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
||||
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
||||
for (const pool of status.backend_pools || []) {
|
||||
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
|
||||
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
|
||||
for (const member of members) {
|
||||
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
|
||||
const label = vhostDisabled ? t("vhDisabled") : (member.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
|
||||
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
const generationAgeRaw = member.upstream_client_generation_age_seconds;
|
||||
const generationAge = Math.max(0, Number(generationAgeRaw));
|
||||
const generationAgeText = generationAgeRaw == null || !Number.isFinite(generationAge) ? "-" : generationAge >= 3600 ? `${Math.floor(generationAge / 3600)}h ${Math.floor((generationAge % 3600) / 60)}m` : generationAge >= 60 ? `${Math.floor(generationAge / 60)}m ${Math.floor(generationAge % 60)}s` : `${Math.floor(generationAge)}s`;
|
||||
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery];
|
||||
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
|
||||
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||
}
|
||||
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
|
||||
summaries.set(pool.id, summary || t("vhHealthEmpty"));
|
||||
aggregate.set(pool.id, {
|
||||
disabled: vhostDisabled,
|
||||
healthMode: pool.health_mode,
|
||||
up,
|
||||
total: members.length,
|
||||
active,
|
||||
requests,
|
||||
errors,
|
||||
traffic,
|
||||
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
|
||||
});
|
||||
}
|
||||
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
|
||||
for (const [id, vhost] of configuredVhosts) {
|
||||
const existing = aggregate.get(id) || {};
|
||||
const metrics = status.vhost_metrics?.[id];
|
||||
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
const disabled = vhost.enabled === false;
|
||||
aggregate.set(id, {
|
||||
...existing,
|
||||
disabled,
|
||||
staticOnly: !hasProxySource,
|
||||
healthMode: existing.healthMode,
|
||||
up: existing.up ?? 0,
|
||||
total: existing.total ?? 0,
|
||||
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
|
||||
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
|
||||
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
|
||||
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
|
||||
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
|
||||
});
|
||||
}
|
||||
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
|
||||
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
$("pool-rows").replaceChildren(...rows);
|
||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
||||
applyPoolTableView();
|
||||
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
||||
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
||||
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
|
||||
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
|
||||
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
|
||||
const now = performance.now();
|
||||
for (const [id, data] of aggregate) {
|
||||
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
|
||||
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
|
||||
history.pendingSum += rate; history.pendingCount += 1;
|
||||
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
|
||||
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; }
|
||||
chartHistory.set(id, history);
|
||||
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
|
||||
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
|
||||
}
|
||||
const editingVhost = editing ? configuredVhosts.get(editing) : null;
|
||||
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
|
||||
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
|
||||
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
|
||||
}
|
||||
form.addEventListener("input", changed); form.addEventListener("change", changed);
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy || uploading) return;
|
||||
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) {
|
||||
const healthPanel = invalid.closest(".vh-backend-health-panel");
|
||||
if (healthPanel) backendHealthEditorControl?.reveal([...form.querySelector(".vh-members").children].find(row => row.memberHealthPanel === healthPanel));
|
||||
activateTab(invalid.closest(".vh-group")?.dataset.tab || "details");
|
||||
const details = invalid.closest("details"); if (details) details.open = true;
|
||||
invalid.reportValidity(); return;
|
||||
}
|
||||
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
|
||||
try {
|
||||
const epoch = draftEpoch; uploading = true;
|
||||
try { await basicEditor.prepare(); } finally { uploading = false; }
|
||||
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
|
||||
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
|
||||
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
|
||||
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
|
||||
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
|
||||
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
|
||||
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
|
||||
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
|
||||
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
|
||||
if (saved) { load(value, [], false); dialog.close(); render(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
$("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
|
||||
$("vhost-search").addEventListener("input", render);
|
||||
$("vhost-sort")?.addEventListener("change", event => { vhostListPrefs.sort = event.target.value; void saveVhostListPrefs(); render(); });
|
||||
$("vhost-sort-direction")?.addEventListener("click", () => { vhostListPrefs.descending = !vhostListPrefs.descending; syncVhostSortDirection(); void saveVhostListPrefs(); render(); });
|
||||
$("vhost-reset").addEventListener("click", async () => {
|
||||
if (!(await allowDiscard())) return;
|
||||
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
|
||||
load(saved ? structuredClone(saved) : defaults());
|
||||
});
|
||||
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
initPoolTableTools();
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
@@ -1,6 +1,4 @@
|
||||
"use strict";
|
||||
// Shared profile editor is independent of the Web Service editor. Values are
|
||||
// never rendered as HTML and saves use the revisioned configuration API.
|
||||
window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams, parsePhpValues, validatePhpCustomData }) => {
|
||||
function refreshProfileSelect(chosen = $("web-service-php-profile").value) {
|
||||
const select = $("web-service-php-profile");
|
||||
@@ -12,8 +10,6 @@ window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams,
|
||||
select.value = chosen;
|
||||
}
|
||||
|
||||
// Use exactly the same validators and FastCGI field semantics for local and
|
||||
// shared configuration. None of the profile values are interpolated as HTML.
|
||||
const profileFields = [
|
||||
["enabled", "webServicePhpEnabled", "checkbox"],
|
||||
["endpoint", "webServicePhpEndpoint", "text"],
|
||||
@@ -109,9 +105,9 @@ window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams,
|
||||
for (const input of Object.values(controls)) if (!input.checkValidity()) throw new Error(t("phpProfileInvalid"));
|
||||
const php = Object.fromEntries(profileFields.map(([key, , kind]) => [key,
|
||||
kind === "checkbox" ? controls[key].checked : kind === "number" ? Number(controls[key].value) :
|
||||
key === "extensions" ? lines(controls[key].value).map(item => item.replace(/^\./, "").toLowerCase()) :
|
||||
key === "custom_params" ? parseFastCgiParams(controls[key].value) :
|
||||
key === "php_values" || key === "php_admin_values" ? parsePhpValues(controls[key].value) : controls[key].value.trim()
|
||||
key === "extensions" ? lines(controls[key].value).map(item => item.replace(/^\./, "").toLowerCase()) :
|
||||
key === "custom_params" ? parseFastCgiParams(controls[key].value) :
|
||||
key === "php_values" || key === "php_admin_values" ? parsePhpValues(controls[key].value) : controls[key].value.trim()
|
||||
]));
|
||||
validatePhpCustomData(php.custom_params, php.php_values, php.php_admin_values);
|
||||
const next = structuredClone(envelope.config.php_fpm_profiles || []);
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
}
|
||||
|
||||
function validatePhpCustomData(customParams, phpValues, phpAdminValues = {}) {
|
||||
// Never allow an admin override to conflict with ordinary PHP_VALUE.
|
||||
if (Object.keys(phpValues).some(name => Object.hasOwn(phpAdminValues, name))) throw new Error(t("webServicePhpValuesInvalid"));
|
||||
const size = [...Object.entries(customParams), ...Object.entries(phpValues), ...Object.entries(phpAdminValues)]
|
||||
.reduce((total, [name, value]) => total + name.length + value.length + 2, 0);
|
||||
@@ -174,7 +173,7 @@
|
||||
function syncCompression() {
|
||||
const disabled = $("web-service-compression").value === "off";
|
||||
$("web-service-compression-options").hidden = disabled;
|
||||
for (const name of ["zstd", "br", "gzip", "deflate"]) $( `web-service-compression-${name}` ).disabled = disabled;
|
||||
for (const name of ["zstd", "br", "gzip", "deflate"]) $(`web-service-compression-${name}`).disabled = disabled;
|
||||
}
|
||||
|
||||
function syncPrecompressed() {
|
||||
@@ -245,7 +244,7 @@
|
||||
$("web-service-cache-rules").value = cacheLines(item.cache_rules);
|
||||
$("web-service-response-headers").value = mapLines(item.response_headers);
|
||||
$("web-service-compression").value = item.compression?.enabled === true ? "on" : item.compression?.enabled === false ? "off" : "";
|
||||
for (const name of ["zstd", "br", "gzip", "deflate"]) $( `web-service-compression-${name}` ).value = item.compression?.[name] === true ? "on" : item.compression?.[name] === false ? "off" : "";
|
||||
for (const name of ["zstd", "br", "gzip", "deflate"]) $(`web-service-compression-${name}`).value = item.compression?.[name] === true ? "on" : item.compression?.[name] === false ? "off" : "";
|
||||
$("web-service-precompressed").checked = item.precompressed?.enabled === true;
|
||||
$("web-service-precompressed-br").checked = item.precompressed?.brotli !== false;
|
||||
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
|
||||
|
||||
@@ -7,7 +7,7 @@ import tomllib
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
read = lambda path: (root / path).read_text(encoding='utf-8')
|
||||
version = tomllib.loads(read('Cargo.toml'))['package']['version']
|
||||
assert version == '0.32.0', version
|
||||
assert version == '0.32.1', version
|
||||
spec = json.loads(read('src/admin/openapi.json'))
|
||||
assert spec['info']['version'] == version
|
||||
paths = spec['paths']
|
||||
@@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'):
|
||||
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
|
||||
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
|
||||
assert strings.get(key), (language, key)
|
||||
assert '0.32.0' in read('docs/TEST_REPORT.md')
|
||||
assert '0.32.1' in read('docs/TEST_REPORT.md')
|
||||
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
|
||||
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
|
||||
|
||||
preflight(root)
|
||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
assert manifest["package"]["version"] == "0.32.0"
|
||||
assert manifest["package"]["version"] == "0.32.1"
|
||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||
h3dep = manifest["dependencies"]["h3"]
|
||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"]
|
||||
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
||||
lock_path = root / "Cargo.lock"
|
||||
if lock_path.exists():
|
||||
lock = lock_path.read_text()
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.0"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.1"' in lock
|
||||
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
||||
for release_doc, marker in {
|
||||
"README.md": "# RustProxy 0.32.0",
|
||||
"CHANGELOG.md": "## 0.32.0",
|
||||
"ROADMAP.md": "## 0.32.0",
|
||||
"AGENTS.md": "Current source version: 0.32.0.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.0",
|
||||
"README.md": "# RustProxy 0.32.1",
|
||||
"CHANGELOG.md": "## 0.32.1",
|
||||
"ROADMAP.md": "## 0.32.1",
|
||||
"AGENTS.md": "Current source version: 0.32.1.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.1",
|
||||
}.items():
|
||||
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
||||
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
||||
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
|
||||
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
||||
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
||||
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
||||
for marker in ['"version": "0.32.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
for marker in ['"version": "0.32.1"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
assert marker in openapi, marker
|
||||
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
@@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
|
||||
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
||||
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
||||
assert 'location.pathname === "/logging"' in settings_js_0241
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.32.0"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.32.1"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = rust_source(root, "src/tls.rs")
|
||||
|
||||
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
|
||||
args = parser.parse_args()
|
||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
||||
assert manifest['package']['version'] == '0.32.0'
|
||||
assert manifest['package']['version'] == '0.32.1'
|
||||
assert manifest['package']['rust-version'] == '1.99'
|
||||
deps = manifest['dependencies']
|
||||
expected_requirements = {
|
||||
|
||||
@@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert readme.startswith("# RustProxy 0.32.0")
|
||||
assert readme.startswith("# RustProxy 0.32.1")
|
||||
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
||||
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
||||
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
||||
|
||||
@@ -54,10 +54,10 @@ assert.match(logs, /cell\.dataset\.mobileLabel = label/);
|
||||
assert.match(logs, /if \(selectedIndex === index\) nodes\.push\(detailRow\(entry\)\)/);
|
||||
assert.match(css, /@media \(max-width: 700px\)/);
|
||||
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\) \{\s*display: grid/);
|
||||
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\) > td \{\s*display: block/);
|
||||
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\)\s*>\s*td \{\s*display: block/);
|
||||
assert.match(css, /overflow-wrap: anywhere/);
|
||||
assert.match(css, /#panel-filelogs \.file-logs-table,\s*#panel-filelogs \.file-log-aggregate-table \{[^}]*overflow-x: auto/s);
|
||||
assert.match(css, /#panel-filelogs \.file-log-inline-detail pre \{ max-width: 100%; overflow-x: auto; \}/);
|
||||
assert.match(css, /#panel-filelogs \.file-log-inline-detail pre \{[^}]*max-width:\s*100%;[^}]*overflow-x:\s*auto;[^}]*\}/s);
|
||||
|
||||
// Exercise the actual browser code's entry-summary function with a logged
|
||||
// H3 cancellation and a normal access event; this is a JS unit simulation.
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
html = (ROOT / 'src/admin/web/index.html').read_text()
|
||||
assert '0.32.0' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '0.32.1' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
|
||||
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
|
||||
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// 0.32.1 regression: restore pre-0.32.0 content-driven tile heights and
|
||||
// align inter-tile gaps with the same spacing as other Dashboard sections.
|
||||
// These are source/CSS contracts, not a browser or authenticated-UI test.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
||||
|
||||
function rule(selector) {
|
||||
// Target precise blocks rather than accepting an unrelated matching rule.
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = css.match(new RegExp(`${escaped}\\s*\\{([^{}]*)\\}`));
|
||||
assert(match, `Missing CSS rule: ${selector}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
for (const density of ['normal', 'compact', 'ultra']) {
|
||||
const declarations = rule(`html[data-dashboard-density="${density}"] #panel-overview .stats-grid`);
|
||||
assert.match(declarations, /gap:\s*var\(--ui-gap\)\s*;/,
|
||||
`${density} tiles must use the same density-aware gap as Dashboard sections`);
|
||||
}
|
||||
const sharedGap = rule('.overview-grid,\n.diagnostics-grid,\n.settings-grid,\n.config-columns');
|
||||
assert.match(sharedGap, /gap:\s*var\(--ui-gap\)/);
|
||||
for (const density of ['dense', 'comfortable', 'compact']) {
|
||||
assert.match(css, new RegExp(`body\\[data-density="${density}"\\]\\s*\\{[^}]*--ui-gap:`),
|
||||
`UI density ${density} must provide the shared gap variable`);
|
||||
}
|
||||
assert.match(css, /@media \(min-width: 1201px\)\s*\{\s*#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/,
|
||||
'six desktop columns are preserved');
|
||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
|
||||
'desktop cards should grow naturally to their legend/content height');
|
||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
|
||||
'card descriptions must remain visible, not hover-only');
|
||||
|
||||
const heading = rule('html[data-dashboard-density="compact"] #panel-overview .stat>span');
|
||||
const metric = rule('html[data-dashboard-density="compact"] #panel-overview .stat strong');
|
||||
const caption = rule('html[data-dashboard-density="compact"] #panel-overview .stat small');
|
||||
const payload = rule('html[data-dashboard-density="compact"] #panel-overview .payload-split');
|
||||
assert.match(heading, /font-size:\s*11px/);
|
||||
assert.match(metric, /font-size:\s*25px/);
|
||||
assert.match(caption, /font-size:\s*10px/);
|
||||
assert.match(payload, /font-size:\s*10px\s*!important/);
|
||||
assert.match(css, /#panel-overview \.stats-grid \.stat-chart>\.stat-chart-legend\s*\{[^}]*flex-wrap:\s*wrap/s);
|
||||
|
||||
const overview = html.slice(html.indexOf('id="panel-overview"'), html.indexOf('class="overview-grid"'));
|
||||
assert.equal([...overview.matchAll(/data-dashboard-widget="/g)].length, 16,
|
||||
'no metric/Stream cards may be removed');
|
||||
assert.match(overview, /id="metric-bytes-split"/);
|
||||
assert.match(overview, /id="http-protocol-chart"/);
|
||||
assert.match(overview, /id="ws-protocol-chart"/);
|
||||
console.log('PASS: 0.32.1 Overview six-column content height, 90% compact typography, shared tile spacing and inline legends.');
|
||||
@@ -8,9 +8,9 @@ from html.parser import HTMLParser
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.0'
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.0"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.0'
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.1'
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.1"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.1'
|
||||
|
||||
pages = read('src/pages.rs')
|
||||
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
|
||||
|
||||
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert backup.is_dir()
|
||||
assert (backup / "src/obsolete.rs").is_file()
|
||||
assert not (target / "src/obsolete.rs").exists()
|
||||
assert 'version = "0.32.0"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.32.1"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
||||
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
||||
assert (target / "langs/site.json").is_file()
|
||||
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert (target / "docker-agent/certificate.go").is_file()
|
||||
assert (target / "docker-agent/server.go").is_file()
|
||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||
assert manifest["to_version"] == "0.32.0"
|
||||
assert manifest["to_version"] == "0.32.1"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -617,7 +617,7 @@ def source_contract_smoke() -> None:
|
||||
assert marker in admin, marker
|
||||
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
||||
assert '"/api/status"' not in anonymous_block
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.0"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.1"' in openapi_0241
|
||||
assert 'id="panel-logging"' not in html
|
||||
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
||||
assert marker in html, marker
|
||||
@@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None:
|
||||
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
||||
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
||||
assert '"/api/config/history/restore"' in openapi
|
||||
for marker in ['"version": "0.32.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
for marker in ['"version": "0.32.1"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
assert '"software_update"' in openapi and '"update_available"' in openapi
|
||||
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
||||
|
||||
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
|
||||
const en = json('langs/en_US.json').translations;
|
||||
const pl = json('langs/pl_pl.json').translations;
|
||||
|
||||
assert.match(manifest, /version = "0\.32\.0"/);
|
||||
assert.match(manifest, /version = "0\.32\.1"/);
|
||||
assert.match(lib, /pub mod user_agent;/);
|
||||
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
@@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
||||
assert.match(fileLogs, /aggregate_min_count/);
|
||||
assert.match(fileLogs, /file-log-aggregate-copy/);
|
||||
|
||||
assert.equal(openapi.info.version, '0.32.0');
|
||||
assert.equal(openapi.info.version, '0.32.1');
|
||||
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
|
||||
|
||||
@@ -13,14 +13,14 @@ assert(html.includes('id="panel-overview"'));
|
||||
for (const id of ids) assert(html.includes(`data-dashboard-widget="${id}"`));
|
||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
|
||||
'Overview details must never become hover/focus popovers');
|
||||
assert.match(css, /#panel-overview \.stats-grid \.stat-chart > \.stat-chart-legend/,
|
||||
assert.match(css, /#panel-overview \.stats-grid \.stat-chart\s*>\s*\.stat-chart-legend/,
|
||||
'in-card chart legends must be visible, including on touch devices');
|
||||
assert.match(css, /#panel-overview \.stats-grid \{ grid-template-columns: repeat\(6, minmax\(0, 1fr\)\); \}/,
|
||||
assert.match(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);\s*\}/,
|
||||
'desktop Overview must retain the original six cards per row');
|
||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid \{ grid-template-columns: repeat\(4, minmax\(0, 1fr\)\); \}/,
|
||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\);\s*\}/,
|
||||
'do not replace the original six-column Overview with a four-column layout');
|
||||
assert.match(css, /#panel-overview \.stats-grid > \.stat \{ min-height: 126px;/,
|
||||
'cards should be tall enough for their inline metric legends');
|
||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
|
||||
'0.32.0 fixed height produced empty tiles; let inline metric legends size the row');
|
||||
assert.match(html, /id="stream-dashboard-traffic-detail"/);
|
||||
assert.match(html, /id="stream-dashboard-rx-rate"/);
|
||||
assert.match(html, /id="stream-dashboard-tx-rate"/);
|
||||
@@ -95,9 +95,38 @@ assert.equal(elements.get('stream-dashboard-overview-detail').textContent, 'stre
|
||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ids });
|
||||
assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work');
|
||||
assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
|
||||
assert.match(css, /dashboard-widget-setting:is\(:hover, :focus-within\) \.dashboard-widget-description/, 'description is a tooltip');
|
||||
assert.match(css, /\.dashboard-widget-description \{[^}]*position: absolute/s, 'tooltip must not occupy a second row');
|
||||
assert.match(prefsSource, /toggle\.setAttribute\("aria-describedby", description\.id\)/, 'description must be accessible');
|
||||
// Native title tooltips do not participate in layout or create horizontal scroll.
|
||||
assert.doesNotMatch(css, /dashboard-widget-(?:description|setting-label-block)/, 'obsolete custom tooltip styles must be removed');
|
||||
assert.match(css, /grid-template-columns: repeat\(auto-fit, minmax\(min\(180px, 100%\), 1fr\)\)/,
|
||||
'settings card columns must fit even a narrower mobile container');
|
||||
assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/,
|
||||
'old popover DOM and associations must be removed');
|
||||
const widgetList = { children: [], replaceChildren(...children) { this.children = children; } };
|
||||
const makeElement = (tag, text, className) => ({
|
||||
tag, textContent: text || '', className, dataset: {}, children: [], attributes: {},
|
||||
classList: { toggle() {} }, setAttribute(name, value) { this.attributes[name] = value; },
|
||||
append(...children) { this.children.push(...children); }, addEventListener() {},
|
||||
});
|
||||
for (const card of cards.values()) card.querySelector = () => ({ textContent: card.dataset.dashboardWidget });
|
||||
elements.set('dashboard-widget-list', widgetList);
|
||||
context.el = makeElement;
|
||||
context.CSS = { escape: value => value };
|
||||
vm.runInContext(prefsSource.slice(prefsSource.indexOf('function dashboardWidgetLabel'),
|
||||
prefsSource.indexOf('function refreshChartStylePalette')), context);
|
||||
context.renderDashboardWidgetSettings();
|
||||
assert.equal(widgetList.children.length, cardIds.length, 'all cards retain settings controls');
|
||||
for (const id of ids) {
|
||||
const row = widgetList.children.find(item => item.dataset.dashboardWidgetSetting === id);
|
||||
const button = row.children[0];
|
||||
const key = ({ stream_overview: 'streamWidgetOverviewDescription',
|
||||
stream_traffic: 'streamWidgetTrafficDescription', stream_sessions: 'streamWidgetSessionsDescription',
|
||||
stream_backend_health: 'streamWidgetHealthDescription' })[id];
|
||||
assert.equal(row.title, key, `${id} must have a localized browser tooltip`);
|
||||
assert.equal(button.attributes['aria-description'], key, 'the tooltip must remain accessible');
|
||||
assert.equal(button.children.length, 2, 'label must not contain an injected custom tooltip');
|
||||
}
|
||||
assert.equal(widgetList.children.filter(row => row.title).length, 4,
|
||||
'only the four translated Stream descriptions have tooltip text');
|
||||
// Editor-specific regressions: avoid the redundant enable hint and label the
|
||||
// two distinct inheritance layers instead of saying "Inherit" everywhere.
|
||||
const editor = fs.readFileSync('src/admin/web/streams.js', 'utf8');
|
||||
@@ -117,4 +146,4 @@ for (const locale of [pl, en]) {
|
||||
}
|
||||
assert.equal(pl.streamMetricsFromStream, 'Z ustawień Stream');
|
||||
assert.equal(pl.streamMetricsDefaultOff, 'Domyślnie wyłączone');
|
||||
console.log('PASS: Stream visibility, inline Overview legends, preserved Appearance tooltip and Vhost enable label.');
|
||||
console.log('PASS: Stream visibility, inline Overview legends, scroll-safe native Appearance tooltips and Vhost enable label.');
|
||||
@@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {
|
||||
const elements = new Map();
|
||||
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
|
||||
const status = {
|
||||
version: '0.32.0', uptime_seconds: 4201,
|
||||
version: '0.32.1', uptime_seconds: 4201,
|
||||
runtime: {
|
||||
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
|
||||
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
|
||||
@@ -63,7 +63,7 @@ const context = {
|
||||
vm.runInNewContext(code, context);
|
||||
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
|
||||
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.32.0');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.32.1');
|
||||
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
|
||||
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
|
||||
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
|
||||
|
||||
@@ -5,8 +5,8 @@ import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
|
||||
const first = editor.indexOf(' const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");');
|
||||
const last = editor.indexOf(' field(g, "forward-client-ip",', first);
|
||||
const first = editor.indexOf('const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");');
|
||||
const last = editor.indexOf('field(g, "forward-client-ip",', first);
|
||||
if (first === -1 || last === -1) throw new Error('WWW editor component missing');
|
||||
const source = editor.slice(first, last);
|
||||
if (editor.indexOf('readHosts = hostTagEditor(g, value.frontend?.hosts') < editor.indexOf('g = group("vhFrontends")')) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import vm from "node:vm";
|
||||
// Exercise the actual domain-choice sync function from the vhost editor with
|
||||
// DOM stand-ins. This does not replace browser or Rust runtime acceptance.
|
||||
const source = fs.readFileSync("src/admin/web/vhosts/editor.js", "utf8");
|
||||
const start = source.indexOf(" const syncCanonical = () => {");
|
||||
const end = source.indexOf(" refreshCanonical = syncCanonical;", start);
|
||||
const start = source.search(/^\s*const syncCanonical = \(\) => \{/m);
|
||||
const end = source.indexOf("refreshCanonical = syncCanonical;", start);
|
||||
assert.ok(start >= 0 && end > start, "WWW selection logic must remain discoverable");
|
||||
const selectedLogic = source.slice(start, end);
|
||||
|
||||
|
||||
Reference in new issue
Block a user