From 672c84bc13e49cca0d7fd23922fc3c29b66dd1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 7 Nov 2026 13:13:25 +0100 Subject: [PATCH] v0.36.0 --- AGENTS.md | 2 +- CHANGELOG.md | 8 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- SHA256SUMS | 38 ++++----- docs/API.md | 7 +- docs/FEATURES.md | 1 + docs/README.md | 2 +- integrations/routeros-oci-build/README.md | 4 +- src/admin/logging_api.rs | 70 ++++++++++++----- src/admin/openapi.json | 67 ++++++++++++++-- src/admin/operations.rs | 39 ++++++++-- src/admin/tests.rs | 85 ++++++++++++++++++++ src/admin/web/app/core.js | 10 ++- src/admin/web/cache-panel.js | 15 +++- src/admin/web/certificate-api.js | 1 - src/admin/web/settings.js | 4 +- src/admin/web/traffic-panel.js | 16 +++- src/admin/web/transform-panel.js | 13 +++- tests/panel_interface_contract.py | 95 +++++++++++++++++++++++ tests/panel_refresh_contract.js | 39 ++++++++++ 21 files changed, 450 insertions(+), 70 deletions(-) create mode 100644 tests/panel_interface_contract.py create mode 100644 tests/panel_refresh_contract.js diff --git a/AGENTS.md b/AGENTS.md index e7da4cd..6922a31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # RustProxy source and documentation maintenance -Current source version: **0.35.5**. The Cargo package and binary are named `rust-proxy`. +Current source version: **0.36.0**. The Cargo package and binary are named `rust-proxy`. Before editing, read [README.md](README.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff37c2..ad1eb33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in the latest release. +## 0.36.0 — leaner administration polling + +- **Polling API:** `GET /api/cache`, `GET /api/transform` and `GET /api/traffic` now accept `view=live` for lightweight periodic refreshes. The default response remains the complete backward-compatible payload. Live responses carry the configuration `revision`, and the panel automatically reloads static data when that revision changes. +- **Panel network load:** Cache and Transform polling no longer retransmit static settings/Vhost configuration on every refresh; Traffic polling omits the static Vhost list. Certificate API credentials are fetched only when the corresponding Settings tab is opened. +- **Refresh behavior:** configuration writes and operational actions refresh status without also forcing a full `/api/logs` snapshot. Initial load, explicit log refresh and the existing live-log/fallback path retain log coverage. +- **API contract:** OpenAPI documents the polling views and revision fields; regression tests cover compatibility, invalid views and the panel refresh contract. +- **Upgrade notes:** no configuration or database changes are required. Existing API clients that do not send `view=live` retain their previous response shapes, with the additive `revision` field on Cache, Transform and Traffic observations. + ## 0.35.5 — Overview polish and cache build fix - **Overview charts:** the cache-results heading now matches the other chart headings and its HIT/MISS/BYPASS legend stays on one line; system uptime is shown in the System load header; the backend count is aligned with Open connections; the redundant Requests per second footer was removed from the PHP requests tile. diff --git a/Cargo.lock b/Cargo.lock index c198904..f93ec9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.35.5" +version = "0.36.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 9f8c845..eca1e06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.35.5" +version = "0.36.0" edition = "2021" rust-version = "1.99.1" description = "RustProxy - independent reverse proxy with local administration" diff --git a/SHA256SUMS b/SHA256SUMS index be225ee..92b3a90 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,10 +1,10 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example e76e688874828e3743d03693b1d2872b9633b4db09909673786caf30f7cfe489 .gitignore -140ddb17001c2c821dd763335c1cecbfd75b27166b2a3ab1e815a8d53471a856 AGENTS.md -cb316bbf4b2558700a932d564177e3ec84e5b8c151400100b6e3fbaad18fdef5 CHANGELOG.md -f7c3fa3eb61e00a0ec0156229877a01d14bd3fa568dff0b04d0d73bb12cf6fae Cargo.lock -bf974faca111295d73a0c092be3536d70efeb222d5599f28814cba169cbeef25 Cargo.toml +eb7cb7121e65b91809f94b8994983635e0086bfeff30eef42e1a450de5e98517 AGENTS.md +a21c3c493427f528178010fa4670d57f40db350890ff3c204d5917bf3f6d1bf3 CHANGELOG.md +57cdec24db9391f2427445e6585b075219a57c215e054b20b5d9e8fa2a9ccb4e Cargo.lock +ba5cf6364e861c263c8b8edf2243e5b45ee417ea873863904df3fb37aa8b5636 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 LICENSE 82d1dee2fc68158f0ea73259151461e64c8b18d807cd1fecfb1ed99dc30cf5f4 README.md @@ -28,7 +28,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 60d108fd168064e4d6980ed7499f3219017f9878c743697e77f2efc592e4d629 docs/ACCESS.md -a1b6b8675d3b8321160406bc716f1355356e1e3dd056d4785be1d06a99313f31 docs/API.md +7d9fc7238ed04fe0e7258f7b9fced737ce7b85b578b32e7405cd5bea7983b5a3 docs/API.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md 4fb55a549535f37467f14536cbf0db7be027ca8a019c2919a384af4f23655300 docs/CACHE.md 32599d67a11fd0ebbc612e82b9f602789f4ed4103cda482c6870d8741100c8b4 docs/CERTIFICATES.md @@ -36,14 +36,14 @@ a1b6b8675d3b8321160406bc716f1355356e1e3dd056d4785be1d06a99313f31 docs/API.md fec29ce846d6705eb45819d85d382f28e28a146b265feab2e4c7cce2a35e5883 docs/CUSTOM_LOCATIONS.md 57db0ce916e01af49d41910ede6885e1a77744484ceb084606d34f8a72362c53 docs/DASHBOARD.md 11a08bf2110bbeabc924eb049a30177a216af5607ccdaf8be9178e751f78a2c4 docs/DOCKER_DISCOVERY.md -811b48c185fad69429e14db2cfaa65f9ad163dbc49f5bc538947439111eca24e docs/FEATURES.md +54a9b66016457e104aae7de30121fad87c887e98fd3834b60051afbcc81e014a docs/FEATURES.md 3939c14e83ef6856a421f358a3f7fce3d50ea375bc1b9f0419f27a3eed658f6f docs/INSTALL.md 694cb7b15c5bbdc94cecd15dc516c05dcb3467849322cdd125b872d36dee4699 docs/LOGS.md 0e9ae9509a78d00951d2c7c3f13eaf518d1f3448a29b048f5933e1f5f8440275 docs/MIGRATION.md 0972248b7062b37e61edcc34dab5fe73ee9bb50195a0aa475c72efa0dd311aa9 docs/MONITORING.md e2e8fc307885bd02c177aea5740b2ac767fc7bdcfe7034dd75b8646f3014b39f docs/PERFORMANCE.md d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md -9095d0df62a4548459ca739c97fcca37d8902cee1ae04b7f9da57aaea7749a9a docs/README.md +954b1602c865e157c867f1c81dca615f2beaa33c34de0b0fa8a6069db2aae7d0 docs/README.md 490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md 70e8c8f3352629f5600537058b1997739d9e465747cf3051da8a2cc779c3b728 docs/SCREENSHOTS.md 3a48184b36d225757fc5e2d22c629f89bfe5d4007ff087f0be0ce02a5fb04c28 docs/SECURITY.md @@ -86,7 +86,7 @@ de6550ab8f48d556d752c252212c38ba65491fd224f972c10fbe552f91ed7520 integrations/o 45be151cd85fc2ef2f1fdc3003ace63381af262e73af8a685ab48cb697cc0de1 integrations/routeros-oci-build/.env.routeros.example 11aa102de1089ecc375f20c4bd5420fcfd3b2ef23fe0c8bdf04953fcfaf40ef0 integrations/routeros-oci-build/Dockerfile.routeros 7c7a3e51f43b43e1c962a4b0bb591f99c1f06977bf77f23719f7f867dfe7eb8e integrations/routeros-oci-build/Dockerfile.routeros.dockerignore -70852c6f13ef6a287c8f815aab085cc53e1150461a88342ad83715a9c7b0bbef integrations/routeros-oci-build/README.md +a10d61ad26d111358dbe8b4bcf5b0df1eb01e52a9c632aab8f2a1dc7af4f253a integrations/routeros-oci-build/README.md 3f092a9ab80221eddd67be4d1a776acbc6e04c1eb6011b588280c28e83b2582f integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md @@ -160,10 +160,10 @@ b9c2952e3fcfca540d0ad64f2d3acc5c1f5240e707f4f23ef4d452f46fcb520f src/admin/docu 059218501907355b22bc84e7e0c41ec72565af283f5e97170ce7707ea5874009 src/admin/file_logs.rs d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file_logs_io.rs 77b8c05645969834cc8a58e249a7b60a57d6c47ad1c5cd92ffa5e4a9b5a8e73c src/admin/file_logs_query.rs -444d7715406ffd2305a72decf7fdf633aa4c2752cd881c8fa3b95ed971d79820 src/admin/logging_api.rs +77645eb8a845e64b76726ffcfd31f2e009667c6285a80915c60c2a02ecacf236 src/admin/logging_api.rs 243f4a9c3fa99781e19c3952354d2632c0ce65e1dafac729567cbe3c177f94cb src/admin/mod.rs -30b0ea16c6960e8efd682e95429c70c1b2ff178aef67e570e678d0dd82dce060 src/admin/openapi.json -1f4ec45d146e00ef664fae37517f8fcb5455d47daec496c02b2fc15ddec49131 src/admin/operations.rs +192350fb8b6557cf05e49dce9597228a00b21566cd6da5129871bea026983669 src/admin/openapi.json +f7a356109786635dd58cba590c71c796dbf36b47f25845190c75ee5c6a9fcb86 src/admin/operations.rs 257d93a29d0bda96462bcf695aba8d0ec0f9412c379e066ec46ae8ee034e3a53 src/admin/panel_assets.rs f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs b3c3c2d33c150eb42ce210c6074e5eff6a8af9391118012e413bc9323d3ae50c src/admin/preferences.rs @@ -178,7 +178,7 @@ e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/sess 2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs 8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -3bcd09729f3718d9ea88afae10c9e37b127d742babedd46e20ffd05eb31885e3 src/admin/tests.rs +e427a72973381c47f140c20d0d23ac88fb57e4acc2c44979b53503b6944e7bbb src/admin/tests.rs c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js @@ -186,7 +186,7 @@ c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/ aed3aea4eef0011c6b51d39736782e7d1ec52662e422f40e0c7b9ddfc19d0378 src/admin/web/app/certificate-inspection.js 1a4d40a86067493e75b47f2d735d50fa4d161344ce145d4e502b0225144ed45a src/admin/web/app/chart-scales.js e5b39b5592ba81292571033f26934ac0cd42bcdca7395e91094973cecb3eeaf8 src/admin/web/app/config-history.js -0f6e70d10ec732b06aeaed7e4c72f36fdf21f295315d0716556b8c32cc775425 src/admin/web/app/core.js +892833d21c58a8b0d4e4efc3752391cf3d3ec5f24fceae8fd4f180849fe9187c src/admin/web/app/core.js d695ec90fb5c98c27d7ba1d2bebf54f9840985fc530efcec3fcaf48d74b36c6b src/admin/web/app/dashboard-inline-layout.js e4238322a985d3e8b7f2de94b7af31e1c718775590b2c7b3428b878cd43337b7 src/admin/web/app/dashboard-layout-editor.js a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/app/dashboard-presets.js @@ -203,8 +203,8 @@ c6ae53c0e95e67b9165822813efc622001be5354c19bb221a52514221d1df23e src/admin/web/ c7a18238ac426de4d1cf84a395ee4dc66410d60a57a3822695dc10adc668d956 src/admin/web/app/settings.js b7ead49224a4ec67181d3f24f9d9634bd6d76756f826a4d5d993300637d1d8f3 src/admin/web/app/stream-dashboard.js 7de070cc8cdde45d7e0932259299e341e25b32c9ffbd87e4fcce934d0f090de4 src/admin/web/app/web-overview.js -596205feca534302699120b84848cb0f3e9ef2ad7ba24eac18d5751532a40194 src/admin/web/cache-panel.js -e9788a9d1e0bce8775f612a5c842afea8a0dcf01301acdef4b69ff6a52d2ef45 src/admin/web/certificate-api.js +f5855db3327f51170a28114e07d207745b4d6c8822d88d24c1a81eb92f345b14 src/admin/web/cache-panel.js +9f323507649910561518b63cf6632e81b3ecc4b09501ae53b635705f34ea6587 src/admin/web/certificate-api.js a09f060b47771c56e9b9e57f1b896a32a2f87f1fb33401ac76932a91d247c67e src/admin/web/css/00-tokens.css 77b8b748f31ef383c64ba5ca1740e9b6073f0de1e77c2c8c62d0f38bb52dd296 src/admin/web/css/10-base.css 6ed54b27a4a81b073f1d30cf61e366f3d5c1f6f8495f0d0a8af48e061937eb4e src/admin/web/css/20-shell.css @@ -249,11 +249,11 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/ 2842346eb66569b0aba2938ff0f724b50d21254b2aa0d2c00f6380ad03449b61 src/admin/web/partials/overview-metrics.html 3928afce2ed6d5f2d3e29475dd29e4c3bd3c6df180ad5119beaff0c7a58e3b3b src/admin/web/prometheus-token.js 2d491fe9a547fe109dc5db6a74806d50b08c3daca15a6622c79c9fea33b10c63 src/admin/web/redirects.js -2e891f34cad2a2950a47e529e15ed9dd41beedec71280b8035b19cfb91a5d317 src/admin/web/settings.js +76a01f590d49649dcb1f554c221290919b2a8438e6001737f3c096fa6daec74d src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html 1ea78a6e5b5d813febf2359ecec7fde42dc1278531053e91428f4d72b8a1d62b src/admin/web/streams.js -d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js -c7e0ca45c19498d40ef6f4122de23f0e73094e905d39f798942f70d15b9cf86e src/admin/web/transform-panel.js +ae0259cf32f53d69a22a640b1c34562018d4ffb3864494012de3a31c0bb4ac1e src/admin/web/traffic-panel.js +59ffba513c543ce2dc0b7c33a3401474f8963874fa234a0a37e7e26344092508 src/admin/web/transform-panel.js 87917b0388090f2b27ec6460be56fea96dc09b42735319e0a91330748848e288 src/admin/web/vhosts/core.js 9bb38cdb8c0f49144d6058e007e375fb3b2e47359988029fe28addc6a900d0ca src/admin/web/vhosts/editor.js 3776f72d7051af86ac5fbdd4581040dd88a424c0c6cde789301b032b159094d8 src/admin/web/vhosts/health.js @@ -553,6 +553,8 @@ e5f5fbe702f66bfcbcbc1d1f3cd3d89bdbb929bb86aed94868a2a06a4b9e59e3 src/web_server b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs 714418c86ab241020a561fcf662a8fe324a6a70cc2fd3a313c56d7939c14b9e8 src/web_server/tests.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json +d1b019e702f1b0f1fc2fd03eed3893b7f966e7ff3025d76c17452b02a563a231 tests/panel_interface_contract.py +b2c39608573fef61e2a50e17dff3868c2ada770abea691872ec261c5cb3c554b tests/panel_refresh_contract.js e24a0f84f1bfc46964df865ac387d651f7b384d781cbc8f3a9a5b0b60f3d69ca tests/vhost_auth_selection.js eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md 7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py diff --git a/docs/API.md b/docs/API.md index 661e221..0a12dd5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -20,10 +20,11 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot | `POST /api/config/geoip/update` | Run a GeoIP download/check/reload immediately using already-saved GeoIP settings; does not change revision. | | `GET /api/config/history` · `POST /api/config/history/restore` | Review/restore retained configuration snapshots. | | `GET /api/status` · `GET /api/backends` | Operational status and backend observations. | -| `GET /api/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. | +| `GET /api/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. `GET` supports the lightweight `view=live` polling view. | +| `GET /api/transform` | Transform observations and resource usage. Supports the lightweight `view=live` polling view. | | `GET /api/logs` · `GET /api/live-logs` | Recent and streaming request logs. | | `GET /api/file-logs` · `GET /api/file-logs/query` | Stored-log exploration. | -| `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. | +| `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. Traffic supports the lightweight `view=live` polling view. | | `GET /api/metrics/json` · `GET /api/metrics/summary` | Full JSON snapshot (includes all Prometheus samples and `summary`) or compact computed `summary` only, both pull-only. [Monitoring](MONITORING.md). | | `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. | | `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. | @@ -35,6 +36,8 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot Some paths have additional DELETE/PATCH operations, job-status resources, query parameters or restricted actions. Use OpenAPI for the **actual fields/methods**, not guesses based on a table. `GET /metrics` here is not a replacement for the separate Prometheus exporter. +For browser polling, `GET /api/cache?view=live`, `GET /api/transform?view=live` and `GET /api/traffic?view=live` omit static response members that do not need to be retransmitted on every interval. Their `revision` identifies the active configuration; when it changes, clients should fetch the corresponding endpoint without `view=live` and refresh their cached static fields. Requests without `view=live` remain the complete compatibility form. Cache live responses contain metrics/dashboard/bans, Transform live responses contain resources/metrics, and Traffic live responses retain current settings/GeoIP/analytics while omitting the static Vhost list. Unknown `view` values are rejected. + Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed file or an absolute external path and returns `{ "cert_file": "...", "covered_hosts": ["example.com"] }`. It applies the existing certificate-file trust and size checks and reads no private key. It makes no configuration or permission changes. This POST requires the signed-in administrator's normal Origin, request-marker and CSRF checks; general RO/RW API tokens and certificate-only credentials cannot call it. ## Access and authentication diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cd9837f..cabd264 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -81,6 +81,7 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se - Live request log in the browser and in the terminal (`--livelogs`). - Stored JSON access logs with search syntax, per-Vhost log files and an error log. - Prometheus exporter with token/CIDR protection, JSON metrics and a compact summary endpoint. +- Lightweight revision-aware polling views for Cache, Traffic and Transform keep the administration panel responsive without repeatedly transferring static configuration. - Grafana dashboard and Nagios checks included. ![Live logs](images/logs.png) diff --git a/docs/README.md b/docs/README.md index 079c76d..fa87e50 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,7 +46,7 @@ Guides for operating RustProxy: installation, publishing services, security, cer - [Backup and recovery](BACKUP.md) — encrypted backups, restore and the Secret Store. - [Migration](MIGRATION.md) — upgrades and Nginx Proxy Manager import. - [Docker discovery](DOCKER_DISCOVERY.md) — Docker agent and generated Vhosts. -- [Administration API](API.md) — authentication, tokens, endpoints and configuration sections. +- [Administration API](API.md) — authentication, tokens, endpoints, lightweight polling views and configuration sections. ## Integrations diff --git a/integrations/routeros-oci-build/README.md b/integrations/routeros-oci-build/README.md index a22652b..c233c39 100644 --- a/integrations/routeros-oci-build/README.md +++ b/integrations/routeros-oci-build/README.md @@ -77,10 +77,10 @@ ENV_FILE=integrations/routeros-oci-build/.env \ bash integrations/routeros-oci-build/build-routeros-oci.sh push ``` -For example, with RustProxy version `0.35.5` and the defaults this publishes: +For example, with RustProxy version `0.36.0` and the defaults this publishes: ```text -zot.example.net/network/rustproxy:0.35.5 +zot.example.net/network/rustproxy:0.36.0 zot.example.net/network/rustproxy:latest zot.example.net/network/rustproxy:routeros ``` diff --git a/src/admin/logging_api.rs b/src/admin/logging_api.rs index 9277666..c20dac7 100644 --- a/src/admin/logging_api.rs +++ b/src/admin/logging_api.rs @@ -237,11 +237,22 @@ async fn events( #[serde(default, deny_unknown_fields)] struct TrafficQuery { vhost: Option, + view: Option, } async fn traffic_status( State(state): State>, Query(query): Query, ) -> Result, ApiError> { + let live = match query.view.as_deref() { + None => false, + Some("live") => true, + _ => { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "invalid traffic view".into(), + )); + } + }; let snapshot = state.config.load_full(); if query.vhost.as_ref().is_some_and(|id| { id != "*" @@ -258,14 +269,8 @@ async fn traffic_status( )); } let scope = query.vhost.as_deref().unwrap_or("*"); - let vhosts = snapshot - .envelope - .config - .vhosts - .iter() - .map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts})) - .collect::>(); - Ok(Json(json!({ + let mut payload = json!({ + "revision": snapshot.envelope.revision, "scope": scope, "since": "process_start", "settings": { @@ -273,9 +278,19 @@ async fn traffic_status( "capture_country": snapshot.envelope.config.logging.capture_country }, "geoip": state.geoip.load().view(), - "analytics": state.traffic.view(Some(scope)), - "vhosts": vhosts - }))) + "analytics": state.traffic.view(Some(scope)) + }); + if live { + return Ok(Json(payload)); + } + payload["vhosts"] = json!(snapshot + .envelope + .config + .vhosts + .iter() + .map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts})) + .collect::>()); + Ok(Json(payload)) } #[derive(Default, Deserialize)] @@ -284,11 +299,22 @@ struct CacheQuery { vhost: Option, offset: Option, limit: Option, + view: Option, } async fn cache_status( State(state): State>, Query(query): Query, ) -> Result, ApiError> { + let live = match query.view.as_deref() { + None => false, + Some("live") => true, + _ => { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "invalid cache view".into(), + )); + } + }; let snapshot = state.config.load_full(); if query .vhost @@ -300,16 +326,24 @@ async fn cache_status( "unknown vhost cache scope".into(), )); } - let scopes: Vec<_> = snapshot.envelope.config.vhosts.iter().map(|vhost| { - json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id), - "settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()}) - }).collect(); let dashboard = state.cache.dashboard( query.vhost.as_deref(), query.offset.unwrap_or(0).min(16384), query.limit.unwrap_or(50), ); - Ok(Json( - json!({"settings":snapshot.envelope.config.cache, "metrics":state.cache.view(), "vhosts":scopes, "dashboard":dashboard, "bans":state.cache.bans()}), - )) + let mut payload = json!({ + "revision": snapshot.envelope.revision, + "metrics": state.cache.view(), + "dashboard": dashboard, + "bans": state.cache.bans() + }); + if live { + return Ok(Json(payload)); + } + payload["settings"] = json!(snapshot.envelope.config.cache); + payload["vhosts"] = json!(snapshot.envelope.config.vhosts.iter().map(|vhost| { + json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id), + "settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()}) + }).collect::>()); + Ok(Json(payload)) } diff --git a/src/admin/openapi.json b/src/admin/openapi.json index bb17b76..a2d40a2 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.35.5", + "version": "0.36.0", "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only." }, "servers": [ @@ -1618,6 +1618,11 @@ "Cache": { "type": "object", "properties": { + "revision": { + "type": "integer", + "format": "int64", + "description": "Configuration revision used for this snapshot." + }, "settings": { "type": "object", "properties": {}, @@ -4790,6 +4795,11 @@ "type": "object", "additionalProperties": false, "properties": { + "revision": { + "type": "integer", + "format": "int64", + "description": "Configuration revision used for this snapshot." + }, "settings": { "$ref": "#/components/schemas/TransformSettings" }, @@ -6194,6 +6204,11 @@ "type": "object", "additionalProperties": false, "properties": { + "revision": { + "type": "integer", + "format": "int64", + "description": "Configuration revision used for this snapshot." + }, "scope": { "type": "string" }, @@ -9872,14 +9887,14 @@ }, "/api/cache": { "get": { - "summary": "Cache metrics, domains and paginated objects", + "summary": "Cache metrics, domains and paginated objects; optional lightweight live view", "tags": [ "Cache" ], "operationId": "get_api_cache", "responses": { "200": { - "description": "Successful response", + "description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, live metrics, dashboard objects/domains and BAN rules.", "content": { "application/json": { "schema": { @@ -9960,6 +9975,18 @@ "minimum": 1, "default": 50 } + }, + { + "name": "view", + "in": "query", + "required": false, + "description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.", + "schema": { + "type": "string", + "enum": [ + "live" + ] + } } ], "security": [ @@ -9974,14 +10001,14 @@ }, "/api/transform": { "get": { - "summary": "Transformation runtime metrics and per-vhost effectiveness", + "summary": "Transformation runtime metrics and per-vhost effectiveness; optional lightweight live view", "tags": [ "Transform" ], "operationId": "get_api_transform", "responses": { "200": { - "description": "Successful response", + "description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, resources and live metrics.", "content": { "application/json": { "schema": { @@ -10031,6 +10058,20 @@ } } }, + "parameters": [ + { + "name": "view", + "in": "query", + "required": false, + "description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.", + "schema": { + "type": "string", + "enum": [ + "live" + ] + } + } + ], "security": [ { "AdminSession": [] @@ -12884,7 +12925,7 @@ }, "/api/traffic": { "get": { - "summary": "Bounded per-vhost traffic analytics", + "summary": "Bounded per-vhost traffic analytics; optional lightweight live view", "tags": [ "Traffic" ], @@ -12899,11 +12940,23 @@ "type": "string", "default": "*" } + }, + { + "name": "view", + "in": "query", + "required": false, + "description": "Set to live for polling. Omits the static vhost list; refetch the full view when revision changes.", + "schema": { + "type": "string", + "enum": [ + "live" + ] + } } ], "responses": { "200": { - "description": "Successful response", + "description": "Full response by default. view=live omits the static vhost list while retaining revision, current settings, GeoIP state and analytics.", "content": { "application/json": { "schema": { diff --git a/src/admin/operations.rs b/src/admin/operations.rs index 605c0f5..6f9dfd3 100644 --- a/src/admin/operations.rs +++ b/src/admin/operations.rs @@ -1,6 +1,34 @@ -async fn transform_status(State(state): State>) -> Json { +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct TransformQuery { + view: Option, +} + +async fn transform_status( + State(state): State>, + Query(query): Query, +) -> Result, ApiError> { + let live = match query.view.as_deref() { + None => false, + Some("live") => true, + _ => { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "invalid transform view".into(), + )); + } + }; let snapshot = state.config.load_full(); let global = &snapshot.envelope.config.transform; + let resources = crate::transform::resource_usage(global); + let metrics = state.transform_metrics.view(); + if live { + return Ok(Json(json!({ + "revision": snapshot.envelope.revision, + "resources": resources, + "metrics": metrics + }))); + } let vhosts: Vec<_> = snapshot .envelope .config @@ -14,12 +42,13 @@ async fn transform_status(State(state): State>) -> Json { }) }) .collect(); - Json(json!({ + Ok(Json(json!({ + "revision": snapshot.envelope.revision, "settings": global, - "resources": crate::transform::resource_usage(global), - "metrics": state.transform_metrics.view(), + "resources": resources, + "metrics": metrics, "vhosts": vhosts - })) + }))) } #[derive(Default, Deserialize)] diff --git a/src/admin/tests.rs b/src/admin/tests.rs index abb7ed8..0e0adda 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -3527,6 +3527,91 @@ mod tests { store.close().await; } + #[tokio::test] + async fn polling_views_omit_static_configuration_and_reject_unknown_modes() { + let (router, store, _dir, secret, _) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + + for (path, omitted, retained) in [ + ( + "/api/cache?view=live", + &["settings", "vhosts"][..], + &["revision", "metrics", "dashboard", "bans"][..], + ), + ( + "/api/transform?view=live", + &["settings", "vhosts"][..], + &["revision", "resources", "metrics"][..], + ), + ( + "/api/traffic?view=live", + &["vhosts"][..], + &["revision", "settings", "geoip", "analytics"][..], + ), + ] { + let response = router + .clone() + .oneshot( + request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "GET {path}"); + let value = json_body(response).await; + for &field in omitted { + assert!(value.get(field).is_none(), "{path} unexpectedly contains {field}"); + } + for &field in retained { + assert!(value.get(field).is_some(), "{path} is missing {field}"); + } + } + + for (path, required) in [ + ("/api/cache", &["settings", "vhosts"][..]), + ("/api/transform", &["settings", "vhosts"][..]), + ("/api/traffic", &["vhosts"][..]), + ] { + let response = router + .clone() + .oneshot( + request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "GET {path}"); + let value = json_body(response).await; + assert!(value.get("revision").is_some()); + for &field in required { + assert!(value.get(field).is_some(), "{path} is missing {field}"); + } + } + + for path in [ + "/api/cache?view=unknown", + "/api/transform?view=unknown", + "/api/traffic?view=unknown", + ] { + let response = router + .clone() + .oneshot( + request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "GET {path}"); + } + store.close().await; + } + #[test] fn defaults_and_partial_legacy_refresh_documents() { let previous = serde_json::to_value(AdminPreferences::default()).unwrap(); diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index 18cea3c..5dc6e1c 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -379,9 +379,13 @@ function renderFooter(s) { } let lastLogs = null; function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); } -async function refresh(force = false) { +async function refresh(force = false, includeLogs = false) { if (!authenticated) return; - if (force) return Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback")]); + if (force) { + const tasks = [rustProxyRefresh.manual("status-full")]; + if (includeLogs) tasks.push(rustProxyRefresh.manual("logs-fallback")); + return Promise.all(tasks); + } return rustProxyRefresh.manual("status-fallback"); } function renderLiveStatus(value) { renderStatus(value); } @@ -525,7 +529,7 @@ async function activate(info) { void window.rustProxyApiTokens?.refresh?.(); window.rustProxySettings?.scrollToApiTokens?.(); } - void refresh(true).catch(error => { + void refresh(true, true).catch(error => { if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true); }); return true; diff --git a/src/admin/web/cache-panel.js b/src/admin/web/cache-panel.js index e4a8985..2b07f63 100644 --- a/src/admin/web/cache-panel.js +++ b/src/admin/web/cache-panel.js @@ -1,6 +1,6 @@ "use strict"; (() => { - let loading = false, offset = 0, generation = 0; + let loading = false, offset = 0, generation = 0, staticData = null, staticRevision = null; let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null; const liveChartSamples = { hits: [], misses: [], bypasses: [] }; const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); @@ -229,7 +229,16 @@ const query = new URLSearchParams({ offset: String(offset), limit: "50" }); if (scope() !== "*") query.set("vhost", scope()); try { - const data = await api(`/api/cache?${query}`, { signal }); + const base = `/api/cache?${query}`; + if (staticData) query.set("view", "live"); + let data = await api(`/api/cache?${query}`, { signal }); + if (staticData && data.revision !== staticRevision) data = await api(base, { signal }); + if (data.settings && Array.isArray(data.vhosts)) { + staticData = { settings: data.settings, vhosts: data.vhosts }; + staticRevision = data.revision; + } else if (staticData) { + data = { ...staticData, ...data }; + } if (isCurrent() && token === generation) render(data); } catch (error) { if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) { @@ -343,7 +352,7 @@ $("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync()); window.rustProxyCachePanel = { refresh, configure, reason, reset() { - generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans(); + generation++; offset = 0; staticData = null; staticRevision = null; resetLiveChart(); closeInvalidation(); closeBans(); $("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren(); $("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status"); } diff --git a/src/admin/web/certificate-api.js b/src/admin/web/certificate-api.js index 41ff3b0..a9013e8 100644 --- a/src/admin/web/certificate-api.js +++ b/src/admin/web/certificate-api.js @@ -243,5 +243,4 @@ curl --fail --show-error --request POST \ } }; renderCertificateApi(); - loadCertificateApi(); })(); diff --git a/src/admin/web/settings.js b/src/admin/web/settings.js index 2865eb9..8344cdb 100644 --- a/src/admin/web/settings.js +++ b/src/admin/web/settings.js @@ -50,6 +50,7 @@ window.rustProxyRefresh?.setSettingsTab(name); if (authenticated && name === "security") void window.rustProxyApiTokens?.refresh?.(); if (authenticated && name === "prometheus") void window.rustProxyPrometheusToken?.refresh?.(); + if (authenticated && name === "certificate-api") void window.rustProxyCertificateApi?.load?.(); if (name !== "prometheus") window.rustProxyPrometheusToken?.hideSecret?.(); if (name !== "security") window.rustProxyApiTokens?.hideSecret?.(); } @@ -251,7 +252,8 @@ $("tls-policy-key-exchange").textContent = kxGroups.length ? `${t("tlsPqcKeyExchangeGroups")}: ${kxGroups.join(", ")}` : ""; $("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : ""; $("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : ""; - window.rustProxyCertificateApi?.render(); window.rustProxyCertificateApi?.load(); + window.rustProxyCertificateApi?.render(); + if (authenticated && active === "certificate-api") void window.rustProxyCertificateApi?.load?.(); } $("geoip-format").addEventListener("change", syncGeoipFormat); $("geoip-enabled").addEventListener("change", syncGeoipFormat); bindSettingsForm("geoip-form", next => { diff --git a/src/admin/web/traffic-panel.js b/src/admin/web/traffic-panel.js index f66fad7..769710c 100644 --- a/src/admin/web/traffic-panel.js +++ b/src/admin/web/traffic-panel.js @@ -1,6 +1,6 @@ "use strict"; (() => { - let loading = false, lastData = null, activeTab = "overview"; + let loading = false, lastData = null, activeTab = "overview", staticData = null, staticRevision = null; const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"]; const scope = () => $("traffic-scope")?.value || "*"; const number = value => fmt(Number(value) || 0); @@ -116,7 +116,17 @@ if (!authenticated || currentPanel !== "traffic" || loading) return; loading = true; try { - const data = await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`, { signal }); + const base = `/api/traffic?vhost=${encodeURIComponent(scope())}`; + let data = await api(staticData ? `${base}&view=live` : base, { signal }); + if (staticData && data.revision !== staticRevision) { + data = await api(base, { signal }); + } + if (Array.isArray(data.vhosts)) { + staticData = { vhosts: data.vhosts }; + staticRevision = data.revision; + } else if (staticData) { + data = { ...staticData, ...data }; + } if (isCurrent()) render(data); } catch (error) { if (error?.name !== "AbortError" && isCurrent()) $("traffic-panel-state").textContent = error.message; @@ -125,7 +135,7 @@ function refresh() { return rustProxyRefresh.manual("traffic"); } function reset() { - lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = ""; + lastData = null; staticData = null; staticRevision = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = ""; document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren()); for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) { const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`); diff --git a/src/admin/web/transform-panel.js b/src/admin/web/transform-panel.js index 5d06446..cfbbf95 100644 --- a/src/admin/web/transform-panel.js +++ b/src/admin/web/transform-panel.js @@ -1,6 +1,6 @@ "use strict"; (() => { - let loading = false, generation = 0, lastData = null; + let loading = false, generation = 0, lastData = null, staticData = null, staticRevision = null; let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null; const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] }; const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); @@ -155,7 +155,14 @@ loading = true; const epoch = authEpoch, token = generation; try { - const data = await api("/api/transform", { signal }); + let data = await api(staticData ? "/api/transform?view=live" : "/api/transform", { signal }); + if (staticData && data.revision !== staticRevision) data = await api("/api/transform", { signal }); + if (data.settings && Array.isArray(data.vhosts)) { + staticData = { settings: data.settings, vhosts: data.vhosts }; + staticRevision = data.revision; + } else if (staticData) { + data = { ...staticData, ...data }; + } if (isCurrent() && token === generation) render(data); } catch (error) { if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) { @@ -179,7 +186,7 @@ window.rustProxyTransformPanel = { refresh, reset() { - generation++; lastData = null; resetLiveChart(); + generation++; lastData = null; staticData = null; staticRevision = null; resetLiveChart(); $("transform-type-rows")?.replaceChildren(); $("transform-vhost-rows")?.replaceChildren(); $("transform-reason-list")?.replaceChildren(); diff --git a/tests/panel_interface_contract.py b/tests/panel_interface_contract.py new file mode 100644 index 0000000..a13fdc0 --- /dev/null +++ b/tests/panel_interface_contract.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Static integrity checks for the embedded administration interface. + +The test intentionally uses only the Python standard library. It verifies the +actual HTML assembly and JavaScript files embedded by src/admin/router.rs; it is +not a replacement for Rust/browser integration tests. +""" +from __future__ import annotations + +from collections import Counter +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / "src/admin/web" +ROUTER = (ROOT / "src/admin/router.rs").read_text(encoding="utf-8") + + +def assembled_html() -> str: + html = (WEB / "index.html").read_text(encoding="utf-8") + metrics = (WEB / "partials/overview-metrics.html").read_text(encoding="utf-8") + charts = (WEB / "partials/overview-charts.html").read_text(encoding="utf-8") + metrics_main, metrics_web = metrics.split("", 1) + charts_main, charts_web = charts.split("", 1) + return ( + html.replace("", metrics_main) + .replace("", metrics_web) + .replace("", charts_main) + .replace("", charts_web) + ) + + +def embedded_app_sources() -> list[Path]: + start = ROUTER.index("fn admin_app_javascript()") + end = ROUTER.index("fn admin_vhosts_javascript()", start) + app_block = ROUTER[start:end] + paths = [WEB / rel for rel in re.findall(r'include_str!\("web/([^"\n]+\.js)"\)', app_block)] + + vstart = end + vend = ROUTER.index("pub fn router", vstart) + vhost_block = ROUTER[vstart:vend] + vhost_paths = [WEB / rel for rel in re.findall(r'include_str!\("web/([^"\n]+\.js)"\)', vhost_block)] + + result: list[Path] = [] + for path in [*paths, *vhost_paths]: + if path not in result: + result.append(path) + return result + + +def attribute_values(html: str, name: str) -> set[str]: + return set(re.findall(rf'\b{re.escape(name)}=["\']([^"\']+)', html)) + + +html = assembled_html() +ids = re.findall(r'\bid=["\']([^"\']+)', html) +duplicates = sorted(name for name, count in Counter(ids).items() if count > 1) +assert not duplicates, f"duplicate panel DOM ids: {duplicates}" +id_set = set(ids) + +sources = embedded_app_sources() +assert sources, "no embedded administration JavaScript sources found" +missing_files = [str(path.relative_to(ROOT)) for path in sources if not path.is_file()] +assert not missing_files, f"missing embedded JS sources: {missing_files}" + +literal_refs: dict[str, set[str]] = {} +patterns = [ + re.compile(r'\$\(["\']([^"\']+)["\']\)'), + re.compile(r'document\.getElementById\(["\']([^"\']+)["\']\)'), +] +for path in sources: + source = path.read_text(encoding="utf-8") + for pattern in patterns: + for dom_id in pattern.findall(source): + literal_refs.setdefault(dom_id, set()).add(str(path.relative_to(ROOT))) +missing_refs = {dom_id: sorted(paths) for dom_id, paths in literal_refs.items() if dom_id not in id_set} +assert not missing_refs, f"embedded panel JS references missing DOM ids: {missing_refs}" + +# Every visible primary navigation target must have a panel section. filelogs is +# intentionally opened from the Logs menu rather than the main navigation. +panel_targets = attribute_values(html, "data-panel") +panel_sections = {value.removeprefix("panel-") for value in id_set if value.startswith("panel-")} +assert panel_targets <= panel_sections, f"navigation targets without panels: {sorted(panel_targets - panel_sections)}" +assert panel_sections - panel_targets <= {"filelogs"}, f"unexpected orphan panels: {sorted(panel_sections - panel_targets)}" + +# Tab/page contracts used by the generic tab handlers in the embedded JS. +for prefix in ("settings", "policy-settings", "cache-settings", "logging-settings", "traffic"): + tabs = attribute_values(html, f"data-{prefix}-tab") + pages = attribute_values(html, f"data-{prefix}-page") + assert tabs == pages, f"{prefix} tab/page mismatch: tabs={sorted(tabs - pages)}, pages={sorted(pages - tabs)}" + +print( + f"Panel interface contract: OK ({len(ids)} unique DOM ids, " + f"{len(literal_refs)} literal JS references, {len(sources)} embedded JS files)" +) diff --git a/tests/panel_refresh_contract.js b/tests/panel_refresh_contract.js new file mode 100644 index 0000000..1d31294 --- /dev/null +++ b/tests/panel_refresh_contract.js @@ -0,0 +1,39 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.join(__dirname, ".."); +const read = relative => fs.readFileSync(path.join(root, relative), "utf8"); + +const settings = read("src/admin/web/settings.js"); +const certificateApi = read("src/admin/web/certificate-api.js"); +const core = read("src/admin/web/app/core.js"); +const cache = read("src/admin/web/cache-panel.js"); +const transform = read("src/admin/web/transform-panel.js"); +const traffic = read("src/admin/web/traffic-panel.js"); +const openapi = JSON.parse(read("src/admin/openapi.json")); + +assert.match(settings, /authenticated && name === "certificate-api"[^\n]+rustProxyCertificateApi\?\.load/); +assert.match(settings, /authenticated && active === "certificate-api"[^\n]+rustProxyCertificateApi\?\.load/); +assert.ok(!/renderCertificateApi\(\);\s*loadCertificateApi\(\);\s*\}\)\(\);\s*$/.test(certificateApi), + "Certificate API must not fetch credentials when its module is initialized"); + +assert.match(core, /async function refresh\(force = false, includeLogs = false\)/); +assert.match(core, /if \(includeLogs\) tasks\.push\(rustProxyRefresh\.manual\("logs-fallback"\)\)/); +assert.match(core, /refresh\(true, true\)\.catch/); + +for (const [name, source, endpoint] of [ + ["cache", cache, "/api/cache"], + ["transform", transform, "/api/transform"], + ["traffic", traffic, "/api/traffic"], +]) { + assert.ok(/view=live/.test(source) || /set\(\"view\", \"live\"\)/.test(source), `${name} panel must use the lightweight polling view`); + assert.match(source, /data\.revision !== staticRevision/, `${name} panel must invalidate static data on config revision changes`); + assert.match(source, /reset\(\)[\s\S]{0,220}staticData = null[\s\S]{0,120}staticRevision = null/, `${name} panel reset must discard static cached API data`); + const operation = openapi.paths[endpoint].get; + const view = (operation.parameters || []).find(parameter => parameter.name === "view"); + assert.deepEqual(view?.schema?.enum, ["live"], `${endpoint} must document view=live`); +} + +assert.equal(openapi.info.version, "0.36.0"); +console.log("Panel refresh/API contract checks: OK");