diff --git a/AGENTS.md b/AGENTS.md index 2983fe9..99a54c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # RustProxy source and documentation maintenance -Current source version: **0.35.3**. The Cargo package and binary are named `rust-proxy`. +Current source version: **0.35.4**. 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 d464a8c..0792b11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +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.35.4 — Dashboard status and control cleanup + +- **Overview samples:** the history sample selector is right-aligned on desktop and mobile and now saves immediately in the browser; the separate Remember choice button is removed. +- **Application self-check:** the old runtime/control-plane sidebar block is replaced by a color-coded application status indicator. Clicking it shows checks for the control API, listener restart state, backend readiness and logging pipeline issues. +- **Dashboard footer:** removed the low-value dropped-log counter; API latency and update availability remain. +- **Navigation cleanup:** removed the static `RUNTIME / Rust / Tokio / Hyper / Axum / Tower / SQLite` text from the sidebar. +- **Dashboard layout editing:** blocks are now moved by dragging the ⠿ handle with a live drop placeholder (mouse, pen and touch), smooth movement of neighbouring blocks, edge auto-scroll and **Esc** to cancel; arrow keys on the handle move a block from the keyboard. Custom layouts no longer back-fill gaps automatically, so blocks stay where they were placed; the new **Fill gaps** action packs them on request. The edit toolbar is a single compact row, so the layout keeps its shape while unlocked. +- **Interface fixes:** the WebSocket tunnels title no longer overlaps its **Active tunnels** button; summary tiles on the Cache, Traffic and Transform pages fill each row instead of leaving empty columns; identifier fields (username, certificate, Docker agent and redirect IDs) no longer trigger an invalid-pattern error in current browsers; several layout-editor tooltips showed raw translation keys. +- **Cache under turnover:** returning freed memory to the operating system is now rate-limited (at most four times per second, plus the regular maintenance pass) instead of running after every eviction. With a full HOT cache and constant replacement this measured about 2.3× higher throughput with the default system allocator (2,900 → 7,000 req/s, one core) at about 7% higher resident memory. +- **Cache hot path:** popular objects no longer re-link the LRU list on every hit, HOT/COLD index lookups use the already-uniform cache digest instead of a second SipHash pass, and the request-collapsing bypass marker for uncacheable URLs keeps working when its table is full. +- **Per-response work:** configured `Server` and cache `Via` values are parsed once per worker instead of on every response, and cache diagnostic headers avoid temporary allocations. +- **Upgrade notes:** no configuration or database changes are required. + ## 0.35.3 — Overview layout fixes - **Standard Overview layout restored:** the quick-health tiles added in 0.35.2 (healthy backends, error share, requests rejected by limits, nearest certificate expiry) are now hidden by default and placed at the end of the Overview, so they no longer shift the standard block layout. Enable them in the layout editor when needed. diff --git a/Cargo.lock b/Cargo.lock index c1c30f1..1eb7216 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.35.3" +version = "0.35.4" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 99a221a..6394ed6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.35.3" +version = "0.35.4" edition = "2021" rust-version = "1.99.1" description = "RustProxy - independent reverse proxy with local administration" diff --git a/SHA256SUMS b/SHA256SUMS index 8e84aab..e74b553 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,10 +1,10 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example -ba8bc979322a494cfce59398085259d059142d6a789bbe73ed7992d143123f06 .gitignore -5142c2151f434a42b925a6def77b726abf0c10e8404f8db08c999ca1cad43386 AGENTS.md -c2c23ce76282075cc49bf1609fe693591c900a5a120ff3e209e00f86a97c0141 CHANGELOG.md -77a69eb3906a27fa42d4bcb60e9595e86fc23ad74d72b96f2710527da49f5225 Cargo.lock -5d49aef294682f80d98cc2cc50d3908a8e5684151d38ee5dfbd04cd6cd95f9e0 Cargo.toml +e76e688874828e3743d03693b1d2872b9633b4db09909673786caf30f7cfe489 .gitignore +f8272b8b6a11d50811e2f25b5d43f4713dd7fc7d22c2b4892cb85757d481c10d AGENTS.md +dea41653103926d4306b2b00de9d99225fb9d0c96af9f23ab4e729d31625000d CHANGELOG.md +c593deba438fb6f5acbc6868612ddfa30d4f1e15134e9654c0f399f49b2c32fd Cargo.lock +9795da5b826e58378910a45c3c9d9bb3a9cbdf9e009ad03ee82d1a59da1f4e83 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 LICENSE 82d1dee2fc68158f0ea73259151461e64c8b18d807cd1fecfb1ed99dc30cf5f4 README.md @@ -34,7 +34,7 @@ a1b6b8675d3b8321160406bc716f1355356e1e3dd056d4785be1d06a99313f31 docs/API.md 32599d67a11fd0ebbc612e82b9f602789f4ed4103cda482c6870d8741100c8b4 docs/CERTIFICATES.md 02bb383b5c3bce22616c3cc3db30a9469806dc16decb509973e84c60aa30fc5b docs/CONFIGURATION.md fec29ce846d6705eb45819d85d382f28e28a146b265feab2e4c7cce2a35e5883 docs/CUSTOM_LOCATIONS.md -6e05af7bf62a41a3a5a71e8f0c036dc611408d04f7130c07b585317aaf59ef0b docs/DASHBOARD.md +bb5eae38889dedaa9cef766b88c0a85ec5306900b889aaeac95b3fcbc4b9535d docs/DASHBOARD.md 11a08bf2110bbeabc924eb049a30177a216af5607ccdaf8be9178e751f78a2c4 docs/DOCKER_DISCOVERY.md cebf36532f7366acdcf8808d958b86b5f67246043677ee3dba9474a9b81970e0 docs/FEATURES.md 3939c14e83ef6856a421f358a3f7fce3d50ea375bc1b9f0419f27a3eed658f6f docs/INSTALL.md @@ -86,12 +86,12 @@ de6550ab8f48d556d752c252212c38ba65491fd224f972c10fbe552f91ed7520 integrations/o 45be151cd85fc2ef2f1fdc3003ace63381af262e73af8a685ab48cb697cc0de1 integrations/routeros-oci-build/.env.routeros.example 11aa102de1089ecc375f20c4bd5420fcfd3b2ef23fe0c8bdf04953fcfaf40ef0 integrations/routeros-oci-build/Dockerfile.routeros 7c7a3e51f43b43e1c962a4b0bb591f99c1f06977bf77f23719f7f867dfe7eb8e integrations/routeros-oci-build/Dockerfile.routeros.dockerignore -4c7416b85ddbf66f8434735dfa6452c8b368f496a120861a2024c5775858ad0e integrations/routeros-oci-build/README.md +a68d5780f9eeb084db5ec465791b71332deb0456fdb245a253d464c5c76431c9 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 -582dcbe08575602b83610e316ee3d856d391f87d23f6d91dca7361ed58c470f4 langs/en_US.json -2bdd09e7734a65c828c268e89a85a1e6071be54cdf1b378a67406a1fa6dbf0b7 langs/pl_pl.json +f3a93b7507534394732ee7a28bea743b171decc52cafa4103a9bbce58559ae5d langs/en_US.json +8ea33d229bea8e9b866dd401bd7dde81b8dae844435cd22da88372097ccaa313 langs/pl_pl.json 9664543e4d03422788a65e42f968c1f026c2235cb5be6fc3e0ad318997077538 make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -162,7 +162,7 @@ d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file 77b8c05645969834cc8a58e249a7b60a57d6c47ad1c5cd92ffa5e4a9b5a8e73c src/admin/file_logs_query.rs 444d7715406ffd2305a72decf7fdf633aa4c2752cd881c8fa3b95ed971d79820 src/admin/logging_api.rs 243f4a9c3fa99781e19c3952354d2632c0ce65e1dafac729567cbe3c177f94cb src/admin/mod.rs -61fd5eb6bb4f3cc19151ae96dc5c028e4845479ce1e2bb2691a41094dcd212c9 src/admin/openapi.json +ee4075f5f16bb0940fb785b2c527669a605d4352efe1030d4ca98bddb1ce051c src/admin/openapi.json 1f4ec45d146e00ef664fae37517f8fcb5455d47daec496c02b2fc15ddec49131 src/admin/operations.rs 257d93a29d0bda96462bcf695aba8d0ec0f9412c379e066ec46ae8ee034e3a53 src/admin/panel_assets.rs f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs @@ -186,18 +186,18 @@ 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 -0248fee14bbab0b3d1f308f650061d7a348a8ffe00a46a56e297131f815aae10 src/admin/web/app/core.js +0f6e70d10ec732b06aeaed7e4c72f36fdf21f295315d0716556b8c32cc775425 src/admin/web/app/core.js 622d34e1616fee9a480d208192fc5a84d62d84ef4233d6a7d95e2bfb8cef415b 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 5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js -561f750cacd4d4fcf7d1d1d9167ba296c0b43d7e81f2bff0ab1a070e1b8682d6 src/admin/web/app/overview-samples.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 9e2b12d34ab1fb29716645720577ad49b16c03b46c482a03695e8f645d170e79 src/admin/web/app/rate-sampling.js -ac24300022bb98de2ad5c03a7859a2d80b14420e7a72db49ad649f52d92566a9 src/admin/web/app/refresh.js +d34f9e25a04cc4eafdbc48daa4e69aab18944e8d34e555a50d9c122c680595f8 src/admin/web/app/refresh.js 7888eaea26c5cecc097ad276b73ae6c63def1db896053def6852ddf9d6739906 src/admin/web/app/runtime.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js c7a18238ac426de4d1cf84a395ee4dc66410d60a57a3822695dc10adc668d956 src/admin/web/app/settings.js @@ -207,12 +207,12 @@ b7ead49224a4ec67181d3f24f9d9634bd6d76756f826a4d5d993300637d1d8f3 src/admin/web/ e9788a9d1e0bce8775f612a5c842afea8a0dcf01301acdef4b69ff6a52d2ef45 src/admin/web/certificate-api.js a09f060b47771c56e9b9e57f1b896a32a2f87f1fb33401ac76932a91d247c67e src/admin/web/css/00-tokens.css 77b8b748f31ef383c64ba5ca1740e9b6073f0de1e77c2c8c62d0f38bb52dd296 src/admin/web/css/10-base.css -d3f19407ae97b3f6cca015877d369f4e3dc86d8ea7de08e430c85468fa033f5e src/admin/web/css/20-shell.css -98133ee0b23288dc8d0ed2fa0a1375f9145acce108d215a63c76ea96bbdf8e11 src/admin/web/css/30-overview.css +6ed54b27a4a81b073f1d30cf61e366f3d5c1f6f8495f0d0a8af48e061937eb4e src/admin/web/css/20-shell.css +a589a0a326066b31ee5c0cfa5acf60f6e09a334311e73355f0518e8a1cbc67f6 src/admin/web/css/30-overview.css dd82b5e3ead91c20bb08cf8d71bbba31ea8e3d108c417913c63409f3e34da7e1 src/admin/web/css/40-editors.css 6af9fcb439823ec63abe2c1dd86c9ecba17c233262fd8ab80d321d64ae2758ab src/admin/web/css/50-settings.css f8b515c63080c71ee647cfd8c36628271e8357b31cd84f00cf591e9b752b44fe src/admin/web/css/60-monitoring.css -c7c387e21e7bb148689e16f5fda6cd47c870043260dec059bbe66e09ac86fdd9 src/admin/web/css/70-responsive.css +f52a6fb30199c3d73d3c3d1ef69c35105a91754b6678a0fbaf4cc2f7b224d620 src/admin/web/css/70-responsive.css 8009afdb04d5170687816730cc930a22fead1fe0248835ca7e8de81d6907395d src/admin/web/css/README.md 8241bd9489dfed082863948829a44a49bf0a8e9cd63c55809655109e393e0918 src/admin/web/css/manifest.json ee51ab0912e0ade6a3e1c2ea6f533bb2f65a6d3e6ec9087e25b9f0df009c19b2 src/admin/web/dashboard-defaults.json @@ -239,7 +239,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js 3f0525ca1d4e088ccd4988b8a117b26a8cf8c12213b24c37081d41b68d2b0ff2 src/admin/web/host404.js -64c9c8f4ce6821dbf84cd1aa0912d1b5f47421f592bebf2fdc2bff341e293dde src/admin/web/index.html +4f9afe03da0779f0d9c18374ea2fcf083b7cd33d0d1ccaa2433b5494cb635cc3 src/admin/web/index.html 7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 3539f48..8a6d707 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -25,10 +25,20 @@ Optional quick-health tiles (since 0.35.2, hidden by default; enable them in the Enabled tiles are added at the end of the Overview, so the standard block layout does not change. -The **Remember choice** control next to the history sample count shows a status only when the current choice is temporary (not saved) or briefly after saving. +The Overview history sample count is saved in the browser immediately when the selection changes; no separate confirmation button is required. + +The sidebar application-status indicator runs a lightweight self-check from the live status already used by the dashboard. Green means healthy, amber means attention is needed, and red means a problem was detected; click it to see API, listener, backend and logging details. The unlocked Overview layout exposes a single **Save and lock** action (plus Cancel), removing the redundant lock-and-save button. +### Arranging blocks + +After **Unlock layout**, every block shows a compact toolbar: the **⠿** handle, width **− / +**, and **×** to hide it (wider blocks also show **← / →**). + +- **Drag:** grab the ⠿ handle (mouse, pen or touch). The block lifts and follows the pointer, and a dashed frame of the same width shows exactly where it will land; neighbouring blocks glide aside instead of jumping. Release to drop, or press **Esc** to return the block to its previous place. Dragging near the top or bottom edge of the window scrolls the page. +- **Keyboard:** focus the ⠿ handle and use the arrow keys to move the block one position. +- **Fill gaps:** a saved custom layout keeps blocks in exactly the order you arranged them, so a wide block that does not fit at the end of a row leaves a visible gap instead of other blocks silently moving into it. Use **Fill gaps** to pack blocks into empty space once; nothing is stored until **Save and lock**. + **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. ## Editing configuration safely diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0377e45..cd9837f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -76,7 +76,7 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se ## Monitoring and logs - Overview dashboard with requests per second, bandwidth, status codes, error share, cache hit ratio, protocol mix, system load, backend health, requests rejected by limits and nearest certificate expiry. -- Customizable dashboard layouts and saved presets. +- Customizable dashboard layouts (drag-and-drop with a live drop placeholder, keyboard moves, per-block width, explicit gap filling) and saved presets. - Per-Vhost, per-backend, per-Web-Service and per-Stream statistics. - Live request log in the browser and in the terminal (`--livelogs`). - Stored JSON access logs with search syntax, per-Vhost log files and an error log. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index e192b72..97e107d 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -70,7 +70,16 @@ Choose mimalloc only when all of these apply: - throughput under that turnover matters more than memory; - the server has at least 30% RAM headroom above the configured HOT cache size. -Otherwise keep the default. The system allocator returns memory from evicted cache entries to the operating system right away, so process memory stays close to the configured cache size. +Otherwise keep the default. The system allocator returns memory from evicted cache entries to the operating system shortly after eviction, so process memory stays close to the configured cache size. + +**Since 0.35.4** the default build returns freed memory to the operating system at most four times per second (plus the regular cache maintenance pass) instead of after every eviction. Most of the system allocator's slowdown under turnover came from these repeated trims, so the gap to mimalloc is now much smaller. Measured on the same 2 vCPU machine, one core, 256 MiB HOT cache filled with 100 KiB objects and every request a new URL: + +| Build | RSS after load | Throughput under cache turnover | +|---|---:|---:| +| 0.35.3, system allocator | 595 MiB | 2,900 req/s | +| 0.35.4, system allocator | 639 MiB (+7%) | 7,000 req/s (2.4×) | + +The mimalloc rows above were measured with 0.35.2 and a different object mix; re-measure on your own workload before choosing the optional build. ## Production tuning diff --git a/integrations/routeros-oci-build/README.md b/integrations/routeros-oci-build/README.md index 30214de..57b149d 100644 --- a/integrations/routeros-oci-build/README.md +++ b/integrations/routeros-oci-build/README.md @@ -77,10 +77,10 @@ ENV_FILE=integrations/routeros-oci-build/.env \ bash integrations/routeros-oci-build/build-routeros-oci.sh push ``` -For example, with RustProxy version `0.35.3` and the defaults this publishes: +For example, with RustProxy version `0.35.4` and the defaults this publishes: ```text -zot.example.net/network/rustproxy:0.35.3 +zot.example.net/network/rustproxy:0.35.4 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 5ffbd42..05bd8cf 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -2749,7 +2749,7 @@ "overviewLayoutTypeFilter": "Filter blocks by type", "overviewLayoutUnlock": "Unlock layout", "overviewLayoutLock": "Lock and save", - "overviewLayoutInlineHint": "Layout unlocked: reorder blocks within groups of similar size, hide with ×, then save or cancel. The grid aligns card heights automatically.", + "overviewLayoutInlineHint": "Layout unlocked: grab a block by its ⠿ handle and drag it — the dashed frame shows where it will land (Esc cancels). Resize with − / +; arrow keys on the handle move a block from the keyboard. “Fill gaps” packs blocks into empty space once.", "overviewLayoutInlineDrag": "Drag to move", "overviewLayoutInlineSave": "Save and lock", "overviewLayoutInlineCancel": "Cancel changes", @@ -2840,11 +2840,35 @@ "overviewTransformErrors": "Errors", "overviewMeterNoLimit": "No configured limit", "overviewCacheHitsMisses": "HIT {hits}, MISS {misses}", - "overviewSamplesRemember": "Remember choice", - "overviewSamplesSaved": "Saved default", - "overviewSamplesTemporary": "For this visit only", "overviewSamplesStorageUnavailable": "Could not save in this browser", "overviewSamplesTitle": "Overview chart samples", + "appHealthTitle": "Application status", + "appHealthSelfCheck": "SELF-CHECK", + "appHealthChecking": "Checking application status", + "appHealthOk": "All systems operational", + "appHealthWarning": "Attention required", + "appHealthError": "Problem detected", + "appHealthSidebarChecking": "CHECKING", + "appHealthSidebarOk": "APPLICATION OK", + "appHealthSidebarWarning": "APPLICATION WARNING", + "appHealthSidebarError": "APPLICATION ERROR", + "appHealthUpdated": "Updated {time}", + "appHealthNoStatus": "No current status", + "appHealthApi": "Control API", + "appHealthApiOk": "Live application status is responding.", + "appHealthApiError": "No current status from the application.", + "appHealthListeners": "Listeners", + "appHealthListenersOk": "Active listeners match the desired configuration.", + "appHealthListenersRestart": "Listener changes are pending; restart RustProxy to apply them.", + "appHealthBackends": "Backends", + "appHealthBackendsNone": "No backend pools are configured.", + "appHealthBackendsOk": "All active backend pools are ready.", + "appHealthBackendsWarning": "{down} down, {checking} checking: {members}.", + "appHealthBackendsError": "Unavailable pools: {pools}.", + "appHealthBackendsAffected": "Affected backends: {members}.", + "appHealthLogging": "Logging", + "appHealthLoggingOk": "Logging pipeline reports no queue drops or write/send errors.", + "appHealthLoggingWarning": "{dropped} queued entries dropped, {errors} write/send errors since start.", "webMetricStatus": "WWW HTTP status", "webMetricStatusHelp": "Completed Web Server status classes, all-time since reload.", "webMetricKind": "Static vs PHP", @@ -2964,11 +2988,13 @@ "certificateInspectKept": "Certificate read; your SNI names were preserved.", "certificateInspectNoNames": "Certificate contains no DNS SAN names.", "certificateInspectFailed": "Cannot read certificate. Check the file and its permissions, then read again.", - "pathPermissionsOptions": "Options and help" - }, - "overviewLayoutNarrow": "Decrease card width", - "overviewLayoutWiden": "Increase card width", - "overviewLayoutWidth": "Card width in 12-column grid", - "overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert.", - "dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again." + "pathPermissionsOptions": "Options and help", + "overviewLayoutNarrow": "Decrease card width", + "overviewLayoutWiden": "Increase card width", + "overviewLayoutWidth": "Card width in 12-column grid", + "dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again.", + "overviewLayoutInlineDragging": "Dragging: drop the block on the dashed frame. Esc restores its previous position.", + "overviewLayoutInlineCompact": "Fill gaps", + "overviewLayoutInlineCompactHint": "Move blocks into empty grid space once (you can still cancel before saving)" + } } diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 9140135..7920022 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -2749,7 +2749,7 @@ "overviewLayoutTypeFilter": "Filtruj bloki według typu", "overviewLayoutUnlock": "Odblokuj układ", "overviewLayoutLock": "Zablokuj i zapisz", - "overviewLayoutInlineHint": "Układ odblokowany: przesuwaj bloki w obrębie grup podobnych rozmiarów, ukrywaj przyciskiem ×, a potem zapisz lub anuluj. Siatka automatycznie wyrównuje wysokości.", + "overviewLayoutInlineHint": "Układ odblokowany: chwyć blok za uchwyt ⠿ i przeciągnij — przerywana ramka pokazuje, gdzie wyląduje (Esc anuluje). Szerokość zmieniasz przyciskami − / +, strzałki na uchwycie przesuwają blok z klawiatury. „Uzupełnij luki” jednorazowo dosuwa bloki w puste miejsca.", "overviewLayoutInlineDrag": "Przeciągnij, aby przesunąć", "overviewLayoutInlineSave": "Zapisz i zablokuj", "overviewLayoutInlineCancel": "Anuluj zmiany", @@ -2840,11 +2840,35 @@ "overviewTransformErrors": "Błędy", "overviewMeterNoLimit": "Brak skonfigurowanego limitu", "overviewCacheHitsMisses": "HIT {hits}, MISS {misses}", - "overviewSamplesRemember": "Zapamiętaj wybór", - "overviewSamplesSaved": "Ustawienie zapisane", - "overviewSamplesTemporary": "Tylko do odświeżenia strony", "overviewSamplesStorageUnavailable": "Nie można zapisać w przeglądarce", "overviewSamplesTitle": "Próbki wykresów na stronie głównej", + "appHealthTitle": "Status aplikacji", + "appHealthSelfCheck": "SELF-CHECK", + "appHealthChecking": "Sprawdzanie statusu aplikacji", + "appHealthOk": "Wszystkie systemy działają", + "appHealthWarning": "Wymaga uwagi", + "appHealthError": "Wykryto problem", + "appHealthSidebarChecking": "SPRAWDZANIE", + "appHealthSidebarOk": "APLIKACJA OK", + "appHealthSidebarWarning": "APLIKACJA: UWAGA", + "appHealthSidebarError": "APLIKACJA: BŁĄD", + "appHealthUpdated": "Aktualizacja: {time}", + "appHealthNoStatus": "Brak aktualnego statusu", + "appHealthApi": "API sterujące", + "appHealthApiOk": "Status live aplikacji odpowiada.", + "appHealthApiError": "Brak aktualnego statusu aplikacji.", + "appHealthListeners": "Listenery", + "appHealthListenersOk": "Aktywne listenery są zgodne z żądaną konfiguracją.", + "appHealthListenersRestart": "Zmiany listenerów czekają na zastosowanie; uruchom RustProxy ponownie.", + "appHealthBackends": "Backendy", + "appHealthBackendsNone": "Brak skonfigurowanych pul backendów.", + "appHealthBackendsOk": "Wszystkie aktywne pule backendów są gotowe.", + "appHealthBackendsWarning": "Niedostępne: {down}, sprawdzane: {checking}: {members}.", + "appHealthBackendsError": "Niedostępne pule: {pools}.", + "appHealthBackendsAffected": "Dotyczy backendów: {members}.", + "appHealthLogging": "Logowanie", + "appHealthLoggingOk": "Kolejki logowania bez odrzuceń i błędów zapisu/wysyłki.", + "appHealthLoggingWarning": "Odrzucone wpisy kolejki: {dropped}, błędy zapisu/wysyłki od startu: {errors}.", "webMetricStatus": "Statusy HTTP WWW", "webMetricStatusHelp": "Klasy odpowiedzi serwera WWW od ostatniego resetu.", "webMetricKind": "Static / PHP", @@ -2964,11 +2988,13 @@ "certificateInspectKept": "Odczytano certyfikat; zachowano wpisane nazwy SNI.", "certificateInspectNoNames": "Certyfikat nie zawiera nazw DNS w SAN.", "certificateInspectFailed": "Nie można odczytać certyfikatu. Sprawdź plik i jego uprawnienia, a następnie ponów odczyt.", - "pathPermissionsOptions": "Opcje i pomoc" - }, - "overviewLayoutNarrow": "Zmniejsz szerokość karty", - "overviewLayoutWiden": "Zwiększ szerokość karty", - "overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn", - "overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany.", - "dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie." + "pathPermissionsOptions": "Opcje i pomoc", + "overviewLayoutNarrow": "Zmniejsz szerokość karty", + "overviewLayoutWiden": "Zwiększ szerokość karty", + "overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn", + "dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie.", + "overviewLayoutInlineDragging": "Przeciąganie: upuść blok w miejscu ramki. Esc przywraca poprzednią pozycję.", + "overviewLayoutInlineCompact": "Uzupełnij luki", + "overviewLayoutInlineCompactHint": "Dosuń bloki w puste miejsca siatki (jednorazowo, przed zapisaniem możesz to anulować)" + } } diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 150a3ab..6071626 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.35.3", + "version": "0.35.4", "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": [ diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index 4b68403..18cea3c 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -44,7 +44,7 @@ async function loadLanguages() { $("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; })); $("language").value = languagePreference === "auto" ? "auto" : language; } -let latestStatus = null, certificateInventory = new Map(); +let latestStatus = null, certificateInventory = new Map(), appHealthUpdatedAt = null; let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null; function translate() { clearTimeout(toastTimer); hideToast(); @@ -179,6 +179,68 @@ function uptime(value) { } function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } } function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; } +function appHealthChecks(status) { + if (!connected || !status) return [{ level: "error", label: t("appHealthApi"), detail: t("appHealthApiError") }]; + const checks = [{ level: "ok", label: t("appHealthApi"), detail: t("appHealthApiOk") }]; + if (status.listeners?.restart_required) { + checks.push({ level: "warning", label: t("appHealthListeners"), detail: t("appHealthListenersRestart") }); + } else { + checks.push({ level: "ok", label: t("appHealthListeners"), detail: t("appHealthListenersOk") }); + } + + const pools = Array.isArray(status.backend_pools) ? status.backend_pools : []; + const members = pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member }))); + const down = members.filter(member => member.status === "down"); + const checking = members.filter(member => member.status === "checking"); + const unavailablePools = pools.filter(pool => pool.ready === false && (pool.members || []).some(member => ["up", "down", "checking"].includes(member.status))).map(pool => pool.id).filter(Boolean); + const affected = [...down, ...checking].slice(0, 5).map(member => `${member.pool}/${member.id}`).join(", "); + if (unavailablePools.length) { + let detail = t("appHealthBackendsError").replace("{pools}", unavailablePools.slice(0, 5).join(", ") + (unavailablePools.length > 5 ? " …" : "")); + if (affected) detail += ` ${t("appHealthBackendsAffected").replace("{members}", affected + (down.length + checking.length > 5 ? " …" : ""))}`; + checks.push({ level: "error", label: t("appHealthBackends"), detail }); + } else if (down.length || checking.length) { + checks.push({ level: "warning", label: t("appHealthBackends"), detail: t("appHealthBackendsWarning").replace("{down}", String(down.length)).replace("{checking}", String(checking.length)).replace("{members}", affected + (down.length + checking.length > 5 ? " …" : "")) }); + } else { + checks.push({ level: "ok", label: t("appHealthBackends"), detail: pools.length ? t("appHealthBackendsOk") : t("appHealthBackendsNone") }); + } + + const sink = status.logging?.sinks || {}, errorFile = status.logging?.error_file || {}; + const loggingDropped = (Number(sink.queue_dropped) || 0) + (Number(errorFile.queue_dropped) || 0); + const loggingErrors = (Number(sink.errors) || 0) + (Number(errorFile.errors) || 0); + if (loggingDropped || loggingErrors) { + checks.push({ level: "warning", label: t("appHealthLogging"), detail: t("appHealthLoggingWarning").replace("{dropped}", String(loggingDropped)).replace("{errors}", String(loggingErrors)) }); + } else { + checks.push({ level: "ok", label: t("appHealthLogging"), detail: t("appHealthLoggingOk") }); + } + return checks; +} +function appHealthLevel(checks) { + if (!checks.length) return "checking"; + if (checks.some(check => check.level === "error")) return "error"; + if (checks.some(check => check.level === "warning")) return "warning"; + return "ok"; +} +function setAppHealthClass(node, level) { + if (!node) return; + node.classList.remove("is-checking", "is-ok", "is-warning", "is-error"); + node.classList.add(`is-${level}`); +} +function renderAppHealth(status = latestStatus) { + const checks = appHealthChecks(status), level = appHealthLevel(checks); + const labelKeys = { checking: "appHealthSidebarChecking", ok: "appHealthSidebarOk", warning: "appHealthSidebarWarning", error: "appHealthSidebarError" }; + const summaryKeys = { checking: "appHealthChecking", ok: "appHealthOk", warning: "appHealthWarning", error: "appHealthError" }; + const button = $("app-health-open"), label = $("app-health-label"), summaryRow = $("app-health-summary-row"), summary = $("app-health-summary"); + setAppHealthClass(button, level); setAppHealthClass(summaryRow, level); + if (label) { label.dataset.i18n = labelKeys[level]; label.textContent = t(labelKeys[level]); } + if (summary) { summary.dataset.i18n = summaryKeys[level]; summary.textContent = t(summaryKeys[level]); } + const updated = $("app-health-updated"); + if (updated) updated.textContent = appHealthUpdatedAt ? t("appHealthUpdated").replace("{time}", appHealthUpdatedAt.toLocaleTimeString(locale())) : t("appHealthNoStatus"); + const list = $("app-health-checks"); + if (list) list.replaceChildren(...checks.map(check => { + const row = el("div", undefined, `app-health-check is-${check.level}`), dot = el("i"), copy = el("div"); + dot.setAttribute("aria-hidden", "true"); copy.append(el("strong", check.label), el("p", check.detail, "fine")); row.append(dot, copy); return row; + })); +} function enhanceEnabledControl(input, hintKey = "entityEnableHint") { const label = input?.closest("label"); if (!label) return () => { }; label.classList.add("vh-enable-control", "vh-wide"); @@ -257,7 +319,7 @@ function renderStatusNow(s, systemSample = false) { logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging }; s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings(); - connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics; + connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); $("app-health-slot").hidden = false; appHealthUpdatedAt = new Date(); renderAppHealth(s); const m = s.metrics; $("metric-ws").textContent = m.websocket_active || 0; $("metric-ws-h1").textContent = m.websocket_h1_active || 0; $("metric-ws-h2").textContent = m.websocket_h2_active || 0; @@ -299,7 +361,7 @@ function renderStatusNow(s, systemSample = false) { function renderFooter(s) { const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`; const footer = $("footer-status"); - footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`)); + footer.replaceChildren(document.createTextNode(apiLatency)); const update = s.software_update; if (update?.update_available && update.latest_version) { footer.append(document.createTextNode(" / ")); @@ -421,12 +483,12 @@ function lock(notify = true) { window.rustProxyInlineLayout?.cancel?.(); window.rustProxyRefresh?.stop(); const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:"); - pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage(""); + pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; $("app-health-slot").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage(""); for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); } $("workspace").querySelectorAll("form").forEach(form => form.reset()); $("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; }); document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); }); - renderSession(); syncAuthForm(); + appHealthUpdatedAt = null; renderAppHealth(null); renderSession(); syncAuthForm(); if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); } } @@ -506,6 +568,8 @@ function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); } function startTunnelRefresh() { window.rustProxyRefresh?.sync(); } $("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } }); $("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } }); +$("app-health-open")?.addEventListener("click", () => { renderAppHealth(connected ? latestStatus : null); const dialog = $("app-health-dialog"); if (!dialog.open) dialog.showModal(); }); +$("app-health-close")?.addEventListener("click", () => $("app-health-dialog").close()); $("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); }); $("about-close").addEventListener("click", () => $("about-dialog").close()); const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar"); diff --git a/src/admin/web/app/dashboard-inline-layout.js b/src/admin/web/app/dashboard-inline-layout.js index 81e9677..78bfc71 100644 --- a/src/admin/web/app/dashboard-inline-layout.js +++ b/src/admin/web/app/dashboard-inline-layout.js @@ -9,9 +9,9 @@ window.rustProxyInlineLayout = (() => { const hiddenList = $("overview-inline-hidden-list"); const hiddenCount = $("overview-inline-hidden-count"); const modalButton = $("overview-layout-open"); + const compactButton = $("overview-inline-compact"); let draft = null; let original = null; - let dragging = null; let opening = false; let saving = false; @@ -72,10 +72,7 @@ window.rustProxyInlineLayout = (() => { if (widen) widen.disabled = at === widths.length - 1; } } - function clearDrag() { - dragging = null; - for (const node of nodes()) node.classList.remove("is-overview-dragging", "is-overview-drop-before", "is-overview-drop-after"); - } + function clearDrag() { if (pointerDrag) settle(false); } function move(id, target, after = false) { if (!draft || id === target) return; if (!visibleIds().includes(id) || !visibleIds().includes(target)) return; @@ -118,7 +115,7 @@ window.rustProxyInlineLayout = (() => { const title = label(id); const bar = el("div", undefined, "overview-inline-toolbar"); const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { }); - grip.draggable = true; + grip.setAttribute("aria-describedby", "overview-inline-hint"); grip.dataset.overviewDragHandle = id; const sizeControls = el("span", undefined, "overview-inline-width"); const widthValue = el("span", "", "overview-inline-width-value"); @@ -142,6 +139,7 @@ window.rustProxyInlineLayout = (() => { actions.hidden = false; hint.hidden = false; hint.textContent = t("overviewLayoutInlineHint"); + if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint"); if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; } if (modalButton) modalButton.disabled = true; for (const node of nodes()) toolbar(node); @@ -195,40 +193,185 @@ window.rustProxyInlineLayout = (() => { finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; } } function cancel() { if (draft && !saving) finish(false); } - function dropTarget(event) { - const widget = event.target?.closest?.("[data-dashboard-widget]"); - return widget && grid.contains(widget) && !widget.hidden && visibleIds().includes(widget.dataset.dashboardWidget) ? widget : null; + // Pointer-driven sorting with a live placeholder. The grid never back-fills + // holes while a block is lifted, so the placeholder shows exactly where the + // block lands; neighbours glide (FLIP) instead of jumping. + const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90; + const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; + let pointerDrag = null; + function flip(mutate) { + const before = new Map(nodes().filter(node => !node.hidden && node !== pointerDrag?.node) + .map(node => [node, node.getBoundingClientRect()])); + mutate(); + if (reducedMotion()) return; + for (const [node, rect] of before) { + const after = node.getBoundingClientRect(); + const dx = rect.left - after.left, dy = rect.top - after.top; + if (!dx && !dy) continue; + node.animate?.([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }], + { duration: 160, easing: "cubic-bezier(.2, .7, .3, 1)" }); + } } - function positionAfter(widget, event) { - const rect = widget.getBoundingClientRect(); - return event.clientX >= rect.left + rect.width / 2; + function placeholderTarget(x, y) { + const drag = pointerDrag; + const hit = document.elementFromPoint(x, y); + const widget = hit?.closest?.("[data-dashboard-widget]"); + if (widget && grid.contains(widget) && widget !== drag.node && !widget.hidden) return { node: widget }; + if (hit === drag.placeholder || drag.placeholder.contains(hit)) return null; + if (hit !== grid) return null; + // Empty grid area (gap or hole at a row end): attach to the nearest block in that row. + const candidates = nodes().filter(node => node !== drag.node && !node.hidden); + let best = null, bestDistance = Infinity; + for (const node of candidates) { + const rect = node.getBoundingClientRect(); + if (y < rect.top - 8 || y > rect.bottom + 8) continue; + const distance = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0; + if (distance < bestDistance) { bestDistance = distance; best = { node, after: x > rect.left + rect.width / 2 }; } + } + if (best) return best; + const last = candidates.at(-1); + if (last && y > last.getBoundingClientRect().bottom) return { node: last, after: true }; + return null; } - grid?.addEventListener("dragstart", event => { + function updatePlaceholder(x, y) { + const drag = pointerDrag; + if (performance.now() < drag.cooldownUntil) return; + const target = placeholderTarget(x, y); + if (!target) return; + const children = [...grid.children]; + const placeholderIndex = children.indexOf(drag.placeholder), targetIndex = children.indexOf(target.node); + // Entering a block swaps the placeholder past it; this is stable and never oscillates. + const after = target.after ?? placeholderIndex < targetIndex; + const reference = after ? target.node.nextSibling : target.node; + if (reference === drag.placeholder || (after ? target.node.nextSibling === drag.placeholder : target.node.previousSibling === drag.placeholder)) return; + flip(() => grid.insertBefore(drag.placeholder, reference)); + drag.cooldownUntil = performance.now() + MOVE_COOLDOWN_MS; + } + function autoScroll() { + const drag = pointerDrag; + if (!drag?.active) return; + const height = window.innerHeight; + let speed = 0; + if (drag.y < EDGE) speed = -Math.ceil((EDGE - drag.y) / 4); + else if (drag.y > height - EDGE) speed = Math.ceil((drag.y - (height - EDGE)) / 4); + if (speed) { + const scroller = document.scrollingElement || document.documentElement; + const previous = scroller.scrollTop; + scroller.scrollTop += speed; + if (scroller.scrollTop !== previous) updatePlaceholder(drag.x, drag.y); + } + drag.frame = requestAnimationFrame(autoScroll); + } + function liftBlock() { + const drag = pointerDrag; + enterFreeLayout(); + paint(); + const rect = drag.node.getBoundingClientRect(); + const placeholder = el("div", undefined, "overview-drop-placeholder"); + placeholder.dataset.dashboardSpan = drag.node.dataset.dashboardSpan || "2"; + placeholder.style.minHeight = `${Math.round(rect.height)}px`; + placeholder.setAttribute("aria-hidden", "true"); + drag.origin = drag.node.nextSibling; + drag.placeholder = placeholder; + drag.offsetX = drag.startX - rect.left; + drag.offsetY = drag.startY - rect.top; + drag.width = rect.width; + drag.height = rect.height; + drag.active = true; + drag.cooldownUntil = 0; + grid.insertBefore(placeholder, drag.node); + grid.classList.add("is-overview-sorting"); + drag.node.classList.add("is-overview-lifted"); + Object.assign(drag.node.style, { + position: "fixed", left: "0px", top: "0px", width: `${rect.width}px`, height: `${rect.height}px`, + zIndex: "60", pointerEvents: "none", margin: "0", + }); + moveLifted(); + hint.textContent = t("overviewLayoutInlineDragging"); + drag.frame = requestAnimationFrame(autoScroll); + } + function moveLifted() { + const drag = pointerDrag; + drag.node.style.transform = `translate(${drag.x - drag.offsetX}px, ${drag.y - drag.offsetY}px) rotate(.6deg) scale(1.015)`; + } + function settle(commit) { + const drag = pointerDrag; + pointerDrag = null; + if (!drag) return; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + window.removeEventListener("keydown", onDragKey, true); + if (!drag.active) return; + cancelAnimationFrame(drag.frame); + for (const key of ["position", "left", "top", "width", "height", "zIndex", "pointerEvents", "margin", "transform"]) drag.node.style[key] = ""; + drag.node.classList.remove("is-overview-lifted"); + grid.classList.remove("is-overview-sorting"); + if (commit) drag.placeholder.replaceWith(drag.node); + else { drag.placeholder.remove(); grid.insertBefore(drag.node, drag.origin?.parentNode === grid ? drag.origin : null); } + hint.textContent = t("overviewLayoutInlineHint"); + if (commit) { + const domOrder = nodes().map(node => node.dataset.dashboardWidget).filter(id => !draft.hidden.has(id)); + const iter = domOrder.values(); + draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value); + } + paint(); + drag.node.querySelector(".overview-inline-grip")?.focus({ preventScroll: true }); + drag.node.animate?.([{ boxShadow: "0 0 0 3px var(--accent)" }, { boxShadow: "0 0 0 0 transparent" }], { duration: 450 }); + } + function onPointerMove(event) { + const drag = pointerDrag; + if (!drag || event.pointerId !== drag.pointerId) return; + drag.x = event.clientX; drag.y = event.clientY; + if (!drag.active) { + if (Math.hypot(drag.x - drag.startX, drag.y - drag.startY) < DRAG_THRESHOLD) return; + liftBlock(); + } + event.preventDefault(); + moveLifted(); + updatePlaceholder(drag.x, drag.y); + } + function onPointerUp(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(true); } + function onPointerCancel(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(false); } + function onDragKey(event) { if (event.key === "Escape" && pointerDrag) { event.preventDefault(); event.stopPropagation(); settle(false); } } + grid?.addEventListener("pointerdown", event => { const handle = event.target?.closest?.("[data-overview-drag-handle]"); - if (!draft || !handle || !grid.contains(handle)) { event.preventDefault(); return; } - dragging = handle.dataset.overviewDragHandle; - event.dataTransfer?.setData("text/plain", dragging); - if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; - nodeFor(dragging)?.classList.add("is-overview-dragging"); - }); - grid?.addEventListener("dragover", event => { - if (!draft || !dragging) return; - const target = dropTarget(event); - if (!target || target.dataset.dashboardWidget === dragging) return; + if (!draft || saving || pointerDrag || !handle || !grid.contains(handle) || event.button !== 0) return; + const node = nodeFor(handle.dataset.overviewDragHandle); + if (!node) return; event.preventDefault(); - for (const node of nodes()) node.classList.remove("is-overview-drop-before", "is-overview-drop-after"); - target.classList.add(positionAfter(target, event) ? "is-overview-drop-after" : "is-overview-drop-before"); - if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + pointerDrag = { node, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, active: false }; + window.addEventListener("pointermove", onPointerMove, { passive: false }); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerCancel); + window.addEventListener("keydown", onDragKey, true); }); - grid?.addEventListener("drop", event => { - if (!draft || !dragging) return; + grid?.addEventListener("keydown", event => { + const handle = event.target?.closest?.("[data-overview-drag-handle]"); + if (!draft || !handle) return; + const delta = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key]; + if (!delta) return; event.preventDefault(); - const target = dropTarget(event), from = dragging; - clearDrag(); - if (target && target.dataset.dashboardWidget !== from) - move(from, target.dataset.dashboardWidget, positionAfter(target, event)); + nudge(handle.dataset.overviewDragHandle, delta); + nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus(); }); - grid?.addEventListener("dragend", clearDrag); + function compact() { + // Explicit, one-off gap filling: reads the dense packing the browser would + // produce and adopts it as the new order, so nothing moves unexpectedly later. + if (!draft) return; + enterFreeLayout(); + paint(); + flip(() => { + grid.style.gridAutoFlow = "row dense"; + const placed = nodes().filter(node => !node.hidden).map(node => ({ id: node.dataset.dashboardWidget, rect: node.getBoundingClientRect() })); + grid.style.gridAutoFlow = ""; + placed.sort((a, b) => Math.round(a.rect.top) - Math.round(b.rect.top) || a.rect.left - b.rect.left); + const iter = placed.map(item => item.id).values(); + draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value); + paint(); + }); + } + compactButton?.addEventListener("click", compact); unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); }); saveButton?.addEventListener("click", () => { void save(); }); cancelButton?.addEventListener("click", cancel); diff --git a/src/admin/web/app/overview-samples.js b/src/admin/web/app/overview-samples.js index e86dde1..37be74f 100644 --- a/src/admin/web/app/overview-samples.js +++ b/src/admin/web/app/overview-samples.js @@ -2,64 +2,40 @@ window.rustProxyOverviewSamples = (() => { const allowed = new Set([60, 120, 300, 600]); const storageKey = "rustproxy.overview.liveChartSamples"; const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60; - let saved = allowed.has(factory) ? factory : 60; + let limit = allowed.has(factory) ? factory : 60; try { const stored = Number(localStorage.getItem(storageKey)); - if (allowed.has(stored)) saved = stored; - } catch { /* Private browsing or storage policy: temporary selection still works. */ } - let limit = saved; + if (allowed.has(stored)) limit = stored; + } catch { /* Private browsing or storage policy: selection still works for this visit. */ } const callbacks = new Set(); - let confirmationTimer = null; const getLimit = () => limit; const trim = array => { if (Array.isArray(array) && array.length > limit) array.splice(0, array.length - limit); return array; }; - // The status is shown only for a temporary choice, a storage problem or briefly after saving. - function sync(messageKey = limit === saved ? null : "overviewSamplesTemporary", confirmation = false) { + function sync() { const select = document.getElementById("overview-live-samples"); if (select) select.value = String(limit); - const state = document.getElementById("overview-samples-state"); - if (state) { - clearTimeout(confirmationTimer); - if (messageKey) { - state.dataset.i18n = messageKey; - state.textContent = t(messageKey); - state.style.visibility = "visible"; - } else { - state.style.visibility = "hidden"; - } - if (confirmation) confirmationTimer = setTimeout(() => { state.style.visibility = "hidden"; }, 2500); - } - const save = document.getElementById("overview-samples-save"); - if (save) save.disabled = limit === saved; } function setLimit(value, persist = false) { const count = Number(value); if (!allowed.has(count)) return false; + const changed = count !== limit; + limit = count; if (persist) { try { localStorage.setItem(storageKey, String(count)); - saved = count; } catch { - const changed = count !== limit; - limit = count; - if (changed) for (const callback of callbacks) callback(limit); - sync("overviewSamplesStorageUnavailable"); - return false; + if (typeof toast === "function") toast(t("overviewSamplesStorageUnavailable"), true); } } - const changed = count !== limit; - limit = count; if (changed) for (const callback of callbacks) callback(limit); - if (persist) sync("overviewSamplesSaved", true); else sync(); + sync(); return true; } function init() { const select = document.getElementById("overview-live-samples"); - const save = document.getElementById("overview-samples-save"); - select?.addEventListener("change", () => setLimit(select.value)); - save?.addEventListener("click", () => setLimit(select.value, true)); + select?.addEventListener("change", () => setLimit(select.value, true)); sync(); } if (document.readyState !== "complete") document.addEventListener("DOMContentLoaded", init, { once: true }); diff --git a/src/admin/web/app/refresh.js b/src/admin/web/app/refresh.js index c96968c..34eb1a7 100644 --- a/src/admin/web/app/refresh.js +++ b/src/admin/web/app/refresh.js @@ -167,7 +167,7 @@ const rustProxyRefresh = (() => { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => { const value = await api("/api/status?view=live", { signal }); if (isCurrent()) renderLiveStatus(value); - }, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); } + }, 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 }) => { diff --git a/src/admin/web/app/runtime.js b/src/admin/web/app/runtime.js index c69b568..399816f 100644 --- a/src/admin/web/app/runtime.js +++ b/src/admin/web/app/runtime.js @@ -48,7 +48,7 @@ function syncCertificateFileSource() { for (const input of [cert, key]) { input.maxLength = external ? 4096 : 96; if (external) input.removeAttribute("pattern"); - else input.pattern = "[A-Za-z0-9_.-]+"; + else input.pattern = "[A-Za-z0-9_.\\-]+"; } cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem"; key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key"; diff --git a/src/admin/web/css/20-shell.css b/src/admin/web/css/20-shell.css index 57d5ddb..69b32b1 100644 --- a/src/admin/web/css/20-shell.css +++ b/src/admin/web/css/20-shell.css @@ -175,6 +175,37 @@ nav { align-items: center; } +.app-health-button { + width: 100%; + border: 0; + background: transparent; + padding: 6px 0; + text-align: left; + cursor: pointer; +} + +.app-health-button i { + flex: 0 0 auto; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.app-health-button.is-checking, +.app-health-summary.is-checking { color: var(--muted); } +.app-health-button.is-ok, +.app-health-summary.is-ok, +.app-health-check.is-ok { color: var(--green); } +.app-health-button.is-warning, +.app-health-summary.is-warning, +.app-health-check.is-warning { color: var(--notice-text); } +.app-health-button.is-error, +.app-health-summary.is-error, +.app-health-check.is-error { color: var(--red); } + +.app-health-button:hover { opacity: .82; } + .nav { display: flex; align-items: center; @@ -216,11 +247,71 @@ nav { padding-top: 32px; } -.sidebar-bottom p { - font-size: 11px; - line-height: 1.9; - color: var(--text-subtle); - margin: 13px 0 26px; +.app-health-dialog { + width: min(620px, calc(100vw - 24px)); +} + +.app-health-card { + display: grid; + gap: 14px; +} + +.app-health-summary { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.app-health-summary>i { + flex: 0 0 auto; + width: 7px; + height: 7px; + margin-top: 6px; + border-radius: 50%; + background: currentColor; +} + +.app-health-summary strong { + color: currentColor; + font-size: 15px; +} + +.app-health-summary .fine { + margin: 3px 0 0; +} + +.app-health-checks { + display: grid; + gap: 7px; +} + +.app-health-check { + display: grid; + grid-template-columns: 9px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 9px 10px; + border: 1px solid var(--line); + background: var(--neutral-soft); +} + +.app-health-check>i { + flex: 0 0 auto; + width: 7px; + height: 7px; + margin-top: 5px; + border-radius: 50%; + background: currentColor; +} + +.app-health-check strong { + color: var(--text); + font-size: 12px; +} + +.app-health-check .fine { + margin: 2px 0 0; + color: var(--muted); } main { diff --git a/src/admin/web/css/30-overview.css b/src/admin/web/css/30-overview.css index 3cfb805..3b33a89 100644 --- a/src/admin/web/css/30-overview.css +++ b/src/admin/web/css/30-overview.css @@ -15,6 +15,18 @@ 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; +} + +.stats-grid.cache-stats>.stat { + flex: 1 1 190px; + min-width: 0; +} + .stat { border: 1px solid var(--line); background: var(--panel); @@ -549,6 +561,25 @@ 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; +} + +#panel-overview .stat-with-action .stat-corner-action { + max-width: 96px; + padding: 3px 7px; + font-size: 11px; + line-height: 1.25; + white-space: normal; + text-align: center; +} + +#panel-overview .dashboard-overview-grid.is-unlocked .stat-with-action .stat-corner-action { + top: 44px; +} + .stat-chart strong { margin-bottom: 3px; } @@ -1154,44 +1185,83 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { position: relative; outline: 1px dashed var(--accent); outline-offset: -2px; - padding-top: 101px; + padding-top: 40px !important; } -#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-dragging { - opacity: .45; +#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:hover, +#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:focus-within { + outline-style: solid; } -#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-before { - box-shadow: inset 4px 0 var(--accent); +/* 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; + cursor: grabbing; } -#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-after { - box-shadow: inset -4px 0 var(--accent); +#panel-overview .dashboard-overview-grid>.overview-drop-placeholder { + grid-column: auto/span 2; + box-sizing: border-box; + border: 2px dashed var(--accent); + border-radius: var(--rp-radius-surface, 9px); + 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>.is-overview-lifted, +#panel-overview .dashboard-overview-grid>.is-overview-lifted * { + pointer-events: none !important; +} + +#panel-overview .dashboard-overview-grid>.is-overview-lifted { + box-shadow: 0 18px 40px rgb(0 0 0 / .28), 0 0 0 2px var(--accent); + opacity: .94; + transition: none; + will-change: transform; + overflow: hidden; } .overview-inline-toolbar { position: absolute; - top: 3px; - left: 4px; - right: 4px; + top: 5px; + left: 5px; + right: 5px; z-index: 4; display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow: hidden; gap: 3px; pointer-events: auto; } #panel-overview .overview-inline-toolbar button { - min-width: 28px; - min-height: 27px; - padding: 2px 7px; + min-width: 26px; + min-height: 26px; + padding: 2px 6px; font-size: 12px; + 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; +} + +#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar .overview-inline-hide { + margin-left: auto; } #panel-overview .overview-inline-toolbar .overview-inline-grip { cursor: grab; user-select: none; + touch-action: none; } #panel-overview .overview-inline-toolbar .overview-inline-grip:active { @@ -1690,26 +1760,13 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty flex-wrap: wrap; max-width: 100%; margin: 0 0 0 auto; + flex: 0 0 auto; } .page-intro .overview-samples-toolbar .function-live-chart-samples select { min-width: 100px; } -.page-intro .overview-samples-toolbar button { - width: auto; - flex: none; - min-height: 28px; - padding: 5px 10px; - white-space: nowrap; -} - -.page-intro .overview-samples-toolbar .fine { - font-size: 10px; - color: var(--muted); - white-space: nowrap; -} - #panel-overview .stat-donut .overview-donut-info { display: flex; flex-direction: column; @@ -1821,8 +1878,10 @@ 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 dense; + grid-auto-flow: row; } #panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="2"] { diff --git a/src/admin/web/css/70-responsive.css b/src/admin/web/css/70-responsive.css index 6614111..59bcc76 100644 --- a/src/admin/web/css/70-responsive.css +++ b/src/admin/web/css/70-responsive.css @@ -286,6 +286,14 @@ display: none; } + .sidebar.mobile-nav-open .sidebar-bottom { + grid-column: 1/-1; + display: flex; + width: 100%; + padding-top: 8px; + border-top: 1px solid var(--line); + } + .topbar { height: 46px; } @@ -702,8 +710,8 @@ } .page-intro .overview-samples-toolbar { - margin-left: 0; - justify-content: flex-start; + margin-left: auto; + justify-content: flex-end; } } @@ -1457,6 +1465,10 @@ } @media (max-width:550px) { + .stats-grid.cache-stats>.stat { + flex-basis: 140px; + } + .overview-layout-footer { flex-wrap: wrap; } @@ -1474,7 +1486,19 @@ } #panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] { - padding-top: 135px; + padding-top: 40px !important; + } + + #panel-overview .dashboard-overview-grid.is-unlocked .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down) { + display: none; + } + + #panel-overview .dashboard-overview-grid>.overview-drop-placeholder:is([data-dashboard-span="2"], [data-dashboard-span="3"]) { + grid-column: auto/span 1; + } + + #panel-overview .dashboard-overview-grid>.overview-drop-placeholder:is([data-dashboard-span="4"], [data-dashboard-span="6"], [data-dashboard-span="12"]) { + grid-column: 1/-1; } } @@ -1623,6 +1647,10 @@ padding-right: 11px; } + #panel-overview .stat-with-action>span:first-child { + padding-right: 0; + } + #panel-overview .dashboard-overview-grid.is-aligned .stat-with-action .stat-corner-action { position: static; display: block; diff --git a/src/admin/web/docker.js b/src/admin/web/docker.js index 3700e74..604c078 100644 --- a/src/admin/web/docker.js +++ b/src/admin/web/docker.js @@ -156,7 +156,7 @@ const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled"))); const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim()); const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions); - const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_-]{1,64}"; if (agent) id.readOnly = true; + const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_\\-]{1,64}"; if (agent) id.readOnly = true; const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true; const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876"; const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password"; diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 2f7cca3..a96dad0 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -58,9 +58,11 @@ data-i18n="transform">Transform - + pattern="[A-Za-z0-9_.\-]{3,64}" required="" spellcheck="false" />
@@ -145,6 +143,9 @@ @@ -771,7 +772,7 @@
+ maxlength="64" pattern="[A-Za-z0-9_\-]{1,64}" required />
@@ -982,7 +983,7 @@
+ pattern="[A-Za-z0-9_\-]{1,64}" required="" /> @@ -1063,7 +1064,7 @@

@@ -1081,7 +1082,7 @@

@@ -1094,10 +1095,10 @@

@@ -3668,7 +3668,7 @@ + pattern="[A-Za-z0-9_\-]{1,64}" required="" />