v0.32.2
This commit is contained in:
1 parent
bb025dcf44
commit
b0f211555a
74 files changed
+5858
-325
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
|
||||
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
|
||||
compatibility. Current source version: 0.32.2.
|
||||
compatibility. Current source version: 0.32.3.
|
||||
|
||||
## Delivery
|
||||
|
||||
@@ -60,7 +60,8 @@ only for compatible members; re-enabled/changed origins require new health check
|
||||
Keep backend leases through request/response/tunnel completion. Do not replay failed
|
||||
writes to another backend. Cookies are routing affinity, never authentication. Health
|
||||
probes must use the same origin/DNS policy, bounded concurrency and cancellation.
|
||||
Retain HTTP/1.1 in TLS HTTP/2 negotiation; do not implement fallback by replaying traffic.
|
||||
Retain HTTP/1.1 in TLS Auto negotiation; require H2 in explicit HTTPS H2 mode,
|
||||
and do not implement fallback by replaying traffic.
|
||||
|
||||
## Vhost configuration invariants
|
||||
|
||||
|
||||
+35
-5
@@ -3,6 +3,40 @@
|
||||
> Operator-facing release history. Historical engineering notes are kept in
|
||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||
|
||||
## 0.32.3
|
||||
|
||||
- Fix unequal heights and ragged Overview rows after customization. Replace absolute-positioned masonry/snapped bands with the original CSS Grid geometry for every layout, grouping numeric tiles, normal charts, large charts, extended diagnostics and the backend table into uniform rows. Each row has equal-height blocks and the same density-aware gutters; order can be customized within a size group without mixing short cards with tall panels. Preserve all 62 widgets, existing persisted settings, Hide/Restore, Default/Reset, theme and version **0.32.3**.
|
||||
|
||||
- Snap customized Overview tiles to shared pixel-aligned rows and **balanced horizontal bands**. Short cards can stack alongside tall diagnostics and, when their combined height is close enough, share the same bottom baseline with consistent density-sensitive gutters. Keep the factory six/three symmetrical rows, prevent stretching a single short tile to a tall panel, preserve drag/save/reset settings and all 62 widgets; version stays 0.32.3.
|
||||
|
||||
- Keep the RustProxy **logo shape fixed** across all Corner rounding presets. The unlocked Dashboard’s × Hide button inherits the same rounding as the adjacent arrow controls (including Square mode).
|
||||
- Preserve the **original symmetric Overview by default** (six metric cards / three large panels, density-aware gutters), while packing **only user-reordered layouts** on wider canvases: shorter graphs keep their natural height and can stack next to tall diagnostics. At narrow widths, retain the original row layout. Measure grid spans in CSS layout pixels so UI zoom 90–130% cannot cause collisions; no preference schema or version changes.
|
||||
- Restore a clean **Settings → Appearance** Overview selector: no block-type badges among the visibility/reorder controls. The modal visual editor and the unlocked Dashboard retain their type labels. Move/Hide/Show controls stay right-aligned.
|
||||
- Open **Appearance** on a first visit to Settings, reopen the last selected Settings tab on later visits (remembered locally), and always honor explicit `/settings?tab=...`, `/configuration` and `/logging` navigation.
|
||||
- Add an account-scoped **Corner rounding** selector with Original, Square, Subtle, Rounded and Extra rounded options. Original retains the established corners in all four themes; other presets apply to cards, fields, dialogs, buttons and panels consistently, including login. Preserve older preference documents with the default preset and reject unknown values.
|
||||
- Restore the RustProxy logo's original coral/soft-green mark accents in Ultra Dark without changing the near-black surfaces or global chart/button palette. Preserve version 0.32.3 and existing dashboard layouts.
|
||||
|
||||
- Restore the original strictly aligned **default** Overview row layout in response to uneven masonry spacing: six equal numeric cards and three equal diagnostic panels per desktop row, with consistent original `--ui-gap` gutters. Customized orders can now opt into independently sized stacking without changing the default geometry. All 62 blocks retain drag-to-order, hide/restore, reset and saved preferences; version stays 0.32.3.
|
||||
- Add six opt-in compact charts (HTTP requests/s, active exchanges, H1/H3 shares, cache MISS/s and in-flight backend requests) and seven opt-in full-size multi-series charts (HTTP protocol rates, system/process CPU, process/cache memory, cache HIT/MISS, connections/WebSockets, backend availability and response/probe latency). All read existing status snapshots with bounded browser histories; unknown counters remain unavailable and resets do not create negative rates. The shared registry/API now offers **62 blocks**, retaining the preceding 49 IDs and their saved settings.
|
||||
- In the visual block selector and unlocked inline editor, place the block-type badge before the action buttons; keep Move/Hide/Show controls aligned to the right even when names vary in length. Keep the release version **0.32.3**.
|
||||
|
||||
- Add optional **Ultra dark** (AMOLED-style black) and **Warm cream** (subdued ivory) admin themes. Persist both via existing per-user Appearance preferences and preserve the original Dark/Light choices, login preview, and default `dark` value.
|
||||
- Add **× Hide from Dashboard** to each block while editing the unlocked real Overview. Restore any numeric card, chart, panel or backend table from the collapsible **Hidden blocks** list; Cancel discards the draft, Save persists visibility and order with one preference update. No registry records or backend metrics are deleted.
|
||||
- Add an **Unlock layout** button on the real Overview canvas. Only the drag handles start movement; keyboard-friendly arrow buttons also reorder number tiles, charts and whole panels. Changes are previewed locally, committed in one existing preferences PUT by **Save and lock**, or discarded by **Cancel** or navigation. The original live widget DOM and callbacks are preserved.
|
||||
- Classify all 49 blocks in the shared registry as numeric, numeric+chart, chart, diagnostic panel or table. Show localized block-type labels in the visual editor while keeping Appearance compact, with a filter for hidden widgets. Retain default visibility, account preferences, all existing metrics and version 0.32.3.
|
||||
|
||||
- Extend the visual Overview editor to all dashboard content, including existing live charts, three diagnostic panels and the backend table. Restore the original ordering by default; allow any registered block to be moved or hidden without losing its live DOM handlers.
|
||||
- Add twelve opt-in, bounded-history metric charts based on existing process, cache, frontend, TLS, HTTP/2 and backend-health counters; unknown data stays neutral. All 49 cards/panels share one registry, the previous settings arrays and the same account-scoped endpoint.
|
||||
- Add **Default view** draft preview and a confirmed **Reset layout** action; both preserve non-dashboard preferences. Keep the 0.32.3 version and upstream protocol configuration unchanged.
|
||||
- Restore `http2` backward compatibility: HTTPS offers H2/H1 via ALPN as it did in 0.32.2, while cleartext uses H2C prior knowledge. The new `h2` value is strict on HTTPS, with no H1 downgrade. `http1` and `auto` retain their prior semantics; no request replay or extra retry loop.
|
||||
- Extend the Origin selector to four clearly described choices (H1, Auto, HTTP/2 compatible, H2 only), keeping existing saved JSON untouched. Preserve separate WebSocket HTTP/1.1 Upgrade and H2 Extended CONNECT paths.
|
||||
- Share a validated Overview Cards registry between Rust and JavaScript. Retain the original 16 widgets and saved order/hidden choices. Unknown identifiers do not invalidate a whole saved profile. No SQLite migration.
|
||||
- Expand to fourteen opt-in HTTP origin cards: keep health, activity, errors/s, probe RTT, traffic, configured modes, requests/s and capacity, and add 5xx, timeouts, DNS/TCP/TLS/I/O errors, retries, queue pressure and client-pool rotations. No extra probes/polls; all new widgets default hidden. Protocol counts describe configuration rather than actual ALPN results.
|
||||
- Synchronize the authenticated Dashboard OpenAPI preference enums, maximum array sizes and visibility defaults with all 30 registry entries.
|
||||
- Add a visual Overview layout dialog with draggable metric previews, keyboard reorder and hide/show list; settings remain account scoped and Save uses one existing preferences PUT. Cancel makes no changes.
|
||||
- Document that HTTPS ALPN runs once per new TLS connection; Hyper pools and reuses negotiated connections. Keep configured per-backend recycling rather than adding unsafe protocol locks, timers or probing traffic.
|
||||
- Add real-peer Rust tests for compatible HTTPS H1/H2 negotiation, strict H2, H2C, request-version safety and WebSocket isolation, plus UI/source preference regression checks. Cargo/runtime acceptance and benchmarks remain pending.
|
||||
|
||||
## 0.32.2
|
||||
|
||||
- Fix Web Server shared-cache revalidation: browser `no-cache`, `max-age=0` and freshness-constrained requests now supply stored origin validators. Honor local origin `304 Not Modified` by refreshing the same cached body and preserving/incrementing `X-Cache-Hits`, matching the existing Reverse Proxy behavior.
|
||||
@@ -23,7 +57,6 @@
|
||||
- Add optional TCP Connect and conservative UDP response-matched probes with configurable interval, timeout and rise/fall, globally bounded concurrency and cancellation. Never label an unverified UDP connect as Healthy.
|
||||
- Add bounded per-backend observations to existing status/SSE and opt-in detailed Prometheus metrics; preserve global `include_streams`, stream-level basic counters and traffic direction meanings.
|
||||
- Extend existing Appearance dashboard card visibility/order with four opt-in Stream cards. Reuse account preferences, existing status updates, translations, style/responsive layout and in-memory browser charts. Migrate older 12-widget layouts to hidden Stream cards.
|
||||
- Update Streams editor, OpenAPI, Settings documentation and regression coverage. No new public routes, scheduler or database format. Backend changes remain restart-required. **Cargo and live TCP/UDP integration gate remain open** until verified.
|
||||
|
||||
### 0.32.0 Overview layout and Vhosts hint correction
|
||||
|
||||
@@ -54,7 +87,6 @@
|
||||
|
||||
## 0.31.8
|
||||
|
||||
- Consolidate 28 numbered Rust unit-test fragments into seven existing `tests.rs` suites (Admin, Balance, Config, Web Server and three Cache suites). Preserve the tests, remove obsolete fragment directories, and exempt Rust test sources (not production modules) from the 500-line check. Fix the test fixture's relative `login-keys.json` include path. No runtime functionality changes.
|
||||
- Fix Docker VHosts startup status: a missing first observation is WAITING, not a connection failure. Read cached agent status when opening the page (including with Live view off) and briefly retry while the first background check is pending. Display an error only after a failed agent check; omit the configured certificate SHA-256 from generic failure details because it is not a diagnosed failure cause. A configured pin alone does not mark the agent connected. Keep agent polling, certificate validation, manual refresh and persisted settings unchanged.
|
||||
- Add server-side Stored Logs sorting by visible columns, including numeric and discovered nested fields; preserve sorting over the complete bounded scan before pagination. Column and direction persist with account preferences, with no SQLite schema change.
|
||||
- Add matching dark HTML built-in pages for HTTP 200, 400, 408, 410, 413, 500 and 502. Align the remaining generic HTML status template with the established RustProxy appearance. Preserve administrator-configured responses, status codes, protocol headers and bodyless responses.
|
||||
@@ -74,7 +106,6 @@
|
||||
- Add spacing between the HTTP/2 and HTTP/3 listener tuning links. Collapse ACL profile **Attached to** by default without hiding its contents or changing profile attachment rules.
|
||||
- Correct `update.sh` retention cleanup (mtime-sort key was previously included in the removal path) and retain **two** installed releases, including the active release. Preserve **three** private stopped-service backups for rollback. Preserve Cargo fingerprints, compiled deps and executables; only expired incremental build state may be removed, after a successful update and only while Cargo's profile lock is available.
|
||||
- Confirm managed-update marker is created before `systemctl stop` and read on shutdown, triggering best-effort HOT→COLD persistence even when `cold_on_shutdown=false` if cold storage is enabled. Add completion diagnostics counting successful persisted objects (including zero), not merely attempted writes; no claim of a successful runtime flush without testing.
|
||||
- Retain SQLite schema 9, COLD format 3 and existing H3/mobile fixes. Compiler/live upgrade acceptance pending.
|
||||
|
||||
## 0.31.6
|
||||
|
||||
@@ -127,8 +158,7 @@
|
||||
|
||||
## 0.31.0
|
||||
|
||||
- Harden BAN/PURGE with scheme/host/vhost isolation, unambiguous headers and bounded background cache-maintenance workers (shared with authenticated admin purge/invalidation); audit BAN-rule removals before mutation. Add a localized curl-examples tooltip to Settings → Cache. See the [0.31.0 API and panel security review](docs/SECURITY_AUDIT_0.31.0.md) for assessed controls and remaining runtime security gates.
|
||||
|
||||
- Harden BAN/PURGE with scheme/host/vhost isolation, unambiguous headers and bounded background cache-maintenance workers (shared with authenticated admin purge/invalidation); audit BAN-rule removals before mutation. Add a localized curl-examples tooltip to Settings → Cache.
|
||||
- Add browser-negotiated Zstd/Brotli/gzip compression for panel HTML and static assets, configurable under Settings → Administration without an extra environment variable; cached representations and `Vary: Accept-Encoding` work with or without a reverse proxy (subject to forwarded headers).
|
||||
- Add strictly opt-in, IP/CIDR-gated HTTP `PURGE` (exact URL) and `BAN` (lazy URL prefix) on public vhost listeners, using the existing invalidation engine, ACL/rate limits, audit and CIDR validation. Empty allowlist keeps the feature disabled; no DB or cache-format migration.
|
||||
- Align the System information manual Refresh control directly next to the API badge.
|
||||
|
||||
Generated
+1
-1
@@ -1881,7 +1881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.32.2"
|
||||
version = "0.32.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.32.2"
|
||||
version = "0.32.3"
|
||||
edition = "2021"
|
||||
rust-version = "1.99"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# RustProxy 0.32.2
|
||||
# RustProxy 0.32.3
|
||||
|
||||
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
|
||||
|
||||
@@ -7,7 +7,28 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos
|
||||
|
||||
**0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.**
|
||||
|
||||
## What changed in 0.32.2
|
||||
## What changed in 0.32.3
|
||||
|
||||
- **Consistently sized Overview rows:** the dashboard now uses ordinary CSS Grid for both default and customized layouts. Six numeric tiles, three standard live charts, two optional wide charts and three diagnostic panels form separate, equal-height rows with the original density-controlled gaps. Blocks cannot float into unfinished rows of another size or acquire arbitrary heights. Ordering and Hide/Show remain editable **inside each size group**; existing saved widget IDs, visibility, preference API, chart data and reset/default behavior stay compatible. CSS Grid automatically resizes whole rows when live content grows, without pixel offsets, layout timers or extra polling.
|
||||
|
||||
- **Layout and shape polish:** all 62 blocks remain movable, hideable, recoverable and resettable using existing account preferences. The X Hide control now matches the rounding of the arrow buttons for all five global corner presets; the RustProxy logo keeps its established outline and is excluded from global corner changes. No new metric polling, backend settings, API routes or version changes.
|
||||
- **62 optional/standard blocks:** six more miniature history cards and seven new full-width multi-series charts. The new views cover requests/s, H1/H3 shares, cache misses, backend activity, protocol traffic rates, CPU, RAM, cache behavior, open connections, backend availability and latency. All 13 additions are hidden by default, consume the existing snapshot and have bounded histories; no extra backend polling. Buttons remain right-aligned, with the badge before them.
|
||||
- **Backend upstream protocols:** four explicit, backward-compatible choices: HTTP/1.1 (`http1`, default), Auto (`auto`: HTTPS ALPN H2/H1, cleartext H1), HTTP/2 compatible (`http2`: HTTPS ALPN H2/H1, cleartext h2c) and H2 only (`h2`: HTTPS requires H2, cleartext h2c). No post-failure request replay, extra retry loop or changes to WebSocket semantics.
|
||||
- **Whole-Overview arrangement:** the same visual editor now manages all 62 independent blocks: the previous 30 metric cards, 12 optional time-series cards and seven full-width panels (live traffic, system, connections, cache, process, network and backend table). A responsive, row-aligned grid allows free ordering/hiding of every block without sacrificing the symmetrical factory layout. **Default view** previews the factory layout without writing it; **Reset layout** requests confirmation and restores only Dashboard order/visibility in one preference PUT. Existing saved layout and unrelated account settings remain unchanged.
|
||||
- **More opt-in charts:** CPU, RSS, file descriptors, open connections, WebSocket tunnels, TLS failures/s, request errors/s, recent cache HIT percentage, cache HOT RAM, incoming HTTP/2 share, mean response latency and unhealthy backend count. All are optional and hidden initially; 30-sample history uses the existing status updates and never creates network calls. Ratios and rates require genuine counter deltas; missing observations remain unavailable, not fabricated zeros.
|
||||
- **Overview Cards registry:** source of truth for widget IDs, translations, selectors, defaults and data-source metadata is `src/admin/web/dashboard-widgets.json`. Both server-side preference validation and the authenticated dashboard JavaScript use this registry. Existing order and opt-ins are preserved; stale unknown widget IDs no longer invalidate an entire saved preference document.
|
||||
- **Optional HTTP backend cards:** fourteen opt-in Overview tiles: origin health, in-flight requests, errors/s, health-probe RTT, byte rates, configured upstream modes, request-rate trend, concurrency-capacity utilization, HTTP 5xx, timeouts, DNS/TCP/TLS/I/O errors, retry attempts, queue pressure and Hyper client-pool rotations. All reuse the existing status stream; all fourteen are hidden by default, including upgraded accounts. Configured protocol modes are not negotiated ALPN results.
|
||||
- **No protocol migration or behavior regression for saved origins:** unchanged `protocol: "http2"` HTTPS origins retain their 0.32.2 H1 TLS fallback. The new strict mode must be selected explicitly as `protocol: "h2"`. The Origin editor labels ALPN/H2C transport behaviors, and HTTP/1.1 WebSocket Upgrade continues using its separate client. The OpenAPI Dashboard preferences schema now matches all 49 registered blocks.
|
||||
- **Two optional appearance themes:** **Ultra dark** uses an AMOLED-style near-black canvas and restrained mint highlights; **Warm cream** uses a muted ivory, low-glare palette. Both are selectable in Settings -> Appearance, persisted per administrator via the existing preferences API, available on the login screen after a cached choice, and preserve the original Dark/Light options and dark default.
|
||||
- **Direct block hiding and restoring:** when Overview is unlocked, each numeric tile, chart, diagnostic panel and table has an accessible **× Hide from Dashboard** button. This changes only the current layout draft; the collapsible **Hidden blocks** shelf restores any block immediately. **Save and lock** persists order/visibility once; **Cancel** reverts both. Hidden blocks also remain available in the visual editor. No measurement, registry or server data is deleted.
|
||||
- **Direct, lockable Dashboard arrangement:** choose **Unlock layout** on Overview, drag the handles on actual number tiles, charts, diagnostics panels or the backend table, or use the arrow buttons. **Save and lock** saves once; **Cancel changes** restores the previous order. Moving to another panel discards unsaved edits. No status re-polling or cloning of live charts is necessary. The modal editor remains available for enabling hidden blocks.
|
||||
- **Labeled block types:** all 49 entries have explicit registry metadata for **Number**, **Number + chart**, **Chart**, **Diagnostics panel** or **Table**. Appearance settings and the arrangement modal show the type, and the modal filters hidden blocks by type. Existing selections and defaults are unchanged.
|
||||
- **Visual Overview layout editor:** use **Arrange cards visually** on Overview or **Visual layout editor** in Settings -> Appearance -> Dashboard layout. Drag previews of metrics or large panels to reorder; Show/Hide controls and keyboard arrows are available. Save commits both lists in one account-preference update; Cancel leaves the layout unchanged. Existing Appearance controls still work.
|
||||
- **Connection-level ALPN reuse:** H1/H2 is negotiated only when opening a TLS connection, not on every HTTP request. The existing per-backend Hyper keep-alive/pool and bounded generation recycling (default 25,000 requests or 3,600 seconds) reuse negotiated connections and permit renegotiation when new connections open. No shared protocol-decision cache or extra probes were added: DNS and multi-origin backends may have peers with different H2 support. Explicit H1, H2C and H2-only, and WebSocket isolation keep their existing behavior.
|
||||
- **Regression coverage:** add local TLS peers for H2/H1 ALPN, H2-required failures, cleartext Auto, protocol serialization, and WebSocket isolation; add registry/preference regressions. Existing pooling, health probes, HTTP/3/QUIC and WebSocket transport behavior are unchanged.
|
||||
- **Verification limit:** pre-Cargo checks are runnable here, but Rust 1.99+, real-peer Rust tests, process benchmarks and production acceptance must be executed on a Rust-enabled host; see [test report](docs/TEST_REPORT.md).
|
||||
|
||||
## Previously in 0.32.2
|
||||
|
||||
- **Web Server cache revalidation:** browser refresh (`Cache-Control: no-cache` or `max-age=0`), stale objects and freshness-constrained requests now use stored origin `ETag` / `Last-Modified` validators. An unchanged local static resource returns `304` to RustProxy; it reuses the cached body and increases `X-Cache-Hits` rather than resetting it to a new MISS.
|
||||
- **Correct conditional delivery:** retain the client's original validators for downstream `304` decisions (or serve a body on an unconditional refresh). HEAD, single byte ranges, transformed-response headers and `Vary` isolation are retained. If the local resource changes, a successful response replaces the previous representation; an uncacheable 304 discards the shared copy.
|
||||
@@ -214,6 +235,8 @@ Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost
|
||||
### Retained 0.26.4 release notes
|
||||
|
||||
- Extend session-only editor drafts to **404 hosts**, preserving the complete unfinished form state when another editor is opened or the dialog is minimized.
|
||||
- In **Settings -> Appearance**, the Overview list shows clean card names and visibility/reorder actions without type badges; the draggable visual editor still identifies numeric cards, charts, tables and diagnostic panels. Appearance is the first Settings tab on a new browser, then the last active tab is remembered; explicit links such as `/settings?tab=cache` always win.
|
||||
- Independently of Dark, Light, Ultra Dark and Cream, administrators can choose **Corner rounding** (Original, Square, Subtle, Rounded or Extra rounded). Original matches the established geometry in every theme and is the default for existing accounts. Ultra Dark preserves the original coral/soft-green logo mark while keeping its near-black background.
|
||||
- Complete the Polish translation for **Settings -> Appearance -> Button style** and keep the button-size preference independent from global interface scale.
|
||||
- Keep search/filter inputs in compact toolbars no taller than the active button style, including Backends, vhost-style lists, Docker, Logs and Settings search controls.
|
||||
- Reuse the vhost Enabled control presentation in 404 hosts, Redirects and Streams so modal enable/disable state is visually consistent.
|
||||
|
||||
+28
@@ -1,5 +1,33 @@
|
||||
# RustProxy roadmap
|
||||
|
||||
## 0.32.3 - Backward-compatible upstream modes and Overview Cards registry (runtime acceptance pending)
|
||||
|
||||
- [x] Replace ragged custom Overview masonry with symmetric CSS Grid rows grouped by widget footprint. Equal-height peers, traditional gaps, responsive columns and live-content growth are verified in Chromium. Block dragging remains supported inside its size group; group boundaries prevent visually inconsistent mixing. Registry (62), preferences and version stay unchanged.
|
||||
|
||||
- [x] Snap manually rearranged Overview columns to common pixel-aligned coordinates and align the bottoms of a tall panel and its stacked short neighbors where bounded distribution allows. Keep factory rows, saved widget IDs/visibility, spacing controls and narrow-screen layout unchanged. Verify with a Chromium fixture, including dynamic height, density and zoom.
|
||||
|
||||
- [x] Remove numeric/chart type badges from the Settings → Appearance Overview list, retaining them in the actual visual editor; keep right-aligned controls.
|
||||
- [x] Open Appearance first unless Settings has a remembered last tab; honor explicit URL tabs and legacy configuration/logging links.
|
||||
- [x] Decouple roundness from the color palette through a backward-compatible, account-scoped five-preset global `corner_style`. Keep original corners as the default for all four themes; restore coral/green logo marks in Ultra Dark.
|
||||
|
||||
- [x] Preserve original symmetric and density-aware Overview rows for the default configuration. When blocks are manually reordered on desktop, keep the same column widths/gutters while allowing multiple natural-height charts to stack next to tall diagnostics; use CSS-layout-pixel row spans for zoom safety and fall back to classic rows on narrow canvases.
|
||||
- [x] Exclude the RustProxy logo from global corner style overrides. Keep unlocked Overview × Hide and arrow controls consistent under all five presets, without changing color themes or account data.
|
||||
- [x] Add 6 small and 7 large opt-in time-series views (62 registered blocks total), reusing existing status updates, bounded samples and version 0.32.3. Place block metadata before right-anchored editing controls.
|
||||
|
||||
- [x] Preserve legacy `http2`: HTTPS ALPN H2/H1 fallback; HTTP H2C. Add a separate explicit `h2` mode requiring H2 over TLS / H2C over plaintext. Retain H1/Auto and isolate WebSocket clients.
|
||||
- [x] Retain one safe Auto ALPN negotiation before the first request, without H1 retry after a potentially executed operation.
|
||||
- [x] Establish a shared JSON registry for Overview IDs/metadata, server validation, ordered visibility settings and compatibility with unknown saved widget IDs.
|
||||
- [x] Add fourteen opt-in HTTP origin monitoring cards, including request-rate, capacity, 5xx, timeout, network errors, retries, queue pressure and client-pool recycling; reuse existing status counters without extra polling. Retain the original 16 and existing account preferences.
|
||||
- [x] Add an authenticated Overview modal for draggable visual card ordering, keyboard move, hide/show catalog, draft cancel and a single preferences PUT for Save. Keep existing Appearance controls and defaults.
|
||||
- [x] Add opt-in AMOLED **Ultra dark** and muted **Warm cream** palettes with translated selectors, login-cache support, server-side preference validation and high-contrast palette checks. Preserve current dark/light themes, default and schema version.
|
||||
- [x] Let administrators hide/remove visible Overview blocks directly while unlocked, including panels and tables; recover them from a compact hidden-block shelf in the same draft and persist visibility only after Save.
|
||||
- [x] Add a direct Overview **Unlock layout** mode: drag actual blocks by dedicated grips, move with arrows, cancel without persisting, and save+lock in one request. Classify and label all 49 blocks by display type, including mixed number+chart cards, with a hidden-block type filter.
|
||||
- [x] Expand to a shared 49-block Overview grid (30 prior widgets, 12 new opt-in charts and seven original full-size sections). Add visual default-layout preview, confirmed reset and account preference compatibility without new metrics probes or API routes.
|
||||
- [x] Verify ALPN is already negotiated on connection establishment, then reused by Hyper per-backend pooling. Retain age/request-count generation rotation and new-connection negotiation rather than introduce a global H2/H1 cache or unsafe protocol-retry probe.
|
||||
- [x] Present all four modes with clear HTTPS ALPN versus h2c semantics, keeping persisted `http2` records valid with unchanged meaning.
|
||||
- [x] Add source/JS checks and Rust tests for strict ALPN, old TLS H1 fallback, H2C, WebSocket isolation and widget preferences.
|
||||
- [x] Resolve the 0.32.2 HTTPS `http2` compatibility regression with a new serialized strict `h2` mode rather than a silent migration.
|
||||
|
||||
## 0.32.2 - Web Server conditional cache revalidation (runtime acceptance pending)
|
||||
|
||||
- [x] Reuse local Web Server `ETag` / `Last-Modified` revalidation candidates for forced browser refresh and stale/freshness-constrained requests, keeping hit counts and cache bodies on `304`.
|
||||
|
||||
+73
-51
@@ -1,18 +1,18 @@
|
||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||
c0f4ff54579d47615a160930b327122ff88cc41e7fa09079c74f484d79a47ed4 AGENTS.md
|
||||
d11f383dd8b2e00aab378e5d220da42681a7e663ae03351ec8ec70028456b40d CHANGELOG.md
|
||||
55d6e709442850feab4c00791e511fbffbf69a5687eb3ae6880c619892ef699d Cargo.lock
|
||||
9458cc57f7787ab61b3bc685761f22e40dde12d313ca3cc7603ae24f39b5e8a7 Cargo.toml
|
||||
157ab33b00fb316a269a75d512c99bf9ef5eb96dc5562b05bc6e8c25ece05d28 AGENTS.md
|
||||
562de8040199fb3dca73ba6eea84c2c09ac5ebd7bf820da74cc10f217ed33548 CHANGELOG.md
|
||||
9fea43a5b2abe527270f98c5f8fd62bf5a16474d82058608c6e2badd7cf3c570 Cargo.lock
|
||||
8e52a0678ef89c708694ec29db062a54fd226dd21df15352d8be7b5ba497fd17 Cargo.toml
|
||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||
e40a3dc858b85ccf3895b960d0881db1b6576ff41f7afda9696c9cbaaacd591f README.md
|
||||
025cfe7ef2551d6232108249aadccd4d587eccc66ecc344f7de2f8763000b074 ROADMAP.md
|
||||
bfaf1bd47a8bf26f4a876c7fc3ea9e6c1c752bbe0e837b86f14561abe3efca41 README.md
|
||||
c2fcc8006f00484403a1939289962beb9ef82b46f52a05cd87bb8a3a1f36110c ROADMAP.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
||||
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
|
||||
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
|
||||
a895f2d1aaa2903ae8960a51145951a6f801da902dbea995375d836f5f610867 dev.sh
|
||||
93b82655c1b38313ea059f14e287845446853095a2e462447e9f6e32109eb526 dev.sh
|
||||
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
||||
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
||||
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
||||
@@ -27,7 +27,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
|
||||
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
|
||||
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
|
||||
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
|
||||
ed9edea5a28c34aa8bacadf1f6d39257ae1421b1fe907b0cc038fb0de898c67f docs/API.md
|
||||
34c7943fc747294b80f55e1d5232ece6fc9123610a33c8978b55ee1e714b8121 docs/API.md
|
||||
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
|
||||
6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md
|
||||
fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md
|
||||
@@ -44,7 +44,7 @@ a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.m
|
||||
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
|
||||
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
|
||||
633ed36201bf8c0c72d21596183aac9f28113c0ad2d9154abd532a8cb7c91543 docs/MEMORY_STRESS.md
|
||||
fea1ff4055d0930edeffb0c8501a6ba1ac55ec7a7064dcd0e300a43915e8432e docs/MIGRATION.md
|
||||
abab7cda74e23e4779eccb2630ee8398517b31abda8e45ab7eec993c0821c076 docs/MIGRATION.md
|
||||
5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md
|
||||
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
|
||||
e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md
|
||||
@@ -61,13 +61,13 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m
|
||||
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
|
||||
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
||||
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
||||
919aed5a97f8b08aac1a0784672170b49b420012cd8f2159dd3e46e654787dbd docs/SETTINGS.md
|
||||
c90409f4dc979e72998983f4c470c10a07e9ae1f9a551d4c06d6b77400ca9831 docs/SETTINGS.md
|
||||
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
|
||||
c81b3f1f86b33abb744d27dee8abffd7fc2a3149210522fee9ccf591f88cdf7e docs/TEST_REPORT.md
|
||||
e47b6a011260bcc376ff98fafbe7a3a90d834850845d808ea188637151a5de7a docs/TEST_REPORT.md
|
||||
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
||||
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
||||
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
|
||||
88a4adeb99003681a5aaf8d9d1f252d59f76e61d4c62d5542e62ae7b49eccb21 docs/VHOSTS.md
|
||||
caf3b2cadba7de582348186261a82ca60a00d11475835d6453aaee720c82f567 docs/VHOSTS.md
|
||||
09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md
|
||||
04a42e4629fdfb7224173dcf57cd0992cba2edd109204bbf503a255886605542 docs/WEB_SERVER.md
|
||||
c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md
|
||||
@@ -95,8 +95,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
|
||||
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
|
||||
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
||||
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
|
||||
613b10b42885a7f91174961fe6d1b0d7a4bd29363f152ec6dd6bc53e5731ad87 langs/en_US.json
|
||||
a656e36b55370a3763893762f791ef99a444baffbc4ddec5780a4d2dd38d7efa langs/pl_pl.json
|
||||
8f864f4be97fef14962423955a10efaff74bfed2fadac0e03641b299494678fa langs/en_US.json
|
||||
a08a6b6ab9a9068623be05b2ce353df9e22e0ef23f026cc1e6d1d092e9e7bc9b langs/pl_pl.json
|
||||
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
||||
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
|
||||
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
|
||||
@@ -154,33 +154,41 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
|
||||
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
||||
7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs
|
||||
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
|
||||
bcddb40675acbabd38ce5b1c73d8784052796c91030c9653dce7cc1913c5ce78 src/admin/openapi.json
|
||||
3813cf0ae6e1a22255cd8bd07d46f04a604ba32952e5998d8c2a934e1613262a src/admin/openapi.json
|
||||
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
|
||||
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
||||
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
||||
c132a03c165293a7e248a3a232327aab1b061ff61b4752135927cb9fe1598dec src/admin/preferences.rs
|
||||
fd9846943a57f59e6db9e9d91de81eaa2688799a113102b28c9e93694505b3fe src/admin/preferences_columns.rs
|
||||
acab05cccb07e99f440c2b2cb2dafd91cbe28b3b2ed2e0e43983cc5b9b38510c src/admin/preferences.rs
|
||||
cd0fda2b5a7b86a6619d6fa7cf5cef5a2598f033a8e9304475c4a05a169c958c src/admin/preferences_columns.rs
|
||||
f98f93304808330d0fc696cd87f16f6c9f4efbbc640d56d429026364782ebc67 src/admin/preferences_tests.rs
|
||||
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
|
||||
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
|
||||
6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs
|
||||
fc0db7f27163ef107a9a03dc174eac35346b8d9742f4a83464398ee8b49768e7 src/admin/router.rs
|
||||
ab8d92d79d0c4d595fdb2558ea877fa8cfa17bf07708375fef4668c4367950fe src/admin/router.rs
|
||||
a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
|
||||
e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs
|
||||
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs
|
||||
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
|
||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||
50da77927c0344e4eda0cd6a8d11c23780109c6d20c32585c2fad8cbc8369ffa src/admin/tests.rs
|
||||
7be070a259d534fd6d1069207a464434602d94143f8f463b1dddb1c45f3dc797 src/admin/tests.rs
|
||||
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
|
||||
f7760d52336fa3968ed793806e68d8ffa15299e567cef90cd7a1ce66dd30488d src/admin/web/app/backend-dashboard.js
|
||||
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
|
||||
cb9def393b7f57a6cfecbb8edaff4b7e20e7805231d08e354ad0b7deb294a103 src/admin/web/app/core.js
|
||||
40ca254a6c7f4c2697e8ccf43d31fc24cc925be05b6070b974aaf044a928ee3e src/admin/web/app/preferences.js
|
||||
446ad63b6ff96e9ebc6455c454c539703aa751d4c26386abbb8512ceff9e3d42 src/admin/web/app/core.js
|
||||
7bc87a423dc3e4daf4e8469714981bec60ab8407ff2291b5db7113e24eeca13b src/admin/web/app/dashboard-inline-layout.js
|
||||
cb8cdad3d9dd045deb443387b4be657101261cf3632251e9602a83cb2ad96075 src/admin/web/app/dashboard-layout-editor.js
|
||||
42f601077b96ebf6cc225cb114b70010c2cceb32b0b48cacfe5bab9f249ac949 src/admin/web/app/overview-extra-metrics.js
|
||||
3581ad6fffa625537fe62159747902fbf22e284257470e3f4b07ef542c16d563 src/admin/web/app/overview-masonry.js
|
||||
223c08f8956bdb8cf69e28037751838d56256dea096c57b7884f68afb5c96535 src/admin/web/app/overview-wide-metrics.js
|
||||
b24c4892ccce39c41a3835cf8849374c7773e94f2e4fd73a4d295a36d8654544 src/admin/web/app/preferences.js
|
||||
6a544baaf40763b4b612e53845b3bc33396b9bb6518565fd6b7c128c278b8d5d src/admin/web/app/refresh.js
|
||||
a3715a35e11b45395897e91003f90d2a4674f84cc93ebb90044cdf32eaddde1f src/admin/web/app/runtime.js
|
||||
0a1d0daadd8140113359ad3fde8c1df4cdb0a4402a33d19dd573c54ce297cebd src/admin/web/app/runtime.js
|
||||
31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js
|
||||
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
|
||||
cc566f8293cc935f5080ebfb8076293c256bb6a87e1e6a69be03512448871ec9 src/admin/web/app/stream-dashboard.js
|
||||
a854e08f947a4d13fc4b27286a096dcb2773dc0358a28b27703a2320ba7e8bf7 src/admin/web/cache-panel.js
|
||||
02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js
|
||||
93b8dea4eeb67a69aea62a3aa46c3b809974018fcae270fa3b5f8268e4af1943 src/admin/web/dashboard-widgets.json
|
||||
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
|
||||
d01a2e48f344fe074c1e63ee0a9bbf7151dfe26140690418535197e13a2a16d7 src/admin/web/docker.js
|
||||
d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css
|
||||
@@ -202,22 +210,23 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
|
||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
|
||||
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
||||
0ee5b6ac3cf2022c5b88166dc4816868161351471e81b424b53d0f174eca7ed1 src/admin/web/index.html
|
||||
0233facbfa4cad515ecf92096456e86001e57c64d942f118a246f8a94b3e2b0c src/admin/web/index.html
|
||||
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
|
||||
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
||||
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
|
||||
203f2b7e960d4cf6b3fc0970e73716e7da998e29c46dcb4a72f7f310aa8d2232 src/admin/web/login.js
|
||||
c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js
|
||||
36af1f8f83af7e6f9a60b4d9524714e02c077e539a90aac0ce990d0587c0c3ed src/admin/web/management.js
|
||||
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
|
||||
ba245b331ab045b25c9fe62670d108b5e7cfa1d1cbbcce5dc22ce37521fa92f4 src/admin/web/settings.js
|
||||
c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js
|
||||
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
||||
e3075af59644e42fa5735574d8e9caa1bcebfe8de184b78aff9f4026b91915f1 src/admin/web/streams.js
|
||||
500e3d77e981ad2fe5f09539b0ed47f0d1f85b3a593dfa2c0d6e193c3955ffe9 src/admin/web/style.css
|
||||
78d166c9489fc7df8742f10134fc3c94cf7a13cb18e8834766f5ac6b8942bdc3 src/admin/web/style.css
|
||||
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
||||
a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js
|
||||
22f29bb06cb650b6adefe257f5f706656e36e7fdd5088f7abc10f63247e04165 src/admin/web/vhosts/core.js
|
||||
470aa4fd399ba93e37ff47cd4df8e1745f9087a305984e15c613276e9eee1f74 src/admin/web/vhosts/core.js
|
||||
b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js
|
||||
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
|
||||
a6fad3c9243c9502a549b44201e8f51146e9f1092f43db0d10125ba2a7404280 src/admin/web/vhosts/protocol.js
|
||||
2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js
|
||||
dfab53c9b9d4c633db639c37e99a46169aa86f7a28e6731e305c27ac8587a7ef src/admin/web/vhosts/render.js
|
||||
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
|
||||
@@ -239,7 +248,7 @@ a1e5d43f5417d71f3974812cb539c0932f206e2290090917f067494f3587fc72 src/balance/le
|
||||
4d3aa415af2fbb42a4e19f2ca10be8b1d1ad42f366a013cd9c33806abc1fbea2 src/balance/model.rs
|
||||
41c3c020a60c372dbf965bd277c330dd9cf2ab079f6608b472adf72a656f4cb3 src/balance/pool_core.rs
|
||||
477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs
|
||||
519cb798040ac9a65d9ebe43ae11ef729bd78c5c4a5c756490458add4b6fdc24 src/balance/pool_view.rs
|
||||
b464e92974c48a37ae831f38057e31d95072fcdb08b9bac4fce323f8ef0649f2 src/balance/pool_view.rs
|
||||
656a6ab3c9855f92b7e3e0dd6de1bcc408da003db8bcfcda52b71c5234c56693 src/balance/tests.rs
|
||||
5049d2bd01a9c866f3bad2288d8d594df96bba6045dfe15cd22b4173562b7388 src/basic_auth.rs
|
||||
9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs
|
||||
@@ -286,7 +295,7 @@ e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compressio
|
||||
7ba52d3de4d77d1f3cad1b10de418953adc33901727a0791980847ae223929aa src/config/backend.rs
|
||||
a3d6a9d01dd9b3b602ea2f3dd5559503ea82129544c8944c26ad39445aaa3ad3 src/config/compile.rs
|
||||
490d05d84b2fe349ddde54eae44880993fab70811652484dd9ba38b5069d603b src/config/internal_routing.rs
|
||||
d5b851aa5823af638fd2b2ba8ef4eeba12c55296c0a01f8ca7dad0674d124acc src/config/model.rs
|
||||
a19fc675720c7c2dbf4bba706e1b1c30c83c6df1da3f29e3cbb3b82442e7246a src/config/model.rs
|
||||
f66c2388f31b19142c12469df23d43451e0f7c48b158d4996920282c06d32076 src/config/routing.rs
|
||||
818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs
|
||||
b20dea6e67919379ac56f64c852e05082974300c183e7b5884e70db68f89ce5f src/config/tests.rs
|
||||
@@ -355,20 +364,20 @@ aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cach
|
||||
771cc1a38927f96e593b144dbab66cd64a5dc0ed42d54b1615c359845b2b7223 src/proxy/error_fallback.rs
|
||||
bf2e6bbd64fb45fceed54af0bff1c1e00da22b79c64dd8f5c23d2af0dbab0b7d src/proxy/forward/cache_lookup.rs
|
||||
526592b613fbe20f532f11be5a3236acaeb8cad400f70e1d28cc1c2dfc126c52 src/proxy/forward/response.rs
|
||||
d3b9aa0c0055fa9a29f180ca8b10c890c0b91127ee603e5d89cce4c99d5a6bd4 src/proxy/forward/route_cache.rs
|
||||
3b220cfa474325bfe7853d62daf2c71059d0dc582ac749fe61e2864094545d63 src/proxy/forward/route_cache.rs
|
||||
cea9e47ecb31b0e4c0bbe0eb83e0e53b5e7bdcb9930107b67a8c34eef01a3f49 src/proxy/forward/success_finish.rs
|
||||
902cbf8dc3c1ec8d25411bc7fe0400d301f3a7cc885afada594e4eaee3bedd08 src/proxy/forward/success_prepare.rs
|
||||
23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs
|
||||
3eb8f519b59f81c10c368abda280fbb455891862b6d0e1f2c5299af78c4dfaf7 src/proxy/forward/upstream.rs
|
||||
b05c4f5c009c5e410f472ceaa999fa3a9c7fb60364d5b62de219f0924c9d524e src/proxy/forward/web_source.rs
|
||||
a9c011061585c067f04f7885215d53e6c40474bdced15d2be5a5ba65190910a0 src/proxy/forward_entry.rs
|
||||
c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs
|
||||
5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs
|
||||
8d17b97d90fd6a749ba8d37a8d28c94e6d7302b6c86905f073e2dc595506961a src/proxy/mod.rs
|
||||
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
|
||||
cc682f0d5754e0b806a4eb5950e4cc00611aca5ce4668414fddef1d692489805 src/proxy/tests/authority_tests.rs
|
||||
aa99330c67a8a2c33d43c318974c4df4024d3489d478e9d2cced3655feafaf24 src/proxy/tests/authority_tests.rs
|
||||
0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs
|
||||
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs
|
||||
e80c7fe44abb7607c64ea02d56542753dc25290b3bcf4c4d45b75f2db6ca90d0 src/proxy/types.rs
|
||||
65e226e75e2edf9083e13de7db019e2b6af9668c13291086a47cbcfd68d9b2f3 src/proxy/types.rs
|
||||
9999f717492755d62fc05cd5c1aff802ff0da901fe3e6b928bd8a000833c8799 src/proxy/upstream_helpers.rs
|
||||
2d9142e3ae95895b5271b4009ab48236a3a2074ce51179c021193b35777560e4 src/proxy/web_cache_revalidation.rs
|
||||
5af991311e4ed22cd8aaff15a4b9d45bb55fcdfcd0ebbfb4879dc1c7edf88ac4 src/proxy/web_cache_revalidation/tests.rs
|
||||
@@ -440,9 +449,10 @@ a4e1d2aa2913587c250f251356431097b76995aee6e8bf9e35c4978a95491de1 src/transform/
|
||||
6146c59f36cf419623747d497b3de685da6c84964df43317bebdcb21deb9ee09 src/update_check.rs
|
||||
c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs
|
||||
a67e58a597a5eeb7efe605ae6e438a8f84ff6a1cedf8cd228141cb95551872a8 src/upstream.rs
|
||||
225e3078e6775ffaa6dc6f12bc87503a123983cbb347edf38cae4c37ff02a5db src/upstream/client.rs
|
||||
1e8873de677e7faeb9a612ee06cd78044082481fb188b03ad2a39c58a50314a2 src/upstream/client.rs
|
||||
4aa2420a92d33c50f7027f57ac4eccdc050bdb7e9ec8fa6e0eaa809228b71cd8 src/upstream/client_tests.rs
|
||||
6f932b8e59de8a5c89d3b29b838eb2a30f6e3ddd342fec29e39af1ce1b1c7d11 src/upstream/tests.rs
|
||||
b7375571a45ab5ee6c7558cb185d40e69cb04c45e32c7e13cb41c7338c57e1fe src/upstream/protocol_tests.rs
|
||||
8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs
|
||||
a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs
|
||||
6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs
|
||||
3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs
|
||||
@@ -484,47 +494,59 @@ b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.m
|
||||
e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/admin_public_origin_0314_config_sim.mjs
|
||||
0060f8217640403c6af2d5091fdc9087d0fb2fc30cd545a3c58277a396d8121c tools/checks/admin_public_origin_0314_sim.mjs
|
||||
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
|
||||
a7dfb58212d2172f3fbe0ccdcd23e797a7baadc5627d14615c94427e8ad24b21 tools/checks/backend_cache_0297_ui_test.mjs
|
||||
8946c2f93915322062fca5a1a406ed5d88efa9a8b1cefc7e2d1c7cafb2ba8e57 tools/checks/backend_health_0300_ui_test.mjs
|
||||
1255853a2a53db73a2820d9aadad5f30c8e404de95287410fa4f2443f74b2eed tools/checks/certificate_api_0319.py
|
||||
c9d0bef4b8ed7a2f49b0fad3e116cace62d3e6e1ca31e02a3073d1fdde5157d8 tools/checks/admin_themes_0323_ui_test.mjs
|
||||
02b02ad7831524f963da3b80b1b42bb6efef9c4a63ce89649b18f70a0110d32d tools/checks/appearance_ux_0323_browser.py
|
||||
8b6e1a355cae58901118b399fa2d165f7e3fd8465c89cac82776906b8b23f0a6 tools/checks/appearance_ux_0323_ui_test.mjs
|
||||
f3dba206c60cd2e6fcc5f4a2c8d652ccf47362694448312cc1f8dc4112d93e7e tools/checks/backend_cache_0297_ui_test.mjs
|
||||
f34f0f78edc4b6c6226006337090c72d1f8e57ddd77939b37cf00538a05abfa8 tools/checks/backend_dashboard_0323_ui_test.mjs
|
||||
45cd21192248ffe6f66c20bab03da1a5ce299f040883764d09a640859e19924d tools/checks/backend_health_0300_ui_test.mjs
|
||||
8c128e77bea2807e224b31229e1e7b4eae0e30b96de0a707f5454fb641be5f62 tools/checks/certificate_api_0319.py
|
||||
526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs
|
||||
4907ec98d4fd64f8670f0ef5783553cfbed86a2882348f07089c6c43ff176b0c tools/checks/check_source.py
|
||||
cf47181bafeed62642a5613b20d00d8e0cd74e1487349e2a57a235c09bf726b6 tools/checks/check_source.py
|
||||
dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
|
||||
7508f232a9e48049002dd4c9f34f9737bc92d304c51219f50430578dbc721855 tools/checks/dashboard_inline_layout_0323_ui_test.mjs
|
||||
dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/dashboard_layout_editor_0323_ui_test.mjs
|
||||
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||
45981344fb95c2b64e24fa8b20af6ad53c0e101c5511f2182456bc0069e7c2d5 tools/checks/dependency_upgrade_0312.py
|
||||
977f55c6671d68dab86a68c0e4a581559b8407fc666dad0187b3f5050d24bca9 tools/checks/dependency_upgrade_0312.py
|
||||
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
|
||||
86d0d1674a713f1d10d93be5b920fd3de2ebc4b69ac1259907d96d10233ae176 tools/checks/docs_check.py
|
||||
6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs
|
||||
b59049a2855f746b2acf10753a3b4baf83bb4e1e3a2e856a1e63d277969e8e5f tools/checks/docs_check.py
|
||||
5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs
|
||||
72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs
|
||||
e9cdaf709f16760b01389c00cc05974459573fd3ae89e30c21e826781408c191 tools/checks/h3_navigation_0316.py
|
||||
046cedbeab1df4f786adbe8422f3061604340387864c03bd3d1ba5dc84899066 tools/checks/h3_navigation_0316.py
|
||||
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
|
||||
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
|
||||
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py
|
||||
af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs
|
||||
295bd8b9dc1f80cfaa74a44074887d0ccefc0e0a066ab4cdab44da82e66243e1 tools/checks/overview_0321_layout_regression.mjs
|
||||
0387a9e29244b58cf37a6673a88ae2b7fea3aa965c032423f3783a84eab4ba05 tools/checks/overview_0321_layout_regression.mjs
|
||||
53917376134d73fff32a68239b513c4eedf98bb65224c1c41c0e5cfc8fcb119b tools/checks/overview_extra_metrics_0323_ui_test.mjs
|
||||
bc047e51ba17d7062a8a46a273c871c9b0b0ace16ef738a86e1517d0792d4c0a tools/checks/overview_grid_spacing_0323_browser.py
|
||||
b565aef4594265350240c24067a554f91fd605d0e920c03eef0b5b62c3934aad tools/checks/overview_registry_0323_ui_test.mjs
|
||||
f450e97dccf75c24e228e696f21ed4ae826a823ea4edde2429894a6b6b36eaab tools/checks/overview_uniform_grid_0323_ui_test.mjs
|
||||
d4b0bef666310ee75dd0761645ec05f8dad0cf1e86971898291d7209dd7a6d26 tools/checks/overview_wide_masonry_0323_ui_test.mjs
|
||||
1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs
|
||||
eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/preflight.py
|
||||
021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs
|
||||
c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
|
||||
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs
|
||||
43db52365768cf1c40474544c8201ee0993e5cba1b3dd112cca82811cc0e790f tools/checks/release_0318.py
|
||||
6e5cad3f2ec2c2e92db7ab49f0760e11f22f2cff1f56847c6953265adb23e6a4 tools/checks/release_0318.py
|
||||
e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs
|
||||
aaf65a5a9ce7494728035ecd7c654778cacd878a9b095be688b9c1491986d654 tools/checks/release_smoke.py
|
||||
08b096cff7485f75f778e6ae9b0be5e3552b85aa95eb9e4dcdc587c720018f31 tools/checks/release_smoke.py
|
||||
31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py
|
||||
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
||||
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
||||
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
||||
4823b7e30d70a9bf7f856907cc9ee74f274d326322df86c367a0909e1da3b922 tools/checks/security_logs_0315_ui_test.mjs
|
||||
70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py
|
||||
68a4cffda268df47e600dcec725ae605765084d9076dbed6836e843285488cf8 tools/checks/security_logs_0315_ui_test.mjs
|
||||
011bbe65dae072999a3951aba3d74a520926abf836b784cdb2395023d2942867 tools/checks/source_module_contracts.py
|
||||
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
|
||||
e9f96a2a44712feb090ddb0eb273124ac780cb68e428292f7c7f106de11be050 tools/checks/stream_0320_ui_test.mjs
|
||||
4d066f8cae3162a5574b5523b775e6e7c351379f6bf3d63706d5fe082b6900bc tools/checks/stream_0320_visibility_regression.mjs
|
||||
bb862620104f925488e36904e258b7c934a097362ef879ecb275c64008cebb83 tools/checks/stream_0320_ui_test.mjs
|
||||
19cb347c87b0fa462fc2f3f2f5bef62e93628334ab4b8a1776df417e47c91ef0 tools/checks/stream_0320_visibility_regression.mjs
|
||||
8542f5c668155aa9f70de11e6fdaf64c5a80629a15ecc5f1d8c40f3d655f9b55 tools/checks/system_info_0301_ui_test.mjs
|
||||
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
|
||||
4474b1b3d3550f28644c20a7b7d5ec6120765dbdf606bc66597d35c9d1bd3d3c tools/checks/upstream_protocols_0323.py
|
||||
a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
|
||||
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||
ea98e8ce2bdc841814e486ea4c13f94e284b2c50a5887598b7a288f75a14deaf tools/checks/vhost_www_ui_test.mjs
|
||||
7aeef339092ab07aa0d906fce223ff34ecc1ee8dbd6b3f173d94c521a79558ac tools/checks/web_assets.py
|
||||
37a74b5fea295efc3ff77609bedf0c3c5352b92418157961419b914d738efe6b tools/checks/web_assets.py
|
||||
8d2e1837f94302d09a704aca452cc367e6892f289aca3dc2d82c086a2e7de26d tools/checks/web_cache_revalidation_0322.py
|
||||
8ec055a16198701dd4fa35eac42a71b666d2b9bbe90edb1c797c7ed759615487 tools/checks/web_server_contracts.py
|
||||
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
|
||||
|
||||
@@ -98,6 +98,7 @@ if [[ "$mode" == "test" ]]; then
|
||||
python3 tools/checks/system_info_no_subprocess_contracts.py
|
||||
python3 tools/checks/source_module_contracts.py
|
||||
python3 tools/checks/version_contracts.py
|
||||
python3 tools/checks/upstream_protocols_0323.py
|
||||
python3 tools/checks/check_source.py
|
||||
python3 tools/checks/release_smoke.py
|
||||
python3 tools/checks/memory_contracts.py
|
||||
@@ -116,6 +117,15 @@ if [[ "$mode" == "test" ]]; then
|
||||
node tools/checks/refresh_0310_ui_test.mjs
|
||||
node tools/checks/stream_0320_ui_test.mjs
|
||||
node tools/checks/stream_0320_visibility_regression.mjs
|
||||
node tools/checks/overview_registry_0323_ui_test.mjs
|
||||
node tools/checks/backend_dashboard_0323_ui_test.mjs
|
||||
node tools/checks/dashboard_layout_editor_0323_ui_test.mjs
|
||||
node tools/checks/dashboard_inline_layout_0323_ui_test.mjs
|
||||
node tools/checks/admin_themes_0323_ui_test.mjs
|
||||
node tools/checks/appearance_ux_0323_ui_test.mjs
|
||||
node tools/checks/overview_extra_metrics_0323_ui_test.mjs
|
||||
node tools/checks/overview_wide_masonry_0323_ui_test.mjs
|
||||
node tools/checks/overview_uniform_grid_0323_ui_test.mjs
|
||||
node tools/checks/overview_0321_layout_regression.mjs
|
||||
node tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||
node tools/checks/dashboard_boot_0311_ui_test.mjs
|
||||
@@ -124,6 +134,7 @@ if [[ "$mode" == "test" ]]; then
|
||||
node tools/checks/admin_public_origin_0314_config_sim.mjs
|
||||
node tools/checks/admin_panel_config_0314_ui_test.mjs
|
||||
node tools/checks/security_logs_0315_ui_test.mjs
|
||||
node tools/checks/file_logs_tabs_0315_ui_test.mjs
|
||||
node tools/checks/release_0317_ui_test.mjs
|
||||
node tools/checks/release_0318_ui_test.mjs
|
||||
node tools/checks/docker_startup_0318_ui_test.mjs
|
||||
|
||||
+21
-1
@@ -397,6 +397,26 @@ and rust_proxy_http3_requests_total. See source `metrics.rs` for exact HELP/type
|
||||
contracts and INTEGRATIONS_040.md for configuration examples.
|
||||
|
||||
|
||||
## 0.32.3 Overview registry and compatible upstream modes
|
||||
|
||||
The backend `protocol` wire values are `http1` (default), `auto`, `http2`
|
||||
(legacy HTTPS H2/H1 ALPN fallback or cleartext H2C) and new `h2` (strict
|
||||
HTTPS H2 or cleartext H2C). Old `http2` configurations are not rewritten.
|
||||
See [Vhosts](VHOSTS.md).
|
||||
|
||||
The authenticated `/api/preferences` Dashboard card ID lists are backed by a
|
||||
shared server/frontend registry with **49** IDs: the original 16, fourteen
|
||||
optional HTTP backend cards, twelve optional performance charts and seven existing
|
||||
full-width panels (live traffic, system, connections, cache, process, network,
|
||||
backend diagnostics). OpenAPI `dashboard_widgets` and
|
||||
`dashboard_hidden_widgets` enum, `maxItems` and defaults reflect that registry.
|
||||
Existing saved card order is retained. New performance charts are opt-in,
|
||||
while previously displayed full-width sections remain visible by default.
|
||||
Unknown saved IDs are ignored rather than failing a whole profile.
|
||||
The visual editor offers a default-layout preview and a confirmed Reset layout,
|
||||
both using the existing per-account preference endpoint.
|
||||
No new routes, tokens or permissions are introduced.
|
||||
|
||||
## 0.22.3 certificate hot reload and external deployment
|
||||
|
||||
`POST /api/certificates/reload` is an administrator-session-only, Origin/CSRF-protected control-plane
|
||||
@@ -622,6 +642,6 @@ The revisioned `/api/config` vhost rate-limit object accepts optional `user_agen
|
||||
|
||||
`GET /api/status` retains `streams.active`, `streams.desired`, `streams.restart_required` and `streams.metrics`. Each active Stream metric view includes `backends[]` for health/active information and optional detailed counters. `/api/status?view=live` and the existing authenticated SSE snapshots carry the same backend observations inside `streams.metrics` without a separate polling source. Backend status is `unverified`, `checking`, `healthy`, `unhealthy` or `disabled`.
|
||||
|
||||
`GET`/`PUT /api/preferences` continue to own `dashboard_widgets` and `dashboard_hidden_widgets` (now 16 supported IDs, adding `stream_overview`, `stream_traffic`, `stream_sessions`, `stream_backend_health`). Legacy accounts have their missing Stream widget IDs appended **hidden**. Preferences affect only Dashboard presentation, not proxy configuration or metrics export. No new endpoint or permission was introduced.
|
||||
In the 0.32.0 Stream addition, `GET`/`PUT /api/preferences` own `dashboard_widgets` and `dashboard_hidden_widgets` (at that stage 16 supported IDs, adding `stream_overview`, `stream_traffic`, `stream_sessions`, `stream_backend_health`). Legacy accounts have their missing Stream widget IDs appended **hidden**. Preferences affect only Dashboard presentation, not proxy configuration or metrics export. No new endpoint or permission was introduced.
|
||||
|
||||
Detailed backend metrics default to off in v0.32.0; per-Stream `metrics_enabled` sets the inherited value for its backends, and explicit per-backend `metrics_enabled` overrides it. Basic per-Stream counters remain available regardless of these flags.
|
||||
@@ -1,5 +1,20 @@
|
||||
# Deployment and configuration migration history
|
||||
|
||||
## 0.32.2 -> 0.32.3 upstream protocol and Dashboard compatibility
|
||||
|
||||
No database or backend JSON rewrite is required. In 0.32.2 an HTTPS origin
|
||||
saved as `"protocol": "http2"` could negotiate HTTP/1.1 via TLS ALPN. The same
|
||||
value **still** permits H2/H1 over TLS in 0.32.3 and still uses H2C on plaintext
|
||||
HTTP. To enforce HTTP/2 only, explicitly select new `"protocol": "h2"`.
|
||||
Existing `http1` and `auto` values, HTTP/1.1 WebSocket Upgrade, configured
|
||||
keepalive and pool settings retain their meaning.
|
||||
|
||||
The dashboard registry grows to 24 IDs. Eight HTTP backend cards are optional,
|
||||
hidden by default for all accounts until enabled; old ordering and hide/show
|
||||
preferences survive. These use existing status data with no extra background
|
||||
polling or schema migration. Verify live behavior and WebSocket negotiation on
|
||||
a Rust-enabled staging environment before deployment.
|
||||
|
||||
## 0.28.1 -> 0.28.2
|
||||
|
||||
SQLite schema 8 is upgraded automatically to schema 9. The additive migration creates `certificate_api_grants`. Existing Certificate API credentials become reload-only compatibility credentials covering all certificate IDs (`*`); rotate them in **Settings -> Certificate API** to replace the wildcard with explicit certificate IDs. New credentials require 1-32 explicit certificate IDs and are reload-only unless deploy permission is enabled.
|
||||
|
||||
@@ -540,3 +540,70 @@ PROMETHEUS.md for immediate revocation, restart rules and scrape configuration.
|
||||
## Stream Dashboard cards (0.32.0)
|
||||
|
||||
Under **Settings → Appearance → Dashboard layout**, the existing Show/Hide and Up/Down card controls now include Stream overview, traffic, sessions and backend health. They are opt-in and account-scoped, saved through the existing `/api/preferences` flow. Legacy 12-card orders migrate in memory with all four Stream cards hidden. They use the existing status/SSE stream and stop computing their charts while hidden; no backend monitoring or listener settings are changed when toggled. An explicitly enabled Stream card stays on Overview and shows a neutral no-data state when no Stream is active. Overview details and chart legends stay visible inside the cards (without hover tooltips). Appearance Stream card descriptions use browser-native `title` hints on hover, with the same localized descriptions available to assistive technology through `aria-description` on the visibility buttons; they no longer add floating boxes or horizontal scrolling.
|
||||
|
||||
## Full Overview layout and additional metrics (0.32.3)
|
||||
|
||||
Open **Overview -> Arrange cards visually** (or **Settings -> Appearance -> Dashboard layout -> Visual layout editor**) to change **all 49** dashboard blocks: 30 previous tiles, 12 new optional charts, three large live charts, three diagnostic panels and the backend table. Drag tiles/sections, use arrow buttons for keyboard access, or hide and re-enable individual blocks. Large sections retain their own original charts, controls and live data after being moved. The responsive six-column desktop view scales to narrower widths. Visibility and order are account-scoped; they never alter backend behavior.
|
||||
|
||||
**Default view** loads the factory order and visibility into the editor **without saving**; **Save layout** applies it. **Reset layout** asks for confirmation, then persists the same default order and visibility in one preferences write. **Cancel** preserves the current layout. All 12 added charts are hidden until explicitly enabled and use a maximum of 30 status samples without additional HTTP polling. They cover process CPU/RSS/file descriptors, connections, WebSocket tunnels, TLS handshake failure and request-error rates, cache HIT share and HOT RAM usage, incoming HTTP/2 request share, mean response duration, and unhealthy backends. Unknown and first-delta measurements show `-`; response time is a mean, not a percentile, and HTTP/2 share is **frontend** traffic rather than negotiated upstream ALPN.
|
||||
|
||||
## HTTP backend Overview cards (0.32.3)
|
||||
|
||||
**Settings -> Appearance -> Dashboard layout** now contains fourteen opt-in
|
||||
HTTP origin cards: **Backend health**, **Active backend requests**, **Backend
|
||||
errors**, **Health probe RTT**, **Backend traffic**, **Configured backend
|
||||
modes**, **Backend request rate**, **Backend capacity**, **HTTP 5xx**, **Backend
|
||||
timeouts**, **Upstream connection errors**, **Backend retries**, **Backend
|
||||
queue pressure**, and **Backend connection pools**. All are hidden by default on new and migrated administrator profiles;
|
||||
card visibility and ordering use existing per-account `/api/preferences` fields.
|
||||
They do not affect routing, health checks, upstream keep-alive or listeners.
|
||||
|
||||
The cards reuse `backend_pools` already included in the live status payload:
|
||||
|
||||
- Health separates up/down/checking and draining/disabled members. Active
|
||||
requests is a sum of in-flight pool member exchanges, not open TCP sockets.
|
||||
- Errors show lifetime member error counts and a sampled errors/s graph. Traffic
|
||||
uses the positive differences of per-member in/out byte counters; resets and
|
||||
newly added backend members cannot generate an artificial rate spike.
|
||||
- Health probe RTT is the mean last observed healthy probe time, **not** client
|
||||
end-to-end latency. It shows no data when health probes are unavailable.
|
||||
- Configured backend modes distinguish saved `http1`, `auto`, compatible `http2`
|
||||
and strict `h2` values, and count H2C cleartext members. This is **not an observed
|
||||
H1/H2 negotiation rate**: TLS ALPN can select H1 for Auto and compatible HTTP/2.
|
||||
- Backend request rate uses started-request counter deltas, with per-member
|
||||
baselines after a restart or config change. Backend capacity compares active
|
||||
requests against finite per-member concurrency limits; unlimited members are
|
||||
excluded and a neutral state is shown when no such limit is configured.
|
||||
|
||||
The six additional diagnostics use pre-existing `backend_pools.members` counters:
|
||||
HTTP 5xx responses, all timeout classes, combined connect/DNS/TLS/I/O failures,
|
||||
retry attempts and successes from the existing safe retry policy, queue timeouts
|
||||
and capacity rejections, and Hyper client-generation rotations and age. Rotations
|
||||
are **not TLS handshake counts**. Rates are based on per-member positive counter
|
||||
differences; newly added or reset members cannot create arbitrary spikes.
|
||||
|
||||
To arrange the actual Overview cards visually, click **Arrange cards visually**
|
||||
on Overview or **Visual layout editor** in Settings -> Appearance -> Dashboard
|
||||
layout. The modal displays copies of the existing telemetry cards; drag to
|
||||
reorder, or use the move arrows for keyboard-only input. Hide/show tiles in
|
||||
the modal and select **Save layout** once to persist order and visibility in
|
||||
the existing `dashboard_widgets` and `dashboard_hidden_widgets` preferences.
|
||||
Cancel/Escape discards the draft. The existing Appearance visibility and move
|
||||
buttons remain available. No extra API endpoint, refresh loop, or database
|
||||
migration is needed.
|
||||
|
||||
TLS ALPN selects H2 or H1 only when opening a **new TLS connection**, not for
|
||||
each request. Hyper already pools reusable connections per compiled backend;
|
||||
subsequent requests share the negotiated connection (H2 multiplexing when
|
||||
possible). Existing bounded pool recycling (`pool_recycle_requests` and
|
||||
`pool_recycle_seconds`), configured idle timeouts, and reopening failed
|
||||
connections naturally re-evaluate TLS capabilities. A backend-global protocol
|
||||
cache or scheduled H2 probe would be unsafe for origins with multiple DNS/peer
|
||||
capabilities and would add network work. No traffic replay/downgrade on
|
||||
post-request errors was added; strict H2, H2C, H1 and WebSocket separation
|
||||
remain intact.
|
||||
|
||||
The sample charts are browser-local, bounded and reset on a changed config
|
||||
revision, logout or source unavailability. They are not computed while hidden.
|
||||
No additional polling, SQLite schema migration or new administrator endpoint
|
||||
is introduced. Existing Stream widgets remain independent.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Verification report - RustProxy 0.32.2
|
||||
|
||||
## Scope
|
||||
|
||||
Source-based patch on the supplied 0.32.1 archive. The Web Server's cache
|
||||
revalidation now mirrors the existing Reverse Proxy's 304 handling for local
|
||||
files, while preserving original browser conditionals, cache policy and cached
|
||||
transformed representations. No backend, PHP-FPM, API or SQLite schema change.
|
||||
|
||||
## Regression cases added (Rust, execution pending)
|
||||
|
||||
- F5-style `Cache-Control: max-age=0` and hard-refresh `no-cache` preserve body
|
||||
and advance `X-Cache-Hits` when local origin returns 304.
|
||||
- Browser `If-None-Match` produces downstream 304 only when the client's validator
|
||||
matches the public stored representation, not merely the origin's validator.
|
||||
- Unrelated client validators cannot incorrectly validate the stored body.
|
||||
- `HEAD` and single-range conditional refresh reuse the validated full body.
|
||||
- A changed asset (different on-disk ETag) returns 200 rather than a false 304.
|
||||
- Newly private origin revalidation metadata cannot remain in shared storage.
|
||||
|
||||
## Results and limitations
|
||||
|
||||
- Python static/structural checks via `./dev.sh test`: **PASS** (including
|
||||
version, full source preflight, cache regression contracts, HTTP/cache,
|
||||
security, documentation, schema-v9/upgrade and release smoke contracts).
|
||||
- All **24 Node.js UI simulations** configured in `./dev.sh test`: **PASS**.
|
||||
Three existing fixture assumptions were aligned with current unindented
|
||||
functions/version (`backend_cache_0297_ui_test.mjs`,
|
||||
`backend_health_0300_ui_test.mjs`, `security_logs_0315_ui_test.mjs`);
|
||||
the production panel logic is unchanged.
|
||||
- Python load-tool selftest against a Python peer: **PASS**; not a RustProxy
|
||||
server test or a memory profile of the proxy.
|
||||
- `./dev.sh check`: **unavailable (exit 127)**; Rust/Cargo is not installed.
|
||||
- `./dev.sh test`: **pre-Cargo checks PASS; entire command exits 127** because
|
||||
Rust/Cargo is not installed, so no Rust tests executed.
|
||||
- Rust `cargo check --locked --all-targets`: **not run, Cargo unavailable**.
|
||||
- Rust `cargo test --locked --all-targets`: **not run, Cargo unavailable**.
|
||||
- Real SQLite schema migration/update on an installed instance: **not run**;
|
||||
only source/upgrade contracts were checked.
|
||||
- Actual RustProxy process, HTTP/1.1, HTTP/2, HTTP/3, PHP-FPM/WordPress and
|
||||
browser DevTools reproduction: **not run** in this source-only environment.
|
||||
|
||||
**Production acceptance remains open.** Run `./dev.sh check`, `./dev.sh test`
|
||||
and repeat browser-refresh/GET comparisons on a Rust 1.99+ build host before
|
||||
rolling the release out. The patched source is delivered, not a tested binary.
|
||||
+22
-3
@@ -239,9 +239,28 @@ Disabling reuse sets the idle pool limit to zero and disables keepalive probes.
|
||||
limit is not a separate per-backend active-request cap; global connection/exchange bounds
|
||||
still apply. HTTP health probes use separate no-reuse HTTP/1.1 connections.
|
||||
|
||||
`http2` over HTTPS prefers h2 but keeps HTTP/1.1 ALPN compatibility. Over plaintext,
|
||||
`http2` remains explicit prior-knowledge h2c with no retry-based downgrade. `auto`
|
||||
over HTTP is HTTP/1.1. `http2`/`auto` require `preserve_host: false` to keep origin
|
||||
Backend upstream `protocol` has four modes:
|
||||
|
||||
| UI mode | Serialized value | Backend behavior |
|
||||
|---|---|---|
|
||||
| **HTTP/1.1** | `http1` | HTTP/1.1 only over HTTPS or HTTP; default for new backends |
|
||||
| **Auto** | `auto` | HTTPS: prefer H2 and negotiate H1 fallback with ALPN before sending a request; HTTP: H1 only, without H2C probing |
|
||||
| **HTTP/2 compatible** | `http2` | **Unchanged 0.32.2 behavior**: HTTPS offers H2/H1 via ALPN; plaintext HTTP uses H2C prior knowledge (no H1 downgrade on HTTP) |
|
||||
| **H2 only** | `h2` | HTTPS: require H2 via ALPN; reject H1-only peers. HTTP: prior-knowledge H2C, no fallback |
|
||||
|
||||
**0.32.2 -> 0.32.3 compatibility:** saved `"protocol": "http2"` retains its existing
|
||||
HTTPS ALPN fallback and cleartext H2C behavior. No conversion, migration or
|
||||
administrator action is needed to preserve the old selection. Choose the new
|
||||
`"protocol": "h2"` only when H2 must be enforced. The editor describes the
|
||||
transport for the selected backend URL; a descriptive notice next to compatible
|
||||
HTTPS `http2` is informational, not a migration warning.
|
||||
|
||||
HTTPS Auto negotiation selects the wire protocol **before sending the request**.
|
||||
RustProxy does not replay an ambiguous failed POST/PATCH/DELETE through H1, and
|
||||
does not interpret backend 4xx/5xx responses as a reason to switch protocols.
|
||||
Existing pool reuse, keep-alive, client generation rotation and the independent
|
||||
HTTP/1.1 WebSocket Upgrade / H2 Extended CONNECT paths are retained.
|
||||
HTTP health probes continue to use HTTP/1.1; H2C-only peers can use TCP probes. `http2`/`h2`/`auto` require `preserve_host: false` to keep origin
|
||||
URI/authority/TLS identity consistent. HTTPS backend certificate verification is disabled when `tls_verify` is omitted. Enable `tls_verify: true` when origin identity must be validated; for IP-based origins use a DNS `tls_server_name` present in the certificate and provide `tls_ca_pem` when the issuer is private.
|
||||
|
||||
## Scheduling
|
||||
|
||||
+190
-6
@@ -176,7 +176,7 @@
|
||||
"auth.unavailable": "Administration storage is temporarily unavailable.",
|
||||
"events.too_many_viewers": "Live viewer limit reached; polling is available.",
|
||||
"protocol": "Upstream protocol",
|
||||
"protocolHelp": "HTTP/2 and auto use the backend authority. Preserve Host is available with HTTP/1.1 only. RFC 8441 can use an explicit HTTP/2 upstream; other WebSocket transports bridge through HTTP/1.1.",
|
||||
"protocolHelp": "HTTP/1.1 sends H1 only. Auto prefers H2 via HTTPS ALPN and uses HTTP/1.1 if H2 is unavailable (plaintext Auto uses H1). H2 requires HTTP/2 via TLS or h2c prior knowledge; there is no HTTP/1.1 downgrade. WebSocket handling remains separate. Preserve Host is available with H1 only.",
|
||||
"addressAllowlist": "Optional address allowlist (CIDR, one per line)",
|
||||
"caPem": "Additional trusted CA / certificate (PEM, optional)",
|
||||
"backendTlsHelp": "TLS encryption remains available, but certificate/hostname verification is off unless enabled explicitly. Private addresses still need explicit approval, including DNS answers.",
|
||||
@@ -354,9 +354,9 @@
|
||||
"vhPrivate": "Allow private origin IP",
|
||||
"vhPreserveHost": "Preserve client Host (HTTP/1.1 only)",
|
||||
"vhProtocol": "Origin protocol",
|
||||
"vhProtocolHttp1": "HTTP/1.1",
|
||||
"vhProtocolAuto": "TLS ALPN: HTTP/2 + HTTP/1.1",
|
||||
"vhProtocolHttp2": "HTTP/2 (TLS: with HTTP/1.1 fallback; plaintext: h2c)",
|
||||
"vhProtocolHttp1": "HTTP/1.1 (TLS or cleartext)",
|
||||
"vhProtocolAuto": "Auto (HTTPS ALPN: H2 + H1; HTTP: H1)",
|
||||
"vhProtocolHttp2": "HTTP/2 compatible (HTTPS: H2 + H1 fallback; HTTP: h2c)",
|
||||
"vhAddressAllowlist": "Origin IP/CIDR allowlist (optional)",
|
||||
"vhTlsServerName": "TLS server name / SNI (optional)",
|
||||
"vhTlsVerify": "Verify backend certificate and hostname",
|
||||
@@ -2207,7 +2207,7 @@
|
||||
"dashboardDensity": "Dashboard density",
|
||||
"dashboardDensityHelp": "Controls Overview telemetry card size without changing the rest of the interface.",
|
||||
"dashboardWidgets": "Overview cards",
|
||||
"dashboardWidgetsHelp": "Show, hide and reorder the telemetry cards at the top of Overview.",
|
||||
"dashboardWidgetsHelp": "Show, hide and reorder all Overview blocks, including charts, diagnostics and the backend table.",
|
||||
"dashboardWidgetVisible": "Visible",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
@@ -2440,6 +2440,190 @@
|
||||
"streamWidgetOverviewDescription": "Configured and running TCP/UDP listeners, including those requiring attention.",
|
||||
"streamWidgetTrafficDescription": "Total Stream receive/transmit rate from the shared live status sample.",
|
||||
"streamWidgetSessionsDescription": "Current TCP connections and UDP client sessions.",
|
||||
"streamWidgetHealthDescription": "Healthy, unhealthy and unverified backends and pools with no eligible target."
|
||||
"streamWidgetHealthDescription": "Healthy, unhealthy and unverified backends and pools with no eligible target.",
|
||||
"backendWidgetHealth": "Backend health",
|
||||
"backendWidgetHealthDescription": "HTTP origin members by observed health: up, down, checking, draining, disabled. Not Stream backends.",
|
||||
"backendWidgetActivity": "Active backend requests",
|
||||
"backendWidgetActivityDescription": "In-flight HTTP requests at pool members, not open TCP connections.",
|
||||
"backendWidgetErrors": "Backend errors",
|
||||
"backendWidgetErrorsDescription": "Cumulative pool member errors; chart shows observed error rate since the previous sample.",
|
||||
"backendWidgetLatency": "Health probe RTT",
|
||||
"backendWidgetLatencyDescription": "Mean last successful health-check latency of probed healthy HTTP backends; not user request latency.",
|
||||
"backendWidgetTraffic": "Backend traffic",
|
||||
"backendWidgetTrafficDescription": "HTTP pool member byte-counter deltas per second in each direction, without extra polling.",
|
||||
"backendWidgetProtocols": "Configured backend modes",
|
||||
"backendWidgetProtocolsDescription": "Configured H1, Auto, compatible HTTP/2 and strict H2 origins (not negotiated ALPN results).",
|
||||
"backendWidgetUnavailable": "No backend data",
|
||||
"backendWidgetUp": "Up",
|
||||
"backendWidgetDown": "Down",
|
||||
"backendWidgetChecking": "Checking",
|
||||
"backendWidgetOther": "Draining/disabled",
|
||||
"backendWidgetInFlight": "In flight",
|
||||
"backendWidgetErrorsRate": "Recent errors/s",
|
||||
"backendWidgetErrorPercent": "Recent failed requests",
|
||||
"backendWidgetProbeCount": "Probed healthy origins",
|
||||
"backendWidgetH2c": "H2C origins",
|
||||
"backendWidgetModeUnknown": "Unknown",
|
||||
"vhProtocolCompatibilityWarning": "Compatibility mode: saved HTTP/2 HTTPS origins retain the previous H2/H1 TLS fallback. Choose H2 only if HTTP/1.1 must be rejected.",
|
||||
"vhProtocolHttp1Details": "Uses HTTP/1.1 for the origin even if it offers HTTP/2.",
|
||||
"vhProtocolAutoHttpsDetails": "TLS ALPN chooses H2 or H1 only when a new TLS connection opens. Requests reuse pooled connections; no post-failure request replay.",
|
||||
"vhProtocolAutoHttpDetails": "Cleartext Auto uses HTTP/1.1 only; select H2 for h2c prior knowledge.",
|
||||
"vhProtocolH2HttpsDetails": "Each new TLS connection must negotiate HTTP/2; pooled H2 connections are reused. H1-only origins fail without downgrade.",
|
||||
"vhProtocolH2HttpDetails": "Cleartext H2 uses HTTP/2 prior knowledge (h2c), not HTTP/1.1 Upgrade.",
|
||||
"backendWidgetCapacityNone": "No finite per-origin limit",
|
||||
"backendWidgetCapacityRejects": "Capacity rejections",
|
||||
"backendWidgetCapacityLimits": "Limited origins",
|
||||
"backendWidgetCapacityDescription": "In-flight HTTP requests against configured finite per-origin concurrency limits. Unlimited origins are excluded; no extra probes.",
|
||||
"backendWidgetCapacity": "Backend capacity",
|
||||
"backendWidgetRequestsTotal": "Total observed requests",
|
||||
"backendWidgetRequestsDescription": "Started origin request counter deltas per second across HTTP pool members; new/restarted members are re-baselined.",
|
||||
"backendWidgetRequests": "Backend request rate",
|
||||
"backendWidgetModeStrict": "H2 only",
|
||||
"backendWidgetModeCompat": "HTTP/2 compatible",
|
||||
"vhProtocolLegacyHttp2HttpDetails": "Compatibility with 0.32.2: cleartext uses HTTP/2 prior knowledge (h2c), without HTTP/1.1 fallback.",
|
||||
"vhProtocolLegacyHttp2HttpsDetails": "0.32.2 compatibility: new HTTPS connections negotiate H2 or HTTP/1.1 via ALPN; later requests reuse the negotiated connection.",
|
||||
"vhProtocolH2Strict": "H2 only (HTTPS: H2 required; HTTP: h2c)",
|
||||
"backendWidgetHttp5xx": "Upstream HTTP 5xx",
|
||||
"backendWidgetHttp5xxDescription": "Backend responses with HTTP status 5xx; this is not the frontend error rate.",
|
||||
"backendWidgetTimeouts": "Backend timeouts",
|
||||
"backendWidgetTimeoutsDescription": "All recorded timeout classes on HTTP backend members.",
|
||||
"backendWidgetNetwork": "Upstream connection errors",
|
||||
"backendWidgetNetworkDescription": "Cumulative DNS, TCP connect, TLS handshake and I/O errors.",
|
||||
"backendWidgetRetries": "Backend retry attempts",
|
||||
"backendWidgetRetriesDescription": "Existing safe route/pool retry policy: attempts and successes; no new retries are initiated.",
|
||||
"backendWidgetQueue": "Backend queue pressure",
|
||||
"backendWidgetQueueDescription": "Member queue timeouts and concurrency-capacity rejections.",
|
||||
"backendWidgetRotations": "Backend connection pools",
|
||||
"backendWidgetRotationsDescription": "Recycled Hyper client generations (not the number of TCP/TLS handshakes).",
|
||||
"backendWidgetRateDetail": "Recent rate",
|
||||
"backendWidgetRetriesSuccess": "Successful retries",
|
||||
"backendWidgetQueueRejected": "Capacity rejections",
|
||||
"backendWidgetTimeoutTotal": "Timeouts total",
|
||||
"backendWidgetNetworkBreakdown": "Connect / DNS / TLS / I/O",
|
||||
"backendWidgetPoolGeneration": "Maximum active pool generation",
|
||||
"backendWidgetPoolAge": "Oldest generation age",
|
||||
"overviewLayoutEditorOpen": "Arrange Overview",
|
||||
"overviewLayoutEditorTitle": "Arrange entire Overview",
|
||||
"overviewLayoutEditorHelp": "Move any tile or panel by dragging or using the arrows. Hidden items can be enabled below. Default view previews the standard layout; Reset layout asks for confirmation before saving.",
|
||||
"overviewLayoutEditorVisible": "Visible cards",
|
||||
"overviewLayoutEditorHidden": "Hidden cards",
|
||||
"overviewLayoutEditorHide": "Hide card",
|
||||
"overviewLayoutEditorShow": "Show card",
|
||||
"overviewLayoutEditorSave": "Save layout",
|
||||
"overviewLayoutEditorCancel": "Cancel",
|
||||
"overviewLayoutEditorSaving": "Saving layout…",
|
||||
"overviewLayoutEditorEmpty": "No visible cards. Enable a card below.",
|
||||
"overviewLayoutEditorHiddenEmpty": "All cards are visible.",
|
||||
"overviewLayoutEditorMoveBefore": "Move card before",
|
||||
"overviewLayoutEditorSaved": "Dashboard layout saved",
|
||||
"overviewLayoutEditorOpenSettings": "Visual Overview editor",
|
||||
"overviewLayoutEditorSearch": "Find hidden card",
|
||||
"overviewLayoutDefaultPreview": "Default view",
|
||||
"overviewLayoutReset": "Reset layout",
|
||||
"overviewLayoutResetTitle": "Reset dashboard layout?",
|
||||
"overviewLayoutResetConfirm": "Restore the default arrangement and visibility of every Overview block. Other settings are preserved.",
|
||||
"overviewLayoutResetApply": "Reset and save",
|
||||
"overviewLayoutDefaultLoaded": "Default view previewed. Save to apply.",
|
||||
"overviewLayoutResetSaved": "Default Dashboard layout restored.",
|
||||
"overviewLayoutEditorSectionPanel": "Panels and charts",
|
||||
"overviewLayoutEditorSectionMetrics": "Metric cards",
|
||||
"overviewPanelTrafficDescription": "Live requests per second and HTTP versions.",
|
||||
"overviewPanelSystemDescription": "System CPU, RAM, swap and uptime.",
|
||||
"overviewPanelConnectionsDescription": "Open connections and active exchanges over time.",
|
||||
"overviewPanelCacheDescription": "Cache statistics and detailed history.",
|
||||
"overviewPanelProcessDescription": "Process resource and memory diagnostics.",
|
||||
"overviewPanelNetworkDescription": "Network throughput and response latency.",
|
||||
"overviewPanelBackendsDescription": "Backend diagnostics table with search and sorting.",
|
||||
"overviewExtraProcessCpu": "Process CPU history",
|
||||
"overviewExtraProcessCpuDescription": "Process CPU utilization; percent from system status.",
|
||||
"overviewExtraProcessRss": "Process RSS history",
|
||||
"overviewExtraProcessRssDescription": "Process resident memory, in bytes.",
|
||||
"overviewExtraProcessFds": "Open file descriptors",
|
||||
"overviewExtraProcessFdsDescription": "Open file descriptors, not an estimate of requests.",
|
||||
"overviewExtraOpenConnections": "Active connections history",
|
||||
"overviewExtraOpenConnectionsDescription": "Frontend open connections as measured by RustProxy.",
|
||||
"overviewExtraWebsocketActive": "WebSocket tunnels history",
|
||||
"overviewExtraWebsocketActiveDescription": "Currently active WebSocket tunnels.",
|
||||
"overviewExtraTlsErrorRate": "TLS handshake failures/s",
|
||||
"overviewExtraTlsErrorRateDescription": "Rate based on TLS handshake error counter deltas.",
|
||||
"overviewExtraRequestErrorRate": "Request errors/s",
|
||||
"overviewExtraRequestErrorRateDescription": "Rate based on request error counter deltas.",
|
||||
"overviewExtraCacheHitRate": "Cache hits share (recent)",
|
||||
"overviewExtraCacheHitRateDescription": "Share of HIT among new HIT and MISS events; skips BYPASS.",
|
||||
"overviewExtraCacheMemory": "Cache hot RAM utilization",
|
||||
"overviewExtraCacheMemoryDescription": "Cache resident bytes as a share of configured hot RAM budget, if available.",
|
||||
"overviewExtraH2Share": "HTTP/2 request share",
|
||||
"overviewExtraH2ShareDescription": "H2 proportion of recent incoming requests, not upstream ALPN negotiation.",
|
||||
"overviewExtraResponseLatency": "Mean response latency",
|
||||
"overviewExtraResponseLatencyDescription": "Mean response duration as reported by the status API (not p95/p99).",
|
||||
"overviewExtraBackendUnhealthy": "Unhealthy backends history",
|
||||
"overviewExtraBackendUnhealthyDescription": "Number of backends reporting down; no extra health probes.",
|
||||
"overviewExtraFdLimit": "File descriptor limit",
|
||||
"overviewExtraRecentSamples": "Recent samples",
|
||||
"overviewBlockTypeValue": "Number",
|
||||
"overviewBlockTypeValueChart": "Number + chart",
|
||||
"overviewBlockTypeChart": "Chart",
|
||||
"overviewBlockTypePanel": "Diagnostics panel",
|
||||
"overviewBlockTypeTable": "Table",
|
||||
"overviewBlockTypeAll": "All block types",
|
||||
"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.",
|
||||
"overviewLayoutInlineDrag": "Drag to move",
|
||||
"overviewLayoutInlineSave": "Save and lock",
|
||||
"overviewLayoutInlineCancel": "Cancel changes",
|
||||
"overviewLayoutInlineError": "Could not save the layout.",
|
||||
"themeUltraDark": "Ultra dark · OLED black",
|
||||
"themeCream": "Warm cream · soft light",
|
||||
"overviewLayoutInlineHide": "Hide from Dashboard",
|
||||
"overviewLayoutInlineHidden": "Hidden blocks",
|
||||
"overviewLayoutInlineRestore": "Restore to Dashboard",
|
||||
"overviewLayoutInlineHiddenEmpty": "No hidden blocks",
|
||||
"overviewExtraTotalRps": "HTTP requests / s",
|
||||
"overviewExtraTotalRpsDetail": "Total HTTP request rate calculated from counters.",
|
||||
"overviewExtraActiveExchanges": "Active HTTP exchanges",
|
||||
"overviewExtraActiveExchangesDetail": "Requests currently being processed.",
|
||||
"overviewExtraH1Share": "HTTP/1.1 share",
|
||||
"overviewExtraH1ShareDetail": "Share of new HTTP requests using H1.",
|
||||
"overviewExtraH3Share": "HTTP/3 share",
|
||||
"overviewExtraH3ShareDetail": "Share of new HTTP requests using H3.",
|
||||
"overviewExtraCacheMissRate": "Cache misses / s",
|
||||
"overviewExtraCacheMissRateDetail": "Rate of cache misses.",
|
||||
"overviewExtraBackendActive": "In-flight backend requests",
|
||||
"overviewExtraBackendActiveDetail": "Current HTTP upstream requests across backends.",
|
||||
"overviewWideProtocolRates": "HTTP/1.1 · HTTP/2 · HTTP/3 traffic",
|
||||
"overviewWideProtocolRatesDetail": "Requests per second by frontend protocol.",
|
||||
"overviewWideCpu": "System and process CPU",
|
||||
"overviewWideCpuDetail": "System and RustProxy CPU utilization percentage.",
|
||||
"overviewWideMemory": "Process RAM and cache memory",
|
||||
"overviewWideMemoryDetail": "Process RSS and cache resident memory.",
|
||||
"overviewWideCacheActivity": "Cache hits / misses per second",
|
||||
"overviewWideCacheActivityDetail": "Cache hits and misses per second.",
|
||||
"overviewWideConnections": "Connections and WebSocket tunnels",
|
||||
"overviewWideConnectionsDetail": "Open connections and active WebSocket tunnels.",
|
||||
"overviewWideBackendHealth": "Backend availability over time",
|
||||
"overviewWideBackendHealthDetail": "Count of healthy and unhealthy HTTP upstream members.",
|
||||
"overviewWideLatency": "Response and backend probe latency",
|
||||
"overviewWideLatencyDetail": "Average HTTP response latency and successful health-check probe latency.",
|
||||
"overviewWideSystem": "System",
|
||||
"overviewWideProcess": "Process",
|
||||
"overviewWideCache": "Cache",
|
||||
"overviewWideHits": "HIT",
|
||||
"overviewWideMisses": "MISS",
|
||||
"overviewWideOpen": "Connections",
|
||||
"overviewWideWebsockets": "WebSockets",
|
||||
"overviewWideHealthy": "Healthy",
|
||||
"overviewWideUnhealthy": "Unhealthy",
|
||||
"overviewWideResponse": "HTTP",
|
||||
"overviewWideProbe": "Probes",
|
||||
"overviewWideNoData": "No samples",
|
||||
"cornerStyle": "Corner rounding",
|
||||
"cornerStyleDefault": "Original (unchanged)",
|
||||
"cornerStyleSquare": "Square",
|
||||
"cornerStyleSubtle": "Subtle",
|
||||
"cornerStyleRounded": "Rounded",
|
||||
"cornerStyleExtra": "Extra rounded",
|
||||
"cornerStyleHelp": "Change corners across the interface independently of your color theme. Original preserves the existing shape."
|
||||
}
|
||||
}
|
||||
+190
-6
@@ -176,7 +176,7 @@
|
||||
"auth.unavailable": "Baza administracyjna jest chwilowo niedostepna.",
|
||||
"events.too_many_viewers": "Osiagnieto limit odbiorcow SSE; pozostaje odpytywanie.",
|
||||
"protocol": "Protokół upstream",
|
||||
"protocolHelp": "HTTP/2 i auto używają authority backendu. Preserve Host jest dostępne tylko z HTTP/1.1. RFC 8441 może użyć jawnego upstreamu HTTP/2; pozostałe transporty WebSocket są mostkowane przez HTTP/1.1.",
|
||||
"protocolHelp": "HTTP/1.1 używa wyłącznie H1. Auto preferuje H2 przez TLS ALPN, a gdy jest niedostępny, używa HTTP/1.1 (bez TLS: H1). H2 wymaga HTTP/2 przez TLS lub h2c prior knowledge, bez obniżania do H1. WebSocket działa osobną ścieżką. Preserve Host jest dostępne tylko dla H1.",
|
||||
"addressAllowlist": "Opcjonalna lista dozwolonych adresów (CIDR, po jednym w wierszu)",
|
||||
"caPem": "Dodatkowy zaufany CA / certyfikat (PEM, opcjonalnie)",
|
||||
"backendTlsHelp": "Szyfrowanie TLS pozostaje dostępne, ale weryfikacja certyfikatu i nazwy jest domyślnie wyłączona, dopóki nie włączysz jej jawnie. Prywatne adresy nadal wymagają zgody, także po rozwiązaniu DNS.",
|
||||
@@ -354,9 +354,9 @@
|
||||
"vhPrivate": "Zezwól na prywatny IP backendu",
|
||||
"vhPreserveHost": "Zachowaj Host klienta (tylko HTTP/1.1)",
|
||||
"vhProtocol": "Protokół backendu",
|
||||
"vhProtocolHttp1": "HTTP/1.1",
|
||||
"vhProtocolAuto": "TLS ALPN: HTTP/2 + HTTP/1.1",
|
||||
"vhProtocolHttp2": "HTTP/2 (TLS: także HTTP/1.1; bez TLS: h2c)",
|
||||
"vhProtocolHttp1": "HTTP/1.1 (TLS lub bez TLS)",
|
||||
"vhProtocolAuto": "Auto (HTTPS ALPN: H2 + H1; HTTP: H1)",
|
||||
"vhProtocolHttp2": "HTTP/2 zgodny wstecz (HTTPS: H2 + fallback H1; HTTP: h2c)",
|
||||
"vhAddressAllowlist": "Dozwolone IP/CIDR backendu (opcjonalne)",
|
||||
"vhTlsServerName": "Nazwa serwera TLS / SNI (opcjonalna)",
|
||||
"vhTlsVerify": "Weryfikuj certyfikat i nazwę hosta backendu",
|
||||
@@ -2207,7 +2207,7 @@
|
||||
"dashboardDensity": "Gęstość dashboardu",
|
||||
"dashboardDensityHelp": "Steruje rozmiarem kafelków telemetrii w Overview bez zmiany reszty interfejsu.",
|
||||
"dashboardWidgets": "Kafelki Overview",
|
||||
"dashboardWidgetsHelp": "Pokazuj, ukrywaj i zmieniaj kolejność kafelków telemetrii na górze Overview.",
|
||||
"dashboardWidgetsHelp": "Pokazuj, ukrywaj i układaj wszystkie bloki Overview, także wykresy, diagnostykę i tabelę backendów.",
|
||||
"dashboardWidgetVisible": "Widoczny",
|
||||
"moveUp": "Przenieś wyżej",
|
||||
"moveDown": "Przenieś niżej",
|
||||
@@ -2440,6 +2440,190 @@
|
||||
"streamWidgetOverviewDescription": "Liczba skonfigurowanych i uruchomionych listenerów TCP/UDP oraz wymagających uwagi.",
|
||||
"streamWidgetTrafficDescription": "Łączna szybkość odbioru i wysyłania na podstawie wspólnego odczytu statusu.",
|
||||
"streamWidgetSessionsDescription": "Aktywne połączenia TCP i sesje klientów UDP.",
|
||||
"streamWidgetHealthDescription": "Zdrowe, niedostępne i niezweryfikowane backendy oraz pule bez dostępnego celu."
|
||||
"streamWidgetHealthDescription": "Zdrowe, niedostępne i niezweryfikowane backendy oraz pule bez dostępnego celu.",
|
||||
"backendWidgetHealth": "Stan backendów",
|
||||
"backendWidgetHealthDescription": "Stan backendów HTTP: dostępne, niedostępne, sprawdzane, opróżniane i wyłączone. Nie dotyczy backendów Stream.",
|
||||
"backendWidgetActivity": "Aktywne żądania backendów",
|
||||
"backendWidgetActivityDescription": "Żądania HTTP w toku po stronie backendów, a nie liczba otwartych połączeń TCP.",
|
||||
"backendWidgetErrors": "Błędy backendów",
|
||||
"backendWidgetErrorsDescription": "Łączna liczba błędów backendów. Wykres pokazuje tempo błędów między pomiarami.",
|
||||
"backendWidgetLatency": "Opóźnienie sond dostępności",
|
||||
"backendWidgetLatencyDescription": "Średni czas ostatnich udanych testów zdrowych backendów HTTP. To nie opóźnienie żądań użytkowników.",
|
||||
"backendWidgetTraffic": "Transfer backendów",
|
||||
"backendWidgetTrafficDescription": "Przyrost liczników bajtów backendów HTTP na sekundę w obu kierunkach, bez dodatkowych zapytań.",
|
||||
"backendWidgetProtocols": "Skonfigurowane tryby backendów",
|
||||
"backendWidgetProtocolsDescription": "Skonfigurowane tryby H1, Auto, zgodny HTTP/2 i ścisły H2 (nie są to wyniki negocjacji ALPN).",
|
||||
"backendWidgetUnavailable": "Brak danych backendów",
|
||||
"backendWidgetUp": "Dostępne",
|
||||
"backendWidgetDown": "Niedostępne",
|
||||
"backendWidgetChecking": "Sprawdzane",
|
||||
"backendWidgetOther": "Opróżniane/wyłączone",
|
||||
"backendWidgetInFlight": "W toku",
|
||||
"backendWidgetErrorsRate": "Błędy/s",
|
||||
"backendWidgetErrorPercent": "Błędne żądania w ostatnim okresie",
|
||||
"backendWidgetProbeCount": "Zdrowe backendy z sondami",
|
||||
"backendWidgetH2c": "Backendy h2c",
|
||||
"backendWidgetModeUnknown": "Nieznane",
|
||||
"vhProtocolCompatibilityWarning": "Tryb zgodności: zapisane wcześniej backendy HTTPS HTTP/2 zachowują fallback TLS H2/H1. Wybierz „Tylko H2”, jeżeli HTTP/1.1 ma być odrzucony.",
|
||||
"vhProtocolHttp1Details": "Wyłącznie HTTP/1.1 do backendu, nawet jeśli oferuje HTTP/2.",
|
||||
"vhProtocolAutoHttpsDetails": "TLS ALPN wybiera H2 lub H1 tylko przy zestawianiu nowego połączenia TLS. Kolejne żądania wykorzystują pulę połączeń. Bez ponawiania żądań po błędach.",
|
||||
"vhProtocolAutoHttpDetails": "Bez TLS tryb Auto używa wyłącznie HTTP/1.1. Dla h2c prior knowledge wybierz H2.",
|
||||
"vhProtocolH2HttpsDetails": "Każde nowe połączenie TLS wymaga HTTP/2; późniejsze żądania korzystają z puli H2. Backend H1-only kończy się błędem, bez downgrade.",
|
||||
"vhProtocolH2HttpDetails": "Bez TLS tryb H2 używa h2c prior knowledge, a nie HTTP/1.1 Upgrade.",
|
||||
"vhProtocolH2Strict": "Tylko H2 (HTTPS: wymagany H2; HTTP: h2c)",
|
||||
"vhProtocolLegacyHttp2HttpsDetails": "Zgodność z 0.32.2: nowe połączenie HTTPS negocjuje H2 lub HTTP/1.1 przez ALPN. Kolejne żądania korzystają z wynegocjowanego połączenia.",
|
||||
"vhProtocolLegacyHttp2HttpDetails": "Zgodność z 0.32.2: bez TLS używane jest HTTP/2 prior knowledge (h2c), bez fallbacku do HTTP/1.1.",
|
||||
"backendWidgetModeCompat": "HTTP/2 zgodny",
|
||||
"backendWidgetModeStrict": "Tylko H2",
|
||||
"backendWidgetRequests": "Żądania do backendów/s",
|
||||
"backendWidgetRequestsDescription": "Przyrost liczników rozpoczętych żądań backendów HTTP na sekundę. Nowe i restartowane backendy tworzą nowy punkt odniesienia.",
|
||||
"backendWidgetRequestsTotal": "Łączne żądania",
|
||||
"backendWidgetCapacity": "Obciążenie limitów backendów",
|
||||
"backendWidgetCapacityDescription": "Żądania HTTP w toku względem ustawionych limitów współbieżności backendów. Backendów bez limitu nie uwzględnia się. Bez nowych sond.",
|
||||
"backendWidgetCapacityLimits": "Backendy z limitem",
|
||||
"backendWidgetCapacityRejects": "Odrzucone przez limit",
|
||||
"backendWidgetCapacityNone": "Brak ustawionych limitów współbieżności",
|
||||
"backendWidgetHttp5xx": "Błędy HTTP 5xx backendów",
|
||||
"backendWidgetHttp5xxDescription": "Odpowiedzi 5xx z backendów; to nie jest odsetek błędów od strony klientów.",
|
||||
"backendWidgetTimeouts": "Timeouty backendów",
|
||||
"backendWidgetTimeoutsDescription": "Wszystkie zarejestrowane klasy timeoutów backendów HTTP.",
|
||||
"backendWidgetNetwork": "Błędy połączeń backendów",
|
||||
"backendWidgetNetworkDescription": "Łączne błędy DNS, TCP, TLS i wejścia/wyjścia.",
|
||||
"backendWidgetRetries": "Ponowienia backendów",
|
||||
"backendWidgetRetriesDescription": "Próby i udane ponowienia według istniejących reguł retry; bez nowych prób.",
|
||||
"backendWidgetQueue": "Kolejki backendów",
|
||||
"backendWidgetQueueDescription": "Timeouty oczekiwania w kolejce i odrzucenia z powodu limitów współbieżności.",
|
||||
"backendWidgetRotations": "Pule połączeń backendów",
|
||||
"backendWidgetRotationsDescription": "Liczba odnowień generacji klienta Hyper (nie liczba handshake TLS).",
|
||||
"backendWidgetRateDetail": "Tempo",
|
||||
"backendWidgetRetriesSuccess": "Udane ponowienia",
|
||||
"backendWidgetQueueRejected": "Odrzucenia z powodu limitu",
|
||||
"backendWidgetTimeoutTotal": "Timeouty łącznie",
|
||||
"backendWidgetNetworkBreakdown": "TCP / DNS / TLS / I/O",
|
||||
"backendWidgetPoolGeneration": "Najwyższa generacja puli",
|
||||
"backendWidgetPoolAge": "Najstarsza generacja",
|
||||
"overviewLayoutEditorOpen": "Ułóż Dashboard",
|
||||
"overviewLayoutEditorTitle": "Ułóż cały Dashboard",
|
||||
"overviewLayoutEditorHelp": "Przeciągaj kafelki i panele lub używaj strzałek. Ukryte elementy włączysz poniżej. Widok domyślny pokazuje standardowy układ, a Resetuj układ prosi o potwierdzenie przed zapisem.",
|
||||
"overviewLayoutEditorVisible": "Widoczne kafelki",
|
||||
"overviewLayoutEditorHidden": "Ukryte kafelki",
|
||||
"overviewLayoutEditorHide": "Ukryj kafelek",
|
||||
"overviewLayoutEditorShow": "Pokaż kafelek",
|
||||
"overviewLayoutEditorSave": "Zapisz układ",
|
||||
"overviewLayoutEditorCancel": "Anuluj",
|
||||
"overviewLayoutEditorSaving": "Zapisywanie układu…",
|
||||
"overviewLayoutEditorEmpty": "Brak widocznych kafelków. Włącz kafelki poniżej.",
|
||||
"overviewLayoutEditorHiddenEmpty": "Wszystkie kafelki są widoczne.",
|
||||
"overviewLayoutEditorMoveBefore": "Przesuń kafelek przed",
|
||||
"overviewLayoutEditorSaved": "Układ Dashboardu zapisany",
|
||||
"overviewLayoutEditorOpenSettings": "Wizualny edytor Overview",
|
||||
"overviewLayoutEditorSearch": "Znajdź ukryty kafelek",
|
||||
"overviewLayoutDefaultPreview": "Widok domyślny",
|
||||
"overviewLayoutReset": "Resetuj układ",
|
||||
"overviewLayoutResetTitle": "Zresetować układ Dashboardu?",
|
||||
"overviewLayoutResetConfirm": "Przywróć domyślną kolejność i widoczność wszystkich bloków Overview. Pozostałe ustawienia nie zmienią się.",
|
||||
"overviewLayoutResetApply": "Resetuj i zapisz",
|
||||
"overviewLayoutDefaultLoaded": "Wczytano podgląd widoku domyślnego. Kliknij Zapisz, aby zatwierdzić.",
|
||||
"overviewLayoutResetSaved": "Przywrócono domyślny układ Dashboardu.",
|
||||
"overviewLayoutEditorSectionPanel": "Panele i wykresy",
|
||||
"overviewLayoutEditorSectionMetrics": "Kafelki metryk",
|
||||
"overviewPanelTrafficDescription": "Żądania na sekundę i wersje HTTP w czasie rzeczywistym.",
|
||||
"overviewPanelSystemDescription": "CPU, RAM, swap i czas działania systemu.",
|
||||
"overviewPanelConnectionsDescription": "Otwarte połączenia i aktywne żądania w czasie.",
|
||||
"overviewPanelCacheDescription": "Statystyki cache i szczegółowa historia.",
|
||||
"overviewPanelProcessDescription": "Zasoby i pamięć procesu.",
|
||||
"overviewPanelNetworkDescription": "Przepustowość sieci i czas odpowiedzi.",
|
||||
"overviewPanelBackendsDescription": "Tabela diagnostyczna backendów z wyszukiwaniem i sortowaniem.",
|
||||
"overviewExtraProcessCpu": "Historia CPU procesu",
|
||||
"overviewExtraProcessCpuDescription": "Użycie CPU procesu; procent ze statusu systemowego.",
|
||||
"overviewExtraProcessRss": "Historia RSS procesu",
|
||||
"overviewExtraProcessRssDescription": "Pamięć rezydentna procesu w bajtach.",
|
||||
"overviewExtraProcessFds": "Otwarte deskryptory plików",
|
||||
"overviewExtraProcessFdsDescription": "Liczba otwartych deskryptorów, nie liczba żądań.",
|
||||
"overviewExtraOpenConnections": "Historia otwartych połączeń",
|
||||
"overviewExtraOpenConnectionsDescription": "Otwarte połączenia frontendowe mierzone przez RustProxy.",
|
||||
"overviewExtraWebsocketActive": "Historia tuneli WebSocket",
|
||||
"overviewExtraWebsocketActiveDescription": "Aktualnie aktywne tunele WebSocket.",
|
||||
"overviewExtraTlsErrorRate": "Błędy handshake TLS/s",
|
||||
"overviewExtraTlsErrorRateDescription": "Tempo wyliczone z przyrostu licznika błędów handshake TLS.",
|
||||
"overviewExtraRequestErrorRate": "Błędy żądań/s",
|
||||
"overviewExtraRequestErrorRateDescription": "Tempo wyliczone z przyrostu licznika błędów żądań.",
|
||||
"overviewExtraCacheHitRate": "Udział trafień cache",
|
||||
"overviewExtraCacheHitRateDescription": "Udział HIT w nowych trafieniach i chybieniach; bez BYPASS.",
|
||||
"overviewExtraCacheMemory": "Wykorzystanie pamięci HOT cache",
|
||||
"overviewExtraCacheMemoryDescription": "Pamięć rezydentna cache względem limitu HOT RAM, gdy jest dostępny.",
|
||||
"overviewExtraH2Share": "Udział żądań HTTP/2",
|
||||
"overviewExtraH2ShareDescription": "Udział H2 w nowych żądaniach przychodzących, nie negocjacja ALPN upstream.",
|
||||
"overviewExtraResponseLatency": "Średni czas odpowiedzi",
|
||||
"overviewExtraResponseLatencyDescription": "Średni czas odpowiedzi ze statusu API (nie p95/p99).",
|
||||
"overviewExtraBackendUnhealthy": "Historia niedostępnych backendów",
|
||||
"overviewExtraBackendUnhealthyDescription": "Liczba backendów w stanie down; bez dodatkowych sond.",
|
||||
"overviewExtraFdLimit": "Limit deskryptorów",
|
||||
"overviewExtraRecentSamples": "Ostatnie próbki",
|
||||
"overviewBlockTypeValue": "Wartość liczbowa",
|
||||
"overviewBlockTypeValueChart": "Liczba + wykres",
|
||||
"overviewBlockTypeChart": "Wykres",
|
||||
"overviewBlockTypePanel": "Panel diagnostyczny",
|
||||
"overviewBlockTypeTable": "Tabela",
|
||||
"overviewBlockTypeAll": "Wszystkie typy",
|
||||
"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.",
|
||||
"overviewLayoutInlineDrag": "Przeciągnij, aby przesunąć",
|
||||
"overviewLayoutInlineSave": "Zapisz i zablokuj",
|
||||
"overviewLayoutInlineCancel": "Anuluj zmiany",
|
||||
"overviewLayoutInlineError": "Nie udało się zapisać układu.",
|
||||
"themeUltraDark": "Ultra ciemny · niemal czarny",
|
||||
"themeCream": "Kremowy · stonowany jasny",
|
||||
"overviewLayoutInlineHide": "Usuń z widoku Dashboardu",
|
||||
"overviewLayoutInlineHidden": "Ukryte bloki",
|
||||
"overviewLayoutInlineRestore": "Przywróć na Dashboard",
|
||||
"overviewLayoutInlineHiddenEmpty": "Brak ukrytych bloków",
|
||||
"overviewExtraTotalRps": "Żądania HTTP / s",
|
||||
"overviewExtraTotalRpsDetail": "Tempo wszystkich żądań HTTP, liczone z liczników.",
|
||||
"overviewExtraActiveExchanges": "Aktywne wymiany HTTP",
|
||||
"overviewExtraActiveExchangesDetail": "Żądania aktualnie obsługiwane.",
|
||||
"overviewExtraH1Share": "Udział HTTP/1.1",
|
||||
"overviewExtraH1ShareDetail": "Udział H1 w nowych żądaniach HTTP.",
|
||||
"overviewExtraH3Share": "Udział HTTP/3",
|
||||
"overviewExtraH3ShareDetail": "Udział H3 w nowych żądaniach HTTP.",
|
||||
"overviewExtraCacheMissRate": "Cache MISS / s",
|
||||
"overviewExtraCacheMissRateDetail": "Tempo nietrafień w cache.",
|
||||
"overviewExtraBackendActive": "Żądania do backendów",
|
||||
"overviewExtraBackendActiveDetail": "Suma aktualnych żądań do backendów HTTP.",
|
||||
"overviewWideProtocolRates": "Ruch HTTP/1.1 · HTTP/2 · HTTP/3",
|
||||
"overviewWideProtocolRatesDetail": "Tempo żądań według protokołu frontendu.",
|
||||
"overviewWideCpu": "CPU systemu i procesu",
|
||||
"overviewWideCpuDetail": "Procent obciążenia CPU systemu i RustProxy.",
|
||||
"overviewWideMemory": "RAM procesu i pamięć cache",
|
||||
"overviewWideMemoryDetail": "Zużycie pamięci RSS procesu i rezydentnej pamięci cache.",
|
||||
"overviewWideCacheActivity": "Cache HIT / MISS na sekundę",
|
||||
"overviewWideCacheActivityDetail": "Tempo trafień i nietrafień w cache.",
|
||||
"overviewWideConnections": "Połączenia i tunele WebSocket",
|
||||
"overviewWideConnectionsDetail": "Otwarte połączenia oraz aktywne WebSockety.",
|
||||
"overviewWideBackendHealth": "Dostępność backendów w czasie",
|
||||
"overviewWideBackendHealthDetail": "Liczba dostępnych i niedostępnych backendów HTTP.",
|
||||
"overviewWideLatency": "Czas odpowiedzi i sond backendów",
|
||||
"overviewWideLatencyDetail": "Średni czas odpowiedzi HTTP oraz średni czas udanych sond.",
|
||||
"overviewWideSystem": "System",
|
||||
"overviewWideProcess": "Proces",
|
||||
"overviewWideCache": "Cache",
|
||||
"overviewWideHits": "HIT",
|
||||
"overviewWideMisses": "MISS",
|
||||
"overviewWideOpen": "Połączenia",
|
||||
"overviewWideWebsockets": "WebSockety",
|
||||
"overviewWideHealthy": "Dostępne",
|
||||
"overviewWideUnhealthy": "Niedostępne",
|
||||
"overviewWideResponse": "HTTP",
|
||||
"overviewWideProbe": "Sondy",
|
||||
"overviewWideNoData": "Brak próbek",
|
||||
"cornerStyle": "Zaokrąglenia interfejsu",
|
||||
"cornerStyleDefault": "Oryginalne (bez zmian)",
|
||||
"cornerStyleSquare": "Kanciaste",
|
||||
"cornerStyleSubtle": "Delikatne",
|
||||
"cornerStyleRounded": "Zaokrąglone",
|
||||
"cornerStyleExtra": "Mocno zaokrąglone",
|
||||
"cornerStyleHelp": "Ustawia zaokrąglenia w całym panelu niezależnie od motywu. Oryginalne zachowują dotychczasowy wygląd."
|
||||
}
|
||||
}
|
||||
+204
-11
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.32.2",
|
||||
"version": "0.32.3",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
@@ -1988,9 +1988,11 @@
|
||||
"enum": [
|
||||
"http1",
|
||||
"http2",
|
||||
"auto"
|
||||
"auto",
|
||||
"h2"
|
||||
],
|
||||
"default": "http1"
|
||||
"default": "http1",
|
||||
"description": "http1: HTTP/1.1 only; auto: negotiate H2/H1 using TLS ALPN (H1 on plaintext); http2: backward-compatible TLS ALPN with H1 fallback or plaintext h2c; h2: strict HTTP/2 over TLS, or h2c on plaintext, with no HTTP/1.1 downgrade."
|
||||
},
|
||||
"address_allowlist": {
|
||||
"type": "array",
|
||||
@@ -3303,10 +3305,12 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dark",
|
||||
"light"
|
||||
"ultra_dark",
|
||||
"light",
|
||||
"cream"
|
||||
],
|
||||
"default": "dark",
|
||||
"description": "Account-scoped administration interface theme. Existing accounts and omitted values default to the current dark theme."
|
||||
"description": "Account-scoped administration interface theme (dark, ultra_dark, light or cream). Existing accounts and omitted values continue to default to dark."
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
@@ -3355,6 +3359,18 @@
|
||||
"default": "normal",
|
||||
"description": "Account-scoped administration button sizing. Normal uses the refreshed controls; Compact restores the smaller source-style button dimensions."
|
||||
},
|
||||
"corner_style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"default",
|
||||
"square",
|
||||
"subtle",
|
||||
"rounded",
|
||||
"extra"
|
||||
],
|
||||
"default": "default",
|
||||
"description": "Account-scoped global corner-rounding preset. Default preserves original geometry for every theme."
|
||||
},
|
||||
"table_density": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -3378,7 +3394,7 @@
|
||||
"dashboard_widgets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 16,
|
||||
"maxItems": 62,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
@@ -3398,7 +3414,53 @@
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health"
|
||||
"stream_backend_health",
|
||||
"backend_health",
|
||||
"backend_activity",
|
||||
"backend_errors",
|
||||
"backend_latency",
|
||||
"backend_traffic",
|
||||
"backend_protocols",
|
||||
"backend_requests",
|
||||
"backend_capacity",
|
||||
"backend_http5xx",
|
||||
"backend_timeouts",
|
||||
"backend_network",
|
||||
"backend_retries",
|
||||
"backend_queue",
|
||||
"backend_rotations",
|
||||
"extra_process_cpu",
|
||||
"extra_process_rss",
|
||||
"extra_process_fds",
|
||||
"extra_open_connections",
|
||||
"extra_websocket_active",
|
||||
"extra_tls_error_rate",
|
||||
"extra_request_error_rate",
|
||||
"extra_cache_hit_rate",
|
||||
"extra_cache_memory",
|
||||
"extra_h2_share",
|
||||
"extra_response_latency",
|
||||
"extra_backend_unhealthy",
|
||||
"extra_total_rps",
|
||||
"extra_active_exchanges",
|
||||
"extra_h1_share",
|
||||
"extra_h3_share",
|
||||
"extra_cache_miss_rate",
|
||||
"extra_backend_active",
|
||||
"wide_protocol_rates",
|
||||
"wide_cpu",
|
||||
"wide_memory",
|
||||
"wide_cache_activity",
|
||||
"wide_connections",
|
||||
"wide_backend_health",
|
||||
"wide_latency",
|
||||
"traffic_chart",
|
||||
"system_chart",
|
||||
"connection_chart",
|
||||
"cache_diagnostics",
|
||||
"process_diagnostics",
|
||||
"network_diagnostics",
|
||||
"backend_table"
|
||||
]
|
||||
},
|
||||
"default": [
|
||||
@@ -3417,13 +3479,59 @@
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health"
|
||||
"stream_backend_health",
|
||||
"backend_health",
|
||||
"backend_activity",
|
||||
"backend_errors",
|
||||
"backend_latency",
|
||||
"backend_traffic",
|
||||
"backend_protocols",
|
||||
"backend_requests",
|
||||
"backend_capacity",
|
||||
"backend_http5xx",
|
||||
"backend_timeouts",
|
||||
"backend_network",
|
||||
"backend_retries",
|
||||
"backend_queue",
|
||||
"backend_rotations",
|
||||
"extra_process_cpu",
|
||||
"extra_process_rss",
|
||||
"extra_process_fds",
|
||||
"extra_open_connections",
|
||||
"extra_websocket_active",
|
||||
"extra_tls_error_rate",
|
||||
"extra_request_error_rate",
|
||||
"extra_cache_hit_rate",
|
||||
"extra_cache_memory",
|
||||
"extra_h2_share",
|
||||
"extra_response_latency",
|
||||
"extra_backend_unhealthy",
|
||||
"extra_total_rps",
|
||||
"extra_active_exchanges",
|
||||
"extra_h1_share",
|
||||
"extra_h3_share",
|
||||
"extra_cache_miss_rate",
|
||||
"extra_backend_active",
|
||||
"wide_protocol_rates",
|
||||
"wide_cpu",
|
||||
"wide_memory",
|
||||
"wide_cache_activity",
|
||||
"wide_connections",
|
||||
"wide_backend_health",
|
||||
"wide_latency",
|
||||
"traffic_chart",
|
||||
"system_chart",
|
||||
"connection_chart",
|
||||
"cache_diagnostics",
|
||||
"process_diagnostics",
|
||||
"network_diagnostics",
|
||||
"backend_table"
|
||||
],
|
||||
"description": "Account-scoped order for configurable Overview telemetry cards."
|
||||
},
|
||||
"dashboard_hidden_widgets": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"maxItems": 62,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
@@ -3443,14 +3551,99 @@
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health"
|
||||
"stream_backend_health",
|
||||
"backend_health",
|
||||
"backend_activity",
|
||||
"backend_errors",
|
||||
"backend_latency",
|
||||
"backend_traffic",
|
||||
"backend_protocols",
|
||||
"backend_requests",
|
||||
"backend_capacity",
|
||||
"backend_http5xx",
|
||||
"backend_timeouts",
|
||||
"backend_network",
|
||||
"backend_retries",
|
||||
"backend_queue",
|
||||
"backend_rotations",
|
||||
"extra_process_cpu",
|
||||
"extra_process_rss",
|
||||
"extra_process_fds",
|
||||
"extra_open_connections",
|
||||
"extra_websocket_active",
|
||||
"extra_tls_error_rate",
|
||||
"extra_request_error_rate",
|
||||
"extra_cache_hit_rate",
|
||||
"extra_cache_memory",
|
||||
"extra_h2_share",
|
||||
"extra_response_latency",
|
||||
"extra_backend_unhealthy",
|
||||
"extra_total_rps",
|
||||
"extra_active_exchanges",
|
||||
"extra_h1_share",
|
||||
"extra_h3_share",
|
||||
"extra_cache_miss_rate",
|
||||
"extra_backend_active",
|
||||
"wide_protocol_rates",
|
||||
"wide_cpu",
|
||||
"wide_memory",
|
||||
"wide_cache_activity",
|
||||
"wide_connections",
|
||||
"wide_backend_health",
|
||||
"wide_latency",
|
||||
"traffic_chart",
|
||||
"system_chart",
|
||||
"connection_chart",
|
||||
"cache_diagnostics",
|
||||
"process_diagnostics",
|
||||
"network_diagnostics",
|
||||
"backend_table"
|
||||
]
|
||||
},
|
||||
"default": [
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health"
|
||||
"stream_backend_health",
|
||||
"backend_health",
|
||||
"backend_activity",
|
||||
"backend_errors",
|
||||
"backend_latency",
|
||||
"backend_traffic",
|
||||
"backend_protocols",
|
||||
"backend_requests",
|
||||
"backend_capacity",
|
||||
"backend_http5xx",
|
||||
"backend_timeouts",
|
||||
"backend_network",
|
||||
"backend_retries",
|
||||
"backend_queue",
|
||||
"backend_rotations",
|
||||
"extra_process_cpu",
|
||||
"extra_process_rss",
|
||||
"extra_process_fds",
|
||||
"extra_open_connections",
|
||||
"extra_websocket_active",
|
||||
"extra_tls_error_rate",
|
||||
"extra_request_error_rate",
|
||||
"extra_cache_hit_rate",
|
||||
"extra_cache_memory",
|
||||
"extra_h2_share",
|
||||
"extra_response_latency",
|
||||
"extra_backend_unhealthy",
|
||||
"extra_total_rps",
|
||||
"extra_active_exchanges",
|
||||
"extra_h1_share",
|
||||
"extra_h3_share",
|
||||
"extra_cache_miss_rate",
|
||||
"extra_backend_active",
|
||||
"wide_protocol_rates",
|
||||
"wide_cpu",
|
||||
"wide_memory",
|
||||
"wide_cache_activity",
|
||||
"wide_connections",
|
||||
"wide_backend_health",
|
||||
"wide_latency"
|
||||
],
|
||||
"description": "Account-scoped Overview telemetry cards hidden from the dashboard."
|
||||
},
|
||||
|
||||
+57
-22
@@ -18,6 +18,9 @@ fn default_admin_chart_style() -> String {
|
||||
fn default_admin_button_style() -> String {
|
||||
"normal".to_owned()
|
||||
}
|
||||
fn default_admin_corner_style() -> String {
|
||||
"default".to_owned()
|
||||
}
|
||||
fn default_admin_table_density() -> String {
|
||||
"normal".to_owned()
|
||||
}
|
||||
@@ -27,29 +30,34 @@ fn default_admin_dashboard_density() -> String {
|
||||
fn default_admin_dashboard_widgets() -> Vec<String> {
|
||||
DASHBOARD_WIDGETS
|
||||
.iter()
|
||||
.map(|value| (*value).to_owned())
|
||||
.map(|widget| widget.id.clone())
|
||||
.collect()
|
||||
}
|
||||
const STREAM_DASHBOARD_WIDGETS: &[&str] = &[
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health",
|
||||
];
|
||||
fn default_hidden_dashboard_widgets() -> Vec<String> {
|
||||
STREAM_DASHBOARD_WIDGETS
|
||||
DASHBOARD_WIDGETS
|
||||
.iter()
|
||||
.map(|id| (*id).to_owned())
|
||||
.filter(|widget| widget.default_hidden)
|
||||
.map(|widget| widget.id.clone())
|
||||
.collect()
|
||||
}
|
||||
// Old accounts stored a complete 12-card order and often an explicit empty
|
||||
// hidden list. Upgrade it in memory: append opt-in Stream cards as hidden.
|
||||
fn migrate_dashboard_widgets(prefs: &mut AdminPreferences) {
|
||||
for id in STREAM_DASHBOARD_WIDGETS {
|
||||
if !prefs.dashboard_widgets.iter().any(|item| item == id) {
|
||||
prefs.dashboard_widgets.push((*id).to_owned());
|
||||
if !prefs.dashboard_hidden_widgets.iter().any(|item| item == id) {
|
||||
prefs.dashboard_hidden_widgets.push((*id).to_owned());
|
||||
|
||||
// Preserve the user's saved order/visibility, drop identifiers unknown to this
|
||||
// binary, and append new registry widgets according to their opt-in policy.
|
||||
// Stored preferences from older/newer releases are normalized on read.
|
||||
fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
prefs.dashboard_widgets.retain(|id| {
|
||||
DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen.insert(id.clone())
|
||||
});
|
||||
let mut seen_hidden = std::collections::HashSet::new();
|
||||
prefs.dashboard_hidden_widgets.retain(|id| {
|
||||
DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id) && seen_hidden.insert(id.clone())
|
||||
});
|
||||
for widget in DASHBOARD_WIDGETS.iter() {
|
||||
if !seen.contains(&widget.id) {
|
||||
prefs.dashboard_widgets.push(widget.id.clone());
|
||||
if widget.default_hidden && seen_hidden.insert(widget.id.clone()) {
|
||||
prefs.dashboard_hidden_widgets.push(widget.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +128,8 @@ struct AdminPreferences {
|
||||
chart_style: String,
|
||||
#[serde(default = "default_admin_button_style")]
|
||||
button_style: String,
|
||||
#[serde(default = "default_admin_corner_style")]
|
||||
corner_style: String,
|
||||
#[serde(default = "default_admin_table_density")]
|
||||
table_density: String,
|
||||
#[serde(default = "default_admin_dashboard_density")]
|
||||
@@ -176,6 +186,7 @@ impl Default for AdminPreferences {
|
||||
time_format: default_admin_time_format(),
|
||||
chart_style: default_admin_chart_style(),
|
||||
button_style: default_admin_button_style(),
|
||||
corner_style: default_admin_corner_style(),
|
||||
table_density: default_admin_table_density(),
|
||||
dashboard_density: default_admin_dashboard_density(),
|
||||
dashboard_widgets: default_admin_dashboard_widgets(),
|
||||
@@ -246,7 +257,10 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
|
||||
"invalid stored-log sort column preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(preferences.theme.as_str(), "dark" | "light") {
|
||||
if !matches!(
|
||||
preferences.theme.as_str(),
|
||||
"dark" | "ultra_dark" | "light" | "cream"
|
||||
) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration theme preference".into(),
|
||||
@@ -293,6 +307,15 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
|
||||
"invalid administration chart style preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
preferences.corner_style.as_str(),
|
||||
"default" | "square" | "subtle" | "rounded" | "extra"
|
||||
) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration corner style preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(preferences.button_style.as_str(), "normal" | "compact") {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
@@ -327,7 +350,9 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for widget in &preferences.dashboard_widgets {
|
||||
if !DASHBOARD_WIDGETS.contains(&widget.as_str()) || !seen.insert(widget.as_str()) {
|
||||
if !DASHBOARD_WIDGETS.iter().any(|entry| entry.id == *widget)
|
||||
|| !seen.insert(widget.as_str())
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration dashboard widget order".into(),
|
||||
@@ -342,7 +367,9 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for widget in &preferences.dashboard_hidden_widgets {
|
||||
if !DASHBOARD_WIDGETS.contains(&widget.as_str()) || !seen.insert(widget.as_str()) {
|
||||
if !DASHBOARD_WIDGETS.iter().any(|entry| entry.id == *widget)
|
||||
|| !seen.insert(widget.as_str())
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration hidden dashboard widgets".into(),
|
||||
@@ -418,7 +445,7 @@ async fn preferences(
|
||||
let preferences = match stored {
|
||||
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
|
||||
Ok(mut preferences) => {
|
||||
migrate_dashboard_widgets(&mut preferences);
|
||||
reconcile_dashboard_widgets(&mut preferences);
|
||||
if validate_admin_preferences(&preferences).is_ok() {
|
||||
preferences
|
||||
} else {
|
||||
@@ -441,7 +468,15 @@ async fn update_preferences(
|
||||
Extension(session): Extension<Session>,
|
||||
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<AdminPreferences>, ApiError> {
|
||||
let Json(preferences) = input.map_err(public::invalid_json)?;
|
||||
let Json(mut preferences) = input.map_err(public::invalid_json)?;
|
||||
// Do not rewrite the caller's saved order on PUT; drop only stale IDs.
|
||||
// Legacy partial lists are expanded on the next GET, as before.
|
||||
preferences
|
||||
.dashboard_widgets
|
||||
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
|
||||
preferences
|
||||
.dashboard_hidden_widgets
|
||||
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
|
||||
validate_admin_preferences(&preferences)?;
|
||||
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
|
||||
if !state
|
||||
|
||||
@@ -75,21 +75,15 @@ const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"maxConnections",
|
||||
"idleTimeout",
|
||||
];
|
||||
const DASHBOARD_WIDGETS: &[&str] = &[
|
||||
"requests",
|
||||
"active",
|
||||
"bandwidth",
|
||||
"errors",
|
||||
"cache",
|
||||
"memory",
|
||||
"websocket",
|
||||
"http2",
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol",
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health",
|
||||
];
|
||||
// The same JSON registry is embedded into the authenticated admin JS bundle.
|
||||
// No independent allowlist is maintained by the browser or backend.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DashboardWidgetDefinition {
|
||||
id: String,
|
||||
default_hidden: bool,
|
||||
}
|
||||
static DASHBOARD_WIDGETS: std::sync::LazyLock<Vec<DashboardWidgetDefinition>> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("web/dashboard-widgets.json"))
|
||||
.expect("invalid bundled Overview Cards registry")
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
#[test]
|
||||
fn overview_registry_is_single_source_of_identifiers_and_visibility_defaults() {
|
||||
let order = default_admin_dashboard_widgets();
|
||||
let hidden = default_hidden_dashboard_widgets();
|
||||
let unique: std::collections::HashSet<&str> = order.iter().map(String::as_str).collect();
|
||||
assert_eq!(unique.len(), order.len());
|
||||
assert_eq!(order.first().map(String::as_str), Some("requests"));
|
||||
assert!(order.contains(&"http3".to_owned()));
|
||||
assert!(hidden.contains(&"stream_overview".to_owned()));
|
||||
assert!(hidden.contains(&"stream_backend_health".to_owned()));
|
||||
for id in [
|
||||
"backend_health",
|
||||
"backend_activity",
|
||||
"backend_errors",
|
||||
"backend_latency",
|
||||
"backend_traffic",
|
||||
"backend_protocols",
|
||||
"backend_requests",
|
||||
"backend_capacity",
|
||||
"backend_http5xx",
|
||||
"backend_timeouts",
|
||||
"backend_network",
|
||||
"backend_retries",
|
||||
"backend_queue",
|
||||
"backend_rotations",
|
||||
] {
|
||||
assert!(order.contains(&id.to_owned()));
|
||||
assert!(hidden.contains(&id.to_owned()));
|
||||
}
|
||||
for id in [
|
||||
"extra_process_cpu",
|
||||
"extra_process_rss",
|
||||
"extra_process_fds",
|
||||
"extra_open_connections",
|
||||
"extra_websocket_active",
|
||||
"extra_tls_error_rate",
|
||||
"extra_request_error_rate",
|
||||
"extra_cache_hit_rate",
|
||||
"extra_cache_memory",
|
||||
"extra_h2_share",
|
||||
"extra_response_latency",
|
||||
"extra_backend_unhealthy",
|
||||
] {
|
||||
assert!(order.contains(&id.to_owned()));
|
||||
assert!(hidden.contains(&id.to_owned()));
|
||||
}
|
||||
for id in [
|
||||
"traffic_chart",
|
||||
"system_chart",
|
||||
"connection_chart",
|
||||
"cache_diagnostics",
|
||||
"process_diagnostics",
|
||||
"network_diagnostics",
|
||||
"backend_table",
|
||||
] {
|
||||
assert!(order.contains(&id.to_owned()));
|
||||
assert!(!hidden.contains(&id.to_owned()));
|
||||
}
|
||||
assert!(!hidden.contains(&"requests".to_owned()));
|
||||
assert!(hidden.iter().all(|id| order.contains(id)));
|
||||
assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_themes_include_optional_black_and_cream_without_changing_default() {
|
||||
let mut preferences = AdminPreferences::default();
|
||||
assert_eq!(preferences.theme, "dark");
|
||||
for theme in ["dark", "ultra_dark", "light", "cream"] {
|
||||
preferences.theme = theme.to_owned();
|
||||
assert!(validate_admin_preferences(&preferences).is_ok(), "{theme}");
|
||||
}
|
||||
preferences.theme = "sepia".to_owned();
|
||||
assert!(validate_admin_preferences(&preferences).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_style_defaults_and_rejects_unknown_presets() {
|
||||
let mut prefs = AdminPreferences::default();
|
||||
assert_eq!(prefs.corner_style, "default");
|
||||
for preset in ["default", "square", "subtle", "rounded", "extra"] {
|
||||
prefs.corner_style = preset.to_string();
|
||||
assert!(validate_admin_preferences(&prefs).is_ok(), "{preset}");
|
||||
}
|
||||
prefs.corner_style = "oversized".to_string();
|
||||
assert!(validate_admin_preferences(&prefs).is_err());
|
||||
let old_json = r#"{"theme":"ultra_dark"}"#;
|
||||
let old: AdminPreferences = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(old.corner_style, "default");
|
||||
assert!(validate_admin_preferences(&old).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() {
|
||||
let mut prefs = AdminPreferences::default();
|
||||
prefs.dashboard_widgets = vec![
|
||||
"errors".into(),
|
||||
"from_future_release".into(),
|
||||
"requests".into(),
|
||||
"errors".into(),
|
||||
];
|
||||
prefs.dashboard_hidden_widgets = vec![
|
||||
"errors".into(),
|
||||
"from_future_release".into(),
|
||||
"errors".into(),
|
||||
];
|
||||
reconcile_dashboard_widgets(&mut prefs);
|
||||
assert_eq!(prefs.dashboard_widgets[0], "errors");
|
||||
assert_eq!(prefs.dashboard_widgets[1], "requests");
|
||||
assert!(!prefs
|
||||
.dashboard_widgets
|
||||
.contains(&"from_future_release".into()));
|
||||
assert!(!prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"from_future_release".into()));
|
||||
assert_eq!(
|
||||
prefs
|
||||
.dashboard_hidden_widgets
|
||||
.iter()
|
||||
.filter(|id| *id == "errors")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"stream_overview".into()));
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"stream_backend_health".into()));
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"backend_health".into()));
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"backend_protocols".into()));
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"backend_requests".into()));
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"backend_capacity".into()));
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"extra_process_cpu".into()));
|
||||
assert!(prefs.dashboard_widgets.contains(&"backend_table".into()));
|
||||
assert!(!prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"backend_table".into()));
|
||||
for id in [
|
||||
"backend_http5xx",
|
||||
"backend_timeouts",
|
||||
"backend_network",
|
||||
"backend_retries",
|
||||
"backend_queue",
|
||||
"backend_rotations",
|
||||
] {
|
||||
assert!(prefs.dashboard_hidden_widgets.contains(&id.into()));
|
||||
}
|
||||
assert!(validate_admin_preferences(&prefs).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_visible_stream_opt_in_is_not_reset_when_normalizing_saved_order() {
|
||||
let mut prefs = AdminPreferences::default();
|
||||
prefs.dashboard_widgets = default_admin_dashboard_widgets();
|
||||
prefs.dashboard_hidden_widgets.clear();
|
||||
reconcile_dashboard_widgets(&mut prefs);
|
||||
assert!(prefs.dashboard_hidden_widgets.is_empty());
|
||||
assert_eq!(prefs.dashboard_widgets, default_admin_dashboard_widgets());
|
||||
assert!(validate_admin_preferences(&prefs).is_ok());
|
||||
}
|
||||
@@ -56,8 +56,17 @@ fn admin_app_javascript() -> &'static str {
|
||||
include_str!("web/app/core.js"),
|
||||
include_str!("web/app/session-resume.js"),
|
||||
include_str!("web/app/refresh.js"),
|
||||
"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(",
|
||||
include_str!("web/dashboard-widgets.json"),
|
||||
");",
|
||||
include_str!("web/app/preferences.js"),
|
||||
include_str!("web/app/dashboard-layout-editor.js"),
|
||||
include_str!("web/app/dashboard-inline-layout.js"),
|
||||
include_str!("web/app/stream-dashboard.js"),
|
||||
include_str!("web/app/backend-dashboard.js"),
|
||||
include_str!("web/app/overview-extra-metrics.js"),
|
||||
include_str!("web/app/overview-wide-metrics.js"),
|
||||
include_str!("web/app/overview-masonry.js"),
|
||||
include_str!("web/app/config-history.js"),
|
||||
include_str!("web/app/runtime.js"),
|
||||
include_str!("web/app/settings.js"),
|
||||
@@ -89,6 +98,7 @@ fn admin_vhosts_javascript() -> &'static str {
|
||||
[
|
||||
"(() => {",
|
||||
include_str!("web/vhosts/core.js"),
|
||||
include_str!("web/vhosts/protocol.js"),
|
||||
include_str!("web/vhosts/health.js"),
|
||||
include_str!("web/vhosts/rates.js"),
|
||||
include_str!("web/vhosts/editor.js"),
|
||||
|
||||
@@ -1673,6 +1673,7 @@ mod tests {
|
||||
assert_eq!(initial["time_format"], "24h");
|
||||
assert_eq!(initial["chart_style"], "default");
|
||||
assert_eq!(initial["button_style"], "normal");
|
||||
assert_eq!(initial["corner_style"], "default");
|
||||
assert_eq!(
|
||||
initial["file_log_columns"].as_array().unwrap().len(),
|
||||
default_file_log_columns().len()
|
||||
@@ -1708,6 +1709,7 @@ mod tests {
|
||||
assert_eq!(legacy["time_format"], "24h");
|
||||
assert_eq!(legacy["chart_style"], "default");
|
||||
assert_eq!(legacy["button_style"], "normal");
|
||||
assert_eq!(legacy["corner_style"], "default");
|
||||
assert_eq!(
|
||||
legacy["file_log_columns"],
|
||||
json!(default_file_log_columns())
|
||||
@@ -1728,6 +1730,7 @@ mod tests {
|
||||
"time_format":"12h",
|
||||
"chart_style":"neon",
|
||||
"button_style":"compact",
|
||||
"corner_style":"rounded",
|
||||
"table_density":"ultra",
|
||||
"dashboard_density":"ultra",
|
||||
"dashboard_widgets":["errors","requests","active","bandwidth","cache","memory","websocket","http2","http3","tls","http_protocol","websocket_protocol"],
|
||||
@@ -1776,6 +1779,20 @@ mod tests {
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health",
|
||||
"backend_health",
|
||||
"backend_activity",
|
||||
"backend_errors",
|
||||
"backend_latency",
|
||||
"backend_traffic",
|
||||
"backend_protocols",
|
||||
"backend_requests",
|
||||
"backend_capacity",
|
||||
"backend_http5xx",
|
||||
"backend_timeouts",
|
||||
"backend_network",
|
||||
"backend_retries",
|
||||
"backend_queue",
|
||||
"backend_rotations",
|
||||
] {
|
||||
upgraded["dashboard_widgets"]
|
||||
.as_array_mut()
|
||||
@@ -3337,4 +3354,5 @@ mod tests {
|
||||
assert_eq!(result.status(), StatusCode::FORBIDDEN);
|
||||
store.close().await;
|
||||
}
|
||||
include!("preferences_tests.rs");
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Optional HTTP origin diagnostics. Reuse status.backend_pools already present
|
||||
// in live SSE/polling; never issue one request per tile or per member.
|
||||
window.rustProxyBackendDashboard = (() => {
|
||||
const ids = ["backend_health", "backend_activity", "backend_errors", "backend_latency", "backend_traffic", "backend_protocols", "backend_requests", "backend_capacity", "backend_http5xx", "backend_timeouts", "backend_network", "backend_retries", "backend_queue", "backend_rotations"];
|
||||
const charts = new Set(["backend_activity", "backend_errors", "backend_latency", "backend_traffic", "backend_requests", "backend_capacity", "backend_http5xx", "backend_timeouts", "backend_network", "backend_retries", "backend_queue"]);
|
||||
const samples = { active: [], errors: [], latency: [], inbound: [], outbound: [], requests: [], capacity: [], http5xx: [], timeouts: [], network: [], retries: [], queue: [] };
|
||||
const tracked = ["requests", "errors", "inbound", "outbound", "http5xx", "timeouts", "network", "retries", "queue"];
|
||||
let previous = null;
|
||||
let previousRevision = null;
|
||||
const card = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
|
||||
const visible = id => card(id) && !card(id).hidden;
|
||||
const set = (id, value) => { const node = $(id); if (node) node.textContent = String(value); };
|
||||
const count = value => Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) : 0;
|
||||
const counter = value => Math.max(0, Math.trunc(count(value)));
|
||||
const shortRate = value => `${value.toFixed(1)}/s`;
|
||||
function reset() {
|
||||
previous = null;
|
||||
previousRevision = null;
|
||||
for (const series of Object.values(samples)) series.length = 0;
|
||||
}
|
||||
function syncVisibility(hiddenOverride = null) {
|
||||
const prefs = window.rustProxyPreferences?.current?.();
|
||||
const hidden = new Set(hiddenOverride ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
|
||||
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
|
||||
if (![...charts].some(visible)) reset();
|
||||
}
|
||||
function line(series, peak) {
|
||||
if (!series.length) return "0,55 300,55";
|
||||
return series.map((value, i) => `${i * 300 / Math.max(1, series.length - 1)},${55 - value / peak * 50}`).join(" ");
|
||||
}
|
||||
function paintSeries(name, id, scale) {
|
||||
const node = $(id);
|
||||
if (node) node.setAttribute("points", line(samples[name], scale));
|
||||
}
|
||||
function push(name, value) {
|
||||
samples[name].push(Math.max(0, value));
|
||||
if (samples[name].length > 30) samples[name].shift();
|
||||
}
|
||||
function unavailable() {
|
||||
for (const id of ids) {
|
||||
if (!visible(id)) continue;
|
||||
set(`backend-dashboard-${id.slice("backend_".length)}`, "-");
|
||||
set(`backend-dashboard-${id.slice("backend_".length)}-detail`, t("backendWidgetUnavailable"));
|
||||
}
|
||||
for (const id of ["backend-dashboard-in-rate", "backend-dashboard-out-rate"]) set(id, "-");
|
||||
for (const id of ["backend-dashboard-active-line", "backend-dashboard-error-line", "backend-dashboard-latency-line", "backend-dashboard-in-line", "backend-dashboard-out-line", "backend-dashboard-request-line", "backend-dashboard-capacity-line", "backend-dashboard-5xx-line", "backend-dashboard-timeout-line", "backend-dashboard-network-line", "backend-dashboard-retry-line", "backend-dashboard-queue-line"])
|
||||
$(id)?.setAttribute("points", "0,55 300,55");
|
||||
const bar = $("backend-mode-h1");
|
||||
if (bar) for (const item of bar.parentElement.children) item.style.flexGrow = "0";
|
||||
reset();
|
||||
}
|
||||
// Counter deltas are matched to stable (pool, member) IDs, not aggregate
|
||||
// totals: removed/rebuilt pools must never become negative or spike rates.
|
||||
function memberCounters(members) {
|
||||
const values = new Map();
|
||||
for (const { pool, member } of members) {
|
||||
const key = JSON.stringify([pool.id, member.id]);
|
||||
values.set(key, {
|
||||
requests: counter(member.requests), errors: counter(member.errors),
|
||||
inbound: counter(member.bytes_in), outbound: counter(member.bytes_out),
|
||||
http5xx: counter(member.http_5xx_total), timeouts: counter(member.timeouts_total),
|
||||
network: ["connect_errors_total", "dns_errors_total", "tls_errors_total", "io_errors_total"].reduce((n, key) => n + counter(member[key]), 0),
|
||||
retries: counter(member.retry_attempts_total),
|
||||
queue: counter(member.queue_timeouts_total) + counter(member.capacity_rejections_total),
|
||||
});
|
||||
}
|
||||
return values;
|
||||
}
|
||||
function deltas(current, earlier) {
|
||||
const total = Object.fromEntries(tracked.map(field => [field, 0]));
|
||||
if (!earlier) return total;
|
||||
for (const [id, next] of current) {
|
||||
const old = earlier.get(id);
|
||||
if (!old) continue;
|
||||
// A generation reset on a member invalidates its entire interval.
|
||||
if (tracked.some(field => next[field] < old[field])) continue;
|
||||
for (const field of tracked) total[field] += next[field] - old[field];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function render(status, hiddenOverride = null, recordSample = true) {
|
||||
syncVisibility(hiddenOverride);
|
||||
if (!status || currentPanel !== "overview" || ids.every(id => !visible(id))) return;
|
||||
if (!Array.isArray(status.backend_pools)) { unavailable(); return; }
|
||||
const members = status.backend_pools.flatMap(pool => (pool.members || []).map(member => ({ pool, member })));
|
||||
if (!members.length) { unavailable(); return; }
|
||||
|
||||
if (visible("backend_health")) {
|
||||
const statuses = { up: 0, down: 0, checking: 0, draining: 0, disabled: 0 };
|
||||
for (const { member } of members) if (Object.hasOwn(statuses, member.status)) statuses[member.status]++;
|
||||
const eligible = statuses.up + statuses.down + statuses.checking;
|
||||
set("backend-dashboard-health", `${statuses.up} / ${eligible}`);
|
||||
set("backend-dashboard-health-detail", `${t("backendWidgetDown")}: ${statuses.down} · ${t("backendWidgetChecking")}: ${statuses.checking} · ${t("backendWidgetOther")}: ${statuses.draining + statuses.disabled}`);
|
||||
}
|
||||
if (visible("backend_protocols")) {
|
||||
const modes = { http1: 0, auto: 0, http2: 0, h2: 0, unknown: 0 };
|
||||
let h2c = 0;
|
||||
for (const { member } of members) {
|
||||
const mode = member.protocol;
|
||||
if (Object.hasOwn(modes, mode)) modes[mode]++;
|
||||
else modes.unknown++;
|
||||
if ((mode === "http2" || mode === "h2") && /^http:\/\//i.test(member.url || "")) h2c++;
|
||||
}
|
||||
set("backend-dashboard-protocols", fmt(members.length));
|
||||
set("backend-dashboard-protocols-detail", `H1 ${modes.http1} · Auto ${modes.auto} · ${t("backendWidgetModeCompat")} ${modes.http2} · ${t("backendWidgetModeStrict")} ${modes.h2} · ${t("backendWidgetH2c")}: ${h2c}${modes.unknown ? ` · ${t("backendWidgetModeUnknown")}: ${modes.unknown}` : ""}`);
|
||||
const bar = $("backend-mode-h1")?.parentElement;
|
||||
if (bar) {
|
||||
for (const [name, count] of [["h1", modes.http1], ["auto", modes.auto], ["compat", modes.http2], ["h2", modes.h2]])
|
||||
$("backend-mode-" + name).style.flexGrow = String(count);
|
||||
bar.setAttribute("aria-label", `${t("backendWidgetProtocols")}: H1 ${modes.http1}, Auto ${modes.auto}, ${t("backendWidgetModeCompat")} ${modes.http2}, ${t("backendWidgetModeStrict")} ${modes.h2}`);
|
||||
}
|
||||
}
|
||||
if (visible("backend_rotations")) {
|
||||
const rotations = members.reduce((n, { member }) => n + counter(member.upstream_client_rotations_total), 0);
|
||||
const generation = Math.max(0, ...members.map(({ member }) => counter(member.upstream_client_generation)));
|
||||
const age = Math.max(0, ...members.map(({ member }) => counter(member.upstream_client_generation_age_seconds)));
|
||||
set("backend-dashboard-rotations", fmt(rotations));
|
||||
set("backend-dashboard-rotations-detail", `${t("backendWidgetPoolGeneration")}: ${fmt(generation)} · ${t("backendWidgetPoolAge")}: ${fmt(age)}s`);
|
||||
}
|
||||
if (![...charts].some(visible)) return;
|
||||
const active = members.reduce((total, { member }) => total + counter(member.active), 0);
|
||||
const probes = members.map(({ member }) => member).filter(member => member.status === "up" && member.health_mode !== "off" && count(member.last_probe_ms) > 0 && Number.isFinite(Number(member.latency_ms)));
|
||||
const latency = probes.length ? probes.reduce((total, member) => total + count(member.latency_ms), 0) / probes.length : null;
|
||||
const totalErrors = members.reduce((total, { member }) => total + counter(member.errors), 0);
|
||||
const totalRequests = members.reduce((total, { member }) => total + counter(member.requests), 0);
|
||||
const limited = members.map(({ member }) => member)
|
||||
.filter(member => counter(member.max_concurrent_requests) > 0);
|
||||
const capacityUsed = limited.reduce((sum, member) => sum + counter(member.active), 0);
|
||||
const capacityLimit = limited.reduce((sum, member) => sum + counter(member.max_concurrent_requests), 0);
|
||||
const capacityPercent = capacityLimit ? capacityUsed * 100 / capacityLimit : null;
|
||||
const capacityRejects = limited.reduce((sum, member) => sum + counter(member.capacity_rejections_total), 0);
|
||||
const revision = status.revision ?? null;
|
||||
if (previousRevision !== revision) { reset(); previousRevision = revision; }
|
||||
let requestsPerSecond = samples.requests.at(-1) ?? 0;
|
||||
let errorsPerSecond = samples.errors.at(-1) ?? 0;
|
||||
let inPerSecond = samples.inbound.at(-1) ?? 0;
|
||||
let outPerSecond = samples.outbound.at(-1) ?? 0;
|
||||
const additional = { http5xx: 0, timeouts: 0, network: 0, retries: 0, queue: 0 };
|
||||
let fraction = null;
|
||||
if (recordSample) {
|
||||
const now = performance.now();
|
||||
const next = memberCounters(members);
|
||||
const elapsed = previous ? (now - previous.when) / 1000 : 0;
|
||||
if (elapsed > 0) {
|
||||
const diff = deltas(next, previous.counters);
|
||||
requestsPerSecond = diff.requests / elapsed;
|
||||
errorsPerSecond = diff.errors / elapsed;
|
||||
inPerSecond = diff.inbound / elapsed;
|
||||
outPerSecond = diff.outbound / elapsed;
|
||||
fraction = diff.requests ? 100 * diff.errors / diff.requests : null;
|
||||
for (const name of Object.keys(additional)) additional[name] = diff[name] / elapsed;
|
||||
} else { requestsPerSecond = 0; errorsPerSecond = 0; inPerSecond = 0; outPerSecond = 0; }
|
||||
previous = { when: now, counters: next };
|
||||
if (visible("backend_activity")) push("active", active);
|
||||
if (visible("backend_requests")) push("requests", requestsPerSecond);
|
||||
if (visible("backend_capacity") && capacityPercent !== null) push("capacity", capacityPercent);
|
||||
if (visible("backend_errors")) push("errors", errorsPerSecond);
|
||||
if (visible("backend_latency") && latency !== null) push("latency", latency);
|
||||
if (visible("backend_traffic")) { push("inbound", inPerSecond); push("outbound", outPerSecond); }
|
||||
for (const name of Object.keys(additional)) if (visible(`backend_${name === "http5xx" ? "http5xx" : name}`)) push(name, additional[name]);
|
||||
}
|
||||
if (!recordSample) for (const name of Object.keys(additional)) additional[name] = samples[name].at(-1) ?? 0;
|
||||
if (visible("backend_activity")) {
|
||||
set("backend-dashboard-activity", fmt(active));
|
||||
set("backend-dashboard-activity-detail", t("backendWidgetInFlight"));
|
||||
paintSeries("active", "backend-dashboard-active-line", Math.max(1, ...samples.active));
|
||||
}
|
||||
if (visible("backend_requests")) {
|
||||
set("backend-dashboard-requests", shortRate(requestsPerSecond));
|
||||
set("backend-dashboard-requests-detail", `${t("backendWidgetRequestsTotal")}: ${fmt(totalRequests)}`);
|
||||
paintSeries("requests", "backend-dashboard-request-line", Math.max(1, ...samples.requests));
|
||||
}
|
||||
if (visible("backend_capacity")) {
|
||||
set("backend-dashboard-capacity", capacityPercent === null ? "-" : `${fmt(capacityPercent, 1)}%`);
|
||||
set("backend-dashboard-capacity-detail", capacityPercent === null ? t("backendWidgetCapacityNone") :
|
||||
`${t("backendWidgetCapacityLimits")}: ${limited.length} · ${capacityUsed}/${capacityLimit} · ${t("backendWidgetCapacityRejects")}: ${fmt(capacityRejects)}`);
|
||||
paintSeries("capacity", "backend-dashboard-capacity-line", Math.max(100, ...samples.capacity));
|
||||
}
|
||||
if (visible("backend_errors")) {
|
||||
set("backend-dashboard-errors", fmt(totalErrors));
|
||||
set("backend-dashboard-errors-detail", `${t("backendWidgetErrorsRate")}: ${shortRate(errorsPerSecond)}${fraction === null ? "" : ` · ${fraction.toFixed(1)}% ${t("backendWidgetErrorPercent")}`}`);
|
||||
paintSeries("errors", "backend-dashboard-error-line", Math.max(1, ...samples.errors));
|
||||
}
|
||||
if (visible("backend_latency")) {
|
||||
set("backend-dashboard-latency", latency === null ? "-" : `${fmt(latency, 1)} ms`);
|
||||
set("backend-dashboard-latency-detail", `${t("backendWidgetProbeCount")}: ${probes.length}`);
|
||||
paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency));
|
||||
}
|
||||
if (visible("backend_traffic")) {
|
||||
set("backend-dashboard-traffic", `${bytes(Math.round(inPerSecond + outPerSecond))}/s`);
|
||||
set("backend-dashboard-in-rate", `${bytes(Math.round(inPerSecond))}/s`);
|
||||
set("backend-dashboard-out-rate", `${bytes(Math.round(outPerSecond))}/s`);
|
||||
const peak = Math.max(1, ...samples.inbound, ...samples.outbound);
|
||||
paintSeries("inbound", "backend-dashboard-in-line", peak);
|
||||
paintSeries("outbound", "backend-dashboard-out-line", peak);
|
||||
}
|
||||
const diagnostics = [
|
||||
["http5xx", "http_5xx_total", "backend-dashboard-http5xx", "backend-dashboard-5xx-line"],
|
||||
["timeouts", "timeouts_total", "backend-dashboard-timeouts", "backend-dashboard-timeout-line"],
|
||||
["network", null, "backend-dashboard-network", "backend-dashboard-network-line"],
|
||||
["retries", "retry_attempts_total", "backend-dashboard-retries", "backend-dashboard-retry-line"],
|
||||
["queue", null, "backend-dashboard-queue", "backend-dashboard-queue-line"],
|
||||
];
|
||||
for (const [name, counterName, field, lineId] of diagnostics) {
|
||||
if (!visible(`backend_${name}`)) continue;
|
||||
const total = members.reduce((n, { member }) => n + (counterName ? counter(member[counterName]) :
|
||||
name === "network" ? ["connect_errors_total", "dns_errors_total", "tls_errors_total", "io_errors_total"].reduce((v, key) => v + counter(member[key]), 0) :
|
||||
counter(member.queue_timeouts_total) + counter(member.capacity_rejections_total)), 0);
|
||||
set(field, fmt(total));
|
||||
let detail = `${t("backendWidgetRateDetail")}: ${shortRate(additional[name])}`;
|
||||
if (name === "retries") detail += ` · ${t("backendWidgetRetriesSuccess")}: ${fmt(members.reduce((n, { member }) => n + counter(member.retry_successes_total), 0))}`;
|
||||
if (name === "queue") detail += ` · ${t("backendWidgetQueueRejected")}: ${fmt(members.reduce((n, { member }) => n + counter(member.capacity_rejections_total), 0))}`;
|
||||
if (name === "network") detail += ` · ${t("backendWidgetNetworkBreakdown")}`;
|
||||
set(`${field}-detail`, detail);
|
||||
paintSeries(name, lineId, Math.max(1, ...samples[name]));
|
||||
}
|
||||
}
|
||||
return { syncVisibility, render, reset };
|
||||
})();
|
||||
@@ -59,7 +59,7 @@ function translate() {
|
||||
window.rustProxyDrafts?.refresh();
|
||||
window.rustProxyPreferences?.refreshAppearanceUi?.();
|
||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); } if (lastLogs) renderLogs(lastLogs);
|
||||
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.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); } if (lastLogs) renderLogs(lastLogs);
|
||||
syncSettingsDirtyIndicators();
|
||||
}
|
||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
@@ -151,9 +151,10 @@ async function api(path, options = {}) {
|
||||
function setPanel(name, navigate = true) {
|
||||
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
|
||||
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
|
||||
const previousPanel = currentPanel;
|
||||
const previousPanel = currentPanel; if (name !== "overview" && previousPanel === "overview") window.rustProxyInlineLayout?.cancel?.();
|
||||
currentPanel = name;
|
||||
$("overview-refresh").hidden = name !== "overview";
|
||||
for (const id of ["overview-layout-open", "overview-layout-unlock"]) if ($(id)) $(id).hidden = name !== "overview";
|
||||
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
|
||||
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
|
||||
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
|
||||
@@ -163,7 +164,7 @@ function setPanel(name, navigate = true) {
|
||||
// Paint Stream tiles immediately on return from Appearance using cached status.
|
||||
// Do not create an extra sample/poll on navigation.
|
||||
if (name === "overview" && previousPanel !== name && typeof latestStatus !== "undefined" && latestStatus)
|
||||
window.rustProxyStreamDashboard?.render?.(latestStatus, null, false);
|
||||
{ window.rustProxyStreamDashboard?.render?.(latestStatus, null, false); window.rustProxyBackendDashboard?.render?.(latestStatus, null, false); window.rustProxyExtraOverview?.render?.(latestStatus, false); window.rustProxyWideOverview?.render?.(latestStatus, false); }
|
||||
// Read the cached Docker status on each visit, independent of the Live-view toggle.
|
||||
// This is GET /api/docker, not the POST that triggers an agent poll.
|
||||
if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker");
|
||||
@@ -266,7 +267,7 @@ function renderStatus(s, systemSample = false) {
|
||||
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
|
||||
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
|
||||
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s);
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s); window.rustProxyBackendDashboard?.render(s); window.rustProxyExtraOverview?.render(s); window.rustProxyWideOverview?.render(s);
|
||||
}
|
||||
function renderFooter(s) {
|
||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||
@@ -371,10 +372,10 @@ function authReturnDestination() {
|
||||
const tab = window.rustProxySettings?.current?.() || new URLSearchParams(location.search).get("tab");
|
||||
return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings";
|
||||
}
|
||||
function lock(notify = true) {
|
||||
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(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { 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; 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(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { 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 = ""; });
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
window.rustProxyInlineLayout = (() => {
|
||||
const grid = $("dashboard-overview-grid");
|
||||
const unlock = $("overview-layout-unlock");
|
||||
const actions = $("overview-inline-actions");
|
||||
const saveButton = $("overview-inline-save");
|
||||
const cancelButton = $("overview-inline-cancel");
|
||||
const hint = $("overview-inline-hint");
|
||||
const hiddenShelf = $("overview-inline-hidden");
|
||||
const hiddenList = $("overview-inline-hidden-list");
|
||||
const hiddenCount = $("overview-inline-hidden-count");
|
||||
const modalButton = $("overview-layout-open");
|
||||
let draft = null;
|
||||
let original = null;
|
||||
let dragging = null;
|
||||
let opening = false;
|
||||
let saving = false;
|
||||
|
||||
const groupFor = id => window.rustProxyMasonry?.groupFor?.(id) ?? 0;
|
||||
const visibleIds = () => draft.order.filter(id => !draft.hidden.has(id))
|
||||
.sort((a, b) => groupFor(a) - groupFor(b));
|
||||
const nodes = () => [...grid.children].filter(node => node.dataset.dashboardWidget);
|
||||
const nodeFor = id => nodes().find(node => node.dataset.dashboardWidget === id);
|
||||
const label = id => dashboardWidgetLabel(id);
|
||||
const dirty = () => draft && original && (draft.order.some((id, i) => id !== original.order[i])
|
||||
|| [...draft.hidden].some(id => !original.hidden.has(id)) || [...original.hidden].some(id => !draft.hidden.has(id)));
|
||||
|
||||
function renderHidden() {
|
||||
if (!hiddenList || !draft) return;
|
||||
const entries = draft.order.filter(id => draft.hidden.has(id));
|
||||
if (hiddenCount) hiddenCount.textContent = `(${entries.length})`;
|
||||
hiddenList.replaceChildren(...(entries.length ? entries.map(id => {
|
||||
const add = control("+", `${t("overviewLayoutInlineRestore")}: ${label(id)}`, "overview-inline-restore", () => {
|
||||
draft.hidden.delete(id);
|
||||
paint();
|
||||
nodeFor(id)?.querySelector(".overview-inline-grip")?.focus();
|
||||
});
|
||||
const item = el("div", undefined, "overview-inline-hidden-item");
|
||||
item.append(el("span", label(id)), dashboardWidgetTypeBadge(id), add);
|
||||
return item;
|
||||
}) : [el("span", t("overviewLayoutInlineHiddenEmpty"), "fine")]));
|
||||
}
|
||||
function hide(id) {
|
||||
if (!draft || !visibleIds().includes(id)) return;
|
||||
draft.hidden.add(id);
|
||||
paint();
|
||||
if (hiddenShelf) { hiddenShelf.open = true; hiddenShelf.querySelector("summary")?.focus(); }
|
||||
}
|
||||
function paint() {
|
||||
applyDashboardLayout(draft.order, [...draft.hidden]);
|
||||
renderHidden();
|
||||
const visible = visibleIds();
|
||||
for (const node of nodes()) {
|
||||
const i = visible.indexOf(node.dataset.dashboardWidget);
|
||||
const up = node.querySelector(".overview-inline-up");
|
||||
const down = node.querySelector(".overview-inline-down");
|
||||
if (up) up.disabled = i <= 0 || groupFor(visible[i - 1]) !== groupFor(node.dataset.dashboardWidget);
|
||||
if (down) down.disabled = i < 0 || i === visible.length - 1 ||
|
||||
groupFor(visible[i + 1]) !== groupFor(node.dataset.dashboardWidget);
|
||||
}
|
||||
}
|
||||
function clearDrag() {
|
||||
dragging = null;
|
||||
for (const node of nodes()) node.classList.remove("is-overview-dragging", "is-overview-drop-before", "is-overview-drop-after");
|
||||
}
|
||||
function move(id, target, after = false) {
|
||||
if (!draft || id === target || groupFor(id) !== groupFor(target)) return;
|
||||
const visible = visibleIds();
|
||||
if (!visible.includes(id) || !visible.includes(target)) return;
|
||||
visible.splice(visible.indexOf(id), 1);
|
||||
visible.splice(visible.indexOf(target) + (after ? 1 : 0), 0, id);
|
||||
const iter = visible.values();
|
||||
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||
paint();
|
||||
}
|
||||
function nudge(id, delta) {
|
||||
const visible = visibleIds();
|
||||
const index = visible.indexOf(id), target = visible[index + delta];
|
||||
if (!target || groupFor(id) !== groupFor(target)) return;
|
||||
move(id, target, delta > 0);
|
||||
nodeFor(id)?.querySelector(delta < 0 ? ".overview-inline-up" : ".overview-inline-down")?.focus();
|
||||
}
|
||||
function control(text, title, className, run) {
|
||||
const button = el("button", text, `quiet ${className}`);
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-label", title);
|
||||
button.title = title;
|
||||
button.addEventListener("click", run);
|
||||
return button;
|
||||
}
|
||||
function toolbar(node) {
|
||||
const id = node.dataset.dashboardWidget;
|
||||
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.dataset.overviewDragHandle = id;
|
||||
bar.append(grip, dashboardWidgetTypeBadge(id),
|
||||
control("←", `${t("moveUp")}: ${title}`, "overview-inline-up", () => nudge(id, -1)),
|
||||
control("→", `${t("moveDown")}: ${title}`, "overview-inline-down", () => nudge(id, 1)),
|
||||
control("×", `${t("overviewLayoutInlineHide")}: ${title}`, "overview-inline-hide", () => hide(id)));
|
||||
node.append(bar);
|
||||
}
|
||||
function showEditing() {
|
||||
grid.classList.add("is-unlocked");
|
||||
unlock.setAttribute("aria-pressed", "true");
|
||||
unlock.dataset.i18n = "overviewLayoutLock";
|
||||
unlock.textContent = t("overviewLayoutLock");
|
||||
actions.hidden = false;
|
||||
hint.hidden = false;
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
|
||||
if (modalButton) modalButton.disabled = true;
|
||||
for (const node of nodes()) toolbar(node);
|
||||
paint();
|
||||
}
|
||||
function finish(preserveSaved = false) {
|
||||
clearDrag();
|
||||
for (const node of nodes()) node.querySelector(".overview-inline-toolbar")?.remove();
|
||||
grid.classList.remove("is-unlocked");
|
||||
unlock.setAttribute("aria-pressed", "false");
|
||||
unlock.dataset.i18n = "overviewLayoutUnlock";
|
||||
unlock.textContent = t("overviewLayoutUnlock");
|
||||
actions.hidden = true;
|
||||
hint.hidden = true;
|
||||
if (hiddenShelf) { hiddenShelf.open = false; hiddenShelf.hidden = true; }
|
||||
if (hiddenList) hiddenList.replaceChildren();
|
||||
if (modalButton) modalButton.disabled = false;
|
||||
if (!preserveSaved && original) applyDashboardLayout(original.order, [...original.hidden]);
|
||||
draft = original = null;
|
||||
}
|
||||
async function begin() {
|
||||
if (draft || opening || saving || $("overview-layout-dialog")?.open) return;
|
||||
opening = true;
|
||||
unlock.disabled = true;
|
||||
try {
|
||||
await window.rustProxyPreferences?.load?.();
|
||||
const saved = window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS;
|
||||
const order = normalizeDashboardWidgets(saved.dashboard_widgets);
|
||||
const hidden = new Set(normalizeHiddenDashboardWidgets(saved.dashboard_hidden_widgets, saved.dashboard_widgets));
|
||||
original = { order: [...order], hidden: new Set(hidden) };
|
||||
draft = { order: [...order], hidden: new Set(hidden) };
|
||||
showEditing();
|
||||
} catch (error) { if (error?.status !== 401) toast(error.message, true); }
|
||||
finally { opening = false; unlock.disabled = false; }
|
||||
}
|
||||
async function save() {
|
||||
if (!draft || saving) return;
|
||||
if (!dirty()) { finish(true); return; }
|
||||
saving = true;
|
||||
saveButton.disabled = cancelButton.disabled = unlock.disabled = true;
|
||||
try {
|
||||
await window.rustProxyPreferences.update({
|
||||
dashboard_widgets: [...draft.order], dashboard_hidden_widgets: [...draft.hidden],
|
||||
});
|
||||
finish(true);
|
||||
toast(t("overviewLayoutEditorSaved"));
|
||||
} catch (error) { hint.textContent = error.message || t("overviewLayoutInlineError"); }
|
||||
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;
|
||||
}
|
||||
function positionAfter(widget, event) {
|
||||
const rect = widget.getBoundingClientRect();
|
||||
return event.clientX >= rect.left + rect.width / 2;
|
||||
}
|
||||
grid?.addEventListener("dragstart", 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 ||
|
||||
groupFor(target.dataset.dashboardWidget) !== groupFor(dragging)) 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";
|
||||
});
|
||||
grid?.addEventListener("drop", event => {
|
||||
if (!draft || !dragging) return;
|
||||
event.preventDefault();
|
||||
const target = dropTarget(event), from = dragging;
|
||||
clearDrag();
|
||||
if (target && target.dataset.dashboardWidget !== from)
|
||||
move(from, target.dataset.dashboardWidget, positionAfter(target, event));
|
||||
});
|
||||
grid?.addEventListener("dragend", clearDrag);
|
||||
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
|
||||
saveButton?.addEventListener("click", () => { void save(); });
|
||||
cancelButton?.addEventListener("click", cancel);
|
||||
return { isEditing: () => !!draft, cancel };
|
||||
})();
|
||||
@@ -0,0 +1,239 @@
|
||||
window.rustProxyDashboardLayout = (() => {
|
||||
const dialog = $("overview-layout-dialog");
|
||||
const visibleRoot = $("overview-layout-visible");
|
||||
const hiddenRoot = $("overview-layout-hidden");
|
||||
const saveButton = $("overview-layout-save");
|
||||
const cancelButton = $("overview-layout-cancel");
|
||||
const search = $("overview-layout-search");
|
||||
const typeFilter = $("overview-layout-type-filter");
|
||||
const message = $("overview-layout-message");
|
||||
const defaultButton = $("overview-layout-default");
|
||||
const resetButton = $("overview-layout-reset");
|
||||
const resetConfirm = $("overview-reset-confirm");
|
||||
const resetCancel = $("overview-reset-cancel");
|
||||
const resetApply = $("overview-reset-apply");
|
||||
let draft = null;
|
||||
let dragging = null;
|
||||
let saving = false;
|
||||
let opening = false;
|
||||
|
||||
function initialDraft() {
|
||||
const prefs = window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS;
|
||||
const order = normalizeDashboardWidgets(prefs.dashboard_widgets);
|
||||
return { order, hidden: new Set(normalizeHiddenDashboardWidgets(prefs.dashboard_hidden_widgets, prefs.dashboard_widgets)) };
|
||||
}
|
||||
function defaultDraft() {
|
||||
return {
|
||||
order: [...ADMIN_DASHBOARD_WIDGETS],
|
||||
hidden: new Set(ADMIN_DEFAULT_HIDDEN_WIDGETS),
|
||||
};
|
||||
}
|
||||
function label(id) {
|
||||
const widget = ADMIN_DASHBOARD_BY_ID.get(id);
|
||||
return widget ? t(widget.label_key) : id;
|
||||
}
|
||||
const groupFor = id => window.rustProxyMasonry?.groupFor?.(id) ?? 0;
|
||||
function visibleIds() {
|
||||
return draft.order.filter(id => !draft.hidden.has(id))
|
||||
.sort((a, b) => groupFor(a) - groupFor(b));
|
||||
}
|
||||
function reorderVisible(nextVisible) {
|
||||
const iter = nextVisible.values();
|
||||
draft.order = draft.order.map(id => draft.hidden.has(id) ? id : iter.next().value);
|
||||
render();
|
||||
}
|
||||
function move(id, target, after = false) {
|
||||
const next = visibleIds();
|
||||
if (id === target || !next.includes(id) || !next.includes(target) || groupFor(id) !== groupFor(target)) return;
|
||||
next.splice(next.indexOf(id), 1);
|
||||
next.splice(next.indexOf(target) + (after ? 1 : 0), 0, id);
|
||||
reorderVisible(next);
|
||||
}
|
||||
function nudge(id, direction) {
|
||||
const next = visibleIds(), index = next.indexOf(id), other = index + direction;
|
||||
if (index < 0 || other < 0 || other >= next.length || groupFor(id) !== groupFor(next[other])) return;
|
||||
[next[index], next[other]] = [next[other], next[index]];
|
||||
reorderVisible(next);
|
||||
visibleRoot.querySelectorAll(".overview-layout-tile").forEach(node => {
|
||||
if (node.dataset.previewWidget === id) node.querySelector(".overview-layout-up")?.focus();
|
||||
});
|
||||
}
|
||||
function preview(id) {
|
||||
const original = document.querySelector(`#panel-overview [data-dashboard-widget="${id}"]`);
|
||||
if (!original) return el("article", label(id), "stat");
|
||||
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") {
|
||||
const card = el("article", undefined, "overview-layout-panel-preview");
|
||||
card.append(el("strong", label(id)));
|
||||
const chart = id === "backend_table" ? null : original.querySelector?.("svg");
|
||||
if (chart) {
|
||||
const graphic = chart.cloneNode(true);
|
||||
for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id");
|
||||
graphic.removeAttribute("data-i18n-aria");
|
||||
graphic.setAttribute("aria-hidden", "true");
|
||||
card.append(graphic);
|
||||
}
|
||||
const detailKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
|
||||
if (detailKey) card.append(el("small", t(detailKey)));
|
||||
card.setAttribute("aria-hidden", "true");
|
||||
return card;
|
||||
}
|
||||
const copy = original.cloneNode(true);
|
||||
copy.hidden = false;
|
||||
copy.removeAttribute("data-dashboard-widget");
|
||||
for (const node of [copy, ...copy.querySelectorAll("[id]")]) node.removeAttribute("id");
|
||||
for (const node of copy.querySelectorAll("[aria-labelledby], [aria-describedby]")) {
|
||||
node.removeAttribute("aria-labelledby"); node.removeAttribute("aria-describedby");
|
||||
}
|
||||
copy.classList.add("overview-layout-card-preview");
|
||||
copy.setAttribute("aria-hidden", "true");
|
||||
return copy;
|
||||
}
|
||||
function render() {
|
||||
if (!draft || !dialog) return;
|
||||
const visible = visibleIds();
|
||||
const tiles = visible.map((id, index) => {
|
||||
const tile = el("div", undefined, "overview-layout-tile");
|
||||
tile.dataset.previewWidget = id;
|
||||
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") tile.classList.add("is-panel");
|
||||
tile.draggable = true;
|
||||
tile.setAttribute("role", "listitem");
|
||||
tile.setAttribute("aria-label", label(id));
|
||||
const toolbar = el("div", undefined, "overview-layout-tile-toolbar");
|
||||
const grip = el("span", "⠿", "overview-layout-grip");
|
||||
grip.setAttribute("aria-hidden", "true");
|
||||
const up = el("button", "←", "quiet overview-layout-up");
|
||||
up.type = "button"; up.disabled = index === 0 || groupFor(id) !== groupFor(visible[index - 1]);
|
||||
up.setAttribute("aria-label", `${t("moveUp")}: ${label(id)}`);
|
||||
up.addEventListener("click", () => nudge(id, -1));
|
||||
const down = el("button", "→", "quiet overview-layout-down");
|
||||
down.type = "button"; down.disabled = index === visible.length - 1 || groupFor(id) !== groupFor(visible[index + 1]);
|
||||
down.setAttribute("aria-label", `${t("moveDown")}: ${label(id)}`);
|
||||
down.addEventListener("click", () => nudge(id, 1));
|
||||
const hide = el("button", t("overviewLayoutEditorHide"), "quiet overview-layout-hide");
|
||||
hide.type = "button";
|
||||
hide.setAttribute("aria-label", `${t("overviewLayoutEditorHide")}: ${label(id)}`);
|
||||
hide.addEventListener("click", () => { draft.hidden.add(id); render(); });
|
||||
const controls = el("span", undefined, "overview-layout-controls");
|
||||
controls.append(up, down, hide);
|
||||
toolbar.append(grip, dashboardWidgetTypeBadge(id), controls);
|
||||
tile.append(toolbar, preview(id));
|
||||
tile.addEventListener("dragstart", event => {
|
||||
if (event.target !== tile) return;
|
||||
dragging = id;
|
||||
event.dataTransfer?.setData("text/plain", id);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
tile.classList.add("is-dragging");
|
||||
});
|
||||
tile.addEventListener("dragover", event => {
|
||||
if (!dragging || dragging === id || groupFor(dragging) !== groupFor(id)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
tile.classList.add("is-drop-target");
|
||||
});
|
||||
tile.addEventListener("dragleave", () => tile.classList.remove("is-drop-target"));
|
||||
tile.addEventListener("drop", event => {
|
||||
event.preventDefault();
|
||||
tile.classList.remove("is-drop-target");
|
||||
if (dragging && dragging !== id) {
|
||||
const startIndex = visibleIds().indexOf(dragging);
|
||||
const targetIndex = visibleIds().indexOf(id);
|
||||
move(dragging, id, startIndex < targetIndex);
|
||||
}
|
||||
dragging = null;
|
||||
});
|
||||
tile.addEventListener("dragend", () => { dragging = null; tile.classList.remove("is-dragging", "is-drop-target"); });
|
||||
return tile;
|
||||
});
|
||||
visibleRoot.replaceChildren(...(tiles.length ? tiles : [el("p", t("overviewLayoutEditorEmpty"), "fine")]));
|
||||
const query = (search?.value || "").trim().toLocaleLowerCase();
|
||||
const hidden = draft.order.filter(id => draft.hidden.has(id)
|
||||
&& (typeFilter?.value === "all" || !typeFilter?.value || dashboardWidgetType(id) === typeFilter.value)
|
||||
&& (`${label(id)} ${dashboardWidgetTypeLabel(id)}`).toLocaleLowerCase().includes(query));
|
||||
const hiddenItems = hidden.map(id => {
|
||||
const item = el("div", undefined, "overview-layout-hidden-item");
|
||||
item.setAttribute("role", "listitem");
|
||||
const name = el("span", label(id));
|
||||
const meta = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
|
||||
if (meta) name.title = t(meta);
|
||||
const add = el("button", t("overviewLayoutEditorShow"), "outline");
|
||||
add.type = "button";
|
||||
add.setAttribute("aria-label", `${t("overviewLayoutEditorShow")}: ${label(id)}`);
|
||||
add.addEventListener("click", () => { draft.hidden.delete(id); render(); });
|
||||
item.append(name, dashboardWidgetTypeBadge(id), add);
|
||||
return item;
|
||||
});
|
||||
hiddenRoot.replaceChildren(...(hiddenItems.length || query ? hiddenItems : [el("p", t("overviewLayoutEditorHiddenEmpty"), "fine")]));
|
||||
}
|
||||
async function open() {
|
||||
if (dialog?.open || opening || window.rustProxyInlineLayout?.isEditing?.()) return;
|
||||
opening = true;
|
||||
try { await window.rustProxyPreferences?.load?.(); }
|
||||
catch (error) { if (error?.status === 401) { opening = false; return; } }
|
||||
opening = false;
|
||||
draft = initialDraft();
|
||||
dragging = null;
|
||||
search.value = "";
|
||||
if (typeFilter) typeFilter.value = "all";
|
||||
message.textContent = "";
|
||||
render();
|
||||
dialog.showModal();
|
||||
cancelButton.focus();
|
||||
}
|
||||
function setSaving(value) {
|
||||
saving = value;
|
||||
for (const control of [saveButton, cancelButton, defaultButton, resetButton, resetCancel, resetApply])
|
||||
if (control) control.disabled = value;
|
||||
}
|
||||
function restoreDefaultPreview() {
|
||||
if (saving || !draft) return;
|
||||
draft = defaultDraft();
|
||||
dragging = null;
|
||||
if (search) search.value = "";
|
||||
if (typeFilter) typeFilter.value = "all";
|
||||
message.textContent = t("overviewLayoutDefaultLoaded");
|
||||
render();
|
||||
}
|
||||
async function resetToDefault() {
|
||||
if (saving || !draft) return;
|
||||
setSaving(true);
|
||||
message.textContent = t("overviewLayoutEditorSaving");
|
||||
const defaults = defaultDraft();
|
||||
try {
|
||||
await window.rustProxyPreferences.update({
|
||||
dashboard_widgets: [...defaults.order], dashboard_hidden_widgets: [...defaults.hidden],
|
||||
});
|
||||
resetConfirm.close();
|
||||
dialog.close();
|
||||
toast(t("overviewLayoutResetSaved"));
|
||||
} catch (error) { message.textContent = error.message; }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
async function save() {
|
||||
if (saving || !draft) return;
|
||||
setSaving(true);
|
||||
message.textContent = t("overviewLayoutEditorSaving");
|
||||
try {
|
||||
await window.rustProxyPreferences.update({
|
||||
dashboard_widgets: [...draft.order], dashboard_hidden_widgets: [...draft.hidden],
|
||||
});
|
||||
dialog.close();
|
||||
toast(t("overviewLayoutEditorSaved"));
|
||||
} catch (error) { message.textContent = error.message; }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
for (const id of ["overview-layout-open", "overview-layout-open-settings"]) $(id)?.addEventListener("click", () => { void open(); });
|
||||
cancelButton.addEventListener("click", () => { if (!saving) dialog.close(); });
|
||||
saveButton.addEventListener("click", () => { void save(); });
|
||||
defaultButton?.addEventListener("click", restoreDefaultPreview);
|
||||
resetButton?.addEventListener("click", () => {
|
||||
if (!saving && draft && !resetConfirm.open) resetConfirm.showModal();
|
||||
});
|
||||
resetCancel?.addEventListener("click", () => { if (!saving) resetConfirm.close(); });
|
||||
resetApply?.addEventListener("click", () => { void resetToDefault(); });
|
||||
resetConfirm?.addEventListener("cancel", event => { if (saving) event.preventDefault(); });
|
||||
search.addEventListener("input", render);
|
||||
typeFilter?.addEventListener("change", render);
|
||||
dialog.addEventListener("cancel", event => { if (saving || resetConfirm.open) event.preventDefault(); });
|
||||
dialog.addEventListener("close", () => { draft = null; dragging = null; message.textContent = ""; });
|
||||
return { open, refresh: () => { if (dialog.open) render(); } };
|
||||
})();
|
||||
@@ -0,0 +1,128 @@
|
||||
window.rustProxyExtraOverview = (() => {
|
||||
const limit = 30;
|
||||
const names = [
|
||||
"extra_process_cpu", "extra_process_rss", "extra_process_fds", "extra_open_connections",
|
||||
"extra_websocket_active", "extra_tls_error_rate", "extra_request_error_rate",
|
||||
"extra_cache_hit_rate", "extra_cache_memory", "extra_h2_share",
|
||||
"extra_response_latency", "extra_backend_unhealthy",
|
||||
"extra_total_rps", "extra_active_exchanges", "extra_h1_share", "extra_h3_share",
|
||||
"extra_cache_miss_rate", "extra_backend_active",
|
||||
];
|
||||
const histories = new Map();
|
||||
const lastCounter = new Map();
|
||||
const lastValue = new Map();
|
||||
const lastUnits = new Map();
|
||||
const positive = value => value != null && Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) : null;
|
||||
const node = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
|
||||
function elapsedDelta(id, counters, now) {
|
||||
const old = lastCounter.get(id);
|
||||
const next = { values: counters, at: now };
|
||||
lastCounter.set(id, next);
|
||||
if (!old || now <= old.at || counters.some((v, i) => v < old.values[i])) return null;
|
||||
return { deltas: counters.map((v, i) => v - old.values[i]), seconds: (now - old.at) / 1000 };
|
||||
}
|
||||
function read(id, status, now, record) {
|
||||
const m = status.metrics || {}, d = status.system || {}, c = status.cache || {};
|
||||
const direct = {
|
||||
extra_process_cpu: d.process_cpu_percent,
|
||||
extra_process_rss: d.process_rss_bytes,
|
||||
extra_process_fds: d.open_fds,
|
||||
extra_open_connections: m.active_connections,
|
||||
extra_websocket_active: m.websocket_active,
|
||||
extra_response_latency: m.mean_duration_ms,
|
||||
extra_active_exchanges: m.active,
|
||||
};
|
||||
if (Object.hasOwn(direct, id)) {
|
||||
const numeric = positive(direct[id]);
|
||||
return {
|
||||
value: numeric, unit: id === "extra_process_cpu" ? "percent" : id === "extra_process_rss" ? "bytes" : id === "extra_response_latency" ? "ms" : "count",
|
||||
detail: id === "extra_process_fds" && positive(d.max_fds) ? `${t("overviewExtraFdLimit")}: ${fmt(d.max_fds)}` : ""
|
||||
};
|
||||
}
|
||||
if (id === "extra_backend_active") {
|
||||
const members = Array.isArray(status.backend_pools) ? status.backend_pools.flatMap(pool => pool.members || []) : null;
|
||||
return {
|
||||
value: members ? members.reduce((sum, member) => sum + (positive(member.active) ?? 0), 0) : null,
|
||||
unit: "count", detail: ""
|
||||
};
|
||||
}
|
||||
if (id === "extra_backend_unhealthy") {
|
||||
return {
|
||||
value: Array.isArray(status.backend_pools) ? status.backend_pools.reduce((sum, pool) =>
|
||||
sum + (pool.members || []).filter(member => member.status === "down").length, 0) : null, unit: "count", detail: ""
|
||||
};
|
||||
}
|
||||
if (id === "extra_cache_memory") {
|
||||
const bytes = positive(c.resident_bytes), budget = positive(c.hot_limit_bytes);
|
||||
const percent = budget && bytes !== null ? bytes / budget * 100 : null;
|
||||
return {
|
||||
value: percent === null ? bytes : percent, unit: percent === null ? "bytes" : "percent",
|
||||
detail: bytes === null ? "" : `${fmtBytes(bytes)}${budget ? ` / ${fmtBytes(budget)}` : ""}`
|
||||
};
|
||||
}
|
||||
if (!["extra_tls_error_rate", "extra_request_error_rate", "extra_cache_hit_rate", "extra_h2_share",
|
||||
"extra_total_rps", "extra_h1_share", "extra_h3_share", "extra_cache_miss_rate"].includes(id))
|
||||
return { value: null, unit: "count", detail: "" };
|
||||
const fields = ({
|
||||
extra_tls_error_rate: [m.tls_handshake_errors],
|
||||
extra_request_error_rate: [m.errors],
|
||||
extra_cache_hit_rate: [c.hits, c.misses],
|
||||
extra_h2_share: [m.http2_requests, m.requests],
|
||||
extra_h1_share: [m.http1_requests, m.requests],
|
||||
extra_h3_share: [m.http3_requests, m.requests],
|
||||
extra_total_rps: [m.requests],
|
||||
extra_cache_miss_rate: [c.misses],
|
||||
})[id].map(positive);
|
||||
const unit = ["extra_cache_hit_rate", "extra_h2_share", "extra_h1_share", "extra_h3_share"].includes(id)
|
||||
? "percent" : "rate";
|
||||
if (fields.some(v => v === null)) { lastCounter.delete(id); return { value: null, unit, detail: "" }; }
|
||||
if (!record) return { value: lastValue.get(id) ?? null, unit, detail: "" };
|
||||
const result = elapsedDelta(id, fields, now);
|
||||
if (!result) return { value: null, unit, detail: "" };
|
||||
const { deltas, seconds } = result;
|
||||
if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" };
|
||||
const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1];
|
||||
return { value: sum ? Math.min(100, deltas[0] / sum * 100) : null, unit, detail: "" };
|
||||
}
|
||||
function format(value, unit) {
|
||||
if (value == null) return "-";
|
||||
if (unit === "percent") return `${fmt(value, 1)}%`;
|
||||
if (unit === "bytes") return fmtBytes(value);
|
||||
if (unit === "ms") return `${fmt(value, 1)} ms`;
|
||||
if (unit === "rate") return `${fmt(value, 1)}/s`;
|
||||
return fmt(value);
|
||||
}
|
||||
function points(history, unit) {
|
||||
if (!history.length) return "0,55 300,55";
|
||||
const peak = unit === "percent" ? Math.max(100, ...history) : Math.max(1, ...history);
|
||||
return history.map((v, i) => `${i * 300 / Math.max(1, history.length - 1)},${55 - v / peak * 50}`).join(" ");
|
||||
}
|
||||
function render(status, record = true) {
|
||||
if (!status || currentPanel !== "overview") return;
|
||||
const now = performance.now();
|
||||
for (const id of names) {
|
||||
const tile = node(id);
|
||||
if (!tile || tile.hidden) { lastCounter.delete(id); continue; }
|
||||
const metric = read(id, status, now, record);
|
||||
const value = metric.value;
|
||||
if (lastUnits.get(id) !== metric.unit) {
|
||||
histories.delete(id);
|
||||
lastUnits.set(id, metric.unit);
|
||||
}
|
||||
const history = histories.get(id) || [];
|
||||
if (record && value !== null) {
|
||||
history.push(value);
|
||||
if (history.length > limit) history.shift();
|
||||
histories.set(id, history);
|
||||
}
|
||||
if (record) lastValue.set(id, value);
|
||||
const field = $(`overview-extra-${id}`);
|
||||
if (field) field.textContent = format(value, metric.unit);
|
||||
const detail = $(`overview-extra-${id}-detail`);
|
||||
if (detail) detail.textContent = metric.detail || t("overviewExtraRecentSamples");
|
||||
$(`overview-extra-${id}-line`)?.setAttribute("points", points(history, metric.unit));
|
||||
}
|
||||
}
|
||||
function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); }
|
||||
return { render, reset };
|
||||
})();
|
||||
@@ -0,0 +1,38 @@
|
||||
window.rustProxyMasonry = (() => {
|
||||
const grid = document.getElementById("dashboard-overview-grid");
|
||||
if (!grid) return { refresh() { } };
|
||||
const widgets = () => [...grid.children].filter(node => node.dataset.dashboardWidget);
|
||||
const group = node => node.classList.contains("pool-card") ? 4
|
||||
: node.classList.contains("stat") ? 0
|
||||
: node.classList.contains("overview-wide-chart") ? 2
|
||||
: node.dataset.dashboardWidget?.endsWith("_diagnostics") ? 3 : 1;
|
||||
|
||||
function refresh() {
|
||||
const previous = widgets();
|
||||
const ordered = previous.map((node, index) => ({ node, index }))
|
||||
.sort((a, b) => group(a.node) - group(b.node) || a.index - b.index)
|
||||
.map(item => item.node);
|
||||
if (ordered.some((node, i) => node !== previous[i])) grid.append(...ordered);
|
||||
let lastGroup = -1;
|
||||
for (const node of ordered) {
|
||||
for (const key of ["position", "left", "top", "width", "minHeight", "gridColumnStart",
|
||||
"gridColumnEnd", "gridRowStart", "gridRowEnd", "alignSelf"]) node.style[key] = "";
|
||||
node.classList.remove("overview-row-start");
|
||||
if (node.hidden) continue;
|
||||
const current = group(node);
|
||||
if (current !== lastGroup) node.classList.add("overview-row-start");
|
||||
lastGroup = current;
|
||||
}
|
||||
grid.classList.remove("is-packed");
|
||||
grid.classList.add("is-aligned");
|
||||
grid.style.height = "";
|
||||
}
|
||||
refresh();
|
||||
return {
|
||||
refresh,
|
||||
groupFor(id) {
|
||||
const node = widgets().find(item => item.dataset.dashboardWidget === id);
|
||||
return node ? group(node) : -1;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,106 @@
|
||||
window.rustProxyWideOverview = (() => {
|
||||
const limit = 60;
|
||||
const configurations = {
|
||||
wide_protocol_rates: {
|
||||
unit: "rate", names: ["h1", "h2", "h3"], counters: s =>
|
||||
[s.metrics?.http1_requests, s.metrics?.http2_requests, s.metrics?.http3_requests]
|
||||
},
|
||||
wide_cpu: {
|
||||
unit: "percent", names: ["system", "process"], values: s =>
|
||||
[s.system?.cpu_percent, s.system?.process_cpu_percent]
|
||||
},
|
||||
wide_memory: {
|
||||
unit: "bytes", names: ["process", "cache"], values: s =>
|
||||
[s.system?.process_rss_bytes, s.cache?.resident_bytes]
|
||||
},
|
||||
wide_cache_activity: {
|
||||
unit: "rate", names: ["hits", "misses"], counters: s =>
|
||||
[s.cache?.hits, s.cache?.misses]
|
||||
},
|
||||
wide_connections: {
|
||||
unit: "count", names: ["open", "websockets"], values: s =>
|
||||
[s.metrics?.active_connections, s.metrics?.websocket_active]
|
||||
},
|
||||
wide_backend_health: {
|
||||
unit: "count", names: ["up", "down"], values: s => {
|
||||
if (!Array.isArray(s.backend_pools)) return [null, null];
|
||||
const members = s.backend_pools.flatMap(pool => pool.members || []);
|
||||
return [members.filter(m => m.status === "up").length, members.filter(m => m.status === "down").length];
|
||||
}
|
||||
},
|
||||
wide_latency: {
|
||||
unit: "ms", names: ["response", "probe"], values: s => {
|
||||
if (!Array.isArray(s.backend_pools)) return [s.metrics?.mean_duration_ms, null];
|
||||
const members = s.backend_pools.flatMap(pool => pool.members || []);
|
||||
const probes = members.filter(m => m.status === "up" && m.health_mode !== "off"
|
||||
&& finite(m.last_probe_ms) !== null && finite(m.latency_ms) !== null
|
||||
&& Number(m.last_probe_ms) > 0);
|
||||
return [s.metrics?.mean_duration_ms,
|
||||
probes.length ? probes.reduce((total, m) => total + Number(m.latency_ms), 0) / probes.length : null];
|
||||
}
|
||||
},
|
||||
};
|
||||
const history = new Map(), baselines = new Map(), last = new Map();
|
||||
let revision = null;
|
||||
const finite = value => value != null && Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) : null;
|
||||
const format = (n, unit) => n === null ? "-" : unit === "bytes" ? fmtBytes(n)
|
||||
: unit === "percent" ? `${fmt(n, 1)}%` : unit === "ms" ? `${fmt(n, 1)} ms`
|
||||
: unit === "rate" ? `${fmt(n, 1)}/s` : fmt(n);
|
||||
const tile = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
|
||||
function counterRates(id, values, now, record) {
|
||||
const counters = values.map(finite);
|
||||
if (counters.some(n => n === null)) { baselines.delete(id); return counters.map(() => null); }
|
||||
if (!record) return last.get(id) || counters.map(() => null);
|
||||
const previous = baselines.get(id);
|
||||
baselines.set(id, { counters, now });
|
||||
if (!previous || now <= previous.now || counters.some((n, i) => n < previous.counters[i]))
|
||||
return counters.map(() => null);
|
||||
const elapsed = (now - previous.now) / 1000;
|
||||
return counters.map((n, i) => (n - previous.counters[i]) / elapsed);
|
||||
}
|
||||
function path(values, peak) {
|
||||
const count = values.length;
|
||||
let result = "", drawing = false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const value = values[i];
|
||||
if (value === null) { drawing = false; continue; }
|
||||
const x = Math.round(600 * i / Math.max(1, count - 1));
|
||||
const y = Math.round(137 - 120 * value / peak);
|
||||
result += `${drawing ? " L" : " M"}${x} ${y}`;
|
||||
drawing = true;
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
function render(status, record = true) {
|
||||
if (!status || currentPanel !== "overview") return;
|
||||
if (revision !== status.revision) { reset(); revision = status.revision; }
|
||||
const now = performance.now();
|
||||
for (const [id, config] of Object.entries(configurations)) {
|
||||
const node = tile(id);
|
||||
if (!node || node.hidden) { baselines.delete(id); continue; }
|
||||
const values = config.counters ? counterRates(id, config.counters(status), now, record)
|
||||
: config.values(status).map(finite);
|
||||
const series = history.get(id) || config.names.map(() => []);
|
||||
if (record) {
|
||||
values.forEach((value, i) => {
|
||||
series[i].push(value);
|
||||
if (series[i].length > limit) series[i].shift();
|
||||
});
|
||||
history.set(id, series);
|
||||
last.set(id, values);
|
||||
}
|
||||
const peak = Math.max(config.unit === "percent" ? 100 : 1,
|
||||
...series.flat().filter(n => n !== null));
|
||||
config.names.forEach((key, i) => {
|
||||
const value = values[i];
|
||||
const field = $(`overview-wide-${id}-${key}-value`);
|
||||
if (field) field.textContent = format(value, config.unit);
|
||||
$(`overview-wide-${id}-${key}-line`)?.setAttribute("d", path(series[i], peak));
|
||||
});
|
||||
const summary = $(`overview-wide-${id}-summary`);
|
||||
if (summary) summary.textContent = format(values[0], config.unit);
|
||||
}
|
||||
}
|
||||
function reset() { history.clear(); baselines.clear(); last.clear(); revision = null; }
|
||||
return { render, reset };
|
||||
})();
|
||||
@@ -1,12 +1,15 @@
|
||||
const ADMIN_THEMES = new Set(["dark", "light"]);
|
||||
const ADMIN_THEMES = new Set(["dark", "ultra_dark", "light", "cream"]);
|
||||
const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]);
|
||||
const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]);
|
||||
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
|
||||
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
|
||||
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol", "stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"]);
|
||||
const ADMIN_CORNER_STYLES = new Set(["default", "square", "subtle", "rounded", "extra"]);
|
||||
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id));
|
||||
const ADMIN_DASHBOARD_BY_ID = new Map(ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => [widget.id, widget]));
|
||||
const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze(ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden).map(widget => widget.id));
|
||||
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
|
||||
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
|
||||
button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"],
|
||||
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS],
|
||||
});
|
||||
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
|
||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
||||
@@ -17,6 +20,7 @@ function normalizeAdminUiScale(value) { const numeric = Number(value); return Nu
|
||||
function normalizeAdminTimeFormat(value) { return ADMIN_TIME_FORMATS.has(value) ? value : "24h"; }
|
||||
function normalizeAdminChartStyle(value) { return ADMIN_CHART_STYLES.has(value) ? value : "default"; }
|
||||
function normalizeAdminButtonStyle(value) { return ADMIN_BUTTON_STYLES.has(value) ? value : "normal"; }
|
||||
function normalizeAdminCornerStyle(value) { return ADMIN_CORNER_STYLES.has(value) ? value : "default"; }
|
||||
function normalizeAdminDensity(value, fallback = "normal") { return ADMIN_DENSITIES.has(value) ? value : fallback; }
|
||||
function normalizeDashboardWidgets(value) {
|
||||
const result = [], seen = new Set();
|
||||
@@ -28,7 +32,7 @@ function normalizeHiddenDashboardWidgets(value, savedOrder = ADMIN_DASHBOARD_WID
|
||||
const seen = new Set();
|
||||
const hidden = (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
|
||||
// Also protect preferences written by 0.31.x with an explicit empty hidden list.
|
||||
for (const id of ADMIN_DASHBOARD_WIDGETS.filter(id => id.startsWith("stream_"))) {
|
||||
for (const id of ADMIN_DEFAULT_HIDDEN_WIDGETS) {
|
||||
if (!savedOrder?.includes(id) && !seen.has(id)) { hidden.push(id); seen.add(id); }
|
||||
}
|
||||
return hidden;
|
||||
@@ -73,6 +77,13 @@ function applyAdminButtonStyle(value) {
|
||||
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
|
||||
const select = $("ui-button-style"); if (select) select.value = style; return style;
|
||||
}
|
||||
function applyAdminCornerStyle(value, cache = true) {
|
||||
const style = normalizeAdminCornerStyle(value);
|
||||
document.documentElement.dataset.cornerStyle = style;
|
||||
const select = $("ui-corner-style"); if (select) select.value = style;
|
||||
if (cache) { try { localStorage.setItem("rp-corner-style", style); } catch { } }
|
||||
return style;
|
||||
}
|
||||
function applyAdminTableDensity(value) {
|
||||
const density = normalizeAdminDensity(value); document.documentElement.dataset.tableDensity = density;
|
||||
const select = $("ui-table-density"); if (select) select.value = density; return density;
|
||||
@@ -87,13 +98,29 @@ function applyDashboardLayout(order, hidden) {
|
||||
const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node]));
|
||||
for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); }
|
||||
window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
|
||||
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview")
|
||||
window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false);
|
||||
window.rustProxyBackendDashboard?.syncVisibility?.([...hiddenSet]);
|
||||
window.rustProxyMasonry?.refresh?.();
|
||||
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview") { window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false); window.rustProxyBackendDashboard?.render?.(latestStatus, [...hiddenSet], false); window.rustProxyExtraOverview?.render?.(latestStatus, false); window.rustProxyWideOverview?.render?.(latestStatus, false); }
|
||||
return { order: normalizedOrder, hidden: [...hiddenSet] };
|
||||
}
|
||||
function dashboardWidgetLabel(id) {
|
||||
const node = document.querySelector(`[data-dashboard-widget="${CSS.escape(id)}"]`);
|
||||
return node?.querySelector("[data-i18n]")?.textContent?.trim() || id;
|
||||
const widget = ADMIN_DASHBOARD_BY_ID.get(id);
|
||||
return widget ? t(widget.label_key) : id;
|
||||
}
|
||||
const DASHBOARD_DISPLAY_TYPE_KEYS = Object.freeze({
|
||||
value: "overviewBlockTypeValue", value_chart: "overviewBlockTypeValueChart",
|
||||
chart: "overviewBlockTypeChart", panel: "overviewBlockTypePanel", table: "overviewBlockTypeTable",
|
||||
});
|
||||
function dashboardWidgetType(id) {
|
||||
return ADMIN_DASHBOARD_BY_ID.get(id)?.display_type || "value";
|
||||
}
|
||||
function dashboardWidgetTypeLabel(id) {
|
||||
return t(DASHBOARD_DISPLAY_TYPE_KEYS[dashboardWidgetType(id)] || "overviewBlockTypeValue");
|
||||
}
|
||||
function dashboardWidgetTypeBadge(id) {
|
||||
const tag = el("span", dashboardWidgetTypeLabel(id), "overview-widget-type");
|
||||
tag.dataset.blockType = dashboardWidgetType(id);
|
||||
return tag;
|
||||
}
|
||||
function renderDashboardWidgetSettings() {
|
||||
const list = $("dashboard-widget-list"); if (!list) return;
|
||||
@@ -102,12 +129,14 @@ function renderDashboardWidgetSettings() {
|
||||
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
|
||||
const visible = !hidden.has(id);
|
||||
const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
|
||||
const descriptionKey = ({ stream_overview: "streamWidgetOverviewDescription", stream_traffic: "streamWidgetTrafficDescription", stream_sessions: "streamWidgetSessionsDescription", stream_backend_health: "streamWidgetHealthDescription" })[id];
|
||||
const descriptionKey = ADMIN_DASHBOARD_BY_ID.get(id)?.description_key;
|
||||
if (descriptionKey) {
|
||||
const description = t(descriptionKey);
|
||||
row.title = description;
|
||||
toggle.setAttribute("aria-description", description);
|
||||
}
|
||||
// Type badges belong in the visual layout editor, not the long
|
||||
// Appearance settings list where they crowd the visibility controls.
|
||||
toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
|
||||
const actions = el("span", undefined, "dashboard-widget-setting-actions");
|
||||
const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp"));
|
||||
@@ -136,7 +165,7 @@ function refreshChartStylePalette(active = adminPreferences?.chart_style || "def
|
||||
});
|
||||
target.replaceChildren(...buttons);
|
||||
}
|
||||
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); renderRefreshPreferences(adminPreferences?.refresh); window.rustProxySettings?.refreshSearch?.(); }
|
||||
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); renderRefreshPreferences(adminPreferences?.refresh); window.rustProxyDashboardLayout?.refresh?.(); window.rustProxySettings?.refreshSearch?.(); }
|
||||
function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
||||
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
||||
function applyAdminPreferences(value, cache = true) {
|
||||
@@ -146,7 +175,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), button_style: applyAdminButtonStyle(value.button_style), table_density: applyAdminTableDensity(value.table_density),
|
||||
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), 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: dashboard.order, dashboard_hidden_widgets: dashboard.hidden,
|
||||
};
|
||||
adminPreferences = normalized;
|
||||
@@ -162,6 +191,7 @@ function applyAdminPreferences(value, cache = true) {
|
||||
}
|
||||
try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); }
|
||||
try { applyAdminUiScale(localStorage.getItem("rp-ui-scale") || 100, false); } catch { applyAdminUiScale(100, false); }
|
||||
try { applyAdminCornerStyle(localStorage.getItem("rp-corner-style") || "default", false); } catch { applyAdminCornerStyle("default", false); }
|
||||
|
||||
async function loadAdminPreferences(force = false) {
|
||||
if (!authenticated) return adminPreferences;
|
||||
@@ -193,7 +223,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.button_style = normalizeAdminButtonStyle(next.button_style); next.table_density = normalizeAdminDensity(next.table_density);
|
||||
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); 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);
|
||||
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
@@ -273,6 +303,15 @@ $("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); });
|
||||
});
|
||||
$("ui-corner-style")?.addEventListener("change", () => {
|
||||
const control = $("ui-corner-style"), previous = adminPreferences?.corner_style || "default", next = normalizeAdminCornerStyle(control.value);
|
||||
applyAdminCornerStyle(next);
|
||||
if (adminPreferences) adminPreferences.corner_style = next;
|
||||
void saveAppearancePreference({ corner_style: next }, control, () => {
|
||||
if (adminPreferences) adminPreferences.corner_style = previous;
|
||||
applyAdminCornerStyle(previous);
|
||||
});
|
||||
});
|
||||
$("ui-table-density")?.addEventListener("change", () => {
|
||||
const control = $("ui-table-density"), previous = adminPreferences?.table_density || "normal", next = normalizeAdminDensity(control.value);
|
||||
applyAdminTableDensity(next); if (adminPreferences) adminPreferences.table_density = next; void saveAppearancePreference({ table_density: next }, control, () => { if (adminPreferences) adminPreferences.table_density = previous; applyAdminTableDensity(previous); });
|
||||
@@ -291,7 +330,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" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
|
||||
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
|
||||
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] },
|
||||
};
|
||||
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
||||
|
||||
@@ -260,7 +260,6 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
||||
$("metric-h3").textContent = fmt(m.http3_requests);
|
||||
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`;
|
||||
$("cache-summary").textContent = `${fmt(c.hits)} HIT / ${fmt(c.misses)} MISS`;
|
||||
$("metric-rss").closest("article.stat").hidden = d.process_rss_bytes == null;
|
||||
if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes);
|
||||
$("process-summary").hidden = d.process_threads == null;
|
||||
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
[
|
||||
{
|
||||
"id": "requests",
|
||||
"label_key": "requests",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"requests\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "active",
|
||||
"label_key": "activeExchanges",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"active\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "bandwidth",
|
||||
"label_key": "bandwidth",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"bandwidth\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "errors",
|
||||
"label_key": "errors",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"errors\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "cache",
|
||||
"label_key": "cacheHitRatio",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"cache\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"label_key": "processMemory",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"memory\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "websocket",
|
||||
"label_key": "wsActive",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"websocket\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "http2",
|
||||
"label_key": "h2Requests",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"http2\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "http3",
|
||||
"label_key": "h3Requests",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"http3\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "tls",
|
||||
"label_key": "tlsFailures",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"tls\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "http_protocol",
|
||||
"label_key": "httpProtocolMix",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"http_protocol\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "websocket_protocol",
|
||||
"label_key": "wsProtocolMix",
|
||||
"description_key": null,
|
||||
"element_selector": "[data-dashboard-widget=\"websocket_protocol\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": false,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "stream_overview",
|
||||
"label_key": "streamWidgetOverview",
|
||||
"description_key": "streamWidgetOverviewDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"stream_overview\"]",
|
||||
"data_source": "stream_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "streams",
|
||||
"default_hidden": true,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "stream_traffic",
|
||||
"label_key": "streamWidgetTraffic",
|
||||
"description_key": "streamWidgetTrafficDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"stream_traffic\"]",
|
||||
"data_source": "stream_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "streams",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "stream_sessions",
|
||||
"label_key": "streamWidgetSessions",
|
||||
"description_key": "streamWidgetSessionsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"stream_sessions\"]",
|
||||
"data_source": "stream_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "streams",
|
||||
"default_hidden": true,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "stream_backend_health",
|
||||
"label_key": "streamWidgetHealth",
|
||||
"description_key": "streamWidgetHealthDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"stream_backend_health\"]",
|
||||
"data_source": "stream_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "streams",
|
||||
"default_hidden": true,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "backend_health",
|
||||
"label_key": "backendWidgetHealth",
|
||||
"description_key": "backendWidgetHealthDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_health\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "backend_activity",
|
||||
"label_key": "backendWidgetActivity",
|
||||
"description_key": "backendWidgetActivityDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_activity\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_errors",
|
||||
"label_key": "backendWidgetErrors",
|
||||
"description_key": "backendWidgetErrorsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_errors\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_latency",
|
||||
"label_key": "backendWidgetLatency",
|
||||
"description_key": "backendWidgetLatencyDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_latency\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_traffic",
|
||||
"label_key": "backendWidgetTraffic",
|
||||
"description_key": "backendWidgetTrafficDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_traffic\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_protocols",
|
||||
"label_key": "backendWidgetProtocols",
|
||||
"description_key": "backendWidgetProtocolsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_protocols\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_requests",
|
||||
"label_key": "backendWidgetRequests",
|
||||
"description_key": "backendWidgetRequestsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_requests\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_capacity",
|
||||
"label_key": "backendWidgetCapacity",
|
||||
"description_key": "backendWidgetCapacityDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_capacity\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_http5xx",
|
||||
"label_key": "backendWidgetHttp5xx",
|
||||
"description_key": "backendWidgetHttp5xxDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_http5xx\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_timeouts",
|
||||
"label_key": "backendWidgetTimeouts",
|
||||
"description_key": "backendWidgetTimeoutsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_timeouts\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_network",
|
||||
"label_key": "backendWidgetNetwork",
|
||||
"description_key": "backendWidgetNetworkDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_network\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_retries",
|
||||
"label_key": "backendWidgetRetries",
|
||||
"description_key": "backendWidgetRetriesDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_retries\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_queue",
|
||||
"label_key": "backendWidgetQueue",
|
||||
"description_key": "backendWidgetQueueDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_queue\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "backend_rotations",
|
||||
"label_key": "backendWidgetRotations",
|
||||
"description_key": "backendWidgetRotationsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_rotations\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value"
|
||||
},
|
||||
{
|
||||
"id": "extra_process_cpu",
|
||||
"label_key": "overviewExtraProcessCpu",
|
||||
"description_key": "overviewExtraProcessCpuDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_process_cpu\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_process_rss",
|
||||
"label_key": "overviewExtraProcessRss",
|
||||
"description_key": "overviewExtraProcessRssDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_process_rss\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_process_fds",
|
||||
"label_key": "overviewExtraProcessFds",
|
||||
"description_key": "overviewExtraProcessFdsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_process_fds\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_open_connections",
|
||||
"label_key": "overviewExtraOpenConnections",
|
||||
"description_key": "overviewExtraOpenConnectionsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_open_connections\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_websocket_active",
|
||||
"label_key": "overviewExtraWebsocketActive",
|
||||
"description_key": "overviewExtraWebsocketActiveDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_websocket_active\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_tls_error_rate",
|
||||
"label_key": "overviewExtraTlsErrorRate",
|
||||
"description_key": "overviewExtraTlsErrorRateDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_tls_error_rate\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_request_error_rate",
|
||||
"label_key": "overviewExtraRequestErrorRate",
|
||||
"description_key": "overviewExtraRequestErrorRateDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_request_error_rate\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_cache_hit_rate",
|
||||
"label_key": "overviewExtraCacheHitRate",
|
||||
"description_key": "overviewExtraCacheHitRateDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_cache_hit_rate\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_cache_memory",
|
||||
"label_key": "overviewExtraCacheMemory",
|
||||
"description_key": "overviewExtraCacheMemoryDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_cache_memory\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_h2_share",
|
||||
"label_key": "overviewExtraH2Share",
|
||||
"description_key": "overviewExtraH2ShareDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_h2_share\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_response_latency",
|
||||
"label_key": "overviewExtraResponseLatency",
|
||||
"description_key": "overviewExtraResponseLatencyDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_response_latency\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_backend_unhealthy",
|
||||
"label_key": "overviewExtraBackendUnhealthy",
|
||||
"description_key": "overviewExtraBackendUnhealthyDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_backend_unhealthy\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_total_rps",
|
||||
"label_key": "overviewExtraTotalRps",
|
||||
"description_key": "overviewExtraTotalRpsDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_total_rps\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_active_exchanges",
|
||||
"label_key": "overviewExtraActiveExchanges",
|
||||
"description_key": "overviewExtraActiveExchangesDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_active_exchanges\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_h1_share",
|
||||
"label_key": "overviewExtraH1Share",
|
||||
"description_key": "overviewExtraH1ShareDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_h1_share\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_h3_share",
|
||||
"label_key": "overviewExtraH3Share",
|
||||
"description_key": "overviewExtraH3ShareDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_h3_share\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_cache_miss_rate",
|
||||
"label_key": "overviewExtraCacheMissRate",
|
||||
"description_key": "overviewExtraCacheMissRateDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_cache_miss_rate\"]",
|
||||
"data_source": "cache_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_backend_active",
|
||||
"label_key": "overviewExtraBackendActive",
|
||||
"description_key": "overviewExtraBackendActiveDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_backend_active\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "wide_protocol_rates",
|
||||
"label_key": "overviewWideProtocolRates",
|
||||
"description_key": "overviewWideProtocolRatesDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"wide_protocol_rates\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "wide_cpu",
|
||||
"label_key": "overviewWideCpu",
|
||||
"description_key": "overviewWideCpuDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"wide_cpu\"]",
|
||||
"data_source": "system_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "wide_memory",
|
||||
"label_key": "overviewWideMemory",
|
||||
"description_key": "overviewWideMemoryDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"wide_memory\"]",
|
||||
"data_source": "system_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "wide_cache_activity",
|
||||
"label_key": "overviewWideCacheActivity",
|
||||
"description_key": "overviewWideCacheActivityDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"wide_cache_activity\"]",
|
||||
"data_source": "cache_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "wide_connections",
|
||||
"label_key": "overviewWideConnections",
|
||||
"description_key": "overviewWideConnectionsDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"wide_connections\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "wide_backend_health",
|
||||
"label_key": "overviewWideBackendHealth",
|
||||
"description_key": "overviewWideBackendHealthDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"wide_backend_health\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "wide_latency",
|
||||
"label_key": "overviewWideLatency",
|
||||
"description_key": "overviewWideLatencyDetail",
|
||||
"element_selector": "[data-dashboard-widget=\"wide_latency\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "traffic_chart",
|
||||
"label_key": "requestsPerSecond",
|
||||
"description_key": "overviewPanelTrafficDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"traffic_chart\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": false,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "system_chart",
|
||||
"label_key": "systemLoad",
|
||||
"description_key": "overviewPanelSystemDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"system_chart\"]",
|
||||
"data_source": "system_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": false,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "connection_chart",
|
||||
"label_key": "connectionActivity",
|
||||
"description_key": "overviewPanelConnectionsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"connection_chart\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": false,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "cache_diagnostics",
|
||||
"label_key": "cacheOverview",
|
||||
"description_key": "overviewPanelCacheDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"cache_diagnostics\"]",
|
||||
"data_source": "cache_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": false,
|
||||
"display_type": "panel"
|
||||
},
|
||||
{
|
||||
"id": "process_diagnostics",
|
||||
"label_key": "processDiagnostics",
|
||||
"description_key": "overviewPanelProcessDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"process_diagnostics\"]",
|
||||
"data_source": "system_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": false,
|
||||
"display_type": "panel"
|
||||
},
|
||||
{
|
||||
"id": "network_diagnostics",
|
||||
"label_key": "networkDiagnostics",
|
||||
"description_key": "overviewPanelNetworkDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"network_diagnostics\"]",
|
||||
"data_source": "system_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": false,
|
||||
"display_type": "panel"
|
||||
},
|
||||
{
|
||||
"id": "backend_table",
|
||||
"label_key": "vhHealthTitle",
|
||||
"description_key": "overviewPanelBackendsDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_table\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": false,
|
||||
"display_type": "table"
|
||||
}
|
||||
]
|
||||
+578
-25
@@ -8,12 +8,10 @@
|
||||
<title>RustProxy / Control room</title>
|
||||
<link href="/favicon.svg" rel="icon" />
|
||||
<link href="/assets/style___RUSTPROXY_ASSET_ID__.css" rel="stylesheet" />
|
||||
<!-- One ordered JS payload: no race between module fetches and session/config API. -->
|
||||
<script defer="" src="/assets/app___RUSTPROXY_ASSET_ID__.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Visible even when the dashboard JavaScript fails to initialize. -->
|
||||
<div class="app-startup" id="app-startup" role="status" aria-live="polite">
|
||||
<div class="app-startup-card">
|
||||
<div class="app-startup-brand" aria-hidden="true">r<span>/</span>p</div>
|
||||
@@ -121,12 +119,30 @@
|
||||
<h1 id="page-title">Traffic, in focus.</h1>
|
||||
<button class="quiet overview-refresh" data-i18n="refreshNow" id="overview-refresh"
|
||||
type="button" hidden>Refresh now</button>
|
||||
<button class="quiet overview-layout-open" data-i18n="overviewLayoutEditorOpen"
|
||||
id="overview-layout-open" type="button" hidden>Arrange cards visually</button>
|
||||
<button class="outline" id="overview-layout-unlock" type="button" hidden
|
||||
data-i18n="overviewLayoutUnlock" aria-pressed="false">Unlock layout</button>
|
||||
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
|
||||
<button class="primary" id="overview-inline-save" type="button"
|
||||
data-i18n="overviewLayoutInlineSave">Save and lock</button>
|
||||
<button class="quiet" id="overview-inline-cancel" type="button"
|
||||
data-i18n="overviewLayoutInlineCancel">Cancel changes</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead" id="page-description"></p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" id="panel-overview">
|
||||
<div class="stats-grid">
|
||||
<p class="fine overview-inline-hint" id="overview-inline-hint" role="status" aria-live="polite"
|
||||
hidden data-i18n="overviewLayoutInlineHint">Layout unlocked: reorder blocks within size groups,
|
||||
hide with × and save or cancel.</p>
|
||||
<details class="overview-inline-hidden" id="overview-inline-hidden" hidden>
|
||||
<summary><span data-i18n="overviewLayoutInlineHidden">Hidden blocks</span> <span
|
||||
id="overview-inline-hidden-count"></span></summary>
|
||||
<div class="overview-inline-hidden-list" id="overview-inline-hidden-list"></div>
|
||||
</details>
|
||||
<div class="stats-grid dashboard-overview-grid" id="dashboard-overview-grid">
|
||||
<article class="stat" data-dashboard-widget="requests"><span
|
||||
data-i18n="requests">Requests</span><strong id="metric-requests">0</strong><small
|
||||
id="request-rate">0 req/s</small></article>
|
||||
@@ -234,9 +250,457 @@
|
||||
<small id="stream-dashboard-health-detail" data-i18n="streamWidgetUnavailable">No stream
|
||||
data</small>
|
||||
</article>
|
||||
<article class="stat" data-dashboard-widget="backend_health" hidden>
|
||||
<span data-i18n="backendWidgetHealth">Backend health</span><strong
|
||||
id="backend-dashboard-health">-</strong>
|
||||
<small id="backend-dashboard-health-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_activity" hidden>
|
||||
<span data-i18n="backendWidgetActivity">Active backend requests</span><strong
|
||||
id="backend-dashboard-activity">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetActivity" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h1-line" id="backend-dashboard-active-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-activity-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_errors" hidden>
|
||||
<span data-i18n="backendWidgetErrors">Backend errors</span><strong
|
||||
id="backend-dashboard-errors">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetErrors" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h3-line" id="backend-dashboard-error-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-errors-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_latency" hidden>
|
||||
<span data-i18n="backendWidgetLatency">Health probe RTT</span><strong
|
||||
id="backend-dashboard-latency">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetLatency" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-latency-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-latency-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_traffic" hidden>
|
||||
<span data-i18n="backendWidgetTraffic">Backend traffic</span><strong
|
||||
id="backend-dashboard-traffic">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetTraffic" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line stream-chart-rx" id="backend-dashboard-in-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
<polyline class="diagnostic-line stream-chart-tx" id="backend-dashboard-out-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small class="stat-chart-legend stream-traffic-legend">
|
||||
<span class="legend-rx"><i></i>IN <b id="backend-dashboard-in-rate">-</b></span>
|
||||
<span class="legend-tx"><i></i>OUT <b id="backend-dashboard-out-rate">-</b></span>
|
||||
</small>
|
||||
</article>
|
||||
<article class="stat" data-dashboard-widget="backend_protocols" hidden>
|
||||
<span data-i18n="backendWidgetProtocols">Configured backend modes</span><strong
|
||||
id="backend-dashboard-protocols">-</strong>
|
||||
<div class="backend-mode-bar" role="img" data-i18n-aria="backendWidgetProtocols">
|
||||
<span class="backend-mode-h1" id="backend-mode-h1"></span>
|
||||
<span class="backend-mode-auto" id="backend-mode-auto"></span>
|
||||
<span class="backend-mode-compat" id="backend-mode-compat"></span>
|
||||
<span class="backend-mode-h2" id="backend-mode-h2"></span>
|
||||
</div><small id="backend-dashboard-protocols-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_requests" hidden>
|
||||
<span data-i18n="backendWidgetRequests">Backend request rate</span><strong
|
||||
id="backend-dashboard-requests">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetRequests" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h1-line" id="backend-dashboard-request-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-requests-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_capacity" hidden>
|
||||
<span data-i18n="backendWidgetCapacity">Backend capacity</span><strong
|
||||
id="backend-dashboard-capacity">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetCapacity" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-capacity-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-capacity-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_http5xx" hidden>
|
||||
<span data-i18n="backendWidgetHttp5xx">backendWidgetHttp5xx</span><strong
|
||||
id="backend-dashboard-http5xx">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetHttp5xx" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-5xx-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-http5xx-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_timeouts" hidden>
|
||||
<span data-i18n="backendWidgetTimeouts">backendWidgetTimeouts</span><strong
|
||||
id="backend-dashboard-timeouts">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetTimeouts" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-timeout-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-timeouts-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_network" hidden>
|
||||
<span data-i18n="backendWidgetNetwork">backendWidgetNetwork</span><strong
|
||||
id="backend-dashboard-network">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetNetwork" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-network-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-network-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_retries" hidden>
|
||||
<span data-i18n="backendWidgetRetries">backendWidgetRetries</span><strong
|
||||
id="backend-dashboard-retries">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetRetries" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-retry-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-retries-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_queue" hidden>
|
||||
<span data-i18n="backendWidgetQueue">backendWidgetQueue</span><strong
|
||||
id="backend-dashboard-queue">-</strong>
|
||||
<svg data-i18n-aria="backendWidgetQueue" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-queue-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="backend-dashboard-queue-detail">-</small>
|
||||
</article>
|
||||
<article class="stat" data-dashboard-widget="backend_rotations" hidden>
|
||||
<span data-i18n="backendWidgetRotations">Backend client pool rotations</span><strong
|
||||
id="backend-dashboard-rotations">-</strong>
|
||||
<small id="backend-dashboard-rotations-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_process_cpu" hidden>
|
||||
<span data-i18n="overviewExtraProcessCpu">overviewExtraProcessCpu</span><strong
|
||||
id="overview-extra-extra_process_cpu">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraProcessCpu" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_process_cpu-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_process_cpu-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_process_rss" hidden>
|
||||
<span data-i18n="overviewExtraProcessRss">overviewExtraProcessRss</span><strong
|
||||
id="overview-extra-extra_process_rss">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraProcessRss" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_process_rss-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_process_rss-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_process_fds" hidden>
|
||||
<span data-i18n="overviewExtraProcessFds">overviewExtraProcessFds</span><strong
|
||||
id="overview-extra-extra_process_fds">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraProcessFds" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_process_fds-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_process_fds-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_open_connections" hidden>
|
||||
<span data-i18n="overviewExtraOpenConnections">overviewExtraOpenConnections</span><strong
|
||||
id="overview-extra-extra_open_connections">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraOpenConnections" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_open_connections-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_open_connections-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_websocket_active" hidden>
|
||||
<span data-i18n="overviewExtraWebsocketActive">overviewExtraWebsocketActive</span><strong
|
||||
id="overview-extra-extra_websocket_active">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraWebsocketActive" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_websocket_active-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_websocket_active-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_tls_error_rate" hidden>
|
||||
<span data-i18n="overviewExtraTlsErrorRate">overviewExtraTlsErrorRate</span><strong
|
||||
id="overview-extra-extra_tls_error_rate">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraTlsErrorRate" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_tls_error_rate-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_tls_error_rate-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_request_error_rate" hidden>
|
||||
<span data-i18n="overviewExtraRequestErrorRate">overviewExtraRequestErrorRate</span><strong
|
||||
id="overview-extra-extra_request_error_rate">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraRequestErrorRate" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_request_error_rate-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_request_error_rate-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_cache_hit_rate" hidden>
|
||||
<span data-i18n="overviewExtraCacheHitRate">overviewExtraCacheHitRate</span><strong
|
||||
id="overview-extra-extra_cache_hit_rate">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraCacheHitRate" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_cache_hit_rate-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_cache_hit_rate-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_cache_memory" hidden>
|
||||
<span data-i18n="overviewExtraCacheMemory">overviewExtraCacheMemory</span><strong
|
||||
id="overview-extra-extra_cache_memory">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraCacheMemory" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_cache_memory-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_cache_memory-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_h2_share" hidden>
|
||||
<span data-i18n="overviewExtraH2Share">overviewExtraH2Share</span><strong
|
||||
id="overview-extra-extra_h2_share">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraH2Share" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_h2_share-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_h2_share-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_response_latency" hidden>
|
||||
<span data-i18n="overviewExtraResponseLatency">overviewExtraResponseLatency</span><strong
|
||||
id="overview-extra-extra_response_latency">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraResponseLatency" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_response_latency-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_response_latency-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_backend_unhealthy" hidden>
|
||||
<span data-i18n="overviewExtraBackendUnhealthy">overviewExtraBackendUnhealthy</span><strong
|
||||
id="overview-extra-extra_backend_unhealthy">-</strong>
|
||||
<svg data-i18n-aria="overviewExtraBackendUnhealthy" role="img" viewBox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_backend_unhealthy-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_backend_unhealthy-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_total_rps" hidden>
|
||||
<span data-i18n="overviewExtraTotalRps">HTTP requests / s</span><strong
|
||||
id="overview-extra-extra_total_rps">-</strong>
|
||||
<svg viewBox="0 0 300 60" role="img" aria-label="HTTP requests / s">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_total_rps-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_total_rps-detail"
|
||||
data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_active_exchanges" hidden>
|
||||
<span data-i18n="overviewExtraActiveExchanges">Active HTTP exchanges</span><strong
|
||||
id="overview-extra-extra_active_exchanges">-</strong>
|
||||
<svg viewBox="0 0 300 60" role="img" aria-label="Active HTTP exchanges">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_active_exchanges-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_active_exchanges-detail"
|
||||
data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_h1_share" hidden>
|
||||
<span data-i18n="overviewExtraH1Share">HTTP/1.1 share</span><strong
|
||||
id="overview-extra-extra_h1_share">-</strong>
|
||||
<svg viewBox="0 0 300 60" role="img" aria-label="HTTP/1.1 share">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_h1_share-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_h1_share-detail"
|
||||
data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_h3_share" hidden>
|
||||
<span data-i18n="overviewExtraH3Share">HTTP/3 share</span><strong
|
||||
id="overview-extra-extra_h3_share">-</strong>
|
||||
<svg viewBox="0 0 300 60" role="img" aria-label="HTTP/3 share">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_h3_share-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_h3_share-detail"
|
||||
data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_cache_miss_rate" hidden>
|
||||
<span data-i18n="overviewExtraCacheMissRate">Cache misses / s</span><strong
|
||||
id="overview-extra-extra_cache_miss_rate">-</strong>
|
||||
<svg viewBox="0 0 300 60" role="img" aria-label="Cache misses / s">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_cache_miss_rate-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_cache_miss_rate-detail"
|
||||
data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_backend_active" hidden>
|
||||
<span data-i18n="overviewExtraBackendActive">In-flight backend requests</span><strong
|
||||
id="overview-extra-extra_backend_active">-</strong>
|
||||
<svg viewBox="0 0 300 60" role="img" aria-label="In-flight backend requests">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_backend_active-line"
|
||||
points="0,55 300,55"></polyline>
|
||||
</svg><small id="overview-extra-extra_backend_active-detail"
|
||||
data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="card overview-wide-chart" data-dashboard-widget="wide_protocol_rates" hidden>
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="overviewWideProtocolRates">HTTP/1.1 · HTTP/2 · HTTP/3 traffic</h2>
|
||||
<span class="overview-wide-summary"
|
||||
id="overview-wide-wide_protocol_rates-summary">-</span>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<article class="card activity-card">
|
||||
<svg class="overview-wide-svg" viewBox="0 0 600 150" role="img"
|
||||
aria-label="HTTP/1.1 · HTTP/2 · HTTP/3 traffic">
|
||||
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></path>
|
||||
<path class="overview-wide-series overview-wide-series-0"
|
||||
id="overview-wide-wide_protocol_rates-h1-line" d=""></path>
|
||||
<path class="overview-wide-series overview-wide-series-1"
|
||||
id="overview-wide-wide_protocol_rates-h2-line" d=""></path>
|
||||
<path class="overview-wide-series overview-wide-series-2"
|
||||
id="overview-wide-wide_protocol_rates-h3-line" d=""></path>
|
||||
</svg>
|
||||
<div class="overview-wide-legend">
|
||||
<span><i class="overview-wide-key-0"></i><span>H1</span><b
|
||||
id="overview-wide-wide_protocol_rates-h1-value">-</b></span>
|
||||
<span><i class="overview-wide-key-1"></i><span>H2</span><b
|
||||
id="overview-wide-wide_protocol_rates-h2-value">-</b></span>
|
||||
<span><i class="overview-wide-key-2"></i><span>H3</span><b
|
||||
id="overview-wide-wide_protocol_rates-h3-value">-</b></span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card overview-wide-chart" data-dashboard-widget="wide_cpu" hidden>
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="overviewWideCpu">System and process CPU</h2>
|
||||
<span class="overview-wide-summary" id="overview-wide-wide_cpu-summary">-</span>
|
||||
</div>
|
||||
<svg class="overview-wide-svg" viewBox="0 0 600 150" role="img"
|
||||
aria-label="System and process CPU">
|
||||
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></path>
|
||||
<path class="overview-wide-series overview-wide-series-0"
|
||||
id="overview-wide-wide_cpu-system-line" d=""></path>
|
||||
<path class="overview-wide-series overview-wide-series-1"
|
||||
id="overview-wide-wide_cpu-process-line" d=""></path>
|
||||
</svg>
|
||||
<div class="overview-wide-legend">
|
||||
<span><i class="overview-wide-key-0"></i><span
|
||||
data-i18n="overviewWideSystem">System</span><b
|
||||
id="overview-wide-wide_cpu-system-value">-</b></span>
|
||||
<span><i class="overview-wide-key-1"></i><span
|
||||
data-i18n="overviewWideProcess">Process</span><b
|
||||
id="overview-wide-wide_cpu-process-value">-</b></span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card overview-wide-chart" data-dashboard-widget="wide_memory" hidden>
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="overviewWideMemory">Process RAM and cache memory</h2>
|
||||
<span class="overview-wide-summary" id="overview-wide-wide_memory-summary">-</span>
|
||||
</div>
|
||||
<svg class="overview-wide-svg" viewBox="0 0 600 150" role="img"
|
||||
aria-label="Process RAM and cache memory">
|
||||
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></path>
|
||||
<path class="overview-wide-series overview-wide-series-0"
|
||||
id="overview-wide-wide_memory-process-line" d=""></path>
|
||||
<path class="overview-wide-series overview-wide-series-1"
|
||||
id="overview-wide-wide_memory-cache-line" d=""></path>
|
||||
</svg>
|
||||
<div class="overview-wide-legend">
|
||||
<span><i class="overview-wide-key-0"></i><span
|
||||
data-i18n="overviewWideProcess">Process</span><b
|
||||
id="overview-wide-wide_memory-process-value">-</b></span>
|
||||
<span><i class="overview-wide-key-1"></i><span
|
||||
data-i18n="overviewWideCache">Cache</span><b
|
||||
id="overview-wide-wide_memory-cache-value">-</b></span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card overview-wide-chart" data-dashboard-widget="wide_cache_activity" hidden>
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="overviewWideCacheActivity">Cache hits / misses per second</h2>
|
||||
<span class="overview-wide-summary"
|
||||
id="overview-wide-wide_cache_activity-summary">-</span>
|
||||
</div>
|
||||
<svg class="overview-wide-svg" viewBox="0 0 600 150" role="img"
|
||||
aria-label="Cache hits / misses per second">
|
||||
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></path>
|
||||
<path class="overview-wide-series overview-wide-series-0"
|
||||
id="overview-wide-wide_cache_activity-hits-line" d=""></path>
|
||||
<path class="overview-wide-series overview-wide-series-1"
|
||||
id="overview-wide-wide_cache_activity-misses-line" d=""></path>
|
||||
</svg>
|
||||
<div class="overview-wide-legend">
|
||||
<span><i class="overview-wide-key-0"></i><span data-i18n="overviewWideHits">HIT</span><b
|
||||
id="overview-wide-wide_cache_activity-hits-value">-</b></span>
|
||||
<span><i class="overview-wide-key-1"></i><span
|
||||
data-i18n="overviewWideMisses">MISS</span><b
|
||||
id="overview-wide-wide_cache_activity-misses-value">-</b></span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card overview-wide-chart" data-dashboard-widget="wide_connections" hidden>
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="overviewWideConnections">Connections and WebSocket tunnels</h2>
|
||||
<span class="overview-wide-summary" id="overview-wide-wide_connections-summary">-</span>
|
||||
</div>
|
||||
<svg class="overview-wide-svg" viewBox="0 0 600 150" role="img"
|
||||
aria-label="Connections and WebSocket tunnels">
|
||||
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></path>
|
||||
<path class="overview-wide-series overview-wide-series-0"
|
||||
id="overview-wide-wide_connections-open-line" d=""></path>
|
||||
<path class="overview-wide-series overview-wide-series-1"
|
||||
id="overview-wide-wide_connections-websockets-line" d=""></path>
|
||||
</svg>
|
||||
<div class="overview-wide-legend">
|
||||
<span><i class="overview-wide-key-0"></i><span
|
||||
data-i18n="overviewWideOpen">Connections</span><b
|
||||
id="overview-wide-wide_connections-open-value">-</b></span>
|
||||
<span><i class="overview-wide-key-1"></i><span
|
||||
data-i18n="overviewWideWebsockets">WebSockets</span><b
|
||||
id="overview-wide-wide_connections-websockets-value">-</b></span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card overview-wide-chart" data-dashboard-widget="wide_backend_health" hidden>
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="overviewWideBackendHealth">Backend availability over time</h2>
|
||||
<span class="overview-wide-summary"
|
||||
id="overview-wide-wide_backend_health-summary">-</span>
|
||||
</div>
|
||||
<svg class="overview-wide-svg" viewBox="0 0 600 150" role="img"
|
||||
aria-label="Backend availability over time">
|
||||
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></path>
|
||||
<path class="overview-wide-series overview-wide-series-0"
|
||||
id="overview-wide-wide_backend_health-up-line" d=""></path>
|
||||
<path class="overview-wide-series overview-wide-series-1"
|
||||
id="overview-wide-wide_backend_health-down-line" d=""></path>
|
||||
</svg>
|
||||
<div class="overview-wide-legend">
|
||||
<span><i class="overview-wide-key-0"></i><span
|
||||
data-i18n="overviewWideHealthy">Healthy</span><b
|
||||
id="overview-wide-wide_backend_health-up-value">-</b></span>
|
||||
<span><i class="overview-wide-key-1"></i><span
|
||||
data-i18n="overviewWideUnhealthy">Unhealthy</span><b
|
||||
id="overview-wide-wide_backend_health-down-value">-</b></span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card overview-wide-chart" data-dashboard-widget="wide_latency" hidden>
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="overviewWideLatency">Response and backend probe latency</h2>
|
||||
<span class="overview-wide-summary" id="overview-wide-wide_latency-summary">-</span>
|
||||
</div>
|
||||
<svg class="overview-wide-svg" viewBox="0 0 600 150" role="img"
|
||||
aria-label="Response and backend probe latency">
|
||||
<path class="chart-grid" d="M0 37H600 M0 75H600 M0 113H600"></path>
|
||||
<path class="overview-wide-series overview-wide-series-0"
|
||||
id="overview-wide-wide_latency-response-line" d=""></path>
|
||||
<path class="overview-wide-series overview-wide-series-1"
|
||||
id="overview-wide-wide_latency-probe-line" d=""></path>
|
||||
</svg>
|
||||
<div class="overview-wide-legend">
|
||||
<span><i class="overview-wide-key-0"></i><span
|
||||
data-i18n="overviewWideResponse">HTTP</span><b
|
||||
id="overview-wide-wide_latency-response-value">-</b></span>
|
||||
<span><i class="overview-wide-key-1"></i><span
|
||||
data-i18n="overviewWideProbe">Probes</span><b
|
||||
id="overview-wide-wide_latency-probe-value">-</b></span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card activity-card" data-dashboard-widget="traffic_chart">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="liveTraffic">LIVE TRAFFIC</span>
|
||||
<h2 data-i18n="requestsPerSecond">Requests per second</h2>
|
||||
@@ -269,7 +733,7 @@
|
||||
<div class="chart-footer"><span data-i18n="lastMinute">Recent samples / resets on
|
||||
reload</span><span id="chart-peak">peak 0 req/s</span></div>
|
||||
</article>
|
||||
<article class="card system-card">
|
||||
<article class="card system-card" data-dashboard-widget="system_chart">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="systemLoad">SYSTEM LOAD</span>
|
||||
<h2><span id="system-cpu">-</span> <small>CPU</small></h2>
|
||||
@@ -289,7 +753,7 @@
|
||||
<div class="chart-footer"><span data-i18n="systemUptime">System uptime</span><span
|
||||
id="system-uptime">-</span></div>
|
||||
</article>
|
||||
<article class="card connection-card">
|
||||
<article class="card connection-card" data-dashboard-widget="connection_chart">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="connectionActivity">CONNECTION ACTIVITY</span>
|
||||
<h2><span id="connection-current">0</span> <small data-i18n="connections">Open
|
||||
@@ -312,9 +776,7 @@
|
||||
<div class="chart-footer"><span><span data-i18n="backends">Backends</span>: <b
|
||||
id="connection-backends">0</b></span></div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="diagnostics-grid">
|
||||
<article class="card">
|
||||
<article class="card" data-dashboard-widget="cache_diagnostics">
|
||||
<div class="mini-heading">
|
||||
<h3 data-i18n="cacheOverview">RAM cache</h3><span class="pill" id="cache-state">-</span>
|
||||
</div>
|
||||
@@ -409,7 +871,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<article class="card" data-dashboard-widget="process_diagnostics">
|
||||
<div class="mini-heading">
|
||||
<h3 data-i18n="processDiagnostics">Process</h3>
|
||||
<div class="mini-heading-actions">
|
||||
@@ -521,7 +983,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<article class="card" data-dashboard-widget="network_diagnostics">
|
||||
<div class="mini-heading">
|
||||
<h3 data-i18n="networkDiagnostics">Network and responses</h3><span class="meta"
|
||||
data-i18n="hostScope">OS scope</span>
|
||||
@@ -581,15 +1043,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<article class="card pool-card" id="backend-diagnostics-card">
|
||||
<article class="card pool-card" id="backend-diagnostics-card"
|
||||
data-dashboard-widget="backend_table">
|
||||
<div class="card-heading backend-card-heading">
|
||||
<h2 data-i18n="vhHealthTitle">Backends</h2>
|
||||
<div class="backend-table-tools">
|
||||
<label class="sr-only" data-i18n="backendSearch" for="pool-search">Search
|
||||
backends</label>
|
||||
<input autocomplete="off" data-i18n-aria="backendSearch"
|
||||
data-i18n-placeholder="backendSearchPlaceholder" id="pool-search" type="search" />
|
||||
data-i18n-placeholder="backendSearchPlaceholder" id="pool-search"
|
||||
type="search" />
|
||||
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
|
||||
by</span><select data-i18n-aria="backendSortBy" id="pool-sort">
|
||||
<option data-i18n="vhVhost" value="vhost">Vhost</option>
|
||||
@@ -600,13 +1063,15 @@
|
||||
<option data-i18n="vhErrors" value="errors">Errors</option>
|
||||
<option data-i18n="vhLatency" value="latency">Latency</option>
|
||||
<option data-i18n="vhTraffic" value="traffic">Traffic/s</option>
|
||||
<option data-i18n="vhClientGeneration" value="generation">Client gen.</option>
|
||||
<option data-i18n="vhClientGeneration" value="generation">Client gen.
|
||||
</option>
|
||||
<option data-i18n="vhGenerationRequests" value="generationRequests">Gen.
|
||||
requests</option>
|
||||
<option data-i18n="vhGenerationAge" value="generationAge">Gen. age</option>
|
||||
<option data-i18n="vhClientRotations" value="rotations">Rotations</option>
|
||||
<option data-i18n="vhLastFailure" value="lastFailure">Last failure</option>
|
||||
<option data-i18n="vhLastRecovery" value="lastRecovery">Last recovery</option>
|
||||
<option data-i18n="vhLastRecovery" value="lastRecovery">Last recovery
|
||||
</option>
|
||||
</select></label>
|
||||
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
|
||||
id="pool-sort-direction" type="button">↑</button>
|
||||
@@ -622,18 +1087,21 @@
|
||||
<label class="check"><input checked data-pool-column-toggle="active"
|
||||
type="checkbox" /><span data-i18n="vhActive">Active</span></label>
|
||||
<label class="check"><input checked data-pool-column-toggle="requests"
|
||||
type="checkbox" /><span data-i18n="vhRequests">Requests</span></label>
|
||||
type="checkbox" /><span
|
||||
data-i18n="vhRequests">Requests</span></label>
|
||||
<label class="check"><input checked data-pool-column-toggle="errors"
|
||||
type="checkbox" /><span data-i18n="vhErrors">Errors</span></label>
|
||||
<label class="check"><input checked data-pool-column-toggle="latency"
|
||||
type="checkbox" /><span data-i18n="vhLatency">Latency</span></label>
|
||||
<label class="check"><input checked data-pool-column-toggle="traffic"
|
||||
type="checkbox" /><span data-i18n="vhTraffic">Traffic/s</span></label>
|
||||
type="checkbox" /><span
|
||||
data-i18n="vhTraffic">Traffic/s</span></label>
|
||||
<label class="check"><input checked data-pool-column-toggle="generation"
|
||||
type="checkbox" /><span data-i18n="vhClientGeneration">Client
|
||||
gen.</span></label>
|
||||
<label class="check"><input checked data-pool-column-toggle="generationRequests"
|
||||
type="checkbox" /><span data-i18n="vhGenerationRequests">Gen.
|
||||
<label class="check"><input checked
|
||||
data-pool-column-toggle="generationRequests" type="checkbox" /><span
|
||||
data-i18n="vhGenerationRequests">Gen.
|
||||
requests</span></label>
|
||||
<label class="check"><input checked data-pool-column-toggle="generationAge"
|
||||
type="checkbox" /><span data-i18n="vhGenerationAge">Gen.
|
||||
@@ -667,11 +1135,15 @@
|
||||
<th data-i18n="vhTraffic" data-pool-column="traffic">Traffic/s</th>
|
||||
<th data-i18n="vhClientGeneration" data-pool-column="generation">Client gen.
|
||||
</th>
|
||||
<th data-i18n="vhGenerationRequests" data-pool-column="generationRequests">Gen.
|
||||
<th data-i18n="vhGenerationRequests" data-pool-column="generationRequests">
|
||||
Gen.
|
||||
requests</th>
|
||||
<th data-i18n="vhGenerationAge" data-pool-column="generationAge">Gen. age</th>
|
||||
<th data-i18n="vhClientRotations" data-pool-column="rotations">Rotations</th>
|
||||
<th data-i18n="vhLastFailure" data-pool-column="lastFailure">Last failure</th>
|
||||
<th data-i18n="vhGenerationAge" data-pool-column="generationAge">Gen. age
|
||||
</th>
|
||||
<th data-i18n="vhClientRotations" data-pool-column="rotations">Rotations
|
||||
</th>
|
||||
<th data-i18n="vhLastFailure" data-pool-column="lastFailure">Last failure
|
||||
</th>
|
||||
<th data-i18n="vhLastRecovery" data-pool-column="lastRecovery">Last recovery
|
||||
</th>
|
||||
</tr>
|
||||
@@ -681,6 +1153,7 @@
|
||||
</div>
|
||||
<p class="fine" data-i18n="vhHealthEmpty" id="pool-empty">No backend diagnostics yet.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" hidden="" id="panel-vhosts">
|
||||
<p class="notice fine" data-i18n="vhIntro">Each vhost owns its bound IPs, paths and backends.
|
||||
@@ -1899,7 +2372,10 @@
|
||||
<div class="appearance-settings-grid">
|
||||
<label><span data-i18n="theme">Theme</span><select id="ui-theme">
|
||||
<option data-i18n="themeDark" value="dark">Dark</option>
|
||||
<option data-i18n="themeUltraDark" value="ultra_dark">Ultra dark ·
|
||||
near-black</option>
|
||||
<option data-i18n="themeLight" value="light">Light</option>
|
||||
<option data-i18n="themeCream" value="cream">Warm cream</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="language">Language</span><select aria-label="Language"
|
||||
data-i18n-aria="language" id="language"></select></label>
|
||||
@@ -1922,6 +2398,18 @@
|
||||
</select><small class="fine" data-i18n="buttonStyleHelp">Normal uses the
|
||||
refreshed controls; Compact restores the smaller source-style buttons
|
||||
across all actions and dialog tabs.</small></label>
|
||||
<label><span data-i18n="cornerStyle">Corner rounding</span><select
|
||||
id="ui-corner-style">
|
||||
<option data-i18n="cornerStyleDefault" value="default">Original
|
||||
(theme-independent)</option>
|
||||
<option data-i18n="cornerStyleSquare" value="square">Square</option>
|
||||
<option data-i18n="cornerStyleSubtle" value="subtle">Subtle</option>
|
||||
<option data-i18n="cornerStyleRounded" value="rounded">Rounded</option>
|
||||
<option data-i18n="cornerStyleExtra" value="extra">Extra rounded
|
||||
</option>
|
||||
</select><small class="fine" data-i18n="cornerStyleHelp">Controls corners
|
||||
throughout the interface independently of the theme. Original keeps the
|
||||
previous geometry.</small></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="appearance-section">
|
||||
@@ -2002,6 +2490,10 @@
|
||||
data-i18n="dashboardWidgets">Overview cards</span><small class="fine"
|
||||
data-i18n="dashboardWidgetsHelp">Show, hide and reorder the telemetry
|
||||
cards at the top of Overview.</small>
|
||||
<button class="outline dashboard-layout-open-settings" type="button"
|
||||
id="overview-layout-open-settings"
|
||||
data-i18n="overviewLayoutEditorOpenSettings">Visual layout
|
||||
editor</button>
|
||||
<div class="dashboard-widget-list" id="dashboard-widget-list"></div>
|
||||
<small class="fine" data-i18n="streamWidgetSettingsHelp">Stream cards are
|
||||
opt-in and only change Dashboard presentation, not Stream routing or
|
||||
@@ -3328,6 +3820,67 @@
|
||||
type="button">Cancel</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="overview-layout-dialog-title" aria-modal="true" class="audit-dialog overview-layout-dialog"
|
||||
id="overview-layout-dialog">
|
||||
<div class="window-heading">
|
||||
<div><span class="small-label" data-i18n="dashboardWidgets">Overview cards</span>
|
||||
<h2 id="overview-layout-dialog-title" data-i18n="overviewLayoutEditorTitle">Arrange Overview cards</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-layout-body">
|
||||
<p class="fine" data-i18n="overviewLayoutEditorHelp">Drag visible cards to change their order. Changes are
|
||||
saved only when you select Save.</p>
|
||||
<section aria-labelledby="overview-layout-visible-title">
|
||||
<h3 id="overview-layout-visible-title" data-i18n="overviewLayoutEditorVisible">Visible cards</h3>
|
||||
<div class="overview-layout-grid" id="overview-layout-visible" role="list"></div>
|
||||
</section>
|
||||
<section aria-labelledby="overview-layout-hidden-title">
|
||||
<div class="overview-layout-section-heading">
|
||||
<h3 id="overview-layout-hidden-title" data-i18n="overviewLayoutEditorHidden">Hidden cards</h3>
|
||||
<div class="overview-layout-filters">
|
||||
<select id="overview-layout-type-filter" data-i18n-aria="overviewLayoutTypeFilter"
|
||||
aria-label="Filter blocks by type">
|
||||
<option value="all" data-i18n="overviewBlockTypeAll">All block types</option>
|
||||
<option value="value" data-i18n="overviewBlockTypeValue">Number</option>
|
||||
<option value="value_chart" data-i18n="overviewBlockTypeValueChart">Number + chart</option>
|
||||
<option value="chart" data-i18n="overviewBlockTypeChart">Chart</option>
|
||||
<option value="panel" data-i18n="overviewBlockTypePanel">Diagnostics panel</option>
|
||||
<option value="table" data-i18n="overviewBlockTypeTable">Table</option>
|
||||
</select>
|
||||
<input id="overview-layout-search" type="search"
|
||||
data-i18n-placeholder="overviewLayoutEditorSearch" aria-label="Find hidden card"
|
||||
data-i18n-aria="overviewLayoutEditorSearch" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-layout-hidden-list" id="overview-layout-hidden" role="list"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="overview-layout-footer">
|
||||
<p id="overview-layout-message" role="alert" aria-live="polite"></p>
|
||||
<button class="quiet" id="overview-layout-default" type="button"
|
||||
data-i18n="overviewLayoutDefaultPreview">Default view</button>
|
||||
<button class="outline" id="overview-layout-reset" type="button" data-i18n="overviewLayoutReset">Reset
|
||||
layout</button>
|
||||
<button class="quiet" id="overview-layout-cancel" type="button"
|
||||
data-i18n="overviewLayoutEditorCancel">Cancel</button>
|
||||
<button class="primary" id="overview-layout-save" type="button" data-i18n="overviewLayoutEditorSave">Save
|
||||
layout</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="overview-reset-confirm-title" aria-modal="true" class="audit-dialog overview-reset-confirm"
|
||||
id="overview-reset-confirm">
|
||||
<div class="window-heading">
|
||||
<h2 id="overview-reset-confirm-title" data-i18n="overviewLayoutResetTitle">Reset dashboard layout?</h2>
|
||||
</div>
|
||||
<p data-i18n="overviewLayoutResetConfirm">This restores the default order and visibility of all Overview blocks.
|
||||
Other settings remain unchanged.</p>
|
||||
<div class="overview-layout-footer">
|
||||
<button class="quiet" id="overview-reset-cancel" type="button"
|
||||
data-i18n="overviewLayoutEditorCancel">Cancel</button>
|
||||
<button class="primary" id="overview-reset-apply" type="button" data-i18n="overviewLayoutResetApply">Reset
|
||||
and save</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="tunnels-dialog-title" class="audit-dialog" id="tunnels-dialog">
|
||||
<div class="window-heading">
|
||||
<div><span class="small-label">WEBSOCKET / CONNECT</span>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
const node = id => document.getElementById(id);
|
||||
const themes = new Set(["dark", "light"]);
|
||||
const themes = new Set(["dark", "ultra_dark", "light", "cream"]);
|
||||
const corners = new Set(["default", "square", "subtle", "rounded", "extra"]);
|
||||
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
|
||||
try { const style = localStorage.getItem("rp-corner-style"); document.documentElement.dataset.cornerStyle = corners.has(style) ? style : "default"; } catch { document.documentElement.dataset.cornerStyle = "default"; }
|
||||
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/logs", "/settings"]);
|
||||
const settingsTabs = new Set(["appearance", "listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "geoip", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
|
||||
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
(() => {
|
||||
const buttons = [...document.querySelectorAll("[data-settings-tab]")];
|
||||
const search = $("settings-search"), searchStatus = $("settings-search-status");
|
||||
const defaultTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "listeners";
|
||||
const legacyTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "appearance";
|
||||
// Explicit URL tabs win. Otherwise reopen the last chosen Settings tab,
|
||||
// falling back to Appearance for new browsers and invalid stored values.
|
||||
let rememberedTab = null;
|
||||
try { rememberedTab = localStorage.getItem("rp-settings-last-tab"); } catch { }
|
||||
const defaultTab = legacyTab === "manual-config" ? legacyTab : (rememberedTab || "appearance");
|
||||
const requestedTab = typeof location !== "undefined" && location.pathname === "/logging"
|
||||
? "logging"
|
||||
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
|
||||
@@ -19,7 +24,8 @@
|
||||
host && (!pinned || host.replace(/\.$/, "").toLowerCase() !== pinned));
|
||||
}
|
||||
const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab;
|
||||
let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab;
|
||||
const validTab = tab => buttons.some(button => button.dataset.settingsTab === tab);
|
||||
let active = validTab(normalizedRequestedTab) ? normalizedRequestedTab : (validTab(defaultTab) ? defaultTab : "appearance");
|
||||
function applySettingsSearch() {
|
||||
const query = String(search?.value || "").trim().toLocaleLowerCase();
|
||||
const matches = [];
|
||||
@@ -41,6 +47,7 @@
|
||||
const target = buttons.find(button => button.dataset.settingsTab === name); if (!target) return;
|
||||
if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
|
||||
active = name;
|
||||
if (persist) { rememberedTab = name; try { localStorage.setItem("rp-settings-last-tab", name); } catch { } }
|
||||
if (typeof window !== "undefined") window.rustProxyRefresh?.setSettingsTab(name);
|
||||
for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); }
|
||||
document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name);
|
||||
@@ -277,6 +284,6 @@
|
||||
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
|
||||
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
|
||||
});
|
||||
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select(validTab(rememberedTab) ? rememberedTab : "appearance", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
select(active); render();
|
||||
})();
|
||||
+520
-4
@@ -132,6 +132,135 @@ html[data-theme="light"] {
|
||||
--scrollbar-thumb-hover: #879684
|
||||
}
|
||||
|
||||
|
||||
/* Optional AMOLED palette: near-black surfaces, readable borders and restrained
|
||||
mint/coral emphasis. Does not change the established dark appearance. */
|
||||
html[data-theme="ultra_dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #030404;
|
||||
--panel: #0b0d0e;
|
||||
--panel2: #121516;
|
||||
--line: #292f30;
|
||||
--muted: #a4afb0;
|
||||
--text: #f1f4f2;
|
||||
--accent: #93dfc8;
|
||||
--logo-accent: #f69b74;
|
||||
--logo-secondary: #cfe0ac;
|
||||
--green: #b1dcb0;
|
||||
--red: #ff9899;
|
||||
--sidebar-bg: #000000;
|
||||
--brand-secondary: #a9cfb5;
|
||||
--nav-active-bg: #172422;
|
||||
--nav-hover-bg: #121819;
|
||||
--text-subtle: #879695;
|
||||
--separator: #526261;
|
||||
--border-hover: #7c9f96;
|
||||
--field-bg: #090b0c;
|
||||
--field-line: #445352;
|
||||
--placeholder: #879593;
|
||||
--row-line: #202627;
|
||||
--outline-line: #79968f;
|
||||
--chart-grid: #303839;
|
||||
--code-bg: #0e1313;
|
||||
--code-alt-bg: #101a1d;
|
||||
--code-alt-text: #d5eae9;
|
||||
--nav-selected-bg: #213730;
|
||||
--accent-ink: #071814;
|
||||
--accent-hover: #b6f2dd;
|
||||
--selected-ink: #071814;
|
||||
--notice-text: #e6c99c;
|
||||
--notice-line: #66533a;
|
||||
--notice-bg: #201a11;
|
||||
--pill-line: #625544;
|
||||
--pill-live-line: #506c59;
|
||||
--toast-bg: #bcf3da;
|
||||
--toast-text: #10221a;
|
||||
--toast-line: #d8ffe8;
|
||||
--toast-error-bg: #ffd5d0;
|
||||
--toast-error-line: #ff9c96;
|
||||
--toast-error-text: #472625;
|
||||
--danger-line: #754245;
|
||||
--danger-line-strong: #965257;
|
||||
--warn-line: #89754c;
|
||||
--blue: #8bbdfb;
|
||||
--transform-chart: #cfaeff;
|
||||
--backdrop: rgb(0 0 0 / .86);
|
||||
--backdrop-soft: rgb(0 0 0 / .77);
|
||||
--shadow-mid: rgb(0 0 0 / .64);
|
||||
--shadow-strong: rgb(0 0 0 / .84);
|
||||
--neutral-soft: rgb(132 171 158 / .045);
|
||||
--scrollbar-track: #060808;
|
||||
--scrollbar-thumb: #364243;
|
||||
--scrollbar-thumb-hover: #617373;
|
||||
}
|
||||
|
||||
/* Warm, muted ivory. No pure-white canvas; numeric values and charts retain
|
||||
clear contrast against their slightly lighter, neutral card surfaces. */
|
||||
html[data-theme="cream"] {
|
||||
color-scheme: light;
|
||||
--bg: #f2ede3;
|
||||
--panel: #fcf9f2;
|
||||
--panel2: #eae3d6;
|
||||
--line: #d5cabb;
|
||||
--muted: #6b6257;
|
||||
--text: #302c27;
|
||||
--accent: #8c5942;
|
||||
--green: #5e7655;
|
||||
--red: #a54645;
|
||||
--sidebar-bg: #eae2d5;
|
||||
--brand-secondary: #668367;
|
||||
--nav-active-bg: #e0d3c0;
|
||||
--nav-hover-bg: #e6dccc;
|
||||
--text-subtle: #786e61;
|
||||
--separator: #afa494;
|
||||
--border-hover: #968776;
|
||||
--field-bg: #fffcf7;
|
||||
--field-line: #baad9a;
|
||||
--placeholder: #807466;
|
||||
--row-line: #e2d9cb;
|
||||
--outline-line: #9c8d7c;
|
||||
--chart-grid: #ddd3c4;
|
||||
--code-bg: #f0e8da;
|
||||
--code-alt-bg: #e7ebe9;
|
||||
--code-alt-text: #364f4e;
|
||||
--nav-selected-bg: #d9cbb7;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-hover: #774632;
|
||||
--selected-ink: #ffffff;
|
||||
--notice-text: #765e40;
|
||||
--notice-line: #d9c3a1;
|
||||
--notice-bg: #f8efdf;
|
||||
--pill-line: #c2a78d;
|
||||
--pill-live-line: #b0bd9d;
|
||||
--toast-bg: #d6ead2;
|
||||
--toast-text: #263d29;
|
||||
--toast-line: #b8d5b6;
|
||||
--toast-error-bg: #f8ded7;
|
||||
--toast-error-line: #dca99c;
|
||||
--toast-error-text: #60352f;
|
||||
--danger-line: #d2a29a;
|
||||
--danger-line-strong: #b97870;
|
||||
--warn-line: #b49c6b;
|
||||
--blue: #4d7292;
|
||||
--transform-chart: #83659a;
|
||||
--backdrop: rgb(37 27 16 / .44);
|
||||
--backdrop-soft: rgb(47 35 21 / .48);
|
||||
--shadow-mid: rgb(64 49 34 / .13);
|
||||
--shadow-strong: rgb(64 49 34 / .24);
|
||||
--neutral-soft: rgb(104 91 69 / .045);
|
||||
--scrollbar-track: #ece4d7;
|
||||
--scrollbar-thumb: #b5a997;
|
||||
--scrollbar-thumb-hover: #978a76;
|
||||
}
|
||||
|
||||
/* Theme palettes change color only; corners are controlled independently. */
|
||||
html[data-theme="ultra_dark"] :is(.card, .stat, dialog) {
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / .18);
|
||||
}
|
||||
html[data-theme="cream"] :is(.card, .stat, dialog) {
|
||||
box-shadow: 0 6px 20px rgb(78 58 38 / .055);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
@@ -198,7 +327,7 @@ html:not(.app-loading) .app-startup {
|
||||
}
|
||||
|
||||
.app-startup-brand span {
|
||||
color: var(--accent, #65cfa2)
|
||||
color: var(--logo-secondary, var(--accent, #65cfa2))
|
||||
}
|
||||
|
||||
.app-startup-spinner {
|
||||
@@ -361,8 +490,8 @@ h2 {
|
||||
|
||||
.brand-mark {
|
||||
font: 700 33px var(--mono);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--logo-accent, var(--accent));
|
||||
color: var(--logo-accent, var(--accent));
|
||||
place-content: center;
|
||||
position: relative
|
||||
}
|
||||
@@ -371,7 +500,7 @@ h2 {
|
||||
font-size: 22px;
|
||||
left: 29px;
|
||||
top: 19px;
|
||||
color: var(--brand-secondary)
|
||||
color: var(--logo-secondary, var(--brand-secondary))
|
||||
}
|
||||
|
||||
.edition {
|
||||
@@ -10176,3 +10305,390 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
/* Optional HTTP backend mode distribution (configured modes, not negotiated ALPN). */
|
||||
#panel-overview .backend-mode-bar {
|
||||
display: flex;
|
||||
height: 7px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
gap: 2px;
|
||||
border-radius: 5px;
|
||||
background: var(--line);
|
||||
margin: 7px 0 5px;
|
||||
}
|
||||
#panel-overview .backend-mode-bar span { min-width: 0; }
|
||||
#panel-overview .backend-mode-h1 { background: var(--traffic-h1); }
|
||||
#panel-overview .backend-mode-auto { background: var(--traffic-h2); }
|
||||
#panel-overview .backend-mode-compat { background: var(--traffic-h2); opacity: .5; }
|
||||
#panel-overview .backend-mode-h2 { background: var(--traffic-h3); }
|
||||
|
||||
#panel-overview .backend-mode-bar[aria-label] { background-clip: padding-box; }
|
||||
.vh-origin-protocol-info { color: var(--muted); }
|
||||
.vh-origin-protocol-warning { color: var(--muted); }
|
||||
|
||||
/* Overview arrangement dialog: visual clones never participate in metric updates. */
|
||||
.overview-layout-dialog {
|
||||
width: min(1180px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 24px);
|
||||
overflow: auto;
|
||||
}
|
||||
.overview-layout-dialog > .window-heading {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.overview-layout-body { display: grid; gap: 16px; padding: 14px; }
|
||||
.overview-layout-body h3 { font-size: 14px; margin: 0 0 10px; }
|
||||
.overview-layout-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--ui-gap, 10px); }
|
||||
.overview-layout-tile { min-width: 0; border: 1px solid var(--line); background: var(--panel); cursor: grab; }
|
||||
.overview-layout-tile:active { cursor: grabbing; }
|
||||
.overview-layout-tile.is-dragging { opacity: .4; }
|
||||
.overview-layout-tile.is-drop-target { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.overview-layout-tile-toolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border-bottom: 1px solid var(--line); }
|
||||
.overview-layout-tile-toolbar button { min-height: 28px; min-width: 28px; padding: 3px 7px; }
|
||||
.overview-layout-grip { padding: 0 5px; color: var(--muted); user-select: none; }
|
||||
.overview-layout-hide { margin-inline-start: auto; font-size: 11px; }
|
||||
.overview-layout-grid .stat { border: 0; padding: 12px; min-height: 98px; pointer-events: none; }
|
||||
.overview-layout-grid .stat > span { font-size: 11px; }
|
||||
.overview-layout-grid .stat strong { font-size: 24px; margin-top: 7px; overflow-wrap: anywhere; }
|
||||
.overview-layout-grid .stat small { display: block; font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.overview-layout-grid .stat svg { width: 100%; height: 53px; }
|
||||
.overview-layout-grid .stat-chart-legend { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.overview-layout-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.overview-layout-section-heading input { max-width: 230px; }
|
||||
.overview-layout-hidden-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.overview-layout-hidden-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--line); padding: 8px; background: var(--panel); min-width: 0; }
|
||||
.overview-layout-hidden-item span { font-size: 12px; min-width: 0; overflow-wrap: anywhere; }
|
||||
.overview-layout-hidden-item button { flex-shrink: 0; }
|
||||
.overview-layout-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px; border-top: 1px solid var(--line); background: var(--bg); z-index: 2; }
|
||||
.overview-layout-footer p { margin: 0 auto 0 0; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.dashboard-layout-open-settings { margin: 5px 0; justify-self: start; }
|
||||
@media (max-width: 850px) {
|
||||
.overview-layout-grid, .overview-layout-hidden-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 550px) {
|
||||
.overview-layout-grid, .overview-layout-hidden-list { grid-template-columns: minmax(0, 1fr); }
|
||||
.overview-layout-section-heading { align-items: stretch; flex-direction: column; }
|
||||
.overview-layout-section-heading input { max-width: none; width: 100%; }
|
||||
.overview-layout-footer { flex-wrap: wrap; }
|
||||
.overview-layout-footer p { width: 100%; }
|
||||
}
|
||||
|
||||
/* One rearrangeable Overview canvas. Existing large charts, diagnostics and the
|
||||
backend table keep their original nodes/IDs and their data update handlers. */
|
||||
#panel-overview .dashboard-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: var(--ui-gap);
|
||||
align-items: stretch;
|
||||
}
|
||||
#panel-overview .dashboard-overview-grid > [data-dashboard-widget] { min-width: 0; }
|
||||
#panel-overview .dashboard-overview-grid > [hidden] { display: none !important; }
|
||||
#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: span 2; }
|
||||
#panel-overview .dashboard-overview-grid > .pool-card { grid-column: 1 / -1; margin-bottom: 0; }
|
||||
#panel-overview .dashboard-overview-grid > .activity-card svg { min-width: 0; }
|
||||
@media (max-width: 1200px) {
|
||||
#panel-overview .dashboard-overview-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
#panel-overview .dashboard-overview-grid > .stat { grid-column: span 2; }
|
||||
#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: span 2; }
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
#panel-overview .dashboard-overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
#panel-overview .dashboard-overview-grid > .stat { grid-column: span 1; }
|
||||
#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 550px) {
|
||||
#panel-overview .dashboard-overview-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
#panel-overview .dashboard-overview-grid > .stat { grid-column: 1; }
|
||||
}
|
||||
.overview-layout-tile.is-panel { grid-column: span 2; }
|
||||
.overview-layout-grid .overview-layout-panel-preview {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 12px;
|
||||
min-height: 98px;
|
||||
background: var(--panel);
|
||||
pointer-events: none;
|
||||
}
|
||||
.overview-layout-panel-preview > strong { font-size: 13px; }
|
||||
.overview-layout-panel-preview svg { width: 100%; height: 80px; }
|
||||
.overview-layout-panel-preview small { color: var(--muted); }
|
||||
.overview-layout-footer #overview-layout-default { margin-right: auto; }
|
||||
.overview-reset-confirm { width: min(460px, calc(100vw - 24px)); padding: 16px; }
|
||||
.overview-reset-confirm > p { line-height: 1.5; }
|
||||
@media (max-width: 850px) { .overview-layout-tile.is-panel { grid-column: span 2; } }
|
||||
@media (max-width: 550px) { .overview-layout-tile.is-panel { grid-column: span 1; } }
|
||||
|
||||
/* Overview block kinds come from the same server-validated registry as the
|
||||
saved order. Labels distinguish numbers, plots, tables and diagnostic panels. */
|
||||
.overview-widget-type {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--panel2);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dashboard-widget-setting .dashboard-widget-toggle { flex-wrap: wrap; }
|
||||
.dashboard-widget-setting .overview-widget-type { margin-left: 15px; }
|
||||
.overview-layout-tile-toolbar .overview-widget-type { margin-left: 3px; }
|
||||
.overview-layout-hidden-item .overview-widget-type { flex: 0 0 auto; }
|
||||
.overview-layout-filters { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.overview-layout-filters select, .overview-layout-filters input { width: min(220px, 100%); max-width: 100%; }
|
||||
|
||||
/* Inline editing of the actual Overview blocks; live charts and original DOM
|
||||
IDs remain in place. Dragging is enabled only on the explicit grip button. */
|
||||
.overview-inline-actions { display: inline-flex; gap: 7px; align-items: center; flex-wrap: wrap; }
|
||||
.overview-inline-actions[hidden], .overview-inline-hint[hidden] { display: none !important; }
|
||||
.overview-inline-hint { margin: 0 0 10px; }
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] {
|
||||
position: relative;
|
||||
outline: 1px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
padding-top: 66px;
|
||||
}
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-dragging { opacity: .45; }
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-drop-before {
|
||||
box-shadow: inset 4px 0 var(--accent);
|
||||
}
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-drop-after {
|
||||
box-shadow: inset -4px 0 var(--accent);
|
||||
}
|
||||
.overview-inline-toolbar {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#panel-overview .overview-inline-toolbar button {
|
||||
min-width: 28px;
|
||||
min-height: 27px;
|
||||
padding: 2px 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-grip { cursor: grab; user-select: none; }
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-grip:active { cursor: grabbing; }
|
||||
#panel-overview .overview-inline-toolbar .overview-widget-type { margin-left: auto; }
|
||||
@media (max-width: 600px) {
|
||||
.overview-layout-hidden-item { flex-wrap: wrap; }
|
||||
.overview-layout-filters select, .overview-layout-filters input { width: 100%; }
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { padding-top: 68px; }
|
||||
}
|
||||
|
||||
|
||||
/* Direct Overview editing: hide is reversible until Save and remains
|
||||
recoverable later from the block selector or the compact hidden shelf. */
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-hide {
|
||||
color: var(--red);
|
||||
border: 1px solid var(--danger-line);
|
||||
background: var(--panel);
|
||||
/* Use the same .quiet radius/corner preset as the arrow buttons. */
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-hide:hover,
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-hide:focus-visible {
|
||||
border-color: var(--danger-line-strong);
|
||||
background: var(--notice-bg);
|
||||
}
|
||||
.overview-inline-hidden {
|
||||
margin: 0 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
.overview-inline-hidden > summary {
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.overview-inline-hidden > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.overview-inline-hidden-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
max-height: min(30vh, 240px);
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.overview-inline-hidden-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 100%;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
color: var(--text);
|
||||
background: var(--panel2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.overview-inline-hidden-item > span:not(.overview-widget-type) { overflow-wrap: anywhere; }
|
||||
.overview-inline-hidden-item > button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 27px;
|
||||
min-height: 27px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--field-line);
|
||||
color: var(--green);
|
||||
background: var(--panel);
|
||||
}
|
||||
.overview-inline-hidden-item > button:hover { border-color: var(--accent); }
|
||||
@media (max-width: 600px) {
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { padding-top: 82px; }
|
||||
.overview-inline-hidden-item { max-width: 100%; }
|
||||
}
|
||||
|
||||
/* The original fixed Overview grid, also used for personalized layouts.
|
||||
Metrics, wide charts, diagnostic panels and tables are separate rows;
|
||||
every tile in a row stretches to the SAME height. This intentionally
|
||||
favors symmetry over masonry or floating short cards beside tall panels.
|
||||
Existing density controls define the identical horizontal/vertical gap. */
|
||||
#panel-overview { container: overview-canvas / inline-size; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: auto;
|
||||
align-items: stretch;
|
||||
align-content: start;
|
||||
gap: var(--ui-gap);
|
||||
}
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget] {
|
||||
grid-column: auto / span 2;
|
||||
grid-row: auto;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
margin-block: 0;
|
||||
position: relative;
|
||||
}
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: auto / span 4; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-wide-chart { grid-column: auto / span 6; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .pool-card { grid-column: 1 / -1; }
|
||||
/* Each size category starts at the first track, even after drag/drop. */
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .overview-row-start,
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-row-start { grid-column-start: 1; }
|
||||
@container overview-canvas (max-width: 1080px) {
|
||||
#panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 2; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: auto / span 3; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-wide-chart,
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .pool-card { grid-column: 1 / -1; }
|
||||
}
|
||||
@container overview-canvas (max-width: 700px) {
|
||||
#panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 1; }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: 1 / -1; }
|
||||
}
|
||||
@container overview-canvas (max-width: 440px) {
|
||||
#panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: minmax(0, 1fr); }
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: 1; }
|
||||
}
|
||||
/* Keep category boundaries intact at every responsive breakpoint. */
|
||||
#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].overview-row-start { grid-column-start: 1; }
|
||||
|
||||
/* Full-size optional charts: static SVG geometry and bounded historical data. */
|
||||
#panel-overview .overview-wide-chart { padding: 15px; overflow: hidden; }
|
||||
#panel-overview .overview-wide-chart .card-heading { min-width: 0; align-items: baseline; gap: 10px; }
|
||||
#panel-overview .overview-wide-chart h2 { min-width: 0; overflow-wrap: anywhere; }
|
||||
#panel-overview .overview-wide-summary { color: var(--accent); font: 500 12px var(--mono); white-space: nowrap; }
|
||||
#panel-overview .overview-wide-svg { display: block; width: 100%; height: clamp(125px, 13vw, 190px); margin-block: 13px 9px; }
|
||||
#panel-overview .overview-wide-svg .chart-grid { stroke: var(--line); fill: none; stroke-width: 1; opacity: .6; }
|
||||
#panel-overview .overview-wide-series { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
#panel-overview .overview-wide-series-0 { stroke: var(--chart-accent); }
|
||||
#panel-overview .overview-wide-series-1 { stroke: var(--chart-secondary); }
|
||||
#panel-overview .overview-wide-series-2 { stroke: var(--chart-tertiary); }
|
||||
#panel-overview .overview-wide-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; color: var(--muted); font-size: 11px; }
|
||||
#panel-overview .overview-wide-legend > span { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
#panel-overview .overview-wide-legend i { width: 8px; height: 8px; border-radius: 100%; background: var(--chart-accent); }
|
||||
#panel-overview .overview-wide-legend .overview-wide-key-1 { background: var(--chart-secondary); }
|
||||
#panel-overview .overview-wide-legend .overview-wide-key-2 { background: var(--chart-tertiary); }
|
||||
#panel-overview .overview-wide-legend b { font: 500 11px var(--mono); color: var(--text); }
|
||||
/* Block metadata stays to the left of every action group. */
|
||||
.dashboard-widget-setting .dashboard-widget-toggle { flex-wrap: nowrap; }
|
||||
.dashboard-widget-setting .dashboard-widget-toggle .overview-widget-type { margin: 0 0 0 auto; flex: 0 0 auto; }
|
||||
.dashboard-widget-setting .dashboard-widget-setting-actions { justify-self: end; flex-shrink: 0; }
|
||||
.overview-layout-tile-toolbar { flex-wrap: wrap; }
|
||||
.overview-layout-tile-toolbar .overview-widget-type { margin: 0; }
|
||||
.overview-layout-tile-toolbar .overview-layout-controls { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; }
|
||||
.overview-layout-hidden-item > button { margin-left: auto; }
|
||||
.overview-layout-hidden-item > .overview-widget-type { margin: 0; }
|
||||
#panel-overview .overview-inline-toolbar .overview-widget-type { margin: 0 auto 0 0; }
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-hide { margin-left: 0; }
|
||||
.overview-inline-hidden-item > .overview-widget-type { margin-right: 0; }
|
||||
.overview-inline-hidden-item > .overview-inline-restore { order: 3; margin-left: auto; }
|
||||
|
||||
/* Appearance selector: always show the block-kind label, independently from
|
||||
the visibility button, with actions pinned to the right edge. */
|
||||
.dashboard-widget-list {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
|
||||
}
|
||||
.dashboard-widget-setting {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
}
|
||||
.dashboard-widget-setting .dashboard-widget-toggle {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.dashboard-widget-setting .dashboard-widget-setting-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.dashboard-widget-setting > .overview-widget-type {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
.dashboard-widget-setting > .dashboard-widget-setting-actions {
|
||||
justify-self: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Global shape preference. "default" applies NO overrides so existing dark,
|
||||
light, cream and Ultra Dark keep the same established corner geometry.
|
||||
Circular status dots, spinners, switches and chart markers retain their
|
||||
semantic shape; underline tabs and navigation accent strips stay intact. */
|
||||
html[data-corner-style="square"] { --rp-radius-surface: 0px; --rp-radius-control: 0px; }
|
||||
html[data-corner-style="subtle"] { --rp-radius-surface: 5px; --rp-radius-control: 4px; }
|
||||
html[data-corner-style="rounded"] { --rp-radius-surface: 12px; --rp-radius-control: 8px; }
|
||||
html[data-corner-style="extra"] { --rp-radius-surface: 18px; --rp-radius-control: 12px; }
|
||||
html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is(
|
||||
.card, .stat, dialog, .login-card, .appearance-section, .settings-tabs,
|
||||
.dashboard-widget-setting, .overview-layout-tile, .overview-layout-hidden-item,
|
||||
.overview-inline-hidden-item, .notice, .toast,
|
||||
.chart-style-choice, .stream-dashboard-card, .pool-card,
|
||||
.file-log-advanced-parser, .table-container, .metric-info-popover
|
||||
) { border-radius: var(--rp-radius-surface) !important; }
|
||||
html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is(
|
||||
button:not(.nav):not(.file-log-tab), input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||
select, textarea, .pill, .overview-widget-type, .badge, .quiet, .outline, .primary,
|
||||
.code, .command, .dashboard-widget-toggle, .settings-tabs button,
|
||||
.overview-inline-hidden-toggle, .overview-inline-restore
|
||||
) { border-radius: var(--rp-radius-control) !important; }
|
||||
|
||||
/* Appearance stays badge-free; badges remain useful in the visual editor. */
|
||||
#settings-page-appearance .dashboard-widget-setting .overview-widget-type { display: none; }
|
||||
@@ -198,7 +198,8 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
|
||||
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"], ["h2", "vhProtocolH2Strict"]] });
|
||||
attachOriginProtocolDetails(main, protocol, url);
|
||||
|
||||
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
|
||||
const controls = {};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
function attachOriginProtocolDetails(main, protocol, url) {
|
||||
const transportInfo = el("small", undefined, "fine vh-wide vh-origin-protocol-info");
|
||||
const compatibility = el("small", undefined, "fine vh-wide vh-origin-protocol-warning");
|
||||
compatibility.dataset.i18n = "vhProtocolCompatibilityWarning";
|
||||
compatibility.textContent = t("vhProtocolCompatibilityWarning");
|
||||
main.append(transportInfo, compatibility);
|
||||
const explainProtocol = () => {
|
||||
const https = /^https:\/\//i.test(url.value.trim());
|
||||
const name = protocol.value === "http1" ? "vhProtocolHttp1Details" :
|
||||
protocol.value === "auto" ? (https ? "vhProtocolAutoHttpsDetails" : "vhProtocolAutoHttpDetails") :
|
||||
protocol.value === "http2" ? (https ? "vhProtocolLegacyHttp2HttpsDetails" : "vhProtocolLegacyHttp2HttpDetails") :
|
||||
(https ? "vhProtocolH2HttpsDetails" : "vhProtocolH2HttpDetails");
|
||||
transportInfo.dataset.i18n = name;
|
||||
transportInfo.textContent = t(name);
|
||||
compatibility.hidden = protocol.value !== "http2" || !https;
|
||||
};
|
||||
protocol.addEventListener("change", explainProtocol);
|
||||
url.addEventListener("input", explainProtocol);
|
||||
explainProtocol();
|
||||
}
|
||||
@@ -26,9 +26,13 @@ impl Pool {
|
||||
let mut item = serde_json::Map::new();
|
||||
item.insert("id".into(), json!(m.definition.id));
|
||||
item.insert("url".into(), json!(m.definition.url));
|
||||
item.insert("protocol".into(), json!(m.definition.protocol));
|
||||
item.insert("weight".into(), json!(m.definition.weight));
|
||||
item.insert("backup".into(), json!(m.definition.backup));
|
||||
item.insert("health_check_enabled".into(), json!(m.definition.health_check_enabled));
|
||||
item.insert(
|
||||
"health_check_enabled".into(),
|
||||
json!(m.definition.health_check_enabled),
|
||||
);
|
||||
item.insert("health_mode".into(), json!(member_health.mode));
|
||||
item.insert(
|
||||
"max_concurrent_requests".into(),
|
||||
|
||||
@@ -136,8 +136,13 @@ pub struct Backend {
|
||||
pub enum UpstreamProtocol {
|
||||
#[default]
|
||||
Http1,
|
||||
/// Legacy value `http2`: HTTPS uses ALPN H2/H1, HTTP uses H2C prior knowledge.
|
||||
/// Retain its 0.32.2 meaning when loading unchanged saved configurations.
|
||||
Http2,
|
||||
/// HTTPS negotiates H2/H1; plaintext uses H1 without H2C probing.
|
||||
Auto,
|
||||
/// New explicit mode `h2`: require H2 for HTTPS and H2C for cleartext.
|
||||
H2,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
|
||||
@@ -235,7 +235,8 @@ let downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h1()) {
|
||||
None
|
||||
};
|
||||
let h2_downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h2())
|
||||
&& (backend.protocol != crate::config::UpstreamProtocol::Http2
|
||||
&& (!matches!(backend.protocol,
|
||||
crate::config::UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2)
|
||||
|| websocket_legacy_support)
|
||||
{
|
||||
Some(std::mem::replace(request.body_mut(), Body::empty()))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
let websocket_upstream_h2 = !websocket_legacy_support
|
||||
&& handshake.as_ref().is_some_and(|handshake| handshake.h2())
|
||||
&& backend.protocol == crate::config::UpstreamProtocol::Http2;
|
||||
&& matches!(backend.protocol,
|
||||
crate::config::UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2);
|
||||
if let Some(handshake) = &handshake {
|
||||
request.headers_mut().remove("content-length");
|
||||
request.headers_mut().remove("transfer-encoding");
|
||||
|
||||
@@ -32,6 +32,27 @@ mod authority_tests {
|
||||
Version::HTTP_2
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required() {
|
||||
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].backends[0].url = "https://127.0.0.1:9000".to_owned();
|
||||
config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::Http2;
|
||||
let snapshot = crate::config::compile(config.clone(), 1, &validation_policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
let origin = route.backend.as_ref().unwrap();
|
||||
assert_eq!(upstream_request_version(origin, false), Version::HTTP_11);
|
||||
// Extended CONNECT still uses H2 only in its existing WebSocket path.
|
||||
assert_eq!(upstream_request_version(origin, true), Version::HTTP_2);
|
||||
|
||||
config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2;
|
||||
let snapshot = crate::config::compile(config, 2, &validation_policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
assert_eq!(
|
||||
upstream_request_version(route.backend.as_ref().unwrap(), false),
|
||||
Version::HTTP_2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_body_capable_methods_without_length_become_known_empty_for_h1() {
|
||||
use bytes::Bytes;
|
||||
@@ -297,18 +318,23 @@ mod authority_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() {
|
||||
#[test]
|
||||
fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() {
|
||||
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
|
||||
let backend = snapshot.select("localhost", "/").unwrap().backend.as_ref().unwrap();
|
||||
let backend = snapshot
|
||||
.select("localhost", "/")
|
||||
.unwrap()
|
||||
.backend
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let suffix = "/convert?url=https%3A//easylist.to/easylist/easylist.txt&ip=0.0.0.0";
|
||||
let uri = upstream_uri(backend, suffix).unwrap();
|
||||
assert_eq!(uri.path_and_query().unwrap().as_str(), suffix);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_fallback_replays_empty_unknown_length_h2_h3_get_and_head() {
|
||||
#[tokio::test]
|
||||
async fn error_fallback_replays_empty_unknown_length_h2_h3_get_and_head() {
|
||||
use bytes::Bytes;
|
||||
|
||||
for version in [Version::HTTP_2, Version::HTTP_3] {
|
||||
@@ -330,18 +356,21 @@ async fn error_fallback_replays_empty_unknown_length_h2_h3_get_and_head() {
|
||||
"an empty {method:?} request over {version:?} should allow fallback"
|
||||
);
|
||||
assert!(request.body().is_end_stream());
|
||||
assert_eq!(request.uri().path_and_query().unwrap().as_str(), "/missing?query=1");
|
||||
assert_eq!(
|
||||
request.uri().path_and_query().unwrap().as_str(),
|
||||
"/missing?query=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_fallback_preserves_unknown_length_get_payload_without_replaying_it() {
|
||||
#[tokio::test]
|
||||
async fn error_fallback_preserves_unknown_length_get_payload_without_replaying_it() {
|
||||
use bytes::Bytes;
|
||||
|
||||
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(
|
||||
Bytes::from_static(b"unexpected-payload"),
|
||||
)]);
|
||||
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(
|
||||
b"unexpected-payload",
|
||||
))]);
|
||||
let mut request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.version(Version::HTTP_3)
|
||||
@@ -357,10 +386,10 @@ async fn error_fallback_preserves_unknown_length_get_payload_without_replaying_i
|
||||
request.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"unexpected-payload")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_fallback_does_not_poll_unknown_length_post_payload() {
|
||||
#[tokio::test]
|
||||
async fn error_fallback_does_not_poll_unknown_length_post_payload() {
|
||||
use bytes::Bytes;
|
||||
|
||||
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
@@ -375,10 +404,10 @@ async fn error_fallback_does_not_poll_unknown_length_post_payload() {
|
||||
Err(response) => panic!("unexpected status {}", response.status()),
|
||||
};
|
||||
assert!(matches!(result, ErrorFallbackBody::Unavailable));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fallback_terminal_404_html_takes_precedence_over_builtin_error_page() {
|
||||
#[tokio::test]
|
||||
async fn fallback_terminal_404_html_takes_precedence_over_builtin_error_page() {
|
||||
use bytes::Bytes;
|
||||
|
||||
let mut response = Response::builder()
|
||||
@@ -408,10 +437,10 @@ async fn fallback_terminal_404_html_takes_precedence_over_builtin_error_page() {
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(should_replace_error_page(&response, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn application_404_is_not_overwritten_by_global_pages_when_interception_is_off() {
|
||||
#[tokio::test]
|
||||
async fn application_404_is_not_overwritten_by_global_pages_when_interception_is_off() {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
for source in ["reverse_proxy", "web_server_php"] {
|
||||
@@ -421,35 +450,53 @@ async fn application_404_is_not_overwritten_by_global_pages_when_interception_is
|
||||
.unwrap();
|
||||
// Both paths use intercept_route_origin_error to set this marker
|
||||
// once the origin response reaches Response<Body>.
|
||||
response.extensions_mut().insert(crate::pages::OriginProvidedResponse);
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::pages::OriginProvidedResponse);
|
||||
assert!(!should_replace_error_page(&response, false), "{source}");
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(bytes.as_ref(), format!("{source}: origin 404 body").as_bytes());
|
||||
assert_eq!(
|
||||
bytes.as_ref(),
|
||||
format!("{source}: origin 404 body").as_bytes()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compiled_vhost_origin_interception_controls_application_404() {
|
||||
#[tokio::test]
|
||||
async fn compiled_vhost_origin_interception_controls_application_404() {
|
||||
for enabled in [false, true] {
|
||||
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.pages.errors.insert(404, crate::pages::Page {
|
||||
config.pages.errors.insert(
|
||||
404,
|
||||
crate::pages::Page {
|
||||
kind: crate::pages::Kind::Html,
|
||||
status: Some(503),
|
||||
html: "RustProxy custom error page".into(),
|
||||
});
|
||||
},
|
||||
);
|
||||
if enabled {
|
||||
config.vhosts[0].pages.intercept_statuses = vec![404];
|
||||
}
|
||||
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
let response = Response::builder().status(StatusCode::NOT_FOUND)
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("application custom 404"))
|
||||
.unwrap();
|
||||
let response = intercept_route_origin_error(response, route, &snapshot);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert!(response.extensions().get::<crate::pages::OriginProvidedResponse>().is_some());
|
||||
assert!(response
|
||||
.extensions()
|
||||
.get::<crate::pages::OriginProvidedResponse>()
|
||||
.is_some());
|
||||
assert!(!should_replace_error_page(&response, false));
|
||||
assert_eq!(response.extensions().get::<crate::pages::PageServed>().is_some(), enabled);
|
||||
assert_eq!(
|
||||
response
|
||||
.extensions()
|
||||
.get::<crate::pages::PageServed>()
|
||||
.is_some(),
|
||||
enabled
|
||||
);
|
||||
if enabled {
|
||||
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
|
||||
}
|
||||
@@ -460,5 +507,5 @@ async fn compiled_vhost_origin_interception_controls_application_404() {
|
||||
assert_eq!(bytes, "application custom 404");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -14,13 +14,13 @@ use axum::{
|
||||
Extension, Router,
|
||||
};
|
||||
use body::{Direction, Exchange, MeteredBody};
|
||||
use bytes::Bytes;
|
||||
use http::{
|
||||
header::{HeaderValue, HOST, USER_AGENT},
|
||||
HeaderMap, Method, StatusCode, Uri, Version,
|
||||
};
|
||||
use http_body::Body as HttpBody;
|
||||
use http_body_util::BodyExt;
|
||||
use bytes::Bytes;
|
||||
#[derive(Clone)]
|
||||
struct ResponseRulesApplied;
|
||||
#[derive(Clone)]
|
||||
@@ -162,7 +162,13 @@ fn upstream_request_version(
|
||||
backend: &crate::config::CompiledBackend,
|
||||
websocket_h2: bool,
|
||||
) -> Version {
|
||||
if websocket_h2 || backend.protocol == crate::config::UpstreamProtocol::Http2 {
|
||||
// HTTPS legacy `http2` must still permit H1 after ALPN negotiation.
|
||||
// Cleartext legacy `http2` and strict `h2` use H2 (prior knowledge on HTTP).
|
||||
if websocket_h2
|
||||
|| backend.protocol == crate::config::UpstreamProtocol::H2
|
||||
|| (backend.protocol == crate::config::UpstreamProtocol::Http2
|
||||
&& backend.origin.starts_with("http://"))
|
||||
{
|
||||
Version::HTTP_2
|
||||
} else {
|
||||
Version::HTTP_11
|
||||
|
||||
+21
-9
@@ -173,12 +173,7 @@ impl RotatingClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate(
|
||||
&self,
|
||||
observed_generation: u64,
|
||||
reason: &'static str,
|
||||
triggering_request: bool,
|
||||
) {
|
||||
fn rotate(&self, observed_generation: u64, reason: &'static str, triggering_request: bool) {
|
||||
if self.generation.load(Ordering::Acquire) != observed_generation
|
||||
|| self
|
||||
.rotating
|
||||
@@ -272,9 +267,12 @@ fn build_raw_client(factory: &ClientFactory) -> Result<RawProxyClient> {
|
||||
let keepalive = &factory.keepalive;
|
||||
let timeout = factory.timeout;
|
||||
let is_https = url::Url::parse(&backend.url)?.scheme() == "https";
|
||||
// WebSocket HTTP/1.1 Upgrade retains its isolated H1 client. Preserve
|
||||
// the 0.32.2 `http2` meaning: HTTPS allows H2/H1 via ALPN, while
|
||||
// plaintext uses H2C. Only the NEW `h2` value is strict on HTTPS.
|
||||
let protocol = if websocket {
|
||||
UpstreamProtocol::Http1
|
||||
} else if backend.protocol == UpstreamProtocol::Http2 && is_https {
|
||||
} else if is_https && backend.protocol == UpstreamProtocol::Http2 {
|
||||
UpstreamProtocol::Auto
|
||||
} else {
|
||||
backend.protocol
|
||||
@@ -326,9 +324,20 @@ fn build_raw_client(factory: &ClientFactory) -> Result<RawProxyClient> {
|
||||
let builder = HttpsConnectorBuilder::new()
|
||||
.with_tls_config(config)
|
||||
.https_or_http();
|
||||
// TLS Auto offers h2 then HTTP/1.1 in a single handshake. Negotiating
|
||||
// HTTP/1.1 here happens *before* any request is sent: there is no unsafe
|
||||
// automatic replay after a timed-out or partially delivered POST/PATCH.
|
||||
// Cleartext Auto uses H1; H2C requires the explicit Http2 mode.
|
||||
let protocol = if !is_https && protocol == UpstreamProtocol::Auto {
|
||||
UpstreamProtocol::Http1
|
||||
} else {
|
||||
protocol
|
||||
};
|
||||
let inner = match protocol {
|
||||
UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http),
|
||||
UpstreamProtocol::Http2 => builder.enable_http2().wrap_connector(http),
|
||||
UpstreamProtocol::Http2 | UpstreamProtocol::H2 => {
|
||||
builder.enable_http2().wrap_connector(http)
|
||||
}
|
||||
UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http),
|
||||
};
|
||||
let mut builder = Client::builder(TokioExecutor::new());
|
||||
@@ -343,7 +352,10 @@ fn build_raw_client(factory: &ClientFactory) -> Result<RawProxyClient> {
|
||||
0
|
||||
})
|
||||
.retry_canceled_requests(false)
|
||||
.http2_only(protocol == UpstreamProtocol::Http2)
|
||||
.http2_only(matches!(
|
||||
protocol,
|
||||
UpstreamProtocol::Http2 | UpstreamProtocol::H2
|
||||
))
|
||||
.timer(TokioTimer::new())
|
||||
.http2_keep_alive_interval(
|
||||
keepalive
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// Real Hyper + Rustls upstream peers, independent of the Python mock.
|
||||
async fn tls_origin(
|
||||
offer_h2: bool,
|
||||
) -> (
|
||||
std::net::SocketAddr,
|
||||
tokio::sync::oneshot::Receiver<(Option<Vec<u8>>, Version)>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let pair = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||
let key = rustls::pki_types::PrivatePkcs8KeyDer::from(pair.signing_key.serialize_der());
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut config = rustls::ServerConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![pair.cert.der().clone()], key.into())
|
||||
.unwrap();
|
||||
config.alpn_protocols = if offer_h2 {
|
||||
vec![b"h2".to_vec(), b"http/1.1".to_vec()]
|
||||
} else {
|
||||
vec![b"http/1.1".to_vec()]
|
||||
};
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
|
||||
let (observed_tx, observed_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
// For an H2-only client and H1-only server, negotiation can fail
|
||||
// before any HTTP request is delivered. That is the desired error.
|
||||
let Ok(tls) = acceptor.accept(tcp).await else {
|
||||
return;
|
||||
};
|
||||
let alpn = tls.get_ref().1.alpn_protocol().map(|value| value.to_vec());
|
||||
let observed = Arc::new(std::sync::Mutex::new(Some(observed_tx)));
|
||||
let negotiated_h2 = alpn.as_deref() == Some(b"h2".as_slice());
|
||||
let service = service_fn(move |request: Request<hyper::body::Incoming>| {
|
||||
let observed = observed.clone();
|
||||
let alpn = alpn.clone();
|
||||
async move {
|
||||
if let Some(sender) = observed.lock().unwrap().take() {
|
||||
let _ = sender.send((alpn, request.version()));
|
||||
}
|
||||
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"upstream-ok"))))
|
||||
}
|
||||
});
|
||||
if negotiated_h2 {
|
||||
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.serve_connection(TokioIo::new(tls), service)
|
||||
.await;
|
||||
} else {
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(tls), service)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
(address, observed_rx, server)
|
||||
}
|
||||
|
||||
fn protocol_backend(address: std::net::SocketAddr, scheme: &str, mode: &str) -> Backend {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "protocol-probe",
|
||||
"url": format!("{scheme}://{address}"),
|
||||
"protocol": mode,
|
||||
"allow_private": true
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn protocol_request(
|
||||
backend: &Backend,
|
||||
websocket: bool,
|
||||
) -> Result<Response<hyper::body::Incoming>, hyper_util::client::legacy::Error> {
|
||||
let client = client(backend, AddressPolicy::new(backend).unwrap(), websocket).unwrap();
|
||||
let request = Request::builder()
|
||||
.uri(format!("{}/probe", backend.url))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), client.request(request))
|
||||
.await
|
||||
.expect("TLS/HTTP handshake exceeded test deadline")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_auto_chooses_h2_by_alpn_without_replaying_request() {
|
||||
let (address, observed, server) = tls_origin(true).await;
|
||||
let backend = protocol_backend(address, "https", "auto");
|
||||
let response = protocol_request(&backend, false).await.unwrap();
|
||||
assert_eq!(response.version(), Version::HTTP_2);
|
||||
assert_eq!(
|
||||
response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"upstream-ok")
|
||||
);
|
||||
let (alpn, version) = observed.await.unwrap();
|
||||
assert_eq!(alpn.as_deref(), Some(b"h2".as_slice()));
|
||||
assert_eq!(version, Version::HTTP_2);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_auto_negotiates_http1_before_sending_to_h1_only_peer() {
|
||||
let (address, observed, server) = tls_origin(false).await;
|
||||
let backend = protocol_backend(address, "https", "auto");
|
||||
let response = protocol_request(&backend, false).await.unwrap();
|
||||
assert_eq!(response.version(), Version::HTTP_11);
|
||||
let (alpn, version) = observed.await.unwrap();
|
||||
assert_eq!(alpn.as_deref(), Some(b"http/1.1".as_slice()));
|
||||
assert_eq!(version, Version::HTTP_11);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_https_h2_requires_h2_and_refuses_h1_only_peer() {
|
||||
let (address, _observed, server) = tls_origin(false).await;
|
||||
let backend = protocol_backend(address, "https", "h2");
|
||||
assert!(protocol_request(&backend, false).await.is_err());
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_https_h2_selects_h2_via_alpn() {
|
||||
let (address, observed, server) = tls_origin(true).await;
|
||||
let backend = protocol_backend(address, "https", "h2");
|
||||
let response = protocol_request(&backend, false).await.unwrap();
|
||||
assert_eq!(response.version(), Version::HTTP_2);
|
||||
let (alpn, version) = observed.await.unwrap();
|
||||
assert_eq!(alpn.as_deref(), Some(b"h2".as_slice()));
|
||||
assert_eq!(version, Version::HTTP_2);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_legacy_https_http2_keeps_h1_fallback() {
|
||||
let (address, observed, server) = tls_origin(false).await;
|
||||
let backend = protocol_backend(address, "https", "http2");
|
||||
let response = protocol_request(&backend, false).await.unwrap();
|
||||
assert_eq!(response.version(), Version::HTTP_11);
|
||||
let (alpn, version) = observed.await.unwrap();
|
||||
assert_eq!(alpn.as_deref(), Some(b"http/1.1".as_slice()));
|
||||
assert_eq!(version, Version::HTTP_11);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_legacy_https_http2_still_prefers_h2() {
|
||||
let (address, observed, server) = tls_origin(true).await;
|
||||
let backend = protocol_backend(address, "https", "http2");
|
||||
let response = protocol_request(&backend, false).await.unwrap();
|
||||
assert_eq!(response.version(), Version::HTTP_2);
|
||||
let (alpn, version) = observed.await.unwrap();
|
||||
assert_eq!(alpn.as_deref(), Some(b"h2".as_slice()));
|
||||
assert_eq!(version, Version::HTTP_2);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_https_http1_does_not_offer_h2_to_h2_capable_peer() {
|
||||
let (address, observed, server) = tls_origin(true).await;
|
||||
let backend = protocol_backend(address, "https", "http1");
|
||||
let response = protocol_request(&backend, false).await.unwrap();
|
||||
assert_eq!(response.version(), Version::HTTP_11);
|
||||
let (alpn, version) = observed.await.unwrap();
|
||||
assert_ne!(alpn.as_deref(), Some(b"h2".as_slice()));
|
||||
assert_eq!(version, Version::HTTP_11);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_isolated_client_stays_on_http1_even_with_h2_backend() {
|
||||
let (address, observed, server) = tls_origin(true).await;
|
||||
let backend = protocol_backend(address, "https", "http2");
|
||||
let response = protocol_request(&backend, true).await.unwrap();
|
||||
assert_eq!(response.version(), Version::HTTP_11);
|
||||
let (alpn, version) = observed.await.unwrap();
|
||||
assert_ne!(alpn.as_deref(), Some(b"h2".as_slice()));
|
||||
assert_eq!(version, Version::HTTP_11);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleartext_auto_does_not_attempt_unsafe_h2c_probe() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
let service = service_fn(|request: Request<hyper::body::Incoming>| async move {
|
||||
assert_eq!(request.version(), Version::HTTP_11);
|
||||
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"h1"))))
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(tcp), service)
|
||||
.await;
|
||||
});
|
||||
let backend = protocol_backend(address, "http", "auto");
|
||||
let response = protocol_request(&backend, false).await.unwrap();
|
||||
assert_eq!(response.version(), Version::HTTP_11);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_protocol_and_serialization_are_backward_compatible() {
|
||||
let backend: Backend = serde_json::from_value(serde_json::json!({
|
||||
"id": "legacy", "url": "http://example.org", "allow_private": false
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(backend.protocol, crate::config::UpstreamProtocol::Http1);
|
||||
for (name, expected) in [
|
||||
("http1", crate::config::UpstreamProtocol::Http1),
|
||||
("auto", crate::config::UpstreamProtocol::Auto),
|
||||
("http2", crate::config::UpstreamProtocol::Http2),
|
||||
("h2", crate::config::UpstreamProtocol::H2),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<crate::config::UpstreamProtocol>(&format!("\"{name}\""))
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&expected).unwrap(),
|
||||
format!("\"{name}\"")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_h2c_backend_uses_http2_prior_knowledge() {
|
||||
// The persisted HTTP/2 value and the new strict H2 value both retain
|
||||
// plaintext HTTP/2 prior knowledge, with no HTTP/1.1 probe.
|
||||
for mode in ["http2", "h2"] {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
@@ -95,7 +98,7 @@ mod tests {
|
||||
let backend: Backend = serde_json::from_value(serde_json::json!({
|
||||
"id": "h2",
|
||||
"url": format!("http://{address}"),
|
||||
"protocol": "http2",
|
||||
"protocol": mode,
|
||||
"allow_private": true
|
||||
}))
|
||||
.unwrap();
|
||||
@@ -113,6 +116,7 @@ mod tests {
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_dns_answer_fails_closed() {
|
||||
@@ -164,4 +168,5 @@ mod tests {
|
||||
let addresses = (1..=33).map(|n| format!("10.0.0.{n}:0").parse().unwrap());
|
||||
assert!(policy.validate_answers(addresses).is_err());
|
||||
}
|
||||
include!("protocol_tests.rs");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Offline UI/theme contract: selectors, cached appearance, and CSS readability.
|
||||
// Does not substitute for a browser session or Rust API integration test.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const base = 'src/admin/web/';
|
||||
const css = fs.readFileSync(`${base}style.css`, 'utf8');
|
||||
const html = fs.readFileSync(`${base}index.html`, 'utf8');
|
||||
const login = fs.readFileSync(`${base}login.js`, 'utf8');
|
||||
const app = fs.readFileSync(`${base}app/preferences.js`, 'utf8');
|
||||
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
|
||||
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
|
||||
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
|
||||
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
||||
const themes = ['dark', 'ultra_dark', 'light', 'cream'];
|
||||
const declaration = app.match(/^const ADMIN_THEMES = .*;$/m)?.[0];
|
||||
const normalize = app.match(/^function normalizeAdminTheme\(value\) \{[^\n]*\}$/m)?.[0];
|
||||
const apply = app.split('function applyAdminTheme(value, cache = true) {')[1]
|
||||
?.split('function applyAdminLanguage(')[0];
|
||||
assert(declaration && normalize && apply);
|
||||
const stored = new Map();
|
||||
const select = { value: '' };
|
||||
const root = { dataset: {} };
|
||||
const ctx = {
|
||||
document: { documentElement: root },
|
||||
$: id => id === 'ui-theme' ? select : null,
|
||||
localStorage: { setItem: (k, v) => stored.set(k, v), getItem: k => stored.get(k) },
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(`${declaration}\n${normalize}\nfunction applyAdminTheme(value, cache = true) {${apply}`, ctx);
|
||||
for (const theme of themes) {
|
||||
assert(html.includes(`value="${theme}"`), `option ${theme}`);
|
||||
assert(login.includes('"ultra_dark", "light", "cream"'), 'cached theme also applies at login');
|
||||
assert(rust.includes('"dark" | "ultra_dark" | "light" | "cream"'));
|
||||
assert.equal(vm.runInContext(`normalizeAdminTheme("${theme}")`, ctx), theme);
|
||||
assert.equal(vm.runInContext(`applyAdminTheme("${theme}")`, ctx), theme);
|
||||
assert.equal(root.dataset.theme, theme);
|
||||
assert.equal(select.value, theme);
|
||||
assert.equal(stored.get('rp-theme'), theme);
|
||||
}
|
||||
assert.equal(vm.runInContext('normalizeAdminTheme("unknown")', ctx), 'dark');
|
||||
vm.runInContext('applyAdminTheme("ultra_dark", false)', ctx);
|
||||
assert.equal(stored.get('rp-theme'), 'cream', 'preview without caching preserves selected preference');
|
||||
assert(en.themeUltraDark && en.themeCream && pl.themeUltraDark && pl.themeCream);
|
||||
assert.equal(schema.components.schemas.AdminPreferences.properties.theme.enum.length, 4);
|
||||
|
||||
function block(selector) {
|
||||
const start = css.indexOf(`${selector} {`);
|
||||
assert(start >= 0, selector);
|
||||
return css.slice(start, css.indexOf('\n}', start) + 2);
|
||||
}
|
||||
function token(section, key) {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = section.match(new RegExp(`--${escaped}:\\s*(#[0-9a-fA-F]{6});`));
|
||||
assert(match, `missing ${key}`);
|
||||
return match[1];
|
||||
}
|
||||
function luminance(hex) {
|
||||
const rgb = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const [r, g, b] = rgb.map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
function contrast(a, b) {
|
||||
const x = luminance(a), y = luminance(b);
|
||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
||||
}
|
||||
for (const theme of ['ultra_dark', 'cream']) {
|
||||
const section = block(`html[data-theme="${theme}"]`);
|
||||
const bg = token(section, 'bg'), panel = token(section, 'panel'), panel2 = token(section, 'panel2');
|
||||
const text = token(section, 'text'), muted = token(section, 'muted'), accent = token(section, 'accent');
|
||||
for (const [name, foreground, surface] of [
|
||||
['text on background', text, bg], ['text on panel', text, panel],
|
||||
['muted on card', muted, panel2], ['accent on background', accent, bg],
|
||||
]) assert(contrast(foreground, surface) >= 4.5, `${theme}: ${name}`);
|
||||
}
|
||||
console.log('PASS: four account themes, persisted and cached selection, server API contract, and accessible black/cream palette contrast');
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Optional Chromium checks for Appearance controls and global corner geometry.
|
||||
|
||||
Uses shipped CSS and the real Appearance list-rendering function with mocked
|
||||
preferences/translation state. Not a test of the authenticated Rust service.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import shutil
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WEB = ROOT / 'src/admin/web'
|
||||
css = (WEB / 'style.css').read_text()
|
||||
prefs = (WEB / 'app/preferences.js').read_text()
|
||||
widgets = json.loads((WEB / 'dashboard-widgets.json').read_text())
|
||||
translations = json.loads((ROOT / 'langs/en_US.json').read_text())['translations']
|
||||
functions = prefs[:prefs.index('function browserLanguage()')] + '\n' + prefs[prefs.index('function dashboardWidgetLabel'):prefs.index('function refreshChartStylePalette')]
|
||||
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="default"><head><style>{css}</style></head><body>'
|
||||
'<div id="settings-page-appearance" class="settings-page" style="max-width:100%;padding:12px">'
|
||||
'<div class="appearance-section"><div class="dashboard-widget-list" id="dashboard-widget-list"></div></div>'
|
||||
'<div style="display:flex;gap:10px;margin-top:12px">'
|
||||
'<div class="card" id="test-card">Card</div><div class="stat" id="test-stat">Stat</div>'
|
||||
'<button class="outline" id="test-action">Action</button><input id="test-input" value="value"></div>'
|
||||
'<a class="brand" href="#"><span class="brand-mark" id="test-logo">r<span>/</span>p</span>RustProxy</a></div>'
|
||||
'<div class="overview-widget-type" id="editor-type-badge">Chart</div></body></html>')
|
||||
js = f'''const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({json.dumps(widgets)});
|
||||
const translations = {json.dumps(translations)};
|
||||
function t(key) {{ return translations[key] || key; }}
|
||||
function $(id) {{ return document.getElementById(id); }}
|
||||
function el(tag, text, klass) {{ const node = document.createElement(tag); if (text != null) node.textContent = text; if (klass) node.className = klass; return node; }}
|
||||
{functions}
|
||||
renderDashboardWidgetSettings();'''
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
binary = shutil.which('chromium') or shutil.which('chromium-browser')
|
||||
browser = playwright.chromium.launch(headless=True, executable_path=binary, args=['--no-sandbox'])
|
||||
page = browser.new_page(viewport={'width': 1320, 'height': 950})
|
||||
page.set_content(html, wait_until='domcontentloaded')
|
||||
page.add_script_tag(content=js)
|
||||
assert page.locator('#dashboard-widget-list .dashboard-widget-setting').count() == 62
|
||||
assert page.locator('#settings-page-appearance .overview-widget-type').count() == 0
|
||||
assert page.locator('#editor-type-badge').is_visible()
|
||||
for width in (1320, 790, 450, 345):
|
||||
page.set_viewport_size({'width': width, 'height': 950})
|
||||
results = page.locator('.dashboard-widget-setting').evaluate_all('''rows => rows.map(row => {
|
||||
const label = row.querySelector('.dashboard-widget-toggle').getBoundingClientRect();
|
||||
const actions = row.querySelector('.dashboard-widget-setting-actions').getBoundingClientRect();
|
||||
const box = row.getBoundingClientRect();
|
||||
return { labelRight: label.right, actionsLeft: actions.left, actionsRight: actions.right, right: box.right };
|
||||
})''')
|
||||
for i, result in enumerate(results):
|
||||
assert result['actionsLeft'] >= result['labelRight'] - 1, (width, i, result)
|
||||
assert result['actionsRight'] <= result['right'] + 1, (width, i, result)
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth + 2'), width
|
||||
for theme in ('dark', 'light', 'ultra_dark', 'cream'):
|
||||
page.evaluate('theme => { document.documentElement.dataset.theme = theme; document.documentElement.dataset.cornerStyle = "default"; }', theme)
|
||||
stat = page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
|
||||
card = page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
|
||||
assert stat == card == '0px', (theme, stat, card)
|
||||
for style, expected_surface, expected_control in (('square', '0px', '0px'), ('subtle','5px','4px'), ('rounded','12px','8px'),('extra','18px','12px')):
|
||||
page.evaluate('style => document.documentElement.dataset.cornerStyle = style', style)
|
||||
assert page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'stat')
|
||||
assert page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'card')
|
||||
assert page.locator('#test-action').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'button')
|
||||
assert page.locator('#test-input').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'input')
|
||||
page.evaluate('document.documentElement.dataset.theme = "dark"; document.documentElement.dataset.cornerStyle = "default"')
|
||||
original_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
|
||||
page.evaluate('document.documentElement.dataset.theme = "ultra_dark"')
|
||||
black_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
|
||||
assert black_logo == original_logo == 'rgb(246, 155, 116)', (black_logo, original_logo)
|
||||
out = ROOT.parent / 'appearance_ux_0323_preview.png'
|
||||
page.screenshot(path=str(out))
|
||||
browser.close()
|
||||
print('PASS: 62 badge-free Appearance rows, right-pinned buttons at 4 widths, theme-independent corner geometry, original black-theme logo accent')
|
||||
@@ -0,0 +1,96 @@
|
||||
// Regression contract: Appearance list is badge-free, corners are account-scoped,
|
||||
// and Settings opens Appearance/last active tab unless a valid URL tab wins.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const prefs = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
||||
const settings = fs.readFileSync('src/admin/web/settings.js', 'utf8');
|
||||
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
||||
const login = fs.readFileSync('src/admin/web/login.js', 'utf8');
|
||||
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
|
||||
const tests = fs.readFileSync('src/admin/preferences_tests.rs', 'utf8');
|
||||
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
|
||||
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
|
||||
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
||||
const styles = ['default', 'square', 'subtle', 'rounded', 'extra'];
|
||||
|
||||
const appearance = prefs.slice(prefs.indexOf('function renderDashboardWidgetSettings()'), prefs.indexOf('function refreshChartStylePalette'));
|
||||
assert(appearance.includes('dashboard-widget-setting-actions'));
|
||||
assert(!appearance.includes('dashboardWidgetTypeBadge('), 'Appearance rows must not render type badges');
|
||||
assert(prefs.includes('function dashboardWidgetTypeBadge('), 'visual editors retain type labels');
|
||||
assert(html.includes('id="ui-corner-style"'));
|
||||
assert(html.includes('id="dashboard-widget-list"'));
|
||||
const contract = schema.components.schemas.AdminPreferences.properties.corner_style;
|
||||
assert.deepEqual(contract.enum, styles);
|
||||
assert.equal(contract.default, 'default');
|
||||
assert(rust.includes('default_admin_corner_style'));
|
||||
assert(rust.includes('"default" | "square" | "subtle" | "rounded" | "extra"'));
|
||||
assert(tests.includes('fn corner_style_defaults_and_rejects_unknown_presets()'));
|
||||
for (const key of ['cornerStyle', 'cornerStyleHelp', 'cornerStyleDefault', 'cornerStyleSquare', 'cornerStyleSubtle', 'cornerStyleRounded', 'cornerStyleExtra']) {
|
||||
assert(en[key] && pl[key], `missing translation ${key}`);
|
||||
}
|
||||
assert(!/html:is\(\[data-theme="ultra_dark"\], \[data-theme="cream"\]\) :is\(\.card, \.stat, dialog\) \{/.test(css), 'theme may not override corner geometry');
|
||||
assert(css.includes('--logo-accent: #f69b74;'));
|
||||
assert(css.includes('--logo-secondary: #cfe0ac;'));
|
||||
assert(css.includes('var(--logo-accent, var(--accent))'));
|
||||
assert(login.includes('rp-corner-style'), 'cached corners must work on login too');
|
||||
|
||||
const decl = prefs.match(/^const ADMIN_CORNER_STYLES = .*;$/m)?.[0];
|
||||
const normalize = prefs.match(/^function normalizeAdminCornerStyle\(value\) \{[^\n]*\}$/m)?.[0];
|
||||
const body = prefs.split('function applyAdminCornerStyle(value, cache = true) {')[1]?.split('function applyAdminTableDensity(')[0];
|
||||
assert(decl && normalize && body);
|
||||
const storage = new Map();
|
||||
const root = { dataset: {} }, input = { value: '' };
|
||||
const ctx = { document: { documentElement: root }, $: id => id === 'ui-corner-style' ? input : null,
|
||||
localStorage: { setItem: (key, value) => storage.set(key, value) } };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(`${decl}\n${normalize}\nfunction applyAdminCornerStyle(value, cache = true) {${body}`, ctx);
|
||||
for (const style of styles) {
|
||||
assert.equal(vm.runInContext(`applyAdminCornerStyle('${style}')`, ctx), style);
|
||||
assert.equal(root.dataset.cornerStyle, style);
|
||||
assert.equal(storage.get('rp-corner-style'), style);
|
||||
assert.equal(input.value, style);
|
||||
}
|
||||
vm.runInContext("applyAdminCornerStyle('invalid')", ctx);
|
||||
assert.equal(root.dataset.cornerStyle, 'default');
|
||||
assert(prefs.includes('corner_style: "default"'), 'old account defaults preserve original geometry');
|
||||
assert(prefs.includes('corner_style: next'), 'choice persisted via API');
|
||||
assert(prefs.includes('applyAdminCornerStyle(previous)'), 'failed update rolls back');
|
||||
|
||||
const setup = settings.slice(settings.indexOf(' const buttons ='), settings.indexOf(' const policyTabs ='));
|
||||
assert(setup.length > 1000);
|
||||
function boot(path, cached, query = '') {
|
||||
const cache = new Map(cached ? [['rp-settings-last-tab', cached]] : []);
|
||||
const names = ['appearance', 'listeners', 'cache', 'security', 'manual-config', 'logging'];
|
||||
const buttons = names.map(name => ({ dataset: { settingsTab: name }, hidden: false,
|
||||
textContent: name, addEventListener() {}, setAttribute() {}, focus() {} }));
|
||||
const pages = names.map(name => ({ dataset: { settingsPage: name }, hidden: true }));
|
||||
const url = new URL(`https://dashboard.example${path}${query}`);
|
||||
const location = { href: url.href, pathname: url.pathname, search: url.search, hash: '', protocol: url.protocol };
|
||||
const context = { location, URL, URLSearchParams, CSS: { escape: value => value },
|
||||
localStorage: { getItem: key => cache.get(key) || null, setItem: (key, value) => cache.set(key, value) },
|
||||
document: { querySelectorAll: selector => selector === '[data-settings-tab]' ? buttons : pages,
|
||||
querySelector: () => null }, $: () => null,
|
||||
history: { state: {}, replaceState(_state, _title, href) { location.href = 'https://dashboard.example' + href; } },
|
||||
window: { rustProxyRefresh: { setSettingsTab() {} } }, t: key => key,
|
||||
};
|
||||
const vmctx = vm.createContext(context);
|
||||
const result = vm.runInContext(`(() => { ${setup}\nreturn { current: () => active, select }; })()`, vmctx);
|
||||
return { ...result, cache };
|
||||
}
|
||||
assert.equal(boot('/settings').current(), 'appearance', 'first Settings visit opens Appearance');
|
||||
assert.equal(boot('/settings', 'cache').current(), 'cache', 'last Settings tab is restored');
|
||||
assert.equal(boot('/settings', 'unknown').current(), 'appearance', 'invalid cached tab is ignored');
|
||||
assert.equal(boot('/settings', 'cache', '?tab=security').current(), 'security', 'URL tab takes precedence');
|
||||
assert.equal(boot('/configuration', 'cache').current(), 'manual-config', 'legacy /configuration keeps intent');
|
||||
assert.equal(boot('/logging', 'cache').current(), 'logging', 'legacy /logging keeps intent');
|
||||
const active = boot('/settings');
|
||||
active.select('cache');
|
||||
assert.equal(active.cache.get('rp-settings-last-tab'), 'cache');
|
||||
active.select('security');
|
||||
assert.equal(active.cache.get('rp-settings-last-tab'), 'security');
|
||||
active.select('appearance', false, false);
|
||||
assert.equal(active.cache.get('rp-settings-last-tab'), 'security', 'temporary selection does not override remembered tab');
|
||||
console.log('PASS: badge-free Appearance, 5 global corner presets, logo colors, account/API defaults, and last Settings tab navigation');
|
||||
@@ -6,6 +6,7 @@ import vm from 'node:vm';
|
||||
|
||||
const root = process.cwd();
|
||||
const core = fs.readFileSync(`${root}/src/admin/web/vhosts/core.js`, 'utf8');
|
||||
const protocol = fs.readFileSync(`${root}/src/admin/web/vhosts/protocol.js`, 'utf8');
|
||||
const editor = fs.readFileSync(`${root}/src/admin/web/vhosts/editor.js`, 'utf8');
|
||||
const settings = fs.readFileSync(`${root}/src/admin/web/app/settings.js`, 'utf8');
|
||||
const html = fs.readFileSync(`${root}/src/admin/web/index.html`, 'utf8');
|
||||
@@ -19,6 +20,7 @@ class Element {
|
||||
this.tag = tag;
|
||||
this.value = value == null ? '' : String(value);
|
||||
this.checked = false;
|
||||
this.dataset = {};
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.name = '';
|
||||
@@ -61,7 +63,7 @@ const value = {
|
||||
interval_ms: 10000, timeout_ms: 1000, slow_response_ms: 500, fall: 2,
|
||||
rise: 1, expected_status_min: 200, expected_status_max: 399 }
|
||||
};
|
||||
vm.runInNewContext(`${core.slice(start, end)}\nmemberRow(parent, value);`, {
|
||||
vm.runInNewContext(`${protocol}\n${core.slice(start, end)}\nmemberRow(parent, value);`, {
|
||||
parent, value, el, text, field, button, note,
|
||||
t: key => key, toast: () => {}, changed: () => {},
|
||||
appConfirm: async () => true, adminListenerBackendCandidate: () => false, backendHealthEditorControl: null
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// HTTP backend Overview widgets: offline simulated status feed, no Rust execution.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const names = ['backend_health', 'backend_activity', 'backend_errors', 'backend_latency', 'backend_traffic', 'backend_protocols', 'backend_requests', 'backend_capacity', 'backend_http5xx', 'backend_timeouts', 'backend_network', 'backend_retries', 'backend_queue', 'backend_rotations'];
|
||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
||||
for (const name of names) assert(registry.some(widget => widget.id === name && widget.default_hidden && widget.data_source === 'backend_status'));
|
||||
const source = fs.readFileSync('src/admin/web/app/backend-dashboard.js', 'utf8');
|
||||
assert(!/\b(fetch|api)\s*\(/.test(source), 'Overview must not fetch per-widget metrics');
|
||||
const cards = new Map(names.map(name => [name, { hidden: true }]));
|
||||
const nodes = new Map();
|
||||
const get = id => {
|
||||
if (!nodes.has(id)) nodes.set(id, { textContent: '-', style: {}, attrs: {}, children: [], setAttribute(k,v) { this.attrs[k] = v; } });
|
||||
return nodes.get(id);
|
||||
};
|
||||
for (const type of ['h1', 'auto', 'compat', 'h2']) {
|
||||
const node = get('backend-mode-' + type);
|
||||
node.parentElement = get('backend-mode-bar');
|
||||
node.parentElement.children.push(node);
|
||||
}
|
||||
let now = 1000;
|
||||
const window = { rustProxyPreferences: { current: () => null } };
|
||||
const ctx = {
|
||||
window,
|
||||
ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: names },
|
||||
document: { querySelector(selector) { return cards.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null; } },
|
||||
$: get, t: key => key, fmt: (value, precision = 0) => Number(value).toFixed(precision),
|
||||
bytes: v => `${v} B`, currentPanel: 'overview', performance: { now: () => now },
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(source, ctx);
|
||||
const module = window.rustProxyBackendDashboard;
|
||||
const data = {
|
||||
revision: 1,
|
||||
backend_pools: [{ id: 'pool1', members: [
|
||||
{ id: 'alpha', url: 'https://example.invalid', protocol: 'auto', status: 'up', active: 2, max_concurrent_requests: 4, capacity_rejections_total: 1, requests: 10, errors: 1, bytes_in: 1000, bytes_out: 2000, health_mode: 'tcp', last_probe_ms: 1234, latency_ms: 10, http_5xx_total: 2, timeouts_total: 1, connect_errors_total: 1, dns_errors_total: 1, tls_errors_total: 1, io_errors_total: 1, retry_attempts_total: 2, retry_successes_total: 1, queue_timeouts_total: 1, upstream_client_rotations_total: 2, upstream_client_generation: 3, upstream_client_generation_age_seconds: 500 },
|
||||
{ id: 'beta', url: 'http://example.invalid', protocol: 'http2', status: 'down', active: 0, requests: 5, errors: 2, bytes_in: 100, bytes_out: 400, health_mode: 'http', last_probe_ms: 0, latency_ms: 0 },
|
||||
{ id: 'gamma', url: 'http://example.invalid', protocol: 'http1', status: 'checking', active: 1, max_concurrent_requests: 2, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'tcp', last_probe_ms: 0, latency_ms: 0 },
|
||||
{ id: 'delta', url: 'http://example.invalid', status: 'draining', active: 0, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'off', last_probe_ms: 0, latency_ms: 0 },
|
||||
] }],
|
||||
};
|
||||
const text = id => get(`backend-dashboard-${id}`).textContent;
|
||||
|
||||
module.render(data); // Hidden by default: should not start collecting samples.
|
||||
assert(names.every(name => cards.get(name).hidden));
|
||||
assert.equal(text('health'), '-');
|
||||
module.render(data, []);
|
||||
assert(names.every(name => !cards.get(name).hidden));
|
||||
assert.equal(text('health'), '1 / 3');
|
||||
assert.match(text('health-detail'), /backendWidgetDown: 1/);
|
||||
assert.equal(text('activity'), '3');
|
||||
assert.equal(text('errors'), '3');
|
||||
assert.equal(text('latency'), '10.0 ms');
|
||||
assert.equal(text('traffic'), '0 B/s');
|
||||
assert.equal(text('protocols'), '4');
|
||||
assert.match(text('protocols-detail'), /H1 1.*Auto 1.*backendWidgetModeCompat 1.*backendWidgetModeStrict 0.*backendWidgetH2c: 1.*backendWidgetModeUnknown: 1/);
|
||||
assert.equal(get('backend-mode-compat').style.flexGrow, '1');
|
||||
assert.equal(get('backend-mode-h2').style.flexGrow, '0');
|
||||
assert.equal(text('requests'), '0.0/s');
|
||||
assert.match(text('requests-detail'), /backendWidgetRequestsTotal: 15/);
|
||||
assert.equal(text('capacity'), '50.0%');
|
||||
assert.match(text('capacity-detail'), /3\/6.*backendWidgetCapacityRejects: 1/);
|
||||
assert.equal(text('http5xx'), '2');
|
||||
assert.equal(text('timeouts'), '1');
|
||||
assert.equal(text('network'), '4');
|
||||
assert.equal(text('retries'), '2');
|
||||
assert.equal(text('queue'), '2');
|
||||
assert.equal(text('rotations'), '2');
|
||||
assert.match(text('rotations-detail'), /backendWidgetPoolGeneration: 3.*backendWidgetPoolAge: 500s/);
|
||||
|
||||
now = 3000;
|
||||
const next = structuredClone(data);
|
||||
next.backend_pools[0].members[0].requests = 30;
|
||||
next.backend_pools[0].members[0].errors = 3;
|
||||
next.backend_pools[0].members[0].bytes_in = 3000;
|
||||
next.backend_pools[0].members[0].bytes_out = 3000;
|
||||
next.backend_pools[0].members[0].active = 5;
|
||||
next.backend_pools[0].members[0].latency_ms = 22;
|
||||
next.backend_pools[0].members[0].http_5xx_total = 4;
|
||||
next.backend_pools[0].members[0].timeouts_total = 5;
|
||||
next.backend_pools[0].members[0].connect_errors_total = 3;
|
||||
next.backend_pools[0].members[0].retry_attempts_total = 4;
|
||||
next.backend_pools[0].members[0].retry_successes_total = 2;
|
||||
next.backend_pools[0].members[0].queue_timeouts_total = 3;
|
||||
// New member starts with huge absolute counters: must NOT appear as a spike.
|
||||
next.backend_pools[0].members.push({ id: 'new', url:'https://new.invalid', protocol: 'http1', status:'up', requests: 100000, errors: 1000, bytes_in: 10000000, bytes_out: 99999999, active: 0, health_mode: 'off', last_probe_ms: 0 });
|
||||
module.render(next, []);
|
||||
assert.equal(text('errors'), '1005');
|
||||
assert.match(text('errors-detail'), /1\.0\/s/);
|
||||
assert.match(text('errors-detail'), /10\.0%/);
|
||||
assert.equal(text('traffic'), '1500 B/s');
|
||||
assert.equal(get('backend-dashboard-in-rate').textContent, '1000 B/s');
|
||||
assert.equal(get('backend-dashboard-out-rate').textContent, '500 B/s');
|
||||
assert.equal(text('activity'), '6');
|
||||
assert.equal(text('requests'), '10.0/s');
|
||||
assert.equal(text('capacity'), '100.0%');
|
||||
assert.equal(text('latency'), '22.0 ms');
|
||||
assert.match(text('http5xx-detail'), /1\.0\/s/);
|
||||
assert.match(text('timeouts-detail'), /2\.0\/s/);
|
||||
assert.match(text('network-detail'), /1\.0\/s/);
|
||||
assert.match(text('retries-detail'), /1\.0\/s.*backendWidgetRetriesSuccess: 2/);
|
||||
assert.match(text('queue-detail'), /1\.0\/s.*backendWidgetQueueRejected: 1/);
|
||||
|
||||
// Repaint on toggles/translation must not produce additional rate samples.
|
||||
now = 3500;
|
||||
module.render(next, [], false);
|
||||
assert.match(text('errors-detail'), /1\.0\/s/);
|
||||
// Reset of a member's counters should never be treated as an error burst.
|
||||
now = 5000;
|
||||
const restarted = structuredClone(next);
|
||||
restarted.backend_pools[0].members[0].requests = 2;
|
||||
restarted.backend_pools[0].members[0].errors = 0;
|
||||
restarted.backend_pools[0].members[0].bytes_in = 999999;
|
||||
module.render(restarted, []);
|
||||
assert.match(text('errors-detail'), /0\.0\/s/);
|
||||
assert.equal(text('traffic'), '0 B/s');
|
||||
assert.equal(text('requests'), '0.0/s');
|
||||
// Reconfiguration invalidates prior generations, first sample is a baseline.
|
||||
now = 8000;
|
||||
restarted.revision = 2;
|
||||
module.render(restarted, []);
|
||||
assert.match(text('errors-detail'), /0\.0\/s/);
|
||||
// Missing origin data must show neutral state and not previously cached values.
|
||||
module.render({ revision: 3, backend_pools: [] }, []);
|
||||
assert.equal(text('health'), '-');
|
||||
assert.equal(text('traffic'), '-');
|
||||
assert.equal(text('protocols-detail'), 'backendWidgetUnavailable');
|
||||
assert.equal(text('requests'), '-');
|
||||
assert.equal(text('capacity'), '-');
|
||||
for (const name of ['http5xx', 'timeouts', 'network', 'retries', 'queue', 'rotations'])
|
||||
assert.equal(text(name), '-', `neutral empty state for ${name}`);
|
||||
module.render(data, [...names]);
|
||||
assert(names.every(name => cards.get(name).hidden));
|
||||
|
||||
// Origin protocol editor must preserve legacy HTTPS fallback, expose strict
|
||||
// H2 as a separate new option, and distinguish cleartext h2c.
|
||||
const protocolHelp = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
|
||||
const element = () => ({ dataset: {}, textContent: '', hidden: false });
|
||||
const originCtx = { el: element, t: key => key };
|
||||
vm.createContext(originCtx);
|
||||
vm.runInContext(protocolHelp, originCtx);
|
||||
const mounted = [];
|
||||
const panel = { append(...items) { mounted.push(...items); } };
|
||||
const select = { value: 'http2', events: {}, addEventListener(type, handler) { this.events[type] = handler; } };
|
||||
const url = { value: 'https://origin.example', events: {}, addEventListener(type, handler) { this.events[type] = handler; } };
|
||||
originCtx.attachOriginProtocolDetails(panel, select, url);
|
||||
assert.equal(mounted.length, 2);
|
||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpsDetails');
|
||||
assert.equal(mounted[1].hidden, false, 'Saved HTTPS H2 needs a compatibility warning');
|
||||
select.value = 'auto'; select.events.change();
|
||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpsDetails');
|
||||
assert.equal(mounted[1].hidden, true);
|
||||
url.value = 'http://origin.example'; url.events.input();
|
||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpDetails');
|
||||
select.value = 'http2'; select.events.change();
|
||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpDetails');
|
||||
assert.equal(mounted[1].hidden, true);
|
||||
url.value = 'https://origin.example'; url.events.input();
|
||||
assert.equal(mounted[1].hidden, false);
|
||||
select.value = 'h2'; select.events.change();
|
||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpsDetails');
|
||||
assert.equal(mounted[1].hidden, true);
|
||||
url.value = 'http://origin.example'; url.events.input();
|
||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpDetails');
|
||||
// Strict H2 is tracked independently from the backwards-compatible value.
|
||||
const strict = structuredClone(data);
|
||||
strict.backend_pools[0].members[2].protocol = 'h2';
|
||||
module.render(strict, [], false);
|
||||
assert.match(text('protocols-detail'), /backendWidgetModeCompat 1.*backendWidgetModeStrict 1.*backendWidgetH2c: 2/);
|
||||
assert.equal(get('backend-mode-h2').style.flexGrow, '1');
|
||||
console.log('PASS: 14 opt-in HTTP backend cards, ALPN mode distinction, capacity, request rates, fallback UI');
|
||||
@@ -5,6 +5,7 @@ import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const core = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
|
||||
const protocol = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
|
||||
const health = fs.readFileSync('src/admin/web/vhosts/health.js', 'utf8');
|
||||
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
|
||||
const render = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
|
||||
@@ -15,7 +16,7 @@ assert(first >= 0 && last > first, 'Vhost member editor missing');
|
||||
class Element {
|
||||
constructor(tag = 'div', value = '', css = '') {
|
||||
this.tag = tag; this.value = String(value ?? ''); this.textContent = this.value;
|
||||
this.children = []; this.parent = null; this.name = ''; this.checked = false;
|
||||
this.children = []; this.parent = null; this.name = ''; this.checked = false; this.dataset = {};
|
||||
this.disabled = false; this.hidden = false; this.listeners = new Map();
|
||||
this.classes = new Set(css.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
@@ -70,7 +71,7 @@ const context = {
|
||||
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
|
||||
"status-min": "201", "status-max": "398" })[name] } : null),
|
||||
};
|
||||
const exports = vm.runInNewContext(`${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
|
||||
const exports = vm.runInNewContext(`${protocol}\n${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
|
||||
const members = el('div');
|
||||
const probe = (mode, path) => ({ mode, path, host: null, interval_ms: 5000, timeout_ms: 1000, slow_response_ms: 500, fall: 2, rise: 1, expected_status_min: 200, expected_status_max: 399 });
|
||||
const a = { id: 'alpha', url: 'http://192.0.2.1:9000', health_check_enabled: true, health_check: probe('http', '/a') };
|
||||
|
||||
@@ -7,7 +7,7 @@ import tomllib
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
read = lambda path: (root / path).read_text(encoding='utf-8')
|
||||
version = tomllib.loads(read('Cargo.toml'))['package']['version']
|
||||
assert version == '0.32.2', version
|
||||
assert version == '0.32.3', version
|
||||
spec = json.loads(read('src/admin/openapi.json'))
|
||||
assert spec['info']['version'] == version
|
||||
paths = spec['paths']
|
||||
@@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'):
|
||||
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
|
||||
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
|
||||
assert strings.get(key), (language, key)
|
||||
assert '0.32.2' in read('docs/TEST_REPORT.md')
|
||||
assert '0.32.3' in read('docs/TEST_REPORT.md')
|
||||
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
|
||||
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
|
||||
|
||||
preflight(root)
|
||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
assert manifest["package"]["version"] == "0.32.2"
|
||||
assert manifest["package"]["version"] == "0.32.3"
|
||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||
h3dep = manifest["dependencies"]["h3"]
|
||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"]
|
||||
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
||||
lock_path = root / "Cargo.lock"
|
||||
if lock_path.exists():
|
||||
lock = lock_path.read_text()
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.2"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.3"' in lock
|
||||
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
||||
for release_doc, marker in {
|
||||
"README.md": "# RustProxy 0.32.2",
|
||||
"CHANGELOG.md": "## 0.32.2",
|
||||
"ROADMAP.md": "## 0.32.2",
|
||||
"AGENTS.md": "Current source version: 0.32.2.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.2",
|
||||
"README.md": "# RustProxy 0.32.3",
|
||||
"CHANGELOG.md": "## 0.32.3",
|
||||
"ROADMAP.md": "## 0.32.3",
|
||||
"AGENTS.md": "Current source version: 0.32.3.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.3",
|
||||
}.items():
|
||||
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
||||
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
||||
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
|
||||
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
||||
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
||||
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
||||
for marker in ['"version": "0.32.2"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
for marker in ['"version": "0.32.3"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
assert marker in openapi, marker
|
||||
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
@@ -1014,13 +1014,13 @@ assert '`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`' in footer_bloc
|
||||
assert 'update?.update_available && update.latest_version' in footer_block and 'footer-update' in footer_block
|
||||
assert '.page-footer .footer-update {' in style_source
|
||||
assert '.logs-dialog {' in style_source and 'overflow: visible' in style_source
|
||||
assert '/api/preferences' in app_js and 'live_log_columns' in management_ui and 'ADMIN_THEMES = new Set(["dark", "light"])' in app_js
|
||||
assert '/api/preferences' in app_js and 'live_log_columns' in management_ui and 'ADMIN_THEMES = new Set(["dark", "ultra_dark", "light", "cream"])' in app_js
|
||||
assert 'id="ui-theme"' in html and 'data-i18n="themeLight"' in html
|
||||
assert 'html[data-theme="light"] {' in style_source and '--field-bg:' in style_source and '--sidebar-bg:' in style_source
|
||||
assert 'overflow: visible;' in style_source and 'max-height: min(38dvh, 220px)' not in style_source
|
||||
assert 'body[data-density="compact"] input:not([type="checkbox"]):not([type="radio"])' in style_source
|
||||
assert 'input:not([type="checkbox"]):not([type="radio"]),\nselect,\ntextarea {' in style_source
|
||||
assert '#[serde(default = "default_admin_theme")]' in admin_source and 'matches!(preferences.theme.as_str(), "dark" | "light")' in admin_source
|
||||
assert '#[serde(default = "default_admin_theme")]' in admin_source and 'matches!(preferences.theme.as_str(), "dark" | "ultra_dark" | "light" | "cream")' in admin_source
|
||||
# 0.22.3 dashboard/config/cache/log-history regressions.
|
||||
assert 'bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages")' in management_ui
|
||||
assert '"pages" => {' in admin_source and 'config.pages =' in admin_source
|
||||
@@ -1143,7 +1143,7 @@ for marker_0221 in [
|
||||
'target = makeCopyCell(targetValue)', 'makeCopyCell(keyTags)',
|
||||
]:
|
||||
assert marker_0221 in cache_panel_js, marker_0221
|
||||
assert html.count('class="stats-grid"') >= 1
|
||||
assert re.search(r'class="[^"]*\bstats-grid\b[^"]*"', html) is not None
|
||||
assert 'stats-grid transport-stats' not in html and 'transport-websocket' not in html
|
||||
assert 'transport-action' not in html and '.transport-grid .transport-action' not in style_source
|
||||
assert '#panel-cache .cache-copy-cell' in style_source and 'cursor: copy' in style_source
|
||||
@@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
|
||||
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
||||
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
||||
assert 'location.pathname === "/logging"' in settings_js_0241
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.32.2"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.32.3"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = rust_source(root, "src/tls.rs")
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Offline simulation of direct Overview arrangement. No authenticated browser
|
||||
// or Rust networking is exercised by this test.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const widgets = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
||||
const ids = widgets.map(w => w.id);
|
||||
const hiddenDefault = widgets.filter(w => w.default_hidden).map(w => w.id);
|
||||
const source = fs.readFileSync('src/admin/web/app/dashboard-inline-layout.js', 'utf8');
|
||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
||||
for (const id of ['overview-layout-unlock', 'overview-inline-actions', 'overview-inline-save',
|
||||
'overview-inline-cancel', 'overview-inline-hint', 'dashboard-overview-grid'])
|
||||
assert(html.includes(`id="${id}"`), `missing ${id}`);
|
||||
|
||||
class Node {
|
||||
constructor(tag = 'div', value = '', cls = '') {
|
||||
this.tag = tag; this.textContent = value ?? ''; this.classes = new Set(cls.split(' ').filter(Boolean));
|
||||
this.classList = { add: (...names) => names.forEach(name => this.classes.add(name)),
|
||||
remove: (...names) => names.forEach(name => this.classes.delete(name)) };
|
||||
this.children = []; this.listeners = new Map(); this.dataset = {};
|
||||
this.attributes = {}; this.hidden = false; this.disabled = false; this.parent = null;
|
||||
}
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
child.remove(); this.children.push(child); child.parent = this;
|
||||
}
|
||||
}
|
||||
replaceChildren(...children) {
|
||||
for (const child of this.children) child.parent = null;
|
||||
this.children = [];
|
||||
this.append(...children);
|
||||
}
|
||||
remove() {
|
||||
if (this.parent) { const at = this.parent.children.indexOf(this); if (at >= 0) this.parent.children.splice(at, 1); }
|
||||
this.parent = null;
|
||||
}
|
||||
closest(selector) {
|
||||
for (let cur = this; cur; cur = cur.parent) {
|
||||
if (selector === '[data-dashboard-widget]' && cur.dataset.dashboardWidget) return cur;
|
||||
if (selector === '[data-overview-drag-handle]' && cur.dataset.overviewDragHandle) return cur;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
contains(other) { return !!other && (other === this || this.children.some(child => child.contains(other))); }
|
||||
querySelector(selector) {
|
||||
for (const child of this.children) {
|
||||
if (child.classes.has(selector.slice(1)) || child.tag === selector) return child;
|
||||
const nested = child.querySelector(selector);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getBoundingClientRect() { return { left: 0, width: 100 }; }
|
||||
addEventListener(name, cb) { this.listeners.set(name, [...(this.listeners.get(name) || []), cb]); }
|
||||
fire(name, event = {}) {
|
||||
const e = { target: this, clientX: 0, prevented: false, preventDefault() { this.prevented = true; }, ...event };
|
||||
for (const cb of this.listeners.get(name) || []) cb(e);
|
||||
return e;
|
||||
}
|
||||
focus() { this.focused = true; }
|
||||
}
|
||||
const controls = new Map(['dashboard-overview-grid', 'overview-layout-unlock',
|
||||
'overview-inline-actions', 'overview-inline-save', 'overview-inline-cancel',
|
||||
'overview-inline-hint', 'overview-inline-hidden', 'overview-inline-hidden-list',
|
||||
'overview-inline-hidden-count', 'overview-layout-open', 'overview-layout-dialog'].map(id => [id, new Node()]));
|
||||
const grid = controls.get('dashboard-overview-grid');
|
||||
controls.get('overview-inline-hidden').append(new Node('summary', 'Hidden blocks'));
|
||||
const cards = new Map(ids.map(id => {
|
||||
const node = new Node('article'); node.dataset.dashboardWidget = id;
|
||||
node.hidden = hiddenDefault.includes(id);
|
||||
node.setAttribute('id', `real-${id}`);
|
||||
node.append(new Node('strong', id));
|
||||
grid.append(node);
|
||||
return [id, node];
|
||||
}));
|
||||
let current = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault], theme: 'light' };
|
||||
const writes = [], notifications = [];
|
||||
let reject = false;
|
||||
const window = {
|
||||
rustProxyPreferences: {
|
||||
load: async () => current,
|
||||
current: () => current,
|
||||
update: async patch => {
|
||||
if (reject) throw Error('server refused');
|
||||
writes.push(patch);
|
||||
current = { ...current, ...patch };
|
||||
applyDashboardLayout(current.dashboard_widgets, current.dashboard_hidden_widgets);
|
||||
return current;
|
||||
},
|
||||
},
|
||||
};
|
||||
function applyDashboardLayout(order, hidden) {
|
||||
for (const id of order) { cards.get(id).hidden = hidden.includes(id); grid.append(cards.get(id)); }
|
||||
}
|
||||
const $ = id => controls.get(id);
|
||||
const ctx = {
|
||||
window, $, el: (tag, value, cls) => new Node(tag, value, cls),
|
||||
t: key => `loc:${key}`, toast: msg => notifications.push(msg),
|
||||
dashboardWidgetLabel: id => `label:${id}`,
|
||||
dashboardWidgetTypeBadge: id => new Node('span', `kind:${widgets.find(w => w.id === id).display_type}`, 'overview-widget-type'),
|
||||
applyDashboardLayout,
|
||||
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault] },
|
||||
normalizeDashboardWidgets: order => [...new Set([...(Array.isArray(order) ? order.filter(id => ids.includes(id)) : []), ...ids])],
|
||||
normalizeHiddenDashboardWidgets: hidden => [...new Set((hidden || []).filter(id => ids.includes(id)))],
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(source, ctx);
|
||||
const editor = window.rustProxyInlineLayout;
|
||||
const refresh = () => new Promise(resolve => setImmediate(resolve));
|
||||
const visible = () => grid.children.filter(node => !node.hidden).map(node => node.dataset.dashboardWidget);
|
||||
const toolbarCount = () => grid.children.filter(node => node.querySelector('.overview-inline-toolbar')).length;
|
||||
const button = (id, cls) => cards.get(id).querySelector(cls);
|
||||
const grip = id => button(id, '.overview-inline-grip');
|
||||
const dataTransfer = () => ({ setData(k, value) { this[k] = value; }, effectAllowed: '', dropEffect: '' });
|
||||
|
||||
// Opening does not clone cards or trigger network writes. Only the handle drags.
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
assert(editor.isEditing());
|
||||
assert.equal(controls.get('overview-inline-actions').hidden, false);
|
||||
assert.equal(toolbarCount(), 62);
|
||||
assert.equal(cards.get('requests').attributes.id, 'real-requests');
|
||||
assert.equal(grip('requests').dataset.overviewDragHandle, 'requests');
|
||||
const accidental = grid.fire('dragstart', { target: cards.get('requests'), dataTransfer: dataTransfer() });
|
||||
assert(accidental.prevented);
|
||||
assert.equal(writes.length, 0);
|
||||
// Hide removes the real block from the current view, not the registry; restore
|
||||
// works immediately without an API call, and Cancel reverts an uncommitted hide.
|
||||
button('requests', '.overview-inline-hide').fire('click');
|
||||
assert(cards.get('requests').hidden);
|
||||
assert(controls.get('overview-inline-hidden').open);
|
||||
assert.equal(controls.get('overview-inline-hidden').hidden, false);
|
||||
assert(controls.get('overview-inline-hidden-list').children.length > hiddenDefault.length);
|
||||
assert.equal(writes.length, 0);
|
||||
controls.get('overview-inline-hidden-list').children
|
||||
.find(item => item.children.some(child => child.textContent === 'label:requests'))
|
||||
.querySelector('.overview-inline-restore').fire('click');
|
||||
assert(!cards.get('requests').hidden, 'restore makes the block visible in the same draft');
|
||||
button('requests', '.overview-inline-hide').fire('click');
|
||||
assert(cards.get('requests').hidden);
|
||||
controls.get('overview-inline-cancel').fire('click');
|
||||
assert(!cards.get('requests').hidden, 'cancel restores the original visibility');
|
||||
assert.equal(controls.get('overview-inline-hidden').hidden, true);
|
||||
assert.equal(writes.length, 0);
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
const transfer = dataTransfer();
|
||||
grid.fire('dragstart', { target: grip('requests'), dataTransfer: transfer });
|
||||
assert.equal(transfer['text/plain'], 'requests');
|
||||
grid.fire('dragover', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
|
||||
grid.fire('drop', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
|
||||
assert.deepEqual(visible().slice(0, 3), ['active','requests','bandwidth']);
|
||||
assert.equal(writes.length, 0);
|
||||
button('requests', '.overview-inline-up').fire('click');
|
||||
assert.deepEqual(visible().slice(0, 3), ['requests','active','bandwidth']);
|
||||
button('traffic_chart', '.overview-inline-up').fire('click');
|
||||
assert.equal(writes.length, 0);
|
||||
controls.get('overview-inline-cancel').fire('click');
|
||||
assert(!editor.isEditing());
|
||||
assert.deepEqual(visible(), ids.filter(id => !hiddenDefault.includes(id)));
|
||||
assert.equal(toolbarCount(), 0);
|
||||
assert.equal(writes.length, 0);
|
||||
|
||||
// Saving writes exactly one ordered/hidden patch and retains other preferences.
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
grid.fire('dragstart', { target: grip('requests'), dataTransfer: dataTransfer() });
|
||||
grid.fire('drop', { target: cards.get('active'), clientX: 90 });
|
||||
reject = true;
|
||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
||||
assert(editor.isEditing(), 'failed PUT preserves the draft');
|
||||
assert.equal(controls.get('overview-inline-hint').textContent, 'server refused');
|
||||
assert.equal(writes.length, 0);
|
||||
reject = false;
|
||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
||||
assert(!editor.isEditing());
|
||||
assert.equal(toolbarCount(), 0);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
|
||||
assert.deepEqual([...current.dashboard_hidden_widgets], hiddenDefault);
|
||||
assert.equal(current.theme, 'light');
|
||||
assert.equal(notifications.length, 1);
|
||||
|
||||
// Locking without changes does not write again. Navigation discard and dialog guard.
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
assert(!editor.isEditing());
|
||||
assert.equal(writes.length, 1);
|
||||
controls.get('overview-layout-dialog').open = true;
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
assert(!editor.isEditing(), 'do not edit actual cards underneath the open modal');
|
||||
controls.get('overview-layout-dialog').open = false;
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
|
||||
grid.fire('drop', { target: cards.get('requests'), clientX: 0 });
|
||||
assert.notDeepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
|
||||
editor.cancel();
|
||||
assert.deepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
|
||||
assert.equal(writes.length, 1);
|
||||
console.log('PASS: direct Overview unlock, handles, move arrows, cancel, one save, retry, lock, navigation and modal guard');
|
||||
|
||||
// Hide persists across unlock sessions and the list allows recovery without
|
||||
// touching unrelated account settings. The same API path handles a large panel.
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
button('backend_table', '.overview-inline-hide').fire('click');
|
||||
assert(cards.get('backend_table').hidden);
|
||||
assert.equal(writes.length, 1);
|
||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
||||
assert.equal(writes.length, 2);
|
||||
assert(current.dashboard_hidden_widgets.includes('backend_table'));
|
||||
assert(cards.get('backend_table').hidden);
|
||||
assert.equal(current.theme, 'light');
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
controls.get('overview-inline-hidden-list').children
|
||||
.find(item => item.children.some(child => child.textContent === 'label:backend_table'))
|
||||
.querySelector('.overview-inline-restore').fire('click');
|
||||
assert(!cards.get('backend_table').hidden);
|
||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
||||
assert.equal(writes.length, 3);
|
||||
assert(!current.dashboard_hidden_widgets.includes('backend_table'));
|
||||
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
|
||||
console.log('PASS: inline hide, unsaved cancel, hidden shelf restore and one-request persistence');
|
||||
|
||||
// In the real browser the row controller groups cards by footprint. A cross-
|
||||
// group drop should not appear to move a card into an incompatible-height row.
|
||||
window.rustProxyMasonry = {groupFor: id => id === 'traffic_chart' ? 1 : 0};
|
||||
const beforeGroups = [...current.dashboard_widgets];
|
||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
||||
const beforeCrossDrop = visible();
|
||||
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
|
||||
grid.fire('drop', { target: cards.get('requests'), clientX: 50 });
|
||||
assert.deepEqual(visible(), beforeCrossDrop, 'different-size cards must not switch groups');
|
||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
||||
assert.equal(writes.length, 3, 'blocked cross-group move must not write');
|
||||
assert.deepEqual([...current.dashboard_widgets], beforeGroups);
|
||||
console.log('PASS: cross-size drops cannot break symmetric grid groups');
|
||||
@@ -0,0 +1,213 @@
|
||||
// Browser-independent UI simulation of the actual visual Overview modal.
|
||||
// This does not replace an authenticated DOM/browser acceptance test.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
||||
const ids = registry.map(widget => widget.id);
|
||||
const initialHidden = registry.filter(widget => widget.default_hidden).map(widget => widget.id);
|
||||
const source = fs.readFileSync('src/admin/web/app/dashboard-layout-editor.js', 'utf8');
|
||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
||||
for (const id of ['overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden', 'overview-layout-open', 'overview-layout-open-settings', 'overview-layout-save', 'overview-layout-cancel', 'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel'])
|
||||
assert(html.includes(`id="${id}"`), id);
|
||||
|
||||
class FakeNode {
|
||||
constructor(tag = 'div', content = '', classes = '') {
|
||||
this.tag = tag; this.textContent = String(content ?? ''); this.classes = new Set(classes.split(/\s+/).filter(Boolean));
|
||||
this.classList = { add: (...args) => args.forEach(x => this.classes.add(x)), remove: (...args) => args.forEach(x => this.classes.delete(x)) };
|
||||
this.children = []; this.attrs = {}; this.dataset = {}; this.listeners = new Map();
|
||||
this.hidden = false; this.open = false; this.disabled = false; this.value = ''; this.draggable = false;
|
||||
}
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
addEventListener(name, cb) { const list = this.listeners.get(name) || []; list.push(cb); this.listeners.set(name, list); }
|
||||
fire(name, event = {}) { for (const cb of this.listeners.get(name) || []) cb({ preventDefault() {}, target: this, ...event }); }
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
removeAttribute(k) { delete this.attrs[k]; }
|
||||
focus() { this.focused = true; }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; this.fire('close'); }
|
||||
querySelectorAll(selector) {
|
||||
const nodes = [];
|
||||
function visit(node) {
|
||||
for (const child of node.children) {
|
||||
if (selector === '[id]' && child.attrs.id) nodes.push(child);
|
||||
else if (selector === '[aria-labelledby], [aria-describedby]' && (child.attrs['aria-labelledby'] || child.attrs['aria-describedby'])) nodes.push(child);
|
||||
else if (selector === '.overview-layout-tile' && child.classes.has('overview-layout-tile')) nodes.push(child);
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
visit(this); return nodes;
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll('.overview-layout-tile').find(node => node.classes.has(selector.slice(1))) ||
|
||||
(() => { const visited = [this]; while (visited.length) { const node = visited.shift(); if (node.classes.has(selector.slice(1))) return node; visited.push(...node.children); } return null; })();
|
||||
}
|
||||
cloneNode(deep = false) {
|
||||
const copy = new FakeNode(this.tag, this.textContent, [...this.classes].join(' '));
|
||||
copy.hidden = this.hidden; copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
|
||||
if (deep) copy.children = this.children.map(node => node.cloneNode(true));
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
const controls = Object.fromEntries([
|
||||
'overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden',
|
||||
'overview-layout-save', 'overview-layout-cancel', 'overview-layout-search', 'overview-layout-type-filter',
|
||||
'overview-layout-message', 'overview-layout-open', 'overview-layout-open-settings',
|
||||
'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel',
|
||||
].map(id => [id, new FakeNode()]));
|
||||
const originals = new Map(ids.map(id => {
|
||||
const node = new FakeNode('article', 'metric value', 'stat');
|
||||
node.attrs.id = `real-${id}`; node.dataset.dashboardWidget = id;
|
||||
node.append(new FakeNode('strong', '42'));
|
||||
node.children[0].attrs.id = `metric-${id}`;
|
||||
node.hidden = initialHidden.includes(id);
|
||||
return [id, node];
|
||||
}));
|
||||
let saved = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden], theme: 'light' };
|
||||
const writes = [], notifications = [];
|
||||
let failSave = false;
|
||||
const window = { rustProxyPreferences: {
|
||||
current: () => saved,
|
||||
load: async () => saved,
|
||||
update: async patch => {
|
||||
if (failSave) throw Error('server refused');
|
||||
writes.push(patch); saved = { ...saved, ...patch };
|
||||
for (const id of ids) originals.get(id).hidden = patch.dashboard_hidden_widgets.includes(id);
|
||||
return saved;
|
||||
},
|
||||
} };
|
||||
const $ = id => controls[id];
|
||||
const ctx = {
|
||||
window, $, t: text => `loc:${text}`, toast: text => notifications.push(text),
|
||||
el: (tag, content, cls) => new FakeNode(tag, content, cls),
|
||||
ADMIN_DASHBOARD_BY_ID: new Map(registry.map(widget => [widget.id, widget])),
|
||||
ADMIN_DASHBOARD_WIDGETS: [...ids],
|
||||
ADMIN_DEFAULT_HIDDEN_WIDGETS: [...initialHidden],
|
||||
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden] },
|
||||
normalizeDashboardWidgets: value => [...new Set([...(Array.isArray(value) ? value.filter(id => ids.includes(id)) : []), ...ids])],
|
||||
normalizeHiddenDashboardWidgets: value => [...new Set((Array.isArray(value) ? value : []).filter(id => ids.includes(id)))],
|
||||
dashboardWidgetType: id => registry.find(widget => widget.id === id)?.display_type,
|
||||
dashboardWidgetTypeLabel: id => `loc:overviewBlockType${({
|
||||
value: 'Value', value_chart: 'ValueChart', chart: 'Chart', panel: 'Panel', table: 'Table',
|
||||
})[registry.find(widget => widget.id === id)?.display_type]}`,
|
||||
dashboardWidgetTypeBadge: id => new FakeNode('span', `type:${registry.find(widget => widget.id === id)?.display_type}`, 'overview-widget-type'),
|
||||
document: { querySelector: selector => originals.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
|
||||
};
|
||||
vm.createContext(ctx); vm.runInContext(source, ctx);
|
||||
const module = window.rustProxyDashboardLayout;
|
||||
const dialog = controls['overview-layout-dialog'];
|
||||
const visible = controls['overview-layout-visible'];
|
||||
const hidden = controls['overview-layout-hidden'];
|
||||
const refresh = () => new Promise(resolve => setImmediate(resolve));
|
||||
const previewIds = () => visible.children.filter(node => node.dataset.previewWidget).map(node => node.dataset.previewWidget);
|
||||
|
||||
// Cancel changes, no PUT or change of real dashboard.
|
||||
controls['overview-layout-open'].fire('click'); await refresh();
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(previewIds().length, ids.length - initialHidden.length);
|
||||
assert.equal(hidden.children.length, initialHidden.length);
|
||||
assert.equal(visible.children[0].children[0].children[1].textContent, 'type:value');
|
||||
assert.equal(hidden.children[0].children[1].textContent, `type:${registry.find(w => w.id === initialHidden[0]).display_type}`);
|
||||
controls['overview-layout-type-filter'].value = 'chart';
|
||||
controls['overview-layout-type-filter'].fire('change');
|
||||
assert.equal(hidden.children.filter(item => item.classes.has('overview-layout-hidden-item')).length, registry.filter(w => w.default_hidden && w.display_type === 'chart').length, 'type filter shows only hidden charts');
|
||||
controls['overview-layout-type-filter'].value = 'all';
|
||||
controls['overview-layout-type-filter'].fire('change');
|
||||
assert.equal(hidden.children.length, initialHidden.length);
|
||||
const previewCopy = visible.children[0].children[1];
|
||||
assert.equal(previewCopy.attrs.id, undefined, 'preview clones must not duplicate real IDs');
|
||||
assert.equal(previewCopy.children[0].attrs.id, undefined);
|
||||
assert.equal(previewCopy.attrs['aria-hidden'], 'true');
|
||||
const first = previewIds()[0];
|
||||
visible.children[0].children[0].children[2].children[2].fire('click');
|
||||
assert(!previewIds().includes(first));
|
||||
assert.equal(originals.get(first).hidden, false, 'draft hide must not touch live cards');
|
||||
assert.equal(writes.length, 0);
|
||||
controls['overview-layout-cancel'].fire('click');
|
||||
assert.equal(dialog.open, false);
|
||||
assert.equal(writes.length, 0);
|
||||
|
||||
// Settings and Overview entry points are wired to the same editor.
|
||||
controls['overview-layout-open-settings'].fire('click'); await refresh();
|
||||
assert.equal(dialog.open, true);
|
||||
const originalVisible = previewIds();
|
||||
const from = visible.children[0], to = visible.children[2];
|
||||
const dataTransfer = { setData(type, value) { this[type] = value; }, effectAllowed: '', dropEffect: '' };
|
||||
from.fire('dragstart', { target: from, dataTransfer });
|
||||
to.fire('dragover', { dataTransfer });
|
||||
to.fire('drop', { dataTransfer });
|
||||
assert.equal(previewIds()[2], originalVisible[0], 'dropping moves a tile after the drop target');
|
||||
assert.equal(previewIds()[0], originalVisible[1]);
|
||||
// Keyboard buttons move cards back, without rebuilding saved prefs prematurely.
|
||||
visible.children[2].children[0].children[2].children[0].fire('click');
|
||||
assert.equal(previewIds()[1], originalVisible[0]);
|
||||
const firstHiddenId = initialHidden[0];
|
||||
const hiddenItem = hidden.children.find(node => node.children[0].textContent === `loc:${registry.find(x => x.id === firstHiddenId).label_key}`);
|
||||
hiddenItem.children[2].fire('click');
|
||||
assert(previewIds().includes(firstHiddenId));
|
||||
assert.equal(writes.length, 0);
|
||||
controls['overview-layout-save'].fire('click'); await refresh();
|
||||
assert.equal(writes.length, 1, 'one atomic preferences update');
|
||||
assert.equal(dialog.open, false);
|
||||
assert.equal(saved.theme, 'light', 'unrelated preferences are untouched');
|
||||
assert.equal(new Set(saved.dashboard_widgets).size, ids.length);
|
||||
assert.equal(saved.dashboard_widgets.length, ids.length);
|
||||
assert(!saved.dashboard_hidden_widgets.includes(firstHiddenId));
|
||||
assert.equal(notifications.length, 1);
|
||||
|
||||
// Failed save keeps the dialog open and the unsaved draft available for retry.
|
||||
controls['overview-layout-open'].fire('click'); await refresh();
|
||||
failSave = true;
|
||||
const writeCount = writes.length;
|
||||
controls['overview-layout-save'].fire('click'); await refresh();
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(controls['overview-layout-message'].textContent, 'server refused');
|
||||
assert.equal(writes.length, writeCount);
|
||||
failSave = false;
|
||||
controls['overview-layout-cancel'].fire('click');
|
||||
assert.equal(dialog.open, false);
|
||||
// Default view is only a draft until Save: no implicit writes.
|
||||
controls['overview-layout-open'].fire('click'); await refresh();
|
||||
const writesBeforeDefault = writes.length;
|
||||
controls['overview-layout-default'].fire('click');
|
||||
assert.deepEqual(previewIds(), ids.filter(id => !initialHidden.includes(id)));
|
||||
assert(controls['overview-layout-message'].textContent.includes('overviewLayoutDefaultLoaded'));
|
||||
assert.equal(writes.length, writesBeforeDefault);
|
||||
assert(previewIds().includes('backend_table'), 'all seven full-width Overview panels belong to the editor');
|
||||
assert.equal(visible.children.find(node => node.dataset.previewWidget === 'backend_table').classes.has('is-panel'), true);
|
||||
// Reset first prompts for confirmation, cancellation must not change preferences.
|
||||
controls['overview-layout-reset'].fire('click');
|
||||
assert.equal(controls['overview-reset-confirm'].open, true);
|
||||
assert.equal(writes.length, writesBeforeDefault);
|
||||
controls['overview-reset-cancel'].fire('click');
|
||||
assert.equal(controls['overview-reset-confirm'].open, false);
|
||||
assert.equal(writes.length, writesBeforeDefault);
|
||||
// A failed reset keeps the confirmation open and never claims success.
|
||||
controls['overview-layout-reset'].fire('click'); failSave = true;
|
||||
controls['overview-reset-apply'].fire('click'); await refresh();
|
||||
assert.equal(controls['overview-reset-confirm'].open, true);
|
||||
assert.equal(writes.length, writesBeforeDefault);
|
||||
failSave = false;
|
||||
controls['overview-reset-apply'].fire('click'); await refresh();
|
||||
assert.equal(controls['overview-reset-confirm'].open, false);
|
||||
assert.equal(dialog.open, false);
|
||||
assert.equal(writes.length, writesBeforeDefault + 1);
|
||||
assert.deepEqual([...saved.dashboard_widgets], ids);
|
||||
assert.deepEqual([...saved.dashboard_hidden_widgets], initialHidden);
|
||||
assert.equal(saved.theme, 'light', 'reset must not affect unrelated account preferences');
|
||||
console.log('PASS: full Overview editor, drag/keyboard, legacy settings, default preview, confirmed reset, cancel, atomic save and failures');
|
||||
|
||||
// Group boundaries are a UX constraint in the real browser: the modal must
|
||||
// not let a small tile replace the position of a tall diagnostic panel.
|
||||
window.rustProxyMasonry = { groupFor: id => id === 'traffic_chart' ? 1 : id.endsWith('_diagnostics') ? 3 : 0 };
|
||||
controls['overview-layout-open'].fire('click'); await refresh();
|
||||
const beforeGroupMove = previewIds();
|
||||
const small = visible.children.find(node => node.dataset.previewWidget === 'requests');
|
||||
const big = visible.children.find(node => node.dataset.previewWidget === 'traffic_chart');
|
||||
small.fire('dragstart', { target: small, dataTransfer: {setData() {},effectAllowed:''} });
|
||||
big.fire('drop');
|
||||
assert.deepEqual(previewIds(), beforeGroupMove, 'the modal must not mix incompatible tile groups');
|
||||
controls['overview-layout-cancel'].fire('click');
|
||||
console.log('PASS: visual editor respects symmetric height groups');
|
||||
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
|
||||
args = parser.parse_args()
|
||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
||||
assert manifest['package']['version'] == '0.32.2'
|
||||
assert manifest['package']['version'] == '0.32.3'
|
||||
assert manifest['package']['rust-version'] == '1.99'
|
||||
deps = manifest['dependencies']
|
||||
expected_requirements = {
|
||||
|
||||
@@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert readme.startswith("# RustProxy 0.32.2")
|
||||
assert readme.startswith("# RustProxy 0.32.3")
|
||||
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
||||
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
||||
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
||||
|
||||
@@ -23,6 +23,6 @@ assert.match(js, /aria-selected/);
|
||||
assert.match(js, /\$\("file-log-view-" \+ name\)\.hidden = !selected/);
|
||||
assert.match(js, /activeView === "counts" && aggregateField/);
|
||||
assert.match(js, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
||||
assert.match(css, /#file-log-view-counts\[hidden\], #file-log-view-entries\[hidden\]/);
|
||||
assert.match(css, /#file-log-view-counts\[hidden\]\s*,\s*#file-log-view-entries\[hidden\]/);
|
||||
for (const strings of [en, pl]) for (const key of ['fileLogsConsolidateShort', 'fileLogsEntriesTab','fileLogsCountsTab']) assert.ok(strings[key], key);
|
||||
console.log('PASS: Stored Logs separate tabs, shared filter, compact persisted consolidation toggle, localized UI.');
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
html = (ROOT / 'src/admin/web/index.html').read_text()
|
||||
assert '0.32.2' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '0.32.3' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
|
||||
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
|
||||
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
|
||||
|
||||
@@ -43,10 +43,14 @@ assert.match(caption, /font-size:\s*10px/);
|
||||
assert.match(payload, /font-size:\s*10px\s*!important/);
|
||||
assert.match(css, /#panel-overview \.stats-grid \.stat-chart>\.stat-chart-legend\s*\{[^}]*flex-wrap:\s*wrap/s);
|
||||
|
||||
const overview = html.slice(html.indexOf('id="panel-overview"'), html.indexOf('class="overview-grid"'));
|
||||
assert.equal([...overview.matchAll(/data-dashboard-widget="/g)].length, 16,
|
||||
'no metric/Stream cards may be removed');
|
||||
const overview = html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"'));
|
||||
const widgets = [...overview.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(match => match[1]);
|
||||
assert.equal(widgets.length, 62, '49 existing widgets plus 6 small and 7 large opt-in charts');
|
||||
assert.equal(widgets.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_') && !id.startsWith('wide_') && !['traffic_chart','system_chart','connection_chart','cache_diagnostics','process_diagnostics','network_diagnostics','backend_table'].includes(id)).length, 16,
|
||||
'all 16 pre-existing metric/Stream cards must be retained');
|
||||
assert.match(overview, /id="metric-bytes-split"/);
|
||||
assert.match(overview, /id="http-protocol-chart"/);
|
||||
assert.match(overview, /id="ws-protocol-chart"/);
|
||||
assert.match(overview, /id="dashboard-overview-grid"/);
|
||||
assert.doesNotMatch(overview, /class="overview-grid"|class="diagnostics-grid"/, 'all Overview widgets must share one arrangement grid');
|
||||
console.log('PASS: 0.32.1 Overview six-column content height, 90% compact typography, shared tile spacing and inline legends.');
|
||||
@@ -0,0 +1,95 @@
|
||||
// Existing status snapshots only: no API calls, bounded histories and honest no-data.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = fs.readFileSync('src/admin/web/app/overview-extra-metrics.js', 'utf8');
|
||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
||||
const ids = registry.filter(widget => widget.category === 'performance').map(widget => widget.id);
|
||||
assert.equal(ids.length, 18);
|
||||
const widgets = new Map(ids.map(id => [id, { hidden: false }]));
|
||||
const fields = new Map();
|
||||
const field = id => {
|
||||
if (!fields.has(id)) fields.set(id, { textContent: '-', points: '', setAttribute(name, value) { this[name] = value; } });
|
||||
return fields.get(id);
|
||||
};
|
||||
let now = 1000;
|
||||
const context = {
|
||||
window: {}, currentPanel: 'overview', performance: { now: () => now },
|
||||
t: key => key, fmt: (v, places = 0) => Number(v).toFixed(places),
|
||||
fmtBytes: v => `${v} B`, $: field,
|
||||
document: { querySelector: selector => widgets.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context);
|
||||
const render = context.window.rustProxyExtraOverview.render;
|
||||
const text = id => field(`overview-extra-${id}`).textContent;
|
||||
const points = id => field(`overview-extra-${id}-line`).points;
|
||||
const start = {
|
||||
metrics: { requests: 100, http1_requests: 70, http2_requests: 30, http3_requests: 0, tls_handshake_errors: 3, errors: 10, active_connections: 5, active: 2, websocket_active: 1, mean_duration_ms: 25 },
|
||||
system: { process_cpu_percent: 12, process_rss_bytes: 1000, open_fds: 10, max_fds: 500 },
|
||||
cache: { hits: 10, misses: 10, resident_bytes: 10, hot_limit_bytes: 100 },
|
||||
backend_pools: [{ members: [{status: 'up'}, {status: 'down'}] }],
|
||||
};
|
||||
render(start);
|
||||
assert.equal(text('extra_process_cpu'), '12.0%');
|
||||
assert.equal(text('extra_process_rss'), '1000 B');
|
||||
assert.equal(text('extra_backend_unhealthy'), '1');
|
||||
for (const id of ['extra_tls_error_rate','extra_request_error_rate','extra_cache_hit_rate','extra_h2_share'])
|
||||
assert.equal(text(id), '-', `initial ${id} delta must not be invented`);
|
||||
now = 3000;
|
||||
const next = structuredClone(start);
|
||||
next.metrics = { ...start.metrics, requests: 130, http1_requests: 84, http2_requests: 45, http3_requests: 1, tls_handshake_errors: 5, errors: 16, active_connections: 6, active: 4, websocket_active: 3, mean_duration_ms: 40 };
|
||||
next.system = { ...start.system, process_cpu_percent: 22, process_rss_bytes: 2000, open_fds: 20 };
|
||||
next.cache = { ...start.cache, hits: 20, misses: 15, resident_bytes: 25 };
|
||||
next.backend_pools[0].members[0].status = 'down';
|
||||
render(next);
|
||||
assert.equal(text('extra_tls_error_rate'), '1.0/s');
|
||||
assert.equal(text('extra_request_error_rate'), '3.0/s');
|
||||
assert.equal(text('extra_cache_hit_rate'), '66.7%');
|
||||
assert.equal(text('extra_h2_share'), '50.0%');
|
||||
assert.equal(text('extra_cache_memory'), '25.0%');
|
||||
assert.equal(text('extra_response_latency'), '40.0 ms');
|
||||
assert.equal(text('extra_backend_unhealthy'), '2');
|
||||
assert.equal(text('extra_total_rps'), '15.0/s');
|
||||
assert.equal(text('extra_active_exchanges'), '4');
|
||||
assert.equal(text('extra_h1_share'), '46.7%');
|
||||
assert.equal(text('extra_h3_share'), '3.3%');
|
||||
assert.equal(text('extra_cache_miss_rate'), '2.5/s');
|
||||
assert.equal(text('extra_backend_active'), '0');
|
||||
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 2);
|
||||
// Hiding a chart stops sampling; stale cumulative counters are never used as an instant rate.
|
||||
widgets.get('extra_tls_error_rate').hidden = true;
|
||||
now = 5000;
|
||||
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 50 } });
|
||||
assert.equal(text('extra_tls_error_rate'), '1.0/s');
|
||||
widgets.get('extra_tls_error_rate').hidden = false;
|
||||
now = 6000;
|
||||
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 60 } });
|
||||
assert.equal(text('extra_tls_error_rate'), '-', 'first fresh visible sample establishes a new baseline');
|
||||
// After a reset, don't return a negative rate or denominator percentage.
|
||||
now = 7000;
|
||||
render({ ...next, metrics: { ...next.metrics, requests: 1, http2_requests: 0, tls_handshake_errors: 0, errors: 0 }, cache: { ...next.cache, hits: 0, misses: 0 } });
|
||||
assert.equal(text('extra_h2_share'), '-');
|
||||
assert.equal(text('extra_request_error_rate'), '-');
|
||||
assert.equal(text('extra_tls_error_rate'), '-');
|
||||
// Explicit unavailability is not represented as a measured zero.
|
||||
now = 8000;
|
||||
render({ metrics: {}, system: {}, cache: {} });
|
||||
assert.equal(text('extra_process_cpu'), '-');
|
||||
assert.equal(text('extra_websocket_active'), '-');
|
||||
assert.equal(text('extra_backend_unhealthy'), '-');
|
||||
context.window.rustProxyExtraOverview.reset();
|
||||
now = 9000;
|
||||
for (let index = 0; index < 50; index++) {
|
||||
now += 1000;
|
||||
render({ ...next, system: { ...next.system, process_cpu_percent: index } });
|
||||
}
|
||||
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 30, 'bounded history');
|
||||
context.currentPanel = 'vhosts';
|
||||
const pointBefore = points('extra_process_cpu');
|
||||
now += 1000;
|
||||
render(next);
|
||||
assert.equal(points('extra_process_cpu'), pointBefore, 'no extra sampling outside Overview');
|
||||
assert.match(source, /if \(!tile \|\| tile.hidden\)/);
|
||||
console.log('PASS: 18 extra metric series, time-based deltas, no-data, counter resets, hidden opt-ins and 30-sample bounds');
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Overview row symmetry regression using the shipped CSS/JS and simulated status.
|
||||
|
||||
Browser DOM geometry only; does not exercise the running RustProxy backend.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from bs4 import BeautifulSoup
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / 'src/admin/web'
|
||||
soup = BeautifulSoup((ROOT / 'index.html').read_text(), 'html.parser')
|
||||
widgets = list(soup.select('#dashboard-overview-grid > [data-dashboard-widget]'))
|
||||
assert len(widgets) == 62
|
||||
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
|
||||
'<head><meta charset="utf-8"><style>' + (ROOT / 'style.css').read_text() +
|
||||
'</style></head><body style="margin:0"><div id="test-shell" '
|
||||
'style="padding:8px;box-sizing:border-box"><div class="brand">'
|
||||
'<div class="brand-mark" id="logo-test">R<span>P</span></div></div>'
|
||||
'<section id="panel-overview"><div id="dashboard-overview-grid" '
|
||||
'class="stats-grid dashboard-overview-grid">' +
|
||||
''.join(str(widget) for widget in widgets) + '</div></section></div></body></html>')
|
||||
|
||||
SNAPSHOT = """() => {
|
||||
const grid = document.getElementById('dashboard-overview-grid');
|
||||
const style = getComputedStyle(grid), outer = grid.getBoundingClientRect();
|
||||
const group = window.rustProxyMasonry.groupFor;
|
||||
return {
|
||||
packed: grid.classList.contains('is-packed'),
|
||||
gap: parseFloat(style.columnGap),
|
||||
columns: style.gridTemplateColumns.trim().split(/\\s+/).length,
|
||||
grid: {left:outer.left,right:outer.right,width:outer.width},
|
||||
scroll: grid.scrollWidth, client: grid.clientWidth,
|
||||
cards: [...grid.children].filter(node=>node.dataset.dashboardWidget && !node.hidden).map(node=>{
|
||||
const r=node.getBoundingClientRect();return {id:node.dataset.dashboardWidget,
|
||||
group:group(node.dataset.dashboardWidget),x:r.left,y:r.top,right:r.right,
|
||||
bottom:r.bottom,width:r.width,height:r.height};
|
||||
})
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
def validate(data, zoom, width):
|
||||
"""Peers in each row must have one top edge and one bottom edge."""
|
||||
cards = data['cards']; gap = data['gap'] * zoom
|
||||
assert cards and not data['packed'], (width, 'absolute/masonry mode is forbidden')
|
||||
assert data['scroll'] <= data['client'] + 2, (width, 'horizontal overflow')
|
||||
seen_groups = []
|
||||
rows = {}
|
||||
for card in cards:
|
||||
assert card['width'] > 40 and card['height'] > 26, (width, card)
|
||||
assert card['x'] >= data['grid']['left'] - 1, (width, card)
|
||||
assert card['right'] <= data['grid']['right'] + 1, (width, card)
|
||||
if not seen_groups or seen_groups[-1] != card['group']:
|
||||
assert card['group'] not in seen_groups, (width, 'noncontiguous size groups')
|
||||
seen_groups.append(card['group'])
|
||||
row = round(card['y'], 1)
|
||||
rows.setdefault(row, []).append(card)
|
||||
for members in rows.values():
|
||||
assert len({m['group'] for m in members}) == 1, (width, 'mixed sizes in a row', members)
|
||||
assert max(m['bottom'] for m in members) - min(m['bottom'] for m in members) <= 1.5, (width, 'ragged bottom', members)
|
||||
assert max(m['height'] for m in members) - min(m['height'] for m in members) <= 1.5, (width, 'uneven heights', members)
|
||||
for a,b in zip(members,members[1:]):
|
||||
assert abs(b['x']-a['right']-gap) <= 2.0, (width,'wrong horizontal gutter',a,b,gap)
|
||||
line_groups = list(rows.values())
|
||||
for above, below in zip(line_groups,line_groups[1:]):
|
||||
a = above[0]; b=below[0]
|
||||
assert b['y'] >= max(v['bottom'] for v in above) + gap - 2, (width,'vertical gap',a,b)
|
||||
return rows
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True,
|
||||
executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),
|
||||
args=['--no-sandbox'])
|
||||
page = browser.new_page(viewport={'width':1720,'height':950})
|
||||
page.set_content(html, wait_until='domcontentloaded')
|
||||
page.add_script_tag(content=(ROOT / 'app/overview-masonry.js').read_text())
|
||||
page.evaluate("""() => window._overviewOriginal = [...document.querySelectorAll(
|
||||
'#dashboard-overview-grid > [data-dashboard-widget]')].map(n=>[n.dataset.dashboardWidget,n.hidden])""")
|
||||
data=page.evaluate(SNAPSHOT)
|
||||
assert data['columns']==12
|
||||
assert [card['id'] for card in data['cards'][:6]] == [
|
||||
'requests','active','bandwidth','errors','cache','memory']
|
||||
validate(data,1,1720)
|
||||
assert len({round(v['height']) for v in data['cards'][:6]}) == 1
|
||||
page.locator('#dashboard-overview-grid').screenshot(
|
||||
path=str(Path(__file__).parents[3] / 'overview_uniform_default_preview.png'))
|
||||
|
||||
# Screenshot regression: custom ordering previously produced 3 distinctly
|
||||
# sized tiles on the same horizontal band (a metric + two tall charts).
|
||||
ids=['backend_connection_errors','process_diagnostics','network_diagnostics','cache_diagnostics',
|
||||
'connection_chart','traffic_chart','system_chart','backend_table',
|
||||
'requests','active','http_protocol','websocket_protocol',
|
||||
'wide_cpu','wide_memory']
|
||||
page.evaluate("""ids => {
|
||||
const grid=document.getElementById('dashboard-overview-grid');
|
||||
for(const node of grid.children) if(node.dataset.dashboardWidget)
|
||||
node.hidden=!ids.includes(node.dataset.dashboardWidget);
|
||||
grid.prepend(...ids.map(id=>grid.querySelector(`[data-dashboard-widget="${id}"]`)));
|
||||
window.rustProxyMasonry.refresh();
|
||||
}""", ids)
|
||||
changed=page.evaluate(SNAPSHOT)
|
||||
validate(changed,1,1720)
|
||||
assert [card['group'] for card in changed['cards']] == sorted(card['group'] for card in changed['cards'])
|
||||
assert not any(card['id']=='backend_connection_errors' and card['y'] == next(v['y'] for v in changed['cards'] if v['id']=='process_diagnostics') for card in changed['cards'])
|
||||
# 3 uniform diagnostic panels per desktop row, 2 large charts per row.
|
||||
panels=[card for card in changed['cards'] if card['group']==3]
|
||||
assert len({round(card['y']) for card in panels[:3]}) == 1
|
||||
assert len({round(card['height']) for card in panels[:3]}) == 1
|
||||
wides=[card for card in changed['cards'] if card['group']==2]
|
||||
assert len(wides)==2 and abs(wides[0]['height']-wides[1]['height']) < 2
|
||||
page.screenshot(path=str(Path(__file__).parents[3] / 'overview_uniform_preview.png'), full_page=True)
|
||||
|
||||
# Runtime content growth automatically expands all peers in its Grid row,
|
||||
# without ResizeObserver, manual coordinates or animation loops.
|
||||
old_height=panels[0]['height']
|
||||
page.evaluate("""() => {
|
||||
const panel=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
|
||||
const child=document.createElement('div');child.id='expanded';child.style.height='175px';panel.append(child);
|
||||
}""")
|
||||
updated=page.evaluate(SNAPSHOT)
|
||||
validate(updated,1,1720)
|
||||
assert next(card for card in updated['cards'] if card['id']=='process_diagnostics')['height'] > old_height + 80
|
||||
page.evaluate("() => document.getElementById('expanded').remove()")
|
||||
validate(page.evaluate(SNAPSHOT),1,1720)
|
||||
|
||||
radii=page.evaluate("""() => {
|
||||
const node=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
|
||||
const bar=document.createElement('div');bar.className='overview-inline-toolbar';
|
||||
bar.innerHTML='<button class="quiet overview-inline-up">←</button><button class="quiet overview-inline-hide">×</button>';
|
||||
node.append(bar);const out=[];
|
||||
for(const preset of ['default','square','subtle','rounded','extra']) {
|
||||
document.documentElement.dataset.cornerStyle=preset;
|
||||
out.push([getComputedStyle(document.getElementById('logo-test')).borderTopLeftRadius,
|
||||
getComputedStyle(bar.querySelector('.overview-inline-up')).borderTopLeftRadius,
|
||||
getComputedStyle(bar.querySelector('.overview-inline-hide')).borderTopLeftRadius]);
|
||||
}bar.remove();return out;
|
||||
}""")
|
||||
assert len({item[0] for item in radii})==1
|
||||
assert all(item[1]==item[2] for item in radii)
|
||||
|
||||
page.evaluate("""() => {
|
||||
const grid=document.getElementById('dashboard-overview-grid');
|
||||
for(const node of grid.children) if(node.dataset.dashboardWidget) node.hidden=false;
|
||||
window.rustProxyMasonry.refresh();
|
||||
}""")
|
||||
cases=[(1720,0,1,'normal',12),(1350,180,1,'compact',12),
|
||||
(1200,260,1,'normal',6),(900,0,1,'compact',6),
|
||||
(900,190,1,'normal',6),(720,190,1,'ultra',2),
|
||||
(620,0,1,'compact',2),(430,0,1,'ultra',1),
|
||||
(1140,200,.9,'compact',6),(1140,200,1.1,'normal',6),
|
||||
(1140,200,1.3,'normal',6)]
|
||||
for width,sidebar,zoom,density,max_columns in cases:
|
||||
page.set_viewport_size({'width':width,'height':950})
|
||||
page.evaluate("""({sidebar,zoom,density})=>{
|
||||
const shell=document.getElementById('test-shell');
|
||||
shell.style.width=`${(innerWidth-sidebar)/zoom}px`;
|
||||
shell.style.marginLeft=`${sidebar}px`;shell.style.zoom=String(zoom);
|
||||
document.documentElement.dataset.dashboardDensity=density;
|
||||
window.rustProxyMasonry.refresh();
|
||||
}""", {'sidebar':sidebar,'zoom':zoom,'density':density})
|
||||
result=page.evaluate(SNAPSHOT)
|
||||
assert len(result['cards'])==62
|
||||
assert result['columns']<=max_columns, (width, result['columns'], max_columns)
|
||||
validate(result,zoom,width)
|
||||
print(f"PASS: {width}px, zoom={zoom}, groups={len(set(c['group'] for c in result['cards']))}, "
|
||||
f"columns={result['columns']}, gap={result['gap']}px")
|
||||
# Unlock mode adds editing toolbars/padding; rows must remain uniform.
|
||||
page.set_viewport_size({'width':900,'height':950})
|
||||
page.evaluate("""() => {
|
||||
const grid=document.getElementById('dashboard-overview-grid');
|
||||
const shell=document.getElementById('test-shell');
|
||||
shell.style.width='auto';shell.style.zoom='1';shell.style.marginLeft='0';
|
||||
grid.classList.add('is-unlocked');
|
||||
for(const node of grid.children) if(node.dataset.dashboardWidget) {
|
||||
const toolbar=document.createElement('div');toolbar.className='overview-inline-toolbar';
|
||||
toolbar.innerHTML='<button class="quiet overview-inline-grip">⠿</button>'+
|
||||
'<span class="overview-widget-type">Wykres</span>'+
|
||||
'<button class="quiet overview-inline-up">←</button>'+
|
||||
'<button class="quiet overview-inline-down">→</button>'+
|
||||
'<button class="quiet overview-inline-hide">×</button>';
|
||||
node.append(toolbar);
|
||||
}
|
||||
}""")
|
||||
validate(page.evaluate(SNAPSHOT),1,900)
|
||||
page.evaluate("""() => {
|
||||
const grid=document.getElementById('dashboard-overview-grid');
|
||||
grid.classList.remove('is-unlocked');
|
||||
grid.querySelectorAll('.overview-inline-toolbar').forEach(toolbar=>toolbar.remove());
|
||||
}""")
|
||||
|
||||
page.set_viewport_size({'width':1720,'height':950})
|
||||
page.evaluate("""() => {
|
||||
const grid=document.getElementById('dashboard-overview-grid'),shell=document.getElementById('test-shell');
|
||||
shell.style.width='auto';shell.style.marginLeft='0';shell.style.zoom='1';
|
||||
document.documentElement.dataset.dashboardDensity='compact';
|
||||
grid.append(...window._overviewOriginal.map(([id,hidden])=>{
|
||||
const node=grid.querySelector(`[data-dashboard-widget="${id}"]`);
|
||||
node.hidden=hidden;return node;
|
||||
}));window.rustProxyMasonry.refresh();
|
||||
}""")
|
||||
final=page.evaluate(SNAPSHOT)
|
||||
validate(final,1,1720)
|
||||
assert [node['id'] for node in final['cards'][:6]]==['requests','active','bandwidth','errors','cache','memory']
|
||||
browser.close()
|
||||
print('PASS: 62 cards in aligned rows; no floating heights, safe resize and zoom')
|
||||
@@ -0,0 +1,72 @@
|
||||
// Regression for shared server/browser Overview Cards metadata and future opt-in widgets.
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
|
||||
const prefs = fs.readFileSync('src/admin/web/app/preferences.js','utf8');
|
||||
const constants = prefs.slice(prefs.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefs.indexOf('const CHART_STYLE_KEYS'));
|
||||
const normalize = prefs.slice(prefs.indexOf('function normalizeDashboardWidgets'),prefs.indexOf('function browserLanguage'));
|
||||
const label = prefs.slice(prefs.indexOf('function dashboardWidgetLabel'),prefs.indexOf('function renderDashboardWidgetSettings'));
|
||||
assert(constants.startsWith('const ADMIN_DASHBOARD_WIDGETS'));
|
||||
assert(normalize.startsWith('function normalizeDashboardWidgets'));
|
||||
assert(label.startsWith('function dashboardWidgetLabel'));
|
||||
|
||||
function client(definitions) {
|
||||
const context = {t: key => `loc:${key}`};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${JSON.stringify(definitions)});\n${constants}\n${normalize}\n${label}\n`,context);
|
||||
return {
|
||||
order: value => [...vm.runInContext(`normalizeDashboardWidgets(${JSON.stringify(value)})`,context)],
|
||||
hidden: (value,saved) => [...vm.runInContext(`normalizeHiddenDashboardWidgets(${JSON.stringify(value)},${JSON.stringify(saved)})`,context)],
|
||||
label: id => vm.runInContext(`dashboardWidgetLabel(${JSON.stringify(id)})`,context),
|
||||
};
|
||||
}
|
||||
const original = client(registry);
|
||||
const previous = registry.filter(widget => widget.category === 'overview').map(widget => widget.id);
|
||||
assert.deepEqual(original.order(previous),registry.map(widget => widget.id));
|
||||
assert.deepEqual(original.hidden([],previous),registry.filter(widget=>widget.default_hidden).map(widget=>widget.id));
|
||||
assert.equal(original.label('stream_overview'),'loc:streamWidgetOverview');
|
||||
assert.equal(original.label('backend_health'),'loc:backendWidgetHealth');
|
||||
assert.equal(original.label('backend_table'),'loc:vhHealthTitle');
|
||||
assert.equal(registry.length, 62);
|
||||
const types = new Set(['value', 'value_chart', 'chart', 'panel', 'table']);
|
||||
assert(registry.every(widget => types.has(widget.display_type)), 'Every Overview block needs explicit type metadata');
|
||||
assert.equal(registry.find(w => w.id === 'requests').display_type, 'value');
|
||||
assert.equal(registry.find(w => w.id === 'backend_requests').display_type, 'value_chart');
|
||||
assert.equal(registry.find(w => w.id === 'traffic_chart').display_type, 'chart');
|
||||
assert.equal(registry.find(w => w.id === 'cache_diagnostics').display_type, 'panel');
|
||||
assert.equal(registry.find(w => w.id === 'backend_table').display_type, 'table');
|
||||
// Upgrading from the previous 30-widget 0.32.3 archive keeps its exact
|
||||
// visible/order preferences: only twelve new metrics are opt-in, while
|
||||
// seven existing large panels remain visible in the factory layout.
|
||||
const previouslySaved = registry.slice(0, 30).map(widget => widget.id);
|
||||
const migratedOrder = original.order(previouslySaved);
|
||||
const migratedHidden = original.hidden(registry.slice(0, 30).filter(w => w.default_hidden).map(w => w.id), previouslySaved);
|
||||
assert.deepEqual(migratedOrder.slice(0, 30), previouslySaved);
|
||||
assert.deepEqual(migratedOrder.slice(30), registry.slice(30).map(widget => widget.id));
|
||||
for (const widget of registry.filter(widget => widget.category === 'performance'))
|
||||
assert(migratedHidden.includes(widget.id), `${widget.id} must be opt-in after upgrade`);
|
||||
for (const widget of registry.filter(widget => widget.category === 'panels' && !widget.default_hidden))
|
||||
assert(!migratedHidden.includes(widget.id), `${widget.id} must retain default visibility`);
|
||||
for (const widget of registry.filter(widget => widget.id.startsWith('wide_')))
|
||||
assert(migratedHidden.includes(widget.id), `${widget.id} is a new opt-in chart`);
|
||||
assert.equal(registry.filter(widget => widget.category === 'panels').length, 14);
|
||||
for (const widget of registry.filter(widget => widget.category === 'performance')) assert(widget.default_hidden);
|
||||
for (const id of ['backend_health','backend_activity','backend_errors','backend_latency','backend_traffic','backend_protocols','backend_requests','backend_capacity','backend_http5xx','backend_timeouts','backend_network','backend_retries','backend_queue','backend_rotations'])
|
||||
assert(original.hidden([],previous).includes(id), `${id} must remain opt-in for older preferences`);
|
||||
assert.deepEqual(original.order(['errors','unknown-0.33.0','requests','errors']).slice(0,2),['errors','requests']);
|
||||
assert(original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('http3'));
|
||||
assert(!original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('unknown-0.33.0'));
|
||||
|
||||
// Adding a new registry card, with an existing UI element and translation key,
|
||||
// automatically offers it in the ordered preference list and hides it by default.
|
||||
const extra={...registry[0], id:'future_upstream', label_key:'futureUpstreamProtocol',
|
||||
description_key:null, element_selector:'[data-dashboard-widget="future_upstream"]', default_hidden:true};
|
||||
const expanded=client([...registry,extra]);
|
||||
assert.equal(expanded.order(previous).at(-1),'future_upstream');
|
||||
assert(expanded.hidden([],previous).includes('future_upstream'));
|
||||
assert.equal(expanded.label('future_upstream'),'loc:futureUpstreamProtocol');
|
||||
assert(!expanded.hidden([],registry.map(widget=>widget.id).concat('future_upstream')).includes('future_upstream'),
|
||||
'explicitly opted-in widget must not become hidden after a later release');
|
||||
console.log(`PASS: shared widget registry UI preserves order, visibility and ${registry.length} existing cards`);
|
||||
@@ -0,0 +1,15 @@
|
||||
// Source-level guard against reintroducing broken absolute/masonry layout.
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
const source = fs.readFileSync('src/admin/web/app/overview-masonry.js','utf8');
|
||||
const css = fs.readFileSync('src/admin/web/style.css','utf8');
|
||||
assert.match(source,/\.sort\(\(a, b\) => group\(a\.node\) - group\(b\.node\)/);
|
||||
assert.match(source,/grid\.classList\.remove\("is-packed"\)/);
|
||||
assert.match(source,/node\.classList\.add\("overview-row-start"\)/);
|
||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
|
||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
|
||||
assert(!/style\.position\s*=\s*["']absolute["']|requestAnimationFrame\(|new ResizeObserver\(/.test(source),
|
||||
'No absolute positioning, animation loop or ResizeObserver should manage Overview geometry');
|
||||
assert(!/\.dashboard-overview-grid\.is-aligned\.is-packed\s*\{/.test(css));
|
||||
assert(!source.includes('fetch(') && !source.includes('setInterval('));
|
||||
console.log('PASS: regular symmetric CSS Grid for all 62 Overview blocks');
|
||||
@@ -0,0 +1,75 @@
|
||||
// Optional large Overview charts: real status schema, counter resets, no polling.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const source = fs.readFileSync('src/admin/web/app/overview-wide-metrics.js','utf8');
|
||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
|
||||
const ids = registry.filter(w => w.id.startsWith('wide_')).map(w => w.id);
|
||||
assert.equal(ids.length, 7);
|
||||
assert(ids.every(id => registry.find(w => w.id === id).default_hidden));
|
||||
const nodes = new Map(ids.map(id => [id,{ hidden: false }]));
|
||||
const fields = new Map();
|
||||
function $(id) {
|
||||
if (!fields.has(id)) fields.set(id,{ textContent:'-', d:'', setAttribute(name,value) {this[name]=value;} });
|
||||
return fields.get(id);
|
||||
}
|
||||
let now = 1000;
|
||||
const ctx = {window:{},currentPanel:'overview',performance:{now:()=>now},fmt:(n,places=0)=>Number(n).toFixed(places),fmtBytes:n=>`${n} B`,$,
|
||||
document:{querySelector:selector=>nodes.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1])||null}};
|
||||
vm.createContext(ctx);vm.runInContext(source,ctx);
|
||||
const render=ctx.window.rustProxyWideOverview.render;
|
||||
const text=id=>$(id).textContent;
|
||||
const baseline = { revision:1,
|
||||
metrics:{ http1_requests:50,http2_requests:30,http3_requests:20,active_connections:9,websocket_active:3,mean_duration_ms:15 },
|
||||
system:{cpu_percent:45,process_cpu_percent:16,process_rss_bytes:1500},
|
||||
cache:{hits:20,misses:10,resident_bytes:220},
|
||||
backend_pools:[{members:[{status:'up',active:1,last_probe_ms:100,latency_ms:12,health_mode:'active'},{status:'down',active:0}]}]};
|
||||
render(baseline);
|
||||
assert.equal(text('overview-wide-wide_protocol_rates-summary'),'-');
|
||||
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%');
|
||||
assert.equal(text('overview-wide-wide_memory-process-value'),'1500 B');
|
||||
assert.equal(text('overview-wide-wide_backend_health-down-value'),'1');
|
||||
assert.equal(text('overview-wide-wide_latency-probe-value'),'12.0 ms');
|
||||
now = 3000;
|
||||
const second=structuredClone(baseline);
|
||||
second.metrics.http1_requests=70; second.metrics.http2_requests=40; second.metrics.http3_requests=22;
|
||||
second.cache.hits=30; second.cache.misses=18;
|
||||
render(second);
|
||||
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'10.0/s');
|
||||
assert.equal(text('overview-wide-wide_protocol_rates-h2-value'),'5.0/s');
|
||||
assert.equal(text('overview-wide-wide_protocol_rates-h3-value'),'1.0/s');
|
||||
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'5.0/s');
|
||||
assert.equal(text('overview-wide-wide_cache_activity-misses-value'),'4.0/s');
|
||||
assert.equal(text('overview-wide-wide_connections-open-value'),'9');
|
||||
now = 4000;
|
||||
render({...second,metrics:{...second.metrics,http1_requests:0,http2_requests:0,http3_requests:0},cache:{...second.cache,hits:0,misses:0}});
|
||||
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'-', 'counter resets must not create negative traffic');
|
||||
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'-');
|
||||
nodes.get('wide_cpu').hidden=true;
|
||||
now=5000;render({...second, system:{}});
|
||||
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%', 'hidden chart should not re-render');
|
||||
nodes.get('wide_cpu').hidden=false;
|
||||
now=6000;render({metrics:{},system:{},cache:{}});
|
||||
assert.equal(text('overview-wide-wide_cpu-system-value'),'-', 'unavailable metrics are not zero');
|
||||
assert.equal(text('overview-wide-wide_backend_health-up-value'),'-', 'missing backend array is no data');
|
||||
ctx.window.rustProxyWideOverview.reset();
|
||||
for(let i=0;i<75;i++){now+=1000;render({...baseline,system:{...baseline.system,cpu_percent:i}});}
|
||||
assert(source.includes('const limit = 60'));
|
||||
assert(source.includes('if (!node || node.hidden)'));
|
||||
assert(!/fetch\s*\(|setInterval\s*\(/.test(source),'no extra API calls or polling');
|
||||
const css=fs.readFileSync('src/admin/web/style.css','utf8');
|
||||
const aligned = fs.readFileSync('src/admin/web/app/overview-masonry.js', 'utf8');
|
||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-rows:\s*auto;/);
|
||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-flow:\s*row;/);
|
||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*gap:\s*var\(--ui-gap\);/);
|
||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
|
||||
assert.match(css, /#panel-overview\s*\{\s*container:\s*overview-canvas \/ inline-size;/);
|
||||
assert.match(css, /@container overview-canvas \(max-width:\s*700px\)/);
|
||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
|
||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\.overview-row-start\s*\{[^}]*grid-column-start:\s*1;/);
|
||||
assert.match(aligned, /node\.classList\.remove\("overview-row-start"\)/);
|
||||
assert.match(aligned, /node\.classList\.add\("overview-row-start"\)/);
|
||||
assert.match(aligned, /grid\.classList\.remove\("is-packed"\)/);
|
||||
assert(!/style\.position\s*=\s*["']absolute["']/.test(aligned));
|
||||
assert(!aligned.includes('fetch(') && !aligned.includes('setInterval('));
|
||||
console.log('PASS: 7 optional wide charts, regular rows, no uneven masonry');
|
||||
@@ -8,9 +8,9 @@ from html.parser import HTMLParser
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.2'
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.2"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.2'
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.3'
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.3"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.3'
|
||||
|
||||
pages = read('src/pages.rs')
|
||||
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
|
||||
|
||||
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert backup.is_dir()
|
||||
assert (backup / "src/obsolete.rs").is_file()
|
||||
assert not (target / "src/obsolete.rs").exists()
|
||||
assert 'version = "0.32.2"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.32.3"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
||||
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
||||
assert (target / "langs/site.json").is_file()
|
||||
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert (target / "docker-agent/certificate.go").is_file()
|
||||
assert (target / "docker-agent/server.go").is_file()
|
||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||
assert manifest["to_version"] == "0.32.2"
|
||||
assert manifest["to_version"] == "0.32.3"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -617,7 +617,7 @@ def source_contract_smoke() -> None:
|
||||
assert marker in admin, marker
|
||||
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
||||
assert '"/api/status"' not in anonymous_block
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.2"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.3"' in openapi_0241
|
||||
assert 'id="panel-logging"' not in html
|
||||
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
||||
assert marker in html, marker
|
||||
@@ -969,7 +969,7 @@ def source_contract_smoke() -> None:
|
||||
assert '.route("/404-hosts", get(public::index))' in admin and '"/assets/host404.js"' in admin_assets and 'include_str!("web/host404.js")' in admin_assets and '"not_found_hosts"' in admin
|
||||
assert "session_at_with_browser_lifetime" in admin and "session_lifetime_seconds" in admin
|
||||
assert '/api/preferences' in admin and 'live_log_columns' in admin
|
||||
assert 'default_admin_theme' in admin and '"dark" | "light"' in admin
|
||||
assert 'default_admin_theme' in admin and '"dark" | "ultra_dark" | "light" | "cream"' in admin
|
||||
html = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
|
||||
assert 'id="ui-theme"' in html and 'html[data-theme="light"] {' in ui_css
|
||||
assert 'body[data-density="compact"] input:not([type="checkbox"]):not([type="radio"])' in ui_css
|
||||
@@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None:
|
||||
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
||||
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
||||
assert '"/api/config/history/restore"' in openapi
|
||||
for marker in ['"version": "0.32.2"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
for marker in ['"version": "0.32.3"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
assert '"software_update"' in openapi and '"update_available"' in openapi
|
||||
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
||||
|
||||
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
|
||||
const en = json('langs/en_US.json').translations;
|
||||
const pl = json('langs/pl_pl.json').translations;
|
||||
|
||||
assert.match(manifest, /version = "0\.32\.2"/);
|
||||
assert.match(manifest, /version = "0\.32\.3"/);
|
||||
assert.match(lib, /pub mod user_agent;/);
|
||||
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
@@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
||||
assert.match(fileLogs, /aggregate_min_count/);
|
||||
assert.match(fileLogs, /file-log-aggregate-copy/);
|
||||
|
||||
assert.equal(openapi.info.version, '0.32.2');
|
||||
assert.equal(openapi.info.version, '0.32.3');
|
||||
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
|
||||
|
||||
@@ -36,7 +36,7 @@ for old in (ROOT / "src/admin/web/app.js", ROOT / "src/admin/web/vhosts.js"):
|
||||
assert not old.exists(), f"oversized browser monolith returned: {old.relative_to(ROOT)}"
|
||||
for required in (
|
||||
"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/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/vhosts/core.js", "src/admin/web/vhosts/protocol.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",
|
||||
"docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go",
|
||||
"docker-agent/certificate.go", "docker-agent/server.go",
|
||||
"scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py",
|
||||
|
||||
@@ -90,10 +90,11 @@ assert(ids.every(id => cards.get(id).hidden), 'only the Appearance switch hides
|
||||
widget.reset();
|
||||
|
||||
// Reuse original Appearance normalization logic to protect 0.31.x saved layouts.
|
||||
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
|
||||
const definitions = preferencesSource.slice(preferencesSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), preferencesSource.indexOf('const CHART_STYLE_KEYS'));
|
||||
const normalize = preferencesSource.slice(preferencesSource.indexOf('function normalizeDashboardWidgets'), preferencesSource.indexOf('function browserLanguage'));
|
||||
const sandbox = {};
|
||||
vm.runInNewContext(`${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
|
||||
vm.runInNewContext(`${registryPrelude}\n${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
|
||||
const oldOrder = ['requests', 'errors'];
|
||||
const migratedOrder = [...sandbox.normalizeDashboardWidgets(oldOrder)];
|
||||
const migratedHidden = [...sandbox.normalizeHiddenDashboardWidgets([], oldOrder)];
|
||||
|
||||
@@ -24,11 +24,12 @@ assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bm
|
||||
assert.match(html, /id="stream-dashboard-traffic-detail"/);
|
||||
assert.match(html, /id="stream-dashboard-rx-rate"/);
|
||||
assert.match(html, /id="stream-dashboard-tx-rate"/);
|
||||
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('class="overview-grid"')), /<article[^>]*tabindex="0"/,
|
||||
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"')), /<article[^>]*tabindex="0"/,
|
||||
'metric cards must not introduce redundant keyboard stops');
|
||||
|
||||
const cardIds = [...html.matchAll(/<article class="stat[^"]*" data-dashboard-widget="([^"]+)"/g)].map(m => m[1]);
|
||||
assert.equal(cardIds.length, 16, 'existing Overview cards must be retained');
|
||||
assert.equal(cardIds.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_')).length, 16, 'original Overview cards must be retained');
|
||||
assert.equal(cardIds.length, 48, '30 existing metric/Stream/backend tiles and 18 optional small charts must be registered');
|
||||
const cards = new Map(cardIds.map(id => [id, { dataset: { dashboardWidget: id }, hidden: id.startsWith('stream_') }]));
|
||||
const grid = { querySelectorAll: () => [...cards.values()], append(node) { assert(cards.has(node.dataset.dashboardWidget)); } };
|
||||
const elements = new Map();
|
||||
@@ -53,15 +54,16 @@ const context = {
|
||||
normalizeRefreshPreferences: v => v || {}, renderRefreshPreferences() {},
|
||||
applyAdminTheme: v => v, applyAdminLanguage: v => v, applyAdminUiScale: v => v,
|
||||
applyAdminTimeFormat: v => v, applyAdminChartStyle: v => v,
|
||||
applyAdminButtonStyle: v => v, applyAdminTableDensity: v => v,
|
||||
applyAdminButtonStyle: v => v, applyAdminCornerStyle: v => v, applyAdminTableDensity: v => v,
|
||||
applyAdminDashboardDensity: v => v, refreshAppearanceUi() {},
|
||||
};
|
||||
vm.createContext(context);
|
||||
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
|
||||
const constants = prefsSource.slice(prefsSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefsSource.indexOf('const CHART_STYLE_KEYS'));
|
||||
const normalize = prefsSource.slice(prefsSource.indexOf('function normalizeDashboardWidgets'), prefsSource.indexOf('function browserLanguage'));
|
||||
const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboardLayout'), prefsSource.indexOf('function dashboardWidgetLabel'));
|
||||
const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme('));
|
||||
vm.runInContext(`${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
|
||||
vm.runInContext(`${registryPrelude}\n${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
|
||||
window.rustProxyPreferences = { current: () => context.getPrefs() };
|
||||
vm.runInContext(streamSource, context);
|
||||
|
||||
@@ -97,8 +99,8 @@ assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work');
|
||||
assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
|
||||
// Native title tooltips do not participate in layout or create horizontal scroll.
|
||||
assert.doesNotMatch(css, /dashboard-widget-(?:description|setting-label-block)/, 'obsolete custom tooltip styles must be removed');
|
||||
assert.match(css, /grid-template-columns: repeat\(auto-fit, minmax\(min\(180px, 100%\), 1fr\)\)/,
|
||||
'settings card columns must fit even a narrower mobile container');
|
||||
assert.match(css, /grid-template-columns: repeat\(auto-fit, minmax\(min\(260px, 100%\), 1fr\)\)/,
|
||||
'effective Appearance card columns must preserve a readable name and right-aligned arrows');
|
||||
assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/,
|
||||
'old popover DOM and associations must be removed');
|
||||
const widgetList = { children: [], replaceChildren(...children) { this.children = children; } };
|
||||
@@ -114,7 +116,7 @@ context.CSS = { escape: value => value };
|
||||
vm.runInContext(prefsSource.slice(prefsSource.indexOf('function dashboardWidgetLabel'),
|
||||
prefsSource.indexOf('function refreshChartStylePalette')), context);
|
||||
context.renderDashboardWidgetSettings();
|
||||
assert.equal(widgetList.children.length, cardIds.length, 'all cards retain settings controls');
|
||||
assert.equal(widgetList.children.length, 62, 'all cards and full-width panels retain settings controls');
|
||||
for (const id of ids) {
|
||||
const row = widgetList.children.find(item => item.dataset.dashboardWidgetSetting === id);
|
||||
const button = row.children[0];
|
||||
@@ -123,10 +125,12 @@ for (const id of ids) {
|
||||
stream_backend_health: 'streamWidgetHealthDescription' })[id];
|
||||
assert.equal(row.title, key, `${id} must have a localized browser tooltip`);
|
||||
assert.equal(button.attributes['aria-description'], key, 'the tooltip must remain accessible');
|
||||
assert.equal(button.children.length, 2, 'label must not contain an injected custom tooltip');
|
||||
assert.equal(button.children.length, 2, 'Appearance shows only the visibility indicator and label');
|
||||
assert.equal(row.children.length, 2, 'Appearance keeps badge metadata in the visual editor, not in each setting');
|
||||
assert.equal(row.children[1].className, 'dashboard-widget-setting-actions', 'move actions remain on the right');
|
||||
}
|
||||
assert.equal(widgetList.children.filter(row => row.title).length, 4,
|
||||
'only the four translated Stream descriptions have tooltip text');
|
||||
assert.equal(widgetList.children.filter(row => row.title).length, 50,
|
||||
'Stream, backend, performance and full-panel entries have translated descriptions');
|
||||
// Editor-specific regressions: avoid the redundant enable hint and label the
|
||||
// two distinct inheritance layers instead of saying "Inherit" everywhere.
|
||||
const editor = fs.readFileSync('src/admin/web/streams.js', 'utf8');
|
||||
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline source contracts for RustProxy 0.32.3 upstream protocols and card registry.
|
||||
|
||||
These assertions supplement, rather than replace, Rust ALPN, H2C and websocket
|
||||
runtime tests. No code execution against a RustProxy server is implied.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
root=Path(__file__).resolve().parents[2]
|
||||
read=lambda name:(root/name).read_text(encoding='utf-8')
|
||||
registry=json.loads(read('src/admin/web/dashboard-widgets.json'))
|
||||
ids=[widget['id'] for widget in registry]
|
||||
assert len(ids)==len(set(ids)) and len(ids)>=16
|
||||
assert all(re.fullmatch(r'[a-z0-9_]+',id) for id in ids)
|
||||
html=read('src/admin/web/index.html')
|
||||
html_ids=re.findall(r'<article\b[^>]*\bdata-dashboard-widget="([a-z0-9_]+)"',html)
|
||||
assert len(html_ids)==len(set(html_ids)), 'duplicate Overview widget element'
|
||||
assert set(ids)==set(html_ids), f'Registry diverges from rendered Overview cards: {set(ids)^set(html_ids)}'
|
||||
assert ids==html_ids, 'registry should follow the default displayed card order'
|
||||
for language in ['en_US','pl_pl']:
|
||||
translations=json.loads(read(f'langs/{language}.json'))['translations']
|
||||
assert 'Auto' in translations['vhProtocolAuto']
|
||||
assert 'H2' in translations['vhProtocolHttp2']
|
||||
assert 'H2' in translations['vhProtocolH2Strict']
|
||||
assert all(key in translations for key in (
|
||||
'vhProtocolCompatibilityWarning', 'vhProtocolAutoHttpsDetails', 'vhProtocolH2HttpDetails',
|
||||
'vhProtocolLegacyHttp2HttpsDetails', 'vhProtocolLegacyHttp2HttpDetails',
|
||||
'backendWidgetModeCompat', 'backendWidgetModeStrict',
|
||||
'backendWidgetRequests', 'backendWidgetCapacity'))
|
||||
for widget in registry:
|
||||
assert widget['label_key'] in translations, (language,widget['label_key'])
|
||||
if widget['description_key'] is not None:
|
||||
assert widget['description_key'] in translations
|
||||
for widget in registry:
|
||||
assert widget['element_selector']==f'[data-dashboard-widget="{widget["id"]}"]'
|
||||
assert widget['data_source'] in ('status_metrics','stream_status','backend_status','system_status','cache_status')
|
||||
assert widget['no_data']=='neutral_dash'
|
||||
assert isinstance(widget['default_hidden'],bool)
|
||||
assert widget['category'] in ('overview','streams','backends','performance','panels')
|
||||
assert {v['id'] for v in registry if v['default_hidden']}=={
|
||||
'stream_overview','stream_traffic','stream_sessions','stream_backend_health',
|
||||
'backend_health','backend_activity','backend_errors','backend_latency',
|
||||
'backend_traffic','backend_protocols','backend_requests','backend_capacity',
|
||||
'backend_http5xx','backend_timeouts','backend_network','backend_retries','backend_queue','backend_rotations',
|
||||
'extra_process_cpu','extra_process_rss','extra_process_fds','extra_open_connections',
|
||||
'extra_websocket_active','extra_tls_error_rate','extra_request_error_rate',
|
||||
'extra_cache_hit_rate','extra_cache_memory','extra_h2_share','extra_response_latency',
|
||||
'extra_backend_unhealthy',
|
||||
'extra_total_rps', 'extra_active_exchanges', 'extra_h1_share', 'extra_h3_share',
|
||||
'extra_cache_miss_rate', 'extra_backend_active',
|
||||
'wide_protocol_rates', 'wide_cpu', 'wide_memory', 'wide_cache_activity',
|
||||
'wide_connections', 'wide_backend_health', 'wide_latency'
|
||||
}
|
||||
|
||||
backend=read('src/config/model.rs')
|
||||
client=read('src/upstream/client.rs')
|
||||
# Existing saved `http2` is NOT strict on HTTPS: only new `h2` is.
|
||||
assert re.search(r'enum UpstreamProtocol\s*\{\s*#\[default\]\s*Http1,',backend)
|
||||
for name in ['Http2,', 'Auto,', 'H2,']:
|
||||
assert name in backend
|
||||
assert 'is_https && backend.protocol == UpstreamProtocol::Http2' in client
|
||||
assert 'let protocol = if websocket {' in client and 'UpstreamProtocol::Http1' in client
|
||||
assert 'if !is_https && protocol == UpstreamProtocol::Auto' in client
|
||||
assert 'UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http)' in client
|
||||
assert 'UpstreamProtocol::Http2 | UpstreamProtocol::H2 => builder.enable_http2().wrap_connector(http)' in client
|
||||
assert 'UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http)' in client
|
||||
assert '.http2_only(matches!(protocol, UpstreamProtocol::Http2 | UpstreamProtocol::H2))' in client
|
||||
assert 'backend.origin.starts_with("http://")' in read('src/proxy/types.rs')
|
||||
assert 'UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2' in read('src/proxy/forward/upstream.rs')
|
||||
assert '.retry_canceled_requests(false)' in client
|
||||
assert '.pool_idle_timeout(' in client and '.pool_max_idle_per_host(' in client
|
||||
assert 'generation_requests' in client and 'rotating' in client
|
||||
assert 'include!("protocol_tests.rs");' in read('src/upstream/tests.rs')
|
||||
protocol_tests=read('src/upstream/protocol_tests.rs')
|
||||
for test in ['https_auto_chooses_h2_by_alpn_without_replaying_request',
|
||||
'https_auto_negotiates_http1_before_sending_to_h1_only_peer',
|
||||
'saved_legacy_https_http2_keeps_h1_fallback',
|
||||
'saved_legacy_https_http2_still_prefers_h2',
|
||||
'explicit_https_h2_requires_h2_and_refuses_h1_only_peer',
|
||||
'explicit_https_h2_selects_h2_via_alpn',
|
||||
'explicit_https_http1_does_not_offer_h2_to_h2_capable_peer',
|
||||
'websocket_isolated_client_stays_on_http1_even_with_h2_backend',
|
||||
'cleartext_auto_does_not_attempt_unsafe_h2c_probe',
|
||||
'default_protocol_and_serialization_are_backward_compatible']:
|
||||
assert f'fn {test}' in protocol_tests, test
|
||||
assert 'fn explicit_h2c_backend_uses_http2_prior_knowledge' in read('src/upstream/tests.rs')
|
||||
assert 'fn upstream_request_version(' in read('src/proxy/types.rs')
|
||||
assert 'fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required' in read('src/proxy/tests/authority_tests.rs')
|
||||
|
||||
server=read('src/admin/preferences_columns.rs')
|
||||
api=read('src/admin/preferences.rs')
|
||||
router=read('src/admin/router.rs')
|
||||
js=read('src/admin/web/app/preferences.js')
|
||||
assert 'include_str!("web/dashboard-widgets.json")' in server
|
||||
assert 'include_str!("web/dashboard-widgets.json")' in router
|
||||
assert 'const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(' in router
|
||||
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id)' in js
|
||||
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden)' in js
|
||||
assert 'ADMIN_DASHBOARD_BY_ID.get(id)?.description_key' in js
|
||||
assert 'reconcile_dashboard_widgets(&mut preferences);' in api
|
||||
assert 'prefs.dashboard_widgets.retain(' in api and 'prefs.dashboard_hidden_widgets.retain(' in api
|
||||
assert 'fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids' in read('src/admin/preferences_tests.rs')
|
||||
assert 'include!("preferences_tests.rs");' in read('src/admin/tests.rs')
|
||||
|
||||
assert 'item.insert("protocol".into(), json!(m.definition.protocol));' in read('src/balance/pool_view.rs')
|
||||
assert 'include_str!("web/app/backend-dashboard.js")' in router
|
||||
assert 'window.rustProxyBackendDashboard?.render(s)' in read('src/admin/web/app/core.js')
|
||||
assert 'window.rustProxyBackendDashboard?.syncVisibility' in js
|
||||
assert 'window.rustProxyBackendDashboard?.reset()' in read('src/admin/web/app/core.js')
|
||||
assert 'vhProtocolCompatibilityWarning' in read('src/admin/web/vhosts/protocol.js')
|
||||
assert '["h2", "vhProtocolH2Strict"]' in read('src/admin/web/vhosts/core.js')
|
||||
assert 'backendWidgetRequestsTotal' in read('src/admin/web/app/backend-dashboard.js')
|
||||
assert 'backendWidgetCapacityLimits' in read('src/admin/web/app/backend-dashboard.js')
|
||||
assert 'include_str!("web/app/dashboard-layout-editor.js")' in router
|
||||
assert 'include_str!("web/app/dashboard-inline-layout.js")' in router
|
||||
assert all(widget.get('display_type') in {'value','value_chart','chart','panel','table'} for widget in registry)
|
||||
assert 'dashboardWidgetTypeBadge' in js
|
||||
assert 'overview-layout-unlock' in html and 'overview-layout-type-filter' in html
|
||||
assert 'dashboard_widgets: [...draft.order]' in read('src/admin/web/app/dashboard-layout-editor.js')
|
||||
assert 'dashboard_hidden_widgets: [...draft.hidden]' in read('src/admin/web/app/dashboard-layout-editor.js')
|
||||
assert 'overview-layout-dialog' in html and 'overview-layout-open' in html
|
||||
assert 'tls_errors_total' in read('src/admin/web/app/backend-dashboard.js')
|
||||
assert len(registry) == 62
|
||||
assert len([w for w in registry if w['category'] == 'panels']) == 14
|
||||
assert 'include_str!("web/app/overview-extra-metrics.js")' in router
|
||||
assert 'include_str!("web/app/overview-wide-metrics.js")' in router
|
||||
assert 'include_str!("web/app/overview-masonry.js")' in router
|
||||
assert 'window.rustProxyExtraOverview?.render(s)' in read('src/admin/web/app/core.js')
|
||||
assert all(f'id="{id}"' in html for id in ('dashboard-overview-grid','overview-layout-default','overview-layout-reset','overview-reset-confirm'))
|
||||
openapi = json.loads(read('src/admin/openapi.json'))
|
||||
assert openapi['components']['schemas']['VhostBackend']['properties']['protocol']['enum'] == ['http1', 'http2', 'auto', 'h2']
|
||||
prefs_schema = openapi['components']['schemas']['AdminPreferences']['properties']
|
||||
for field in ('dashboard_widgets', 'dashboard_hidden_widgets'):
|
||||
schema = prefs_schema[field]
|
||||
assert schema['maxItems'] == len(registry), field
|
||||
assert schema['items']['enum'] == ids, field
|
||||
expected = ids if field == 'dashboard_widgets' else [widget['id'] for widget in registry if widget['default_hidden']]
|
||||
assert schema['default'] == expected, field
|
||||
|
||||
|
||||
|
||||
assert '"version": "0.32.3"' in read('src/admin/openapi.json')
|
||||
assert 'version = "0.32.3"' in read('Cargo.toml')
|
||||
assert 'version = "0.32.3"' in read('Cargo.lock')
|
||||
print(f'PASS: 0.32.3 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards')
|
||||
@@ -1,13 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "preferences.js", "config-history.js", "runtime.js", "settings.js")
|
||||
APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "preferences.js", "dashboard-layout-editor.js", "dashboard-inline-layout.js", "stream-dashboard.js", "backend-dashboard.js", "config-history.js", "runtime.js", "settings.js")
|
||||
PANEL_PARTS = (
|
||||
"management.js", "drafts.js", "integrations.js", "vhosts.js",
|
||||
"web-server-profiles.js", "web-server.js", "docker.js", "streams.js",
|
||||
"redirects.js", "host404.js", "traffic-panel.js", "cache-panel.js",
|
||||
"transform-panel.js", "acme.js", "settings.js", "certificate-api.js", "file-logs.js",
|
||||
)
|
||||
VHOST_PARTS = ("core.js", "health.js", "rates.js", "editor.js", "render.js")
|
||||
VHOST_PARTS = ("core.js", "protocol.js", "health.js", "rates.js", "editor.js", "render.js")
|
||||
|
||||
def _read(root: Path, folder: str, parts: tuple[str, ...]) -> str:
|
||||
return "\n".join((root / "src/admin/web" / folder / part).read_text(encoding="utf-8") for part in parts)
|
||||
@@ -17,7 +17,15 @@ def read_vhosts_js(root: Path) -> str:
|
||||
return "(() => {\n" + body + "\n})();"
|
||||
|
||||
def read_app_js(root: Path) -> str:
|
||||
primary = _read(root, "app", APP_PARTS)
|
||||
# Mirror the Rust include_str! assembly including the server-validated
|
||||
# widget registry, so QA parses the same core used by the real panel.
|
||||
parts = []
|
||||
for part in APP_PARTS:
|
||||
if part == "preferences.js":
|
||||
registry = (root / "src/admin/web/dashboard-widgets.json").read_text(encoding="utf-8")
|
||||
parts.append(f"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({registry});")
|
||||
parts.append((root / "src/admin/web/app" / part).read_text(encoding="utf-8"))
|
||||
primary = "\n".join(parts)
|
||||
panel = "\n".join(
|
||||
read_vhosts_js(root) if name == "vhosts.js" else (root / "src/admin/web" / name).read_text(encoding="utf-8")
|
||||
for name in PANEL_PARTS
|
||||
|
||||
Reference in new issue
Block a user