This commit is contained in:
Mateusz Gruszczyński committed 2026-11-07 13:13:25 +01:00
1 parent 3a384baa6b
commit 672c84bc13
21 files changed
+450 -70

No files matched your search

+1 -1
View File
@@ -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.
+8
View File
@@ -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.
Generated
+1 -1
View File
@@ -1943,7 +1943,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.35.5"
version = "0.36.0"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -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"
+20 -18
View File
@@ -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
+5 -2
View File
@@ -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
+1
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
```
+52 -18
View File
@@ -237,11 +237,22 @@ async fn events(
#[serde(default, deny_unknown_fields)]
struct TrafficQuery {
vhost: Option<String>,
view: Option<String>,
}
async fn traffic_status(
State(state): State<Arc<App>>,
Query(query): Query<TrafficQuery>,
) -> Result<Json<Value>, 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::<Vec<_>>();
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::<Vec<_>>());
Ok(Json(payload))
}
#[derive(Default, Deserialize)]
@@ -284,11 +299,22 @@ struct CacheQuery {
vhost: Option<String>,
offset: Option<usize>,
limit: Option<usize>,
view: Option<String>,
}
async fn cache_status(
State(state): State<Arc<App>>,
Query(query): Query<CacheQuery>,
) -> Result<Json<Value>, 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::<Vec<_>>());
Ok(Json(payload))
}
+60 -7
View File
@@ -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": {
+34 -5
View File
@@ -1,6 +1,34 @@
async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct TransformQuery {
view: Option<String>,
}
async fn transform_status(
State(state): State<Arc<App>>,
Query(query): Query<TransformQuery>,
) -> Result<Json<Value>, 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<Arc<App>>) -> Json<Value> {
})
})
.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)]
+85
View File
@@ -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();
+7 -3
View File
@@ -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;
+12 -3
View File
@@ -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");
}
-1
View File
@@ -243,5 +243,4 @@ curl --fail --show-error --request POST \
}
};
renderCertificateApi();
loadCertificateApi();
})();
+3 -1
View File
@@ -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 => {
+13 -3
View File
@@ -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`);
+10 -3
View File
@@ -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();
+95
View File
@@ -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("<!-- RP_METRICS_SPLIT -->", 1)
charts_main, charts_web = charts.split("<!-- RP_CHARTS_SPLIT -->", 1)
return (
html.replace("<!-- RP_METRICS_MAIN -->", metrics_main)
.replace("<!-- RP_METRICS_WEB -->", metrics_web)
.replace("<!-- RP_CHARTS_MAIN -->", charts_main)
.replace("<!-- RP_CHARTS_WEB -->", 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)"
)
+39
View File
@@ -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");