From 6f52ac3d00320b9486149a38cd11bec7ac2beb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 7 Nov 2026 13:56:11 +0100 Subject: [PATCH] v0.36.1 --- AGENTS.md | 2 +- CHANGELOG.md | 11 + Cargo.lock | 2 +- Cargo.toml | 2 +- SHA256SUMS | 69 ++--- docs/API.md | 2 +- docs/DASHBOARD.md | 6 + docs/FEATURES.md | 1 + docs/LOGS.md | 2 + integrations/opnsense-acme/README.md | 8 +- .../opnsense-acme/actions_rustproxy_cert.conf | 6 + .../opnsense-acme/rustproxy-acme.conf.example | 21 ++ integrations/routeros-oci-build/README.md | 4 +- langs/en_US.json | 2 + langs/pl_pl.json | 2 + scripts/preflight.py | 2 +- src/admin/logging_api.rs | 15 +- src/admin/openapi.json | 218 ++++++++++++--- src/admin/preferences.rs | 3 + src/admin/preferences_tests.rs | 14 + src/admin/router.rs | 1 + src/admin/tests.rs | 18 +- src/admin/web/app/core.js | 18 +- src/admin/web/app/icons.js | 251 ++++++++++++++++++ src/admin/web/app/overview-extra-metrics.js | 6 +- src/admin/web/app/preferences.js | 19 +- src/admin/web/app/refresh.js | 4 +- src/admin/web/css/10-base.css | 47 ++++ src/admin/web/css/30-overview.css | 110 ++++++-- src/admin/web/css/50-settings.css | 94 ++++++- src/admin/web/index.html | 214 +++++++++++---- src/admin/web/integrations.js | 4 +- src/admin/web/management.js | 17 +- src/admin/web/partials/overview-metrics.html | 51 ++-- tests/panel_icons_browser.py | 82 ++++++ tests/panel_icons_contract.js | 45 ++++ tests/panel_interface_contract.py | 7 + tests/panel_refresh_contract.js | 24 +- 38 files changed, 1183 insertions(+), 221 deletions(-) create mode 100644 integrations/opnsense-acme/actions_rustproxy_cert.conf create mode 100644 integrations/opnsense-acme/rustproxy-acme.conf.example create mode 100644 src/admin/web/app/icons.js create mode 100644 tests/panel_icons_browser.py create mode 100644 tests/panel_icons_contract.js diff --git a/AGENTS.md b/AGENTS.md index 6922a31..3b6c0d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # RustProxy source and documentation maintenance -Current source version: **0.36.0**. The Cargo package and binary are named `rust-proxy`. +Current source version: **0.36.1**. 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 ad1eb33..bfb1883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,19 @@ 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.1 — interface icon polish and on-demand live logs + +- **SVG icon polish:** HTTP/2 and HTTP/3 tabs now use the same protocol icon; listener HTTP/2/HTTP/3 limit links use the settings cog; **Apply preset** uses a preset/wand icon; the top **Logs** menu receives its missing icon. Compact Overview layout controls keep their native `+ / − / ← / → / ×` glyphs instead of receiving duplicate SVGs, and UI icon sizing is protected from chart SVG rules. +- **WebSocket tunnels:** **Active tunnels** is now a compact clickable badge without an SVG icon. +- **Overview metric cards:** removed the redundant **Recent samples / Ostatnie próbki** footer from the lower metric tiles; the footer is shown only when a metric has real detail text to present. +- **Appearance copy:** removed the experimental/default-state sentence from the SVG icon option help text. The option remains account-scoped and off by default. +- **On-demand live logs:** the panel status SSE uses `/api/events?...&logs=false`; `/api/logs` refreshes only while the Live Logs window is open or docked and actively following the bounded buffer. Closing, pausing or expanding the history view stops periodic log reads; reopening/resuming triggers an immediate refresh. Existing `/api/events` API consumers remain compatible because the new `logs` query parameter defaults to `true`. +- **OPNsense certificate deploy:** the source release now includes the previously referenced `rustproxy-acme.conf.example` and `actions_rustproxy_cert.conf`; the integration guide explicitly requires certificate scope plus `allow_deploy` for the deployment credential. +- **Upgrade notes:** no configuration or database changes are required. + ## 0.36.0 — leaner administration polling +- **Experimental interface icons:** **Settings → Appearance → Interface → Show SVG interface icons** adds semantic inline SVG icons to navigation, action buttons, tabs and dialogs. The account-scoped option is **off by default**, keeps text labels as the accessible source of meaning, follows the current theme via `currentColor`, and can be disabled without changing the original control layout or behavior. - **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. diff --git a/Cargo.lock b/Cargo.lock index f93ec9a..c31dbd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.36.0" +version = "0.36.1" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index eca1e06..50a835c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.36.0" +version = "0.36.1" edition = "2021" rust-version = "1.99.1" description = "RustProxy - independent reverse proxy with local administration" diff --git a/SHA256SUMS b/SHA256SUMS index 92b3a90..a1e3d78 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,10 +1,10 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example e76e688874828e3743d03693b1d2872b9633b4db09909673786caf30f7cfe489 .gitignore -eb7cb7121e65b91809f94b8994983635e0086bfeff30eef42e1a450de5e98517 AGENTS.md -a21c3c493427f528178010fa4670d57f40db350890ff3c204d5917bf3f6d1bf3 CHANGELOG.md -57cdec24db9391f2427445e6585b075219a57c215e054b20b5d9e8fa2a9ccb4e Cargo.lock -ba5cf6364e861c263c8b8edf2243e5b45ee417ea873863904df3fb37aa8b5636 Cargo.toml +fded6c62b3bbf31e80efa6a5411431625fb06a3be044eb03399de2e09d02c1d6 AGENTS.md +ad23fed76aa9795ce6a34272504607454a8b60aa4293faf1b5be32526e9313f3 CHANGELOG.md +a9309ef711e6daa6ca9bf7f3d7d9bfffe2411a5557be90d7eceeea06e3f5bd1d Cargo.lock +a27d4c4f02f92ef1d41a96800ec46a9c835d4fe5edc2184f0babfa28e6c053a6 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 LICENSE 82d1dee2fc68158f0ea73259151461e64c8b18d807cd1fecfb1ed99dc30cf5f4 README.md @@ -28,17 +28,17 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 60d108fd168064e4d6980ed7499f3219017f9878c743697e77f2efc592e4d629 docs/ACCESS.md -7d9fc7238ed04fe0e7258f7b9fced737ce7b85b578b32e7405cd5bea7983b5a3 docs/API.md +19a4e028350f6ff786dd799a73fe7f3ec99cb5cf51cc291d359d35452df84862 docs/API.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md 4fb55a549535f37467f14536cbf0db7be027ca8a019c2919a384af4f23655300 docs/CACHE.md 32599d67a11fd0ebbc612e82b9f602789f4ed4103cda482c6870d8741100c8b4 docs/CERTIFICATES.md 02bb383b5c3bce22616c3cc3db30a9469806dc16decb509973e84c60aa30fc5b docs/CONFIGURATION.md fec29ce846d6705eb45819d85d382f28e28a146b265feab2e4c7cce2a35e5883 docs/CUSTOM_LOCATIONS.md -57db0ce916e01af49d41910ede6885e1a77744484ceb084606d34f8a72362c53 docs/DASHBOARD.md +91ce42f259e833b6b5bd351519d27727b81dacf7879516bd024bb11dc728d7bd docs/DASHBOARD.md 11a08bf2110bbeabc924eb049a30177a216af5607ccdaf8be9178e751f78a2c4 docs/DOCKER_DISCOVERY.md -54a9b66016457e104aae7de30121fad87c887e98fd3834b60051afbcc81e014a docs/FEATURES.md +3e7e63d1d8f5d5019d5e4fdbabfe44fb39c2b75e7141d9c94846d946f34849f2 docs/FEATURES.md 3939c14e83ef6856a421f358a3f7fce3d50ea375bc1b9f0419f27a3eed658f6f docs/INSTALL.md -694cb7b15c5bbdc94cecd15dc516c05dcb3467849322cdd125b872d36dee4699 docs/LOGS.md +6e37859bf0dced99d4a7db93790cb38fcd90191932827946d5223eaa4c784fce docs/LOGS.md 0e9ae9509a78d00951d2c7c3f13eaf518d1f3448a29b048f5933e1f5f8440275 docs/MIGRATION.md 0972248b7062b37e61edcc34dab5fe73ee9bb50195a0aa475c72efa0dd311aa9 docs/MONITORING.md e2e8fc307885bd02c177aea5740b2ac767fc7bdcfe7034dd75b8646f3014b39f docs/PERFORMANCE.md @@ -81,17 +81,19 @@ af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhost 1a041c505669625a7f0f8011484cf5b6d2ec69a7de859801312e4fcf4c8d3555 install-debian13.sh 909e9ab94617ace74a425f15084bf85f3086cc52ba5b28f2b441ff800abc6ef9 install.sh 42f9f1d18c021fc7734b5bd020844bf678725a0e539e2fcbe05ec676f6239eae integrations/README.md -2d4c859aae5ba357a6d30024c8487ae8f59f01b192711f13f9c2f8fee0f48b50 integrations/opnsense-acme/README.md +ba753f69557bd9fa990e56beabebccf39f93b5f6fb7e66a2e241c8c461dfd763 integrations/opnsense-acme/README.md +3a1f8c326618a4613bb35f9e83428f6d1cf668b9e37b4731e5d38c3081fc94d8 integrations/opnsense-acme/actions_rustproxy_cert.conf de6550ab8f48d556d752c252212c38ba65491fd224f972c10fbe552f91ed7520 integrations/opnsense-acme/opnsense-rustproxy-cert-deploy.sh +73784ac652708b04ca6c91aeed4524f51f50d63715bbeefe7f5590b353e78943 integrations/opnsense-acme/rustproxy-acme.conf.example 45be151cd85fc2ef2f1fdc3003ace63381af262e73af8a685ab48cb697cc0de1 integrations/routeros-oci-build/.env.routeros.example 11aa102de1089ecc375f20c4bd5420fcfd3b2ef23fe0c8bdf04953fcfaf40ef0 integrations/routeros-oci-build/Dockerfile.routeros 7c7a3e51f43b43e1c962a4b0bb591f99c1f06977bf77f23719f7f867dfe7eb8e integrations/routeros-oci-build/Dockerfile.routeros.dockerignore -a10d61ad26d111358dbe8b4bcf5b0df1eb01e52a9c632aab8f2a1dc7af4f253a integrations/routeros-oci-build/README.md +e655bb17583e46f182226d7d2e1da45e6dffd0a8fd457e0cfb776778445e4358 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 -e1c3eca78541682f98bf1449ce5b0c5e5c69228a608250e6d879275380cde25c langs/en_US.json -34628bddc76c02eff482aa8300e0523e0edad87dfa4ebc3ef3285d68b6c46cfb langs/pl_pl.json +a83dd54e685cdf96c2f3c8dc82c8df2ccc4c3e46268d9c1dbc68d293cbebf8bb langs/en_US.json +8b9b4ea09b9f946167805173f27fab9b17a6579dc28811cbc0aee619a13c5166 langs/pl_pl.json 9664543e4d03422788a65e42f968c1f026c2235cb5be6fc3e0ad318997077538 make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -123,7 +125,7 @@ a59587ce8ad41b1bbb431356b58398f9c8ff7cb96a613a91752e907a70e10c2e scripts/deploy 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py 4c905a387f210c30fbf12a7738676fd39819bc96bc4a272da1db5f18a2d3013e scripts/migration/migrate_npm_core.py -2ae780e30c1212ffe134f1785b2767a4a6591d5f74baef6c2f47cc8df35afbdb scripts/preflight.py +960fdf97ec899107a567405e9b9c2b70ee16951d5ea26c37d885a7920f7b3aa1 scripts/preflight.py 3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh d759315babd9971b354d6f702d4c861eb1cd5c41b1e2e0f1a94ad4d2c2db49a3 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py @@ -160,25 +162,25 @@ b9c2952e3fcfca540d0ad64f2d3acc5c1f5240e707f4f23ef4d452f46fcb520f src/admin/docu 059218501907355b22bc84e7e0c41ec72565af283f5e97170ce7707ea5874009 src/admin/file_logs.rs d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file_logs_io.rs 77b8c05645969834cc8a58e249a7b60a57d6c47ad1c5cd92ffa5e4a9b5a8e73c src/admin/file_logs_query.rs -77645eb8a845e64b76726ffcfd31f2e009667c6285a80915c60c2a02ecacf236 src/admin/logging_api.rs +743bb7436f77e04015328a0e7e7d425e43ef8aa8d61467bde157e462c1dcdc2e src/admin/logging_api.rs 243f4a9c3fa99781e19c3952354d2632c0ce65e1dafac729567cbe3c177f94cb src/admin/mod.rs -192350fb8b6557cf05e49dce9597228a00b21566cd6da5129871bea026983669 src/admin/openapi.json +500e87bb88ccc45806c0967262e76e3ee77ec5c2fbd9881d8aa26ff043f3377d 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 +9080babb5094d24c2cd4b8e6efb6a516142263ff9f45219a13857dfc55f33fa4 src/admin/preferences.rs 35a7f99c7448c90b8637f4b3a7becca138f1f378fe5dc3f87122151525bb20f9 src/admin/preferences_columns.rs -b31595d9522962ae16dff83dd8bc063d31fb027cbf357e9953285d2e622daa26 src/admin/preferences_tests.rs +4bc49f367ea0d7e387151434498366e61836d3132e12657cd2b28073f668f80c src/admin/preferences_tests.rs 62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs -c8454f3092145c5fefdc8d93951ae46f192a6eb8139dbe288284b3a1c803b192 src/admin/router.rs +e399ff25c8f26aab429b329609beb9633a61ed6e32ca82d14cfa59978585dd35 src/admin/router.rs e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/session.rs 7aeb784a3ef58795c8fe26467bb4e1a00e61420911f798a584ab09c12f73a754 src/admin/status.rs 2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs 8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -e427a72973381c47f140c20d0d23ac88fb57e4acc2c44979b53503b6944e7bbb src/admin/tests.rs +0d5bd0e7fe4428959a90256ae1cbdb8ec6ffbc829214907670779564e1b1b2f8 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,18 +188,19 @@ 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 -892833d21c58a8b0d4e4efc3752391cf3d3ec5f24fceae8fd4f180849fe9187c src/admin/web/app/core.js +fcce3b0204ca2c679f5d8049d020619664a3c0a45889975681258d82ae329988 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 -47eaf9486099468b3d686bdca5c6b8d59aa5f5661baa03b3698320cf618db4e2 src/admin/web/app/overview-extra-metrics.js +6c1321059662a15998bba60190419469c04b71be346fb9c2adfd8b83a4ea696b src/admin/web/app/icons.js +32aa11f041eafdf16e66adef6f5847a67413785224da2c19ecbf825996622aff src/admin/web/app/overview-extra-metrics.js 5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js 4d1d4760cdff44771c9d880a7c0da81d7233ef6e3a4868111a522703831a7a54 src/admin/web/app/overview-samples.js db9012546cae8c58fc2bcbee71c9a95956a13a6db9e6d126c6a3770d45a4e4d2 src/admin/web/app/overview-wide-metrics.js bfd9fafb3112197e8f875dbf87a9a66c81aef0fe83687601a84a66f5e13f69bc src/admin/web/app/path-permissions.js -46c7cd36397375eb7e8c230c8aa97b1dbea0a63800beb977efe1ec67a14b237e src/admin/web/app/preferences.js +be276b8c55dc2c48770de200ce3f6e669e4c4146bcd1b4d03c4af32f025c4709 src/admin/web/app/preferences.js 9e2b12d34ab1fb29716645720577ad49b16c03b46c482a03695e8f645d170e79 src/admin/web/app/rate-sampling.js -d34f9e25a04cc4eafdbc48daa4e69aab18944e8d34e555a50d9c122c680595f8 src/admin/web/app/refresh.js +3b8e566d81a5b94ef21c73e7805ae128454f085554de9358040de266bcbdb0e0 src/admin/web/app/refresh.js c6ae53c0e95e67b9165822813efc622001be5354c19bb221a52514221d1df23e src/admin/web/app/runtime.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js c7a18238ac426de4d1cf84a395ee4dc66410d60a57a3822695dc10adc668d956 src/admin/web/app/settings.js @@ -206,11 +209,11 @@ b7ead49224a4ec67181d3f24f9d9634bd6d76756f826a4d5d993300637d1d8f3 src/admin/web/ 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 +922e1bc7631f9e942c31cb8b130bb377791cdc0a3248513c80698af9ed4c8b7b src/admin/web/css/10-base.css 6ed54b27a4a81b073f1d30cf61e366f3d5c1f6f8495f0d0a8af48e061937eb4e src/admin/web/css/20-shell.css -c6d5696f0cdf4149e3341d18f7fd955f2411d50aac49a307c3eb0de2db9f92cb src/admin/web/css/30-overview.css +321f8c767f1bb82d2b684ed72506b753fc01d8d9a81676f28a84dcaa580d5fc8 src/admin/web/css/30-overview.css dd82b5e3ead91c20bb08cf8d71bbba31ea8e3d108c417913c63409f3e34da7e1 src/admin/web/css/40-editors.css -6af9fcb439823ec63abe2c1dd86c9ecba17c233262fd8ab80d321d64ae2758ab src/admin/web/css/50-settings.css +89e33ec7004dda531a15b0b210a59aa125f5954a382e6f7def429293cbdf0f4b src/admin/web/css/50-settings.css f8b515c63080c71ee647cfd8c36628271e8357b31cd84f00cf591e9b752b44fe src/admin/web/css/60-monitoring.css 33c556cd4be0310117ea5324d27e622b0b5ca2190c8d6f70aa551d94fb411d38 src/admin/web/css/70-responsive.css 8009afdb04d5170687816730cc930a22fead1fe0248835ca7e8de81d6907395d src/admin/web/css/README.md @@ -239,14 +242,14 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js 3f0525ca1d4e088ccd4988b8a117b26a8cf8c12213b24c37081d41b68d2b0ff2 src/admin/web/host404.js -cdd390e69ca4cd010262e8fddb653e4097fbcfdc678b081c422edcd68e974f85 src/admin/web/index.html -7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js +c6e8e98a17126b24da815f0d8d55cbc47b451b046aea922638dff9cc9f4397d7 src/admin/web/index.html +7979c490b7fcfcd8f5e52e99fcf7757d78509337a625341c98f62c6da50da374 src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json ac9f667273918e18406df06cb2613c923f50a88897693a03a18f500e22dc6ec2 src/admin/web/login.html c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js -734aeac4855f0913a84a82d7b5c17b37de4504b50a8701edeb775d6926929bce src/admin/web/management.js +16d848a25f744e55d18757ce82cfd80e85bcc5b1777342c41e7bd732e13d8913 src/admin/web/management.js 1f3d10e389c9c20e6eb019509fa6e82b50c6b43bbf988c1210a4d42bb73985d0 src/admin/web/partials/overview-charts.html -2842346eb66569b0aba2938ff0f724b50d21254b2aa0d2c00f6380ad03449b61 src/admin/web/partials/overview-metrics.html +5b98f30f2d0da378221b029e7f7fa81d81fc725424a2242a336f1cf5025e6f87 src/admin/web/partials/overview-metrics.html 3928afce2ed6d5f2d3e29475dd29e4c3bd3c6df180ad5119beaff0c7a58e3b3b src/admin/web/prometheus-token.js 2d491fe9a547fe109dc5db6a74806d50b08c3daca15a6622c79c9fea33b10c63 src/admin/web/redirects.js 76a01f590d49649dcb1f554c221290919b2a8438e6001737f3c096fa6daec74d src/admin/web/settings.js @@ -553,8 +556,10 @@ 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 +2f7f0453c817dc9e3d4ab61296af5821ff3e0826ad70511714afd7486c6105bc tests/panel_icons_browser.py +d136ebe964e83aa7194d70ec7e9dda36cf0e9832e7cd0d357d199440a705e4f2 tests/panel_icons_contract.js +01af3008be27fabb486c141c67099af104f2725cc56f4c656aea9741a3c150cd tests/panel_interface_contract.py +24a815a280de87a7ccec6f4d212d1468022c844e9e7d0b113349ff26bde5fc52 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 0a12dd5..c9fe690 100644 --- a/docs/API.md +++ b/docs/API.md @@ -30,7 +30,7 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot | `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. | | `POST /api/certificates/inspect` | Read DNS SAN coverage from a managed/external public PEM path for the editor; admin browser session only. | | `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. | -| `GET/PUT /api/preferences` | Personal UI settings. | +| `GET/PUT /api/preferences` | Personal UI settings, including theme/layout and optional `interface_icons` (default `false`). | | `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. | | `GET /docs/openapi.json` | Protected machine-readable API specification. | diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 8a6d707..df9973d 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -41,6 +41,12 @@ After **Unlock layout**, every block shows a compact toolbar: the **⠿** handle **Dashboard Presets** supports up to **16 named layouts per administrator**. Save, load, rename, overwrite and delete presets; a factory layout can be restored. Presets preserve the dashboard arrangement, not the live traffic values. Interface theme, scale and other display settings are also account-scoped and do not alter proxy routing or rewrite shared configuration. +### SVG interface icons + +Under **Settings → Appearance → Interface**, enable **Show SVG interface icons** to add semantic line icons to the main navigation, action buttons, tabs and dialogs. The preference is stored per administrator as `interface_icons: true|false` in `/api/preferences` and is **off by default** for both new and upgraded accounts. + +The icons are inline SVGs bundled with RustProxy; no web font, CDN or external icon library is loaded. They use `currentColor`, so the active theme, danger/primary states and disabled-state colors remain authoritative. Text labels and existing ARIA labels remain the source of accessible meaning; the SVGs are `aria-hidden`. Dynamic controls are reclassified when their state changes, so actions such as sort direction, save/loading, show/hide and refresh keep a matching icon. Turning the option off removes the injected icons and restores the original text-only presentation without restarting the panel. + ## Editing configuration safely 1. Open the relevant workspace and inspect existing state before editing. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cabd264..cfff94f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -95,6 +95,7 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se - Nginx Proxy Manager import with a dry-run plan. - Managed install/update scripts with rollback, DEB/RPM packages, Docker image and an OPNsense plugin. - Panel in English and Polish, light and dark themes, adjustable density and corner style. +- Optional account-scoped semantic SVG icons for navigation, actions, tabs and dialogs; off by default. ## Administration and API diff --git a/docs/LOGS.md b/docs/LOGS.md index 3548e18..c9bea8f 100644 --- a/docs/LOGS.md +++ b/docs/LOGS.md @@ -4,6 +4,8 @@ RustProxy has several independent logging paths. Choose the one matching the que ## Live Logs +The administration panel does not fetch `/api/logs` while Live Logs is closed. Opening or docking the Live Logs window starts the bounded live snapshot refresh; closing it stops that polling. Pausing or expanding the history buffer also suspends periodic `/api/logs` reads until live viewing resumes. The main `/api/events` status stream is kept independent by using `logs=false` in the panel. + Use **Logs → Live Logs** in the authenticated panel, or locally: ```bash diff --git a/integrations/opnsense-acme/README.md b/integrations/opnsense-acme/README.md index 2ec624b..25befab 100644 --- a/integrations/opnsense-acme/README.md +++ b/integrations/opnsense-acme/README.md @@ -5,7 +5,7 @@ A simple mechanism for automatically sending a certificate generated by `os-acme ## Files - `opnsense-rustproxy-cert-deploy.sh` - sends the certificate and private key to RustProxy. -- `rustproxy-acme.conf` - connection settings, API token, and certificate file paths. +- `rustproxy-acme.conf.example` - example connection settings, API token, and certificate file paths. - `actions_rustproxy_cert.conf` - optional `configd` action that allows the script to be executed from Cron in OPNsense. ## 1. Installation @@ -50,13 +50,15 @@ KEY_FILE='/path/to/private.key' ## 3. RustProxy API -Create a certificate API key in RustProxy and enter its values in the configuration: +Create a certificate API key in **Settings -> Certificate API** and enter its values in the configuration: ```text RUSTPROXY_KEY_ID RUSTPROXY_TOKEN ``` +The credential must explicitly allow `CERTIFICATE_ID` and **Also allow deploy** (`allow_deploy`) must be enabled. A reload-only credential will be rejected with HTTP 403. + The script sends the certificate to: ```text @@ -88,7 +90,7 @@ cp actions_rustproxy_cert.conf /usr/local/opnsense/service/conf/actions.d/ service configd restart ``` -Then go to **System -> Settings -> Cron** and add a scheduled execution of the `Deploy ACME certificate to RustProxy` action. +Verify the action first with `configctl rustproxy_cert deploy`. Then go to **System -> Settings -> Cron** and add a scheduled execution of the `Deploy ACME certificate to RustProxy` action. The script deliberately reconciles on every run instead of trusting a local cache. The server is the source of truth: an identical RustProxy-owned certificate generation is a no-op, a missing id is created, and changed material is rotated atomically. This also repairs drift when a certificate entry or generation was removed outside OPNsense. diff --git a/integrations/opnsense-acme/actions_rustproxy_cert.conf b/integrations/opnsense-acme/actions_rustproxy_cert.conf new file mode 100644 index 0000000..56fb0dd --- /dev/null +++ b/integrations/opnsense-acme/actions_rustproxy_cert.conf @@ -0,0 +1,6 @@ +[deploy] +command:/usr/local/sbin/opnsense-rustproxy-cert-deploy.sh +parameters: +type:script_output +message:deploy ACME certificate to RustProxy +description:Deploy ACME certificate to RustProxy diff --git a/integrations/opnsense-acme/rustproxy-acme.conf.example b/integrations/opnsense-acme/rustproxy-acme.conf.example new file mode 100644 index 0000000..9df5513 --- /dev/null +++ b/integrations/opnsense-acme/rustproxy-acme.conf.example @@ -0,0 +1,21 @@ +# RustProxy administration endpoint reachable from OPNsense. +RUSTPROXY_URL='https://rustproxy.example.lan:9090' + +# Certificate API credential created in RustProxy Settings -> Certificate API. +# The credential must allow this CERTIFICATE_ID and have deploy permission enabled. +RUSTPROXY_KEY_ID='opnsense-acme' +RUSTPROXY_TOKEN='REPLACE_WITH_CERTIFICATE_API_TOKEN' + +# Stable RustProxy certificate id to create/update. +CERTIFICATE_ID='shared-cert' + +# os-acme-client certificate material. Replace CERT_UUID with the actual UUID. +CERT_FILE='/var/etc/acme-client/certs/CERT_UUID/fullchain.pem' +KEY_FILE='/var/etc/acme-client/keys/CERT_UUID/private.key' + +# Optional: trust a private CA used by the RustProxy administration listener. +# RUSTPROXY_CACERT='/usr/local/etc/ssl/rustproxy-ca.pem' + +# Optional: connect to a fixed address while keeping TLS verification for the URL hostname. +# Format: hostname:port:address +# RUSTPROXY_RESOLVE='rustproxy.example.lan:9090:192.0.2.20' diff --git a/integrations/routeros-oci-build/README.md b/integrations/routeros-oci-build/README.md index c233c39..b14aa76 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.36.0` and the defaults this publishes: +For example, with RustProxy version `0.36.1` and the defaults this publishes: ```text -zot.example.net/network/rustproxy:0.36.0 +zot.example.net/network/rustproxy:0.36.1 zot.example.net/network/rustproxy:latest zot.example.net/network/rustproxy:routeros ``` diff --git a/langs/en_US.json b/langs/en_US.json index 1982a37..4599bc1 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -2402,6 +2402,8 @@ "buttonStyleNormal": "Normal", "buttonStyleCompact": "Compact", "buttonStyleHelp": "Normal uses the refreshed controls; Compact restores the smaller source-style buttons across all actions and dialog tabs.", + "interfaceIcons": "Show SVG interface icons", + "interfaceIconsHelp": "Adds semantic SVG icons to navigation, actions, tabs and dialogs.", "entityEnableHint": "Controls whether this item is active.", "setupCommandExample": "Standard service installation example:", "certificateRuntimeTitle": "ACME runtime", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 0363a3b..f8aff2a 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -2402,6 +2402,8 @@ "buttonStyleNormal": "Normalny", "buttonStyleCompact": "Kompaktowy", "buttonStyleHelp": "Normalny używa odświeżonych przycisków; Kompaktowy przywraca mniejsze przyciski ze źródłowego interfejsu we wszystkich akcjach i zakładkach modali.", + "interfaceIcons": "Pokaż ikony SVG interfejsu", + "interfaceIconsHelp": "Dodaje semantyczne ikony SVG do nawigacji, akcji, zakładek i okien dialogowych.", "entityEnableHint": "Określa, czy ten element jest aktywny.", "setupCommandExample": "Przykład dla standardowej instalacji usługi:", "certificateRuntimeTitle": "Środowisko ACME", diff --git a/scripts/preflight.py b/scripts/preflight.py index 95baed6..8c4d901 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -132,7 +132,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]: flat = module.parent.with_suffix(".rs") if flat.is_file(): raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") - for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "LICENSE", "docs/FEATURES.md", "docs/PERFORMANCE.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/INSTALL.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]: + for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/icons.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "LICENSE", "docs/FEATURES.md", "docs/PERFORMANCE.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/INSTALL.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]: if not (root / path).is_file(): raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}") for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"): diff --git a/src/admin/logging_api.rs b/src/admin/logging_api.rs index c20dac7..049e41f 100644 --- a/src/admin/logging_api.rs +++ b/src/admin/logging_api.rs @@ -171,6 +171,7 @@ async fn metrics_summary(State(state): State>) -> Response { #[serde(default, deny_unknown_fields)] struct EventsQuery { interval: Option, + logs: Option, } async fn events( @@ -179,6 +180,7 @@ async fn events( Query(query): Query, ) -> Result { let interval = query.interval.unwrap_or(3); + let include_logs = query.logs.unwrap_or(true); if ![1, 2, 3, 5, 10].contains(&interval) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, @@ -198,8 +200,8 @@ async fn events( ) })?; let stream = futures_util::stream::unfold( - (state, secret, permit, false, interval), - |(state, secret, permit, delayed, interval)| async move { + (state, secret, permit, false, interval, include_logs), + |(state, secret, permit, delayed, interval, include_logs)| async move { if delayed { tokio::time::sleep(Duration::from_secs(interval)).await; } @@ -218,13 +220,16 @@ async fn events( Ok(Some(_)) => {} _ => return None, } - let payload = + let payload = if include_logs { json!({"status":live_status_payload(&state), "logs":logs_payload(&state)}) - .to_string(); + } else { + json!({"status":live_status_payload(&state)}) + } + .to_string(); let event = Event::default().event("snapshot").data(payload); Some(( Ok::(event), - (state, secret, permit, true, interval), + (state, secret, permit, true, interval, include_logs), )) }, ); diff --git a/src/admin/openapi.json b/src/admin/openapi.json index a2d40a2..b0eadeb 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.36.0", + "version": "0.36.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. 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": [ @@ -443,10 +443,31 @@ "type": "object", "description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.", "additionalProperties": false, - "required": ["provider", "method"], + "required": [ + "provider", + "method" + ], "properties": { - "provider": {"type": "string", "enum": ["basic", "tinyauth", "authelia", "authentik", "pocket_id", "custom"]}, - "method": {"type": "string", "enum": ["basic", "forward_auth", "oidc"], "description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."} + "provider": { + "type": "string", + "enum": [ + "basic", + "tinyauth", + "authelia", + "authentik", + "pocket_id", + "custom" + ] + }, + "method": { + "type": "string", + "enum": [ + "basic", + "forward_auth", + "oidc" + ], + "description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only." + } } }, "OidcConfig": { @@ -454,20 +475,76 @@ "description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.", "additionalProperties": false, "properties": { - "enabled": {"type": "boolean", "default": false}, - "issuer_url": {"type": "string", "format": "uri", "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"}, - "backend_url": {"type": "string", "format": "uri", "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"}, - "redirect_url": {"type": "string", "format": "uri", "description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"}, - "client_id": {"type": "string", "maxLength": 256}, - "client_secret": {"type": "string", "maxLength": 2048, "description": "Confidential OIDC client secret; stored in Vhost configuration"}, - "session_secret": {"type": "string", "minLength": 32, "maxLength": 4096, "description": "High-entropy secret for encrypted session and temporary state cookies"}, - "scopes": {"type": "string", "default": "openid profile email"}, - "allow_private": {"type": "boolean", "default": false}, - "address_allowlist": {"type": "array", "items": {"type": "string"}}, - "tls_ca_pem": {"type": "string", "nullable": true}, - "connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5}, - "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10}, - "session_ttl_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "default": 3600} + "enabled": { + "type": "boolean", + "default": false + }, + "issuer_url": { + "type": "string", + "format": "uri", + "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL" + }, + "backend_url": { + "type": "string", + "format": "uri", + "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged" + }, + "redirect_url": { + "type": "string", + "format": "uri", + "description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback" + }, + "client_id": { + "type": "string", + "maxLength": 256 + }, + "client_secret": { + "type": "string", + "maxLength": 2048, + "description": "Confidential OIDC client secret; stored in Vhost configuration" + }, + "session_secret": { + "type": "string", + "minLength": 32, + "maxLength": 4096, + "description": "High-entropy secret for encrypted session and temporary state cookies" + }, + "scopes": { + "type": "string", + "default": "openid profile email" + }, + "allow_private": { + "type": "boolean", + "default": false + }, + "address_allowlist": { + "type": "array", + "items": { + "type": "string" + } + }, + "tls_ca_pem": { + "type": "string", + "nullable": true + }, + "connect_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 30, + "default": 5 + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 60, + "default": 10 + }, + "session_ttl_seconds": { + "type": "integer", + "minimum": 60, + "maximum": 86400, + "default": 3600 + } } }, "ForwardAuthConfig": { @@ -475,17 +552,70 @@ "description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.", "additionalProperties": false, "properties": { - "enabled": {"type": "boolean", "default": false}, - "url": {"type": "string", "maxLength": 2048, "description": "Internal service/outpost origin or the provider's verifier endpoint"}, - "login_url": {"type": "string", "maxLength": 2048, "description": "Required public HTTPS portal URL for Authelia; ignored for authentik"}, - "allow_private": {"type": "boolean", "default": false}, - "address_allowlist": {"type": "array", "maxItems": 32, "items": {"type": "string"}}, - "tls_ca_pem": {"type": "string", "nullable": true}, - "connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5}, - "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10}, - "request_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"}, - "response_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"}, - "redirect_forbidden": {"type": "boolean", "default": false, "description": "Authelia only: accept trusted portal Location on explicit 403 if provided"} + "enabled": { + "type": "boolean", + "default": false + }, + "url": { + "type": "string", + "maxLength": 2048, + "description": "Internal service/outpost origin or the provider's verifier endpoint" + }, + "login_url": { + "type": "string", + "maxLength": 2048, + "description": "Required public HTTPS portal URL for Authelia; ignored for authentik" + }, + "allow_private": { + "type": "boolean", + "default": false + }, + "address_allowlist": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string" + } + }, + "tls_ca_pem": { + "type": "string", + "nullable": true + }, + "connect_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 30, + "default": 5 + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 60, + "default": 10 + }, + "request_headers": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string", + "maxLength": 128 + }, + "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed" + }, + "response_headers": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string", + "maxLength": 128 + }, + "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB" + }, + "redirect_forbidden": { + "type": "boolean", + "default": false, + "description": "Authelia only: accept trusted portal Location on explicit 403 if provided" + } } }, "Vhost": { @@ -591,8 +721,13 @@ "$ref": "#/components/schemas/ForwardAuthConfig", "description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia." }, - "oidc": {"$ref": "#/components/schemas/OidcConfig"}, - "authentication": {"$ref": "#/components/schemas/AuthenticationChoice", "description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."}, + "oidc": { + "$ref": "#/components/schemas/OidcConfig" + }, + "authentication": { + "$ref": "#/components/schemas/AuthenticationChoice", + "description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects." + }, "cache": { "type": "object", "properties": { @@ -3687,6 +3822,11 @@ "default": false, "description": "Account-scoped optional X/Y scales on sampled time-series charts. Shows the same Y range used to draw each SVG and X sample indices rather than inferred elapsed time; does not change adaptive scaling." }, + "interface_icons": { + "type": "boolean", + "default": false, + "description": "Account-scoped administration UI preference. When enabled, semantic inline SVG icons are added to navigation, actions, tabs and dialogs while preserving text labels and accessibility." + }, "button_style": { "type": "string", "enum": [ @@ -10414,7 +10554,7 @@ }, "/api/events": { "get": { - "summary": "Live SSE status and log snapshots", + "summary": "Live SSE status snapshots with optional logs", "tags": [ "Monitoring" ], @@ -10471,7 +10611,7 @@ } } }, - "description": "SSE snapshot events contain compact volatile status and bounded logs. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.", + "description": "SSE snapshot events always contain compact volatile status. Bounded recent logs are included by default for backward compatibility and can be disabled per viewer with logs=false; the administration panel disables them until Live Logs is open/docked and polls /api/logs only while that view is active. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.", "parameters": [ { "name": "interval", @@ -10489,6 +10629,16 @@ ], "default": 3 } + }, + { + "name": "logs", + "in": "query", + "required": false, + "description": "Include the bounded live log snapshot in each SSE event. Defaults to true for API compatibility; the panel uses false and fetches logs only while Live Logs is active.", + "schema": { + "type": "boolean", + "default": true + } } ] } @@ -14446,4 +14596,4 @@ } }, "tags": [] -} \ No newline at end of file +} diff --git a/src/admin/preferences.rs b/src/admin/preferences.rs index 2636727..57f75f8 100644 --- a/src/admin/preferences.rs +++ b/src/admin/preferences.rs @@ -118,6 +118,8 @@ struct AdminPreferences { chart_style: String, #[serde(default)] chart_axes: bool, + #[serde(default)] + interface_icons: bool, #[serde(default = "default_admin_button_style")] button_style: String, #[serde(default = "default_admin_corner_style")] @@ -180,6 +182,7 @@ impl Default for AdminPreferences { time_format: default_admin_time_format(), chart_style: default_admin_chart_style(), chart_axes: false, + interface_icons: false, button_style: default_admin_button_style(), corner_style: default_admin_corner_style(), table_density: default_admin_table_density(), diff --git a/src/admin/preferences_tests.rs b/src/admin/preferences_tests.rs index 3b41cb0..e0e9e62 100644 --- a/src/admin/preferences_tests.rs +++ b/src/admin/preferences_tests.rs @@ -41,6 +41,20 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() { assert!(hidden.iter().all(|id| order.contains(id))); assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok()); assert!(!AdminPreferences::default().chart_axes); + assert!(!AdminPreferences::default().interface_icons); +} + +#[test] +fn optional_interface_icons_preference_is_backward_compatible_and_off_by_default() { + let legacy: AdminPreferences = serde_json::from_str(r#"{"theme":"light"}"#).unwrap(); + assert!(!legacy.interface_icons); + let mut enabled = legacy.clone(); + enabled.interface_icons = true; + assert!(validate_admin_preferences(&enabled).is_ok()); + let stored = serde_json::to_string(&enabled).unwrap(); + assert!(stored.contains("\"interface_icons\":true")); + let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap(); + assert!(recovered.interface_icons); } #[test] diff --git a/src/admin/router.rs b/src/admin/router.rs index a53151f..9ca25db 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -57,6 +57,7 @@ fn admin_app_javascript() -> &'static str { ");", include_str!("web/app/overview-samples.js"), include_str!("web/app/core.js"), + include_str!("web/app/icons.js"), include_str!("web/app/path-permissions.js"), include_str!("web/app/session-resume.js"), include_str!("web/app/refresh.js"), diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 0e0adda..90505ca 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -2146,6 +2146,7 @@ mod tests { assert_eq!(initial["time_format"], "24h"); assert_eq!(initial["chart_style"], "default"); assert_eq!(initial["chart_axes"], false); + assert_eq!(initial["interface_icons"], false); assert_eq!(initial["button_style"], "normal"); assert_eq!(initial["corner_style"], "default"); assert_eq!( @@ -2183,6 +2184,7 @@ mod tests { assert_eq!(legacy["time_format"], "24h"); assert_eq!(legacy["chart_style"], "default"); assert_eq!(legacy["chart_axes"], false); + assert_eq!(legacy["interface_icons"], false); assert_eq!(legacy["button_style"], "normal"); assert_eq!(legacy["corner_style"], "default"); assert_eq!( @@ -2205,6 +2207,7 @@ mod tests { "time_format":"12h", "chart_style":"neon", "chart_axes":true, + "interface_icons":true, "button_style":"compact", "corner_style":"rounded", "table_density":"ultra", @@ -3524,6 +3527,16 @@ mod tests { .await .unwrap(); assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY); + let bad_sse_logs = router + .oneshot( + request("/api/events?logs=maybe") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(bad_sse_logs.status(), StatusCode::BAD_REQUEST); store.close().await; } @@ -3562,7 +3575,10 @@ mod tests { 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}"); + assert!( + value.get(field).is_none(), + "{path} unexpectedly contains {field}" + ); } for &field in retained { assert!(value.get(field).is_some(), "{path} is missing {field}"); diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index 5dc6e1c..63edc0d 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -59,6 +59,7 @@ function translate() { $("connection-label").textContent = t(connected ? "connected" : "disconnected"); if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs); syncSettingsDirtyIndicators(); + window.rustProxyIcons?.refresh?.(); } function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; } function raiseToast() { @@ -379,13 +380,9 @@ function renderFooter(s) { } let lastLogs = null; function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); } -async function refresh(force = false, includeLogs = false) { +async function refresh(force = false) { if (!authenticated) return; - if (force) { - const tasks = [rustProxyRefresh.manual("status-full")]; - if (includeLogs) tasks.push(rustProxyRefresh.manual("logs-fallback")); - return Promise.all(tasks); - } + if (force) return rustProxyRefresh.manual("status-full"); return rustProxyRefresh.manual("status-fallback"); } function renderLiveStatus(value) { renderStatus(value); } @@ -399,15 +396,16 @@ function startLive() { if (liveEvents && liveInterval === seconds) return; stopLive(); liveInterval = seconds; - const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}`); + const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}&logs=false`); liveEvents = stream; stream.addEventListener("snapshot", event => { if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return; try { const payload = JSON.parse(event.data); - if (!payload.status || !payload.logs) throw new Error("Invalid SSE payload"); + if (!payload.status) throw new Error("Invalid SSE payload"); liveConnected = true; liveFallback = false; - renderLiveStatus(payload.status); renderLogs(payload.logs); + renderLiveStatus(payload.status); + if (payload.logs) renderLogs(payload.logs); window.rustProxyRefresh?.sync(); } catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); } }); @@ -529,7 +527,7 @@ async function activate(info) { void window.rustProxyApiTokens?.refresh?.(); window.rustProxySettings?.scrollToApiTokens?.(); } - void refresh(true, true).catch(error => { + void refresh(true).catch(error => { if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true); }); return true; diff --git a/src/admin/web/app/icons.js b/src/admin/web/app/icons.js new file mode 100644 index 0000000..1676def --- /dev/null +++ b/src/admin/web/app/icons.js @@ -0,0 +1,251 @@ +const UI_ICON_NS = "http://www.w3.org/2000/svg"; +const UI_ICON_SHAPES = Object.freeze({ + activity: [["path", { d: "M3 12h4l2-6 4 12 2-6h6" }]], + alert: [["path", { d: "M12 3 2.8 20h18.4L12 3Z" }], ["path", { d: "M12 9v4" }], ["circle", { cx: "12", cy: "17", r: ".7", fill: "currentColor", stroke: "none" }]], + archive: [["rect", { x: "3", y: "5", width: "18", height: "4", rx: "1" }], ["path", { d: "M5 9v10h14V9M9 13h6" }]], + book: [["path", { d: "M4 4.5h5.5A2.5 2.5 0 0 1 12 7v13a3 3 0 0 0-3-3H4V4.5ZM20 4.5h-5.5A2.5 2.5 0 0 0 12 7v13a3 3 0 0 1 3-3h5V4.5Z" }]], + box: [["path", { d: "m12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z" }], ["path", { d: "m4 7.5 8 4.5 8-4.5M12 12v9" }]], + certificate: [["rect", { x: "4", y: "3", width: "16", height: "14", rx: "2" }], ["path", { d: "M8 7h8M8 10h5M9 17l-1 4 4-2 4 2-1-4" }]], + check: [["path", { d: "m5 12 4 4L19 6" }]], + chevronDown: [["path", { d: "m6 9 6 6 6-6" }]], + chevronLeft: [["path", { d: "m15 6-6 6 6 6" }]], + chevronRight: [["path", { d: "m9 6 6 6-6 6" }]], + chevronUp: [["path", { d: "m6 15 6-6 6 6" }]], + code: [["path", { d: "m9 7-5 5 5 5M15 7l5 5-5 5M13 5l-2 14" }]], + command: [["rect", { x: "4", y: "4", width: "16", height: "16", rx: "4" }], ["path", { d: "M8 12h8M12 8v8" }]], + copy: [["rect", { x: "8", y: "8", width: "11", height: "11", rx: "2" }], ["path", { d: "M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" }]], + cpu: [["rect", { x: "7", y: "7", width: "10", height: "10", rx: "2" }], ["path", { d: "M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4M10 10h4v4h-4z" }]], + database: [["ellipse", { cx: "12", cy: "5", rx: "7", ry: "3" }], ["path", { d: "M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" }]], + download: [["path", { d: "M12 3v12m0 0-4-4m4 4 4-4M4 19h16" }]], + edit: [["path", { d: "M4 20h4l11-11-4-4L4 16v4ZM13.5 6.5l4 4" }]], + external: [["path", { d: "M14 4h6v6M20 4l-9 9" }], ["path", { d: "M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" }]], + eye: [["path", { d: "M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6S2.5 12 2.5 12Z" }], ["circle", { cx: "12", cy: "12", r: "2.5" }]], + eyeOff: [["path", { d: "M3 3l18 18M10.6 6.2A10 10 0 0 1 12 6c6 0 9.5 6 9.5 6a15 15 0 0 1-3 3.6M6.1 6.2C3.8 8 2.5 12 2.5 12s3.5 6 9.5 6a10 10 0 0 0 3.2-.5M9.9 9.9a3 3 0 0 0 4.2 4.2" }]], + file: [["path", { d: "M6 3h8l4 4v14H6V3Z" }], ["path", { d: "M14 3v5h5" }]], + fileX: [["path", { d: "M6 3h8l4 4v14H6V3ZM14 3v5h5M9 13l6 6M15 13l-6 6" }]], + filter: [["path", { d: "M3 5h18l-7 8v6l-4 2v-8L3 5Z" }]], + globe: [["circle", { cx: "12", cy: "12", r: "9" }], ["path", { d: "M3 12h18M12 3c2.5 2.7 3.5 5.7 3.5 9S14.5 18.3 12 21M12 3C9.5 5.7 8.5 8.7 8.5 12S9.5 18.3 12 21" }]], + grid: [["rect", { x: "4", y: "4", width: "6", height: "6", rx: "1" }], ["rect", { x: "14", y: "4", width: "6", height: "6", rx: "1" }], ["rect", { x: "4", y: "14", width: "6", height: "6", rx: "1" }], ["rect", { x: "14", y: "14", width: "6", height: "6", rx: "1" }]], + history: [["path", { d: "M4 8V4m0 0h4M4 4l3 3a8 8 0 1 1-2.2 7" }], ["path", { d: "M12 8v5l3 2" }]], + key: [["circle", { cx: "8", cy: "12", r: "4" }], ["path", { d: "M12 12h9M18 12v3M15 12v2" }]], + list: [["path", { d: "M9 6h11M9 12h11M9 18h11" }], ["circle", { cx: "4.5", cy: "6", r: ".8", fill: "currentColor", stroke: "none" }], ["circle", { cx: "4.5", cy: "12", r: ".8", fill: "currentColor", stroke: "none" }], ["circle", { cx: "4.5", cy: "18", r: ".8", fill: "currentColor", stroke: "none" }]], + loader: [["path", { d: "M20 12a8 8 0 1 1-2.3-5.7" }]], + lock: [["rect", { x: "5", y: "10", width: "14", height: "10", rx: "2" }], ["path", { d: "M8 10V7a4 4 0 0 1 8 0v3" }]], + login: [["path", { d: "M10 5H5v14h5M14 8l4 4-4 4M8 12h10" }]], + logout: [["path", { d: "M14 5h5v14h-5M10 8l-4 4 4 4M16 12H6" }]], + menu: [["path", { d: "M4 7h16M4 12h16M4 17h16" }]], + network: [["rect", { x: "9", y: "3", width: "6", height: "5", rx: "1" }], ["rect", { x: "3", y: "16", width: "6", height: "5", rx: "1" }], ["rect", { x: "15", y: "16", width: "6", height: "5", rx: "1" }], ["path", { d: "M12 8v4M6 16v-4h12v4" }]], + palette: [["path", { d: "M12 3a9 9 0 1 0 0 18h1.5a2 2 0 0 0 0-4H12a2 2 0 0 1 0-4h4a5 5 0 0 0 0-10h-4Z" }], ["circle", { cx: "7", cy: "10", r: "1", fill: "currentColor", stroke: "none" }], ["circle", { cx: "9", cy: "6.5", r: "1", fill: "currentColor", stroke: "none" }], ["circle", { cx: "14", cy: "6", r: "1", fill: "currentColor", stroke: "none" }]], + pause: [["path", { d: "M8 5v14M16 5v14" }]], + pin: [["path", { d: "m9 3 6 6-2 2 4 4-2 2-4-4-2 2-6-6 2-2 4 4 2-2-4-4 2-2ZM5 19l4-4" }]], + play: [["path", { d: "m8 5 11 7-11 7V5Z" }]], + plus: [["path", { d: "M12 5v14M5 12h14" }]], + refresh: [["path", { d: "M20 7v5h-5M4 17v-5h5" }], ["path", { d: "M18.5 9A7 7 0 0 0 6.2 6.2L4 8M5.5 15A7 7 0 0 0 17.8 17.8L20 16" }]], + route: [["circle", { cx: "6", cy: "6", r: "2" }], ["circle", { cx: "18", cy: "18", r: "2" }], ["path", { d: "M8 6h4a4 4 0 0 1 4 4v0a4 4 0 0 1-4 4H8a2 2 0 0 0-2 2v0" }]], + save: [["path", { d: "M5 4h12l2 2v14H5V4Z" }], ["path", { d: "M8 4v6h8V4M8 20v-6h8v6" }]], + search: [["circle", { cx: "10.5", cy: "10.5", r: "6.5" }], ["path", { d: "m15.5 15.5 5 5" }]], + settings: [["circle", { cx: "12", cy: "12", r: "3" }], ["path", { d: "M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4" }]], + shield: [["path", { d: "M12 3 20 6v6c0 5-3.3 8-8 9-4.7-1-8-4-8-9V6l8-3Z" }], ["path", { d: "m8.5 12 2.2 2.2 4.8-5" }]], + sliders: [["path", { d: "M4 7h6M14 7h6M4 17h10M18 17h2M10 4v6M14 14v6" }]], + sortDown: [["path", { d: "M8 5v14M5 16l3 3 3-3M14 8h5M14 12h4M14 16h3" }]], + sortUp: [["path", { d: "M8 19V5M5 8l3-3 3 3M14 8h3M14 12h4M14 16h5" }]], + trash: [["path", { d: "M4 7h16M9 3h6l1 4H8l1-4ZM7 7l1 14h8l1-14M10 11v6M14 11v6" }]], + undo: [["path", { d: "M9 7H4v-5M4 7l4-4M4.5 8A8 8 0 1 1 5 17" }]], + upload: [["path", { d: "M12 16V4m0 0-4 4m4-4 4 4M4 20h16" }]], + users: [["circle", { cx: "9", cy: "9", r: "3" }], ["path", { d: "M3 20v-2a5 5 0 0 1 10 0v2M16 8a3 3 0 0 1 0 6M16 15a5 5 0 0 1 5 5" }]], + wand: [["path", { d: "m4 20 12-12 3 3L7 23 4 20ZM14 4l1-3 1 3 3 1-3 1-1 3-1-3-3-1 3-1ZM19 14l.7-2 .8 2 2 .8-2 .7-.8 2-.7-2-2-.7 2-.8Z" }]], + x: [["path", { d: "M6 6l12 12M18 6 6 18" }]], +}); + +const UI_PANEL_ICONS = Object.freeze({ + overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box", + redirects: "route", notfound: "fileX", streams: "network", "acl-profiles": "shield", + tls: "certificate", traffic: "activity", cache: "database", transform: "wand", settings: "settings", +}); +const UI_SETTINGS_TAB_ICONS = Object.freeze({ + appearance: "palette", listeners: "network", connections: "globe", "docker-agents": "box", + policies: "shield", cache: "database", transform: "wand", forwarding: "route", pages: "file", + logging: "list", geoip: "globe", prometheus: "activity", administration: "settings", + "system-info": "cpu", "certificate-api": "key", "tls-policy": "lock", "manual-config": "code", security: "shield", +}); +const UI_VHOST_TAB_ICONS = Object.freeze({ + details: "sliders", backends: "network", health: "activity", locations: "route", auth: "key", ssl: "lock", + cache: "database", compression: "archive", transform: "wand", security: "shield", pages: "file", advanced: "code", +}); +const UI_TRANSLATED_ICON_KEYS = Object.freeze([ + ["trash", ["remove", "delete", "draftDiscard", "cacheRemoveBan", "certificateApiRevokeAll", "revokeAll"]], + ["x", ["close", "cancelAction", "overviewLayoutInlineCancel"]], + ["save", ["saveCertificate", "savePassword", "dashboardPresetsSave", "overviewLayoutInlineSave"]], + ["check", ["apply", "applyFilters", "savePolicy", "continueAction", "webServicePresetApply", "dockerApplyService"]], + ["plus", ["addCertificate", "vhNew", "webServiceNew", "redirectNew", "notfoundNew", "streamNew", "aclProfileNew", "phpProfileAdd", "redirectAddRule", "streamAddListener", "aclProfileAddIpException", "apiTokenCreate"]], + ["refresh", ["refresh", "refreshNow", "reload", "reloadAudit", "reloadCertificates", "tunnelsReload", "dockerRefresh", "cacheRefresh", "sessionRetry"]], + ["undo", ["configHistoryRestoreAction", "dashboardPresetsFactory", "draftRestore"]], + ["edit", ["edit"]], + ["download", ["export"]], + ["upload", ["acmeImport", "aclProfileImportRules"]], + ["eye", ["certificateReadNames", "dockerShowHidden", "dashboardWidgetsShowAll"]], + ["settings", ["settings", "vhManage", "dockerAgentsSettings"]], + ["book", ["webServiceHtaccessDocs", "webServicePhpDocsButton"]], + ["pause", ["logPause"]], + ["pin", ["logPin"]], + ["history", ["configHistory"]], + ["chevronLeft", ["previous"]], + ["chevronRight", ["next"]], +]); + +function uiIconSvg(name) { + const definition = UI_ICON_SHAPES[name] || UI_ICON_SHAPES.command; + const svg = document.createElementNS(UI_ICON_NS, "svg"); + svg.classList.add("ui-icon"); svg.dataset.uiIcon = name; svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false"); + svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "1.8"); + svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); + for (const [tag, attrs] of definition) { + const node = document.createElementNS(UI_ICON_NS, tag); + for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value); + svg.append(node); + } + return svg; +} +function uiIconStrings(target) { + return [target.textContent, target.getAttribute("aria-label"), target.title].filter(Boolean).map(value => String(value).trim().toLocaleLowerCase()); +} +function uiIconMatchesTranslation(target, keys) { + if (typeof t !== "function") return false; + const strings = uiIconStrings(target); if (!strings.length) return false; + for (const key of keys) { + const translated = String(t(key) || "").trim().toLocaleLowerCase(); + if (!translated || translated === key.toLocaleLowerCase()) continue; + if (strings.some(value => value === translated || value.startsWith(`${translated} `) || value.startsWith(`${translated}:`))) return true; + } + return false; +} +function uiIconForTarget(target) { + const manual = target.dataset.uiIconName; + if (manual === "none") return null; + if (manual && UI_ICON_SHAPES[manual]) return manual; + if (target.matches(".chart-style-choice")) return null; + if (target.matches(".overview-inline-grip, .overview-inline-narrow, .overview-inline-widen, .overview-inline-up, .overview-inline-down, .overview-inline-hide, .overview-layout-narrow, .overview-layout-widen, .overview-layout-up, .overview-layout-down, .overview-layout-hide")) return null; + if (target.matches("#tunnels-open")) return null; + if (target.matches("#web-service-preset-apply")) return "wand"; + if (target.matches(".listener-h2-settings-link, .listener-h3-settings-link")) return "settings"; + if (target.dataset.panel && UI_PANEL_ICONS[target.dataset.panel]) return UI_PANEL_ICONS[target.dataset.panel]; + if (target.dataset.settingsTab && UI_SETTINGS_TAB_ICONS[target.dataset.settingsTab]) return UI_SETTINGS_TAB_ICONS[target.dataset.settingsTab]; + if (target.dataset.tab && UI_VHOST_TAB_ICONS[target.dataset.tab]) return UI_VHOST_TAB_ICONS[target.dataset.tab]; + if (target.dataset.certificateTab) return ({ installed: "certificate", renewal: "refresh", generate: "plus" })[target.dataset.certificateTab] || "certificate"; + if (target.dataset.trafficTab) return ({ overview: "grid", clients: "users", requests: "list", delivery: "activity" })[target.dataset.trafficTab] || "activity"; + if (target.dataset.httpSettingsTab) return "network"; + if (target.dataset.policySettingsTab) return target.dataset.policySettingsTab === "headers" || target.dataset.policySettingsTab === "response" ? "list" : "shield"; + if (target.dataset.cacheSettingsTab) return ({ general: "database", resilience: "shield", cold: "archive", diagnostics: "activity" })[target.dataset.cacheSettingsTab] || "database"; + if (target.dataset.loggingSettingsTab) return ({ outputs: "list", format: "code", errors: "alert" })[target.dataset.loggingSettingsTab] || "list"; + if (target.dataset.dashboardWidgetAction === "toggle") return target.classList.contains("is-hidden") ? "eyeOff" : "eye"; + if (target.dataset.dashboardWidgetAction === "up") return "chevronLeft"; + if (target.dataset.dashboardWidgetAction === "down") return "chevronRight"; + + const signature = [target.id, target.dataset.i18n, target.dataset.i18nAria, target.dataset.action, target.className] + .filter(Boolean).join(" ").toLocaleLowerCase(); + const aria = String(target.getAttribute("aria-label") || "").toLocaleLowerCase(); + if (/mobile-menu|menu-toggle/.test(signature)) return "menu"; + if (/is-saving|saving/.test(signature)) return "loader"; + if (/sort-direction|sort-button/.test(signature)) return /descending|desc/.test(aria) ? "sortDown" : "sortUp"; + if (/scroll-prev|move-earlier|layout-up/.test(signature)) return "chevronLeft"; + if (/scroll-next|move-later|layout-down/.test(signature)) return "chevronRight"; + if (/collapse|narrow/.test(signature)) return "chevronDown"; + if (/expand|widen/.test(signature)) return "chevronUp"; + if (/health/.test(signature) && !/certificate/.test(signature)) return "activity"; + if (/copy/.test(signature)) return "copy"; + if (/export|download/.test(signature)) return "download"; + if (/import|upload/.test(signature)) return "upload"; + if (/search|find/.test(signature)) return "search"; + if (/filter/.test(signature)) return "filter"; + if (/inspect|preview|read-names|readnames|show-hidden|visibility|view-toggle/.test(signature)) return "eye"; + if (/history|revision/.test(signature)) return "history"; + if (/restore|factory/.test(signature)) return "undo"; + if (/refresh|reload|retry|renew|update-now|updatenow/.test(signature)) return "refresh"; + if (/reset/.test(signature)) return "undo"; + if (/revoke|remove|delete|discard|purge|kill/.test(signature)) return "trash"; + if (/close|cancel/.test(signature)) return "x"; + if (/new|add|create|generate|issue/.test(signature)) return "plus"; + if (/save/.test(signature)) return "save"; + if (/apply|submit|continue/.test(signature)) return "check"; + if (/edit/.test(signature)) return "edit"; + if (/pause/.test(signature)) return "pause"; + if (/pin|dock/.test(signature)) return "pin"; + if (/start|run-operation|cleanup-start/.test(signature)) return "play"; + if (/logout|sign-out|lock/.test(signature)) return "logout"; + if (/login|sign-in/.test(signature)) return "login"; + if (/certificate-api|api-token|token|secret|key/.test(signature)) return "key"; + if (/certificate|acme|tls/.test(signature)) return "certificate"; + if (/security|acl|policy|audit/.test(signature)) return "shield"; + if (/settings|manage|configuration/.test(signature)) return "settings"; + if (/docker/.test(signature)) return "box"; + if (/vhost|backend|stream|tunnel|listener/.test(signature)) return "network"; + if (/redirect|forward/.test(signature)) return "route"; + if (/notfound|404/.test(signature)) return "fileX"; + if (/cache|cold/.test(signature)) return "database"; + if (/transform|minif/.test(signature)) return "wand"; + if (/traffic|metric|prometheus/.test(signature)) return "activity"; + if (/log/.test(signature)) return "list"; + if (/docs|help|documentation/.test(signature)) return "book"; + if (/php|json|advanced|code/.test(signature)) return "code"; + if (/layout|preset|dashboard|overview/.test(signature)) return "grid"; + if (/about|info/.test(signature)) return "book"; + if (target.matches("a[href]")) return target.href?.startsWith(location.origin) ? "file" : "external"; + for (const [icon, keys] of UI_TRANSLATED_ICON_KEYS) if (uiIconMatchesTranslation(target, keys)) return icon; + if (aria && typeof t === "function") { + if (aria.startsWith(String(t("remove")).toLocaleLowerCase())) return "trash"; + if (aria.startsWith(String(t("copyValue")).toLocaleLowerCase())) return "copy"; + if (aria === String(t("dashboardWidgetsMoveEarlier")).toLocaleLowerCase()) return "chevronLeft"; + if (aria === String(t("dashboardWidgetsMoveLater")).toLocaleLowerCase()) return "chevronRight"; + } + return "command"; +} +const UI_ICON_TARGET_SELECTOR = "button, a.primary, a.outline, a.quiet, #logs-menu > summary"; +function uiIconTargetIsEligible(target) { + return target instanceof Element && target.matches(UI_ICON_TARGET_SELECTOR); +} +function uiDecorateIconTarget(target) { + if (!uiIconTargetIsEligible(target)) return; + const existing = target.querySelector(":scope > svg.ui-icon"); + if (document.documentElement.dataset.interfaceIcons !== "true") { existing?.remove(); return; } + const name = uiIconForTarget(target); + if (!name) { existing?.remove(); return; } + if (existing?.dataset.uiIcon === name) return; + existing?.remove(); target.prepend(uiIconSvg(name)); +} +function refreshUiIcons(root = document) { + if (root instanceof Element && uiIconTargetIsEligible(root)) uiDecorateIconTarget(root); + root.querySelectorAll?.(UI_ICON_TARGET_SELECTOR).forEach(uiDecorateIconTarget); +} +let uiIconObserverActive = false; +function setUiIconsEnabled(value) { + const enabled = value === true; + document.documentElement.dataset.interfaceIcons = String(enabled); + if (enabled) { + if (!uiIconObserverActive) { + uiIconObserver.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-label", "aria-expanded", "aria-pressed", "data-i18n", "data-action"] }); + uiIconObserverActive = true; + } + refreshUiIcons(); + } else { + if (uiIconObserverActive) { uiIconObserver.disconnect(); uiIconObserverActive = false; } + document.querySelectorAll("svg.ui-icon").forEach(node => node.remove()); + } + const input = $("ui-interface-icons"); if (input) input.checked = enabled; + return enabled; +} +const uiIconObserver = new MutationObserver(records => { + if (document.documentElement.dataset.interfaceIcons !== "true") return; + const targets = new Set(); + for (const record of records) { + const element = record.target instanceof Element ? record.target : record.target.parentElement; + const action = element?.closest?.(UI_ICON_TARGET_SELECTOR); if (action) targets.add(action); + for (const node of record.addedNodes) { + if (!(node instanceof Element)) continue; + if (uiIconTargetIsEligible(node)) targets.add(node); + node.querySelectorAll?.(UI_ICON_TARGET_SELECTOR).forEach(item => targets.add(item)); + } + } + targets.forEach(uiDecorateIconTarget); +}); +window.rustProxyIcons = { refresh: refreshUiIcons, setEnabled: setUiIconsEnabled, iconFor: uiIconForTarget }; diff --git a/src/admin/web/app/overview-extra-metrics.js b/src/admin/web/app/overview-extra-metrics.js index be3df18..59ab39a 100644 --- a/src/admin/web/app/overview-extra-metrics.js +++ b/src/admin/web/app/overview-extra-metrics.js @@ -219,7 +219,11 @@ window.rustProxyExtraOverview = (() => { donut("extra-cache-ratio", slices?.[0], slices?.[1]); } const detail = $(`overview-extra-${id}-detail`); - if (detail && id !== "extra_cache_hit_rate") detail.textContent = metric.detail || t("overviewExtraRecentSamples"); + if (detail && id !== "extra_cache_hit_rate") { + const detailText = metric.detail || ""; + detail.textContent = detailText; + detail.hidden = !detailText; + } } } function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); lastSegments.clear(); window.rustProxyChartScale.reset("extra:"); } diff --git a/src/admin/web/app/preferences.js b/src/admin/web/app/preferences.js index 90b1f12..7973f9a 100644 --- a/src/admin/web/app/preferences.js +++ b/src/admin/web/app/preferences.js @@ -13,7 +13,7 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([ ])]); const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({ theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default", - chart_axes: false, + chart_axes: false, interface_icons: false, button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, }); const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" }); @@ -123,6 +123,7 @@ function applyAdminChartAxes(value) { window.rustProxyChartScale?.refreshAxes?.(); return enabled; } +function applyAdminInterfaceIcons(value) { return window.rustProxyIcons?.setEnabled?.(value === true) ?? false; } function applyAdminButtonStyle(value) { const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style; const select = $("ui-button-style"); if (select) select.value = style; return style; @@ -287,7 +288,7 @@ function applyAdminPreferences(value, cache = true) { ...value, refresh: normalizeRefreshPreferences(value.refresh), theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache), - time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density), + time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), interface_icons: applyAdminInterfaceIcons(value.interface_icons), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density), dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes, }; adminPreferences = normalized; @@ -335,7 +336,7 @@ function updateAdminPreferences(patch) { const next = { ...current, ...patch }; next.refresh = normalizeRefreshPreferences(next.refresh); next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale); - next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density); + next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.interface_icons = next.interface_icons === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density); next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) }); if (!authenticated || epoch !== authEpoch) return adminPreferences; @@ -343,7 +344,7 @@ function updateAdminPreferences(patch) { }); adminPreferencesWrite = task.catch(() => undefined); return task; } -function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); } +function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); applyAdminInterfaceIcons(false); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); } function renderRefreshPreferences(value = REFRESH_DEFAULTS) { const refresh = normalizeRefreshPreferences(value); const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled; @@ -419,6 +420,14 @@ $("ui-chart-axes")?.addEventListener("change", () => { applyAdminChartAxes(previous); }); }); +$("ui-interface-icons")?.addEventListener("change", () => { + const control = $("ui-interface-icons"), previous = adminPreferences?.interface_icons === true, next = control.checked; + applyAdminInterfaceIcons(next); if (adminPreferences) adminPreferences.interface_icons = next; + void saveAppearancePreference({ interface_icons: next }, control, () => { + if (adminPreferences) adminPreferences.interface_icons = previous; + applyAdminInterfaceIcons(previous); + }); +}); $("ui-button-style")?.addEventListener("change", () => { const control = $("ui-button-style"), previous = adminPreferences?.button_style || "normal", next = normalizeAdminButtonStyle(control.value); applyAdminButtonStyle(next); if (adminPreferences) adminPreferences.button_style = next; void saveAppearancePreference({ button_style: next }, control, () => { if (adminPreferences) adminPreferences.button_style = previous; applyAdminButtonStyle(previous); }); @@ -450,7 +459,7 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect } document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => { const patches = { - interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" }, + interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", interface_icons: false, button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" }, dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null }, }; void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control); diff --git a/src/admin/web/app/refresh.js b/src/admin/web/app/refresh.js index 34eb1a7..ece8a08 100644 --- a/src/admin/web/app/refresh.js +++ b/src/admin/web/app/refresh.js @@ -170,7 +170,7 @@ const rustProxyRefresh = (() => { }, onError: error => { if (error?.status !== 401) { connected = false; $("connection-label").textContent = t("disconnected"); $("signal").classList.add("status-error"); $("app-health-slot").hidden = false; renderAppHealth(null); } } }); register("logs-fallback", { - interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => { + interval: () => options.overview_seconds, active: () => !!window.rustProxyLogs?.active?.(), run: async ({ signal, isCurrent }) => { const value = await api("/api/logs", { signal }); if (isCurrent()) renderLogs(value); } @@ -199,6 +199,6 @@ const rustProxyRefresh = (() => { window.rustProxyRefresh = rustProxyRefresh; $("overview-refresh")?.addEventListener("click", () => { - void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback"), rustProxyRefresh.manual("system-info")]); + void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("system-info")]); }); $("system-info-refresh")?.addEventListener("click", () => void rustProxyRefresh.manual("system-info")); diff --git a/src/admin/web/css/10-base.css b/src/admin/web/css/10-base.css index 0452a2b..b6819f7 100644 --- a/src/admin/web/css/10-base.css +++ b/src/admin/web/css/10-base.css @@ -87,6 +87,53 @@ button:disabled { cursor: wait; } +.ui-icon { + display: inline-block; + width: 14px; + height: 14px; + margin-inline-end: 6px; + vertical-align: -2px; + flex: 0 0 auto; + pointer-events: none; +} + +html[data-interface-icons="true"] svg.ui-icon { + width: 14px; + height: 14px; +} + +.nav>.ui-icon { + width: 16px; + height: 16px; + margin-inline-end: 0; +} + +html[data-interface-icons="true"] .nav>svg.ui-icon { + width: 16px; + height: 16px; +} + +.backend-sort-direction>.ui-icon, +.dashboard-widget-scroll-button>.ui-icon, +.host-chip button>.ui-icon, +.acl-picker-remove>.ui-icon, +.draft-dock-remove>.ui-icon, +.vhost-backend-popup-close>.ui-icon, +.dashboard-widget-toggle>.ui-icon { + margin-inline-end: 0; +} + +button.is-saving>.ui-icon[data-ui-icon="loader"] { + animation: ui-icon-spin .8s linear infinite; +} + +@keyframes ui-icon-spin { + to { + transform: rotate(360deg); + } +} + +html[data-interface-icons="true"] .nav>span:first-of-type, [hidden], #file-log-view-counts[hidden], #file-log-view-entries[hidden], diff --git a/src/admin/web/css/30-overview.css b/src/admin/web/css/30-overview.css index 03cc117..9376cc2 100644 --- a/src/admin/web/css/30-overview.css +++ b/src/admin/web/css/30-overview.css @@ -15,8 +15,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) { margin-bottom: var(--ui-gap); } -/* Summary tiles outside the Overview dashboard (Cache, Traffic, Transform) fill - every row instead of leaving empty grid columns after the last tile. */ .stats-grid.cache-stats { display: flex; flex-wrap: wrap; @@ -86,7 +84,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) { stroke-dasharray: var(--chart-grid-dash, 2 5); } -/* Axis labels are HTML overlays, not stretched SVG text. */ .rp-chart-axis-host { position: relative; display: flow-root; @@ -94,7 +91,10 @@ html[data-theme=cream] :is(.card, .stat, dialog) { min-width: 0; } -.rp-chart-axis-host>svg { display: block; vertical-align: top; } +.rp-chart-axis-host>svg { + display: block; + vertical-align: top; +} .rp-chart-axis-y { position: absolute; @@ -115,9 +115,20 @@ html[data-theme=cream] :is(.card, .stat, dialog) { background: color-mix(in srgb, var(--panel) 82%, transparent); } -.rp-chart-axis-top { top: var(--rp-axis-top, 8%); transform: translateY(-30%); } -.rp-chart-axis-middle { top: 50%; transform: translateY(-50%); } -.rp-chart-axis-bottom { top: var(--rp-axis-bottom, 92%); transform: translateY(-70%); } +.rp-chart-axis-top { + top: var(--rp-axis-top, 8%); + transform: translateY(-30%); +} + +.rp-chart-axis-middle { + top: 50%; + transform: translateY(-50%); +} + +.rp-chart-axis-bottom { + top: var(--rp-axis-bottom, 92%); + transform: translateY(-70%); +} .rp-chart-axis-x { display: grid; @@ -133,12 +144,29 @@ html[data-theme=cream] :is(.card, .stat, dialog) { align-items: center; } -.rp-chart-axis-x .rp-chart-axis-start { min-width: 0; overflow: hidden; text-overflow: ellipsis; } -.rp-chart-axis-x .rp-chart-axis-end { min-width: 0; text-align: right; } +.rp-chart-axis-x .rp-chart-axis-start { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.rp-chart-axis-x .rp-chart-axis-end { + min-width: 0; + text-align: right; +} + .rp-chart-axis-compact .rp-chart-axis-y, .rp-chart-axis-multiple .rp-chart-axis-y, -.rp-chart-axis-series[hidden] { display: none; visibility: hidden; } -.rp-chart-axis-compact .rp-chart-axis-x { font-size: 8px; gap: 2px; } +.rp-chart-axis-series[hidden] { + display: none; + visibility: hidden; +} + +.rp-chart-axis-compact .rp-chart-axis-x { + font-size: 8px; + gap: 2px; +} + .rp-chart-axis-series { display: flex; flex-wrap: wrap; @@ -150,8 +178,13 @@ html[data-theme=cream] :is(.card, .stat, dialog) { } @media (max-width: 560px) { - .rp-chart-axis-y .rp-chart-axis-middle { display: none; } - .rp-chart-axis-x { font-size: 8px; } + .rp-chart-axis-y .rp-chart-axis-middle { + display: none; + } + + .rp-chart-axis-x { + font-size: 8px; + } } .chart-footer { @@ -291,13 +324,19 @@ html[data-theme=cream] :is(.card, .stat, dialog) { min-width: 640px; } -.pool-table td:nth-child(n/**/+4) { +.pool-table td:nth-child(n + + /**/ + +4) { font-variant-numeric: tabular-nums; text-align: right; } [dir=rtl] table, -.pool-table th:nth-child(n/**/+4), +.pool-table th:nth-child(n + + /**/ + +4), .logging-builder-action .small-label { text-align: right; } @@ -561,7 +600,6 @@ body[data-density=compact] .pool-card .table-scroll { margin: 0; } -/* Keep the block title clear of the corner button whatever padding the grid applies. */ #panel-overview .stat-with-action>span:first-child { display: block; padding-right: 104px; @@ -576,6 +614,20 @@ body[data-density=compact] .pool-card .table-scroll { text-align: center; } +#panel-overview .stat-with-action .stat-corner-badge { + min-height: 0; + border-radius: 999px; + background: transparent; + cursor: pointer; + text-transform: none; +} + +#panel-overview .stat-with-action .stat-corner-badge:hover, +#panel-overview .stat-with-action .stat-corner-badge:focus-visible { + border-color: var(--accent); + background: var(--notice-bg); +} + #panel-overview .dashboard-overview-grid.is-unlocked .stat-with-action .stat-corner-action { top: 44px; } @@ -1201,7 +1253,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { outline-style: solid; } -/* While sorting, the grid keeps strict DOM order: the placeholder is exactly where the block lands. */ #panel-overview .dashboard-overview-grid.is-overview-sorting { grid-auto-flow: row !important; user-select: none; @@ -1216,10 +1267,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: color-mix(in srgb, var(--accent) 10%, transparent); } -#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] { grid-column: auto/span 3; } -#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] { grid-column: auto/span 4; } -#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] { grid-column: auto/span 6; } -#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] { grid-column: 1/-1; } +#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] { + grid-column: auto/span 3; +} + +#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] { + grid-column: auto/span 4; +} + +#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] { + grid-column: auto/span 6; +} + +#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] { + grid-column: 1/-1; +} #panel-overview .dashboard-overview-grid>.is-overview-lifted, #panel-overview .dashboard-overview-grid>.is-overview-lifted * { @@ -1256,7 +1318,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { flex: 0 0 auto; } -/* Narrow blocks keep only the essentials; arrow keys on the grip still move them. */ #panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down), #panel-overview .dashboard-overview-grid>[data-dashboard-span="3"] .overview-inline-toolbar :is(.overview-inline-up, .overview-inline-down) { display: none; @@ -1886,8 +1947,6 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty margin: 4px 10px 8px; } -/* Custom layouts follow the saved order exactly (no implicit back-filling of gaps); - "Fill gaps" in the layout editor compacts explicitly. */ #panel-overview .dashboard-overview-grid.is-aligned[data-overview-layout-mode=custom] { grid-auto-flow: row; } @@ -2224,7 +2283,6 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over line-height: inherit; } -/* Live values stay on one line so refreshes do not change card heights. */ .mini-live-chart-head> :last-child, .network-interface-row-head .network-interface-head-actions { flex: 0 0 auto; @@ -2236,4 +2294,4 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} +} \ No newline at end of file diff --git a/src/admin/web/css/50-settings.css b/src/admin/web/css/50-settings.css index 49508d3..ed8e805 100644 --- a/src/admin/web/css/50-settings.css +++ b/src/admin/web/css/50-settings.css @@ -81,11 +81,13 @@ max-width: none; } +.appearance-interface-icons-setting, .appearance-chart-axes-setting { align-self: center; min-width: 0; } +.appearance-interface-icons-setting>span, .appearance-chart-axes-setting>span { display: grid; gap: 4px; @@ -1203,6 +1205,7 @@ html[data-dashboard-density=normal] #panel-overview .stat>span, width: 100%; min-width: 0; } + /* Global security policy forms */ .global-policy-form .global-policy-section { display: grid; @@ -1212,18 +1215,81 @@ html[data-dashboard-density=normal] #panel-overview .stat>span, padding-top: 17px; min-width: 0; } -.global-policy-form .global-policy-section h3 { margin: 0; font-size: 14px; } -.global-policy-heading { display: grid; gap: 5px; } -.global-policy-heading p { margin: 0; max-width: 85ch; } -.global-policy-fields { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr); gap: 14px; align-items: start; } -.global-rate-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: end; } -.global-policy-fields label, .global-rate-fields label, .global-policy-section > label:not(.check) { display: grid; gap: 6px; min-width: 0; } -.global-policy-section textarea { width: 100%; box-sizing: border-box; } -.global-policy-form .form-actions { margin-top: 20px; } -.cache-static-guidance { margin: 9px 0 0; max-width: 88ch; } -.cache-header-settings-heading { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 24px; margin-bottom: 13px; } -.transform-operations-title { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 22px; margin-bottom: 14px; } -.transform-operations-grid { margin-top: 3px; row-gap: 12px; } -@media (max-width: 760px) { - .global-policy-fields, .global-rate-fields { grid-template-columns: 1fr; } + +.global-policy-form .global-policy-section h3 { + margin: 0; + font-size: 14px; } + +.global-policy-heading { + display: grid; + gap: 5px; +} + +.global-policy-heading p { + margin: 0; + max-width: 85ch; +} + +.global-policy-fields { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr); + gap: 14px; + align-items: start; +} + +.global-rate-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + align-items: end; +} + +.global-policy-fields label, +.global-rate-fields label, +.global-policy-section>label:not(.check) { + display: grid; + gap: 6px; + min-width: 0; +} + +.global-policy-section textarea { + width: 100%; + box-sizing: border-box; +} + +.global-policy-form .form-actions { + margin-top: 20px; +} + +.cache-static-guidance { + margin: 9px 0 0; + max-width: 88ch; +} + +.cache-header-settings-heading { + border-top: 1px solid var(--line); + padding-top: 18px; + margin-top: 24px; + margin-bottom: 13px; +} + +.transform-operations-title { + border-top: 1px solid var(--line); + padding-top: 18px; + margin-top: 22px; + margin-bottom: 14px; +} + +.transform-operations-grid { + margin-top: 3px; + row-gap: 12px; +} + +@media (max-width: 760px) { + + .global-policy-fields, + .global-rate-fields { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/src/admin/web/index.html b/src/admin/web/index.html index a96dad0..9f185f8 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -61,7 +61,8 @@ @@ -1792,6 +1793,12 @@ Normal uses the refreshed controls; Compact restores the smaller source-style buttons across all actions and dialog tabs. + - + +
-

User-Agent blocklist

Case-insensitive substring matching; * is a wildcard. A missing User-Agent is not blocked.

- - +
+

User-Agent blocklist

+

Case-insensitive substring + matching; * is a wildcard. A missing User-Agent is not blocked.

+
+ +
-

IP / CIDR access

Each line: allow or deny followed by IPv4/IPv6 address, CIDR or allow-only DNS name. Default allow with no rules leaves access unrestricted.

- +
+

IP / CIDR access

+

Each line: allow or deny followed + by IPv4/IPv6 address, CIDR or allow-only DNS name. Default allow with no + rules leaves access unrestricted.

+
+
- - + +
-

HTTP methods

Block selected methods or allow only those listed. Applies before redirects and cache invalidation; denied requests receive 405.

+
+

HTTP methods

+

Block selected methods or allow + only those listed. Applies before redirects and cache invalidation; + denied requests receive 405.

+
- - + +
-

Request Content-Type

Reject declared MIME types (exact or type/*) with 403. This is a header rule, not body inspection.

- - +
+

Request Content-Type

+

Reject declared MIME types + (exact or type/*) with 403. This is a header rule, not body inspection. +

+
+ +
-

Shared rate limit

Optional per-client-IP limit shared by all public vhosts. Runs in addition to per-vhost limits; use conservative values to avoid blocking legitimate shared clients.

- +
+

Shared rate limit

+

Optional per-client-IP limit + shared by all public vhosts. Runs in addition to per-vhost limits; use + conservative values to avoid blocking legitimate shared clients.

+
+
- - - + + +
-
+
-

Content transformations

+

+ Content transformations

@@ -3144,8 +3259,8 @@

TLS 1.3 only: prioritize hybrid X25519MLKEM768 with classic key exchanges as fallback. This is a key exchange group, not a cipher suite. Disabled by default.

- Learn about PQC, compatibility + Learn about PQC, compatibility and testing (documentation)

@@ -4353,7 +4468,8 @@ log_errors = On