Compare commits
No files matched your search
@@ -10,3 +10,4 @@ problems.txt
|
|||||||
todo/
|
todo/
|
||||||
scripts/routeros-oci-build/.env
|
scripts/routeros-oci-build/.env
|
||||||
dist
|
dist
|
||||||
|
/*.*
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# RustProxy source and documentation maintenance
|
# RustProxy source and documentation maintenance
|
||||||
|
|
||||||
Current source version: **0.35.2**. The Cargo package and binary are named `rust-proxy`.
|
Current source version: **0.36.8**. The Cargo package and binary are named `rust-proxy`.
|
||||||
|
|
||||||
Before editing, read [README.md](README.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
|
Before editing, read [README.md](README.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
|
||||||
|
|
||||||
|
|||||||
+117
-1
@@ -2,12 +2,128 @@
|
|||||||
|
|
||||||
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in the latest release.
|
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in the latest release.
|
||||||
|
|
||||||
|
## 0.36.8 — editor forms, toasts and CSP fixes
|
||||||
|
|
||||||
|
- **Editor forms:** the `pattern` attribute of generated ID fields (vhost, location, member, listener, stream ID, error fallback target, affinity cookie name) escaped the hyphen incorrectly for browsers that compile patterns with the `v` flag, which logged "Pattern attribute value ... is not a valid regular expression". The hyphen is now escaped.
|
||||||
|
- **Toasts:** the toast element could be removed together with a rebuilt dialog, after which every save/validation message threw `Cannot set properties of null (setting 'textContent')` and the form could not be saved. The toast is now recreated on demand.
|
||||||
|
- **Cancel new entries:** vhost, redirect, 404 host, stream and web service editors have a **Cancel** button next to "Discard changes". It closes the editor and drops the draft (with a confirmation when something was typed) instead of only minimizing it to the draft dock.
|
||||||
|
- **Backends overview:** more trend charts per row: a wider latency line, request bars (requests per refresh) next to the request count (a faint baseline is drawn for idle rows), and an IN / OUT traffic chart with the inbound and outbound rates on two lines, measured from the per-backend byte counters (the server-reported total still wins when present).
|
||||||
|
- **Tiny backends / Process simple:** the old backend card was forced visible whenever a proxy source existed, ignoring the dashboard layout, so it could not be hidden. Layout visibility is now respected (the "no proxy source" state uses a class instead of the `hidden` attribute).
|
||||||
|
- **Backends overview:** with a row expanded, the periodic refresh no longer scrolls the table back to the focused row (focus is restored without scrolling and the scroll position is kept).
|
||||||
|
- **CSP:** the cache outcomes bar no longer uses inline `style="width:0%"` attributes (blocked by `style-src 'self'`); the initial width comes from the stylesheet.
|
||||||
|
|
||||||
|
## 0.36.7 — Docker vhosts and cache panel polish
|
||||||
|
|
||||||
|
- **Backends table redesigned:** a richer replacement for the 14-column table. It takes over the existing `backend_table` slot (saved layouts keep its position, size and visibility); the previous compact table remains available as the optional **Tiny backends** (`backend_tiny`, hidden by default). Status badges with a coloured row edge, vhost and backend in one cell, error count with a share bar, latency with a short trend line, traffic per second and a "last event" column; filter chips (All / Up / Down / Other) with counts, search, sorting, column selection and "Manage vhosts" work as before and share the same saved sort, direction and column preferences. Client generation, generation requests/age, rotations and the last failure/recovery open under a chevron and stay open across refreshes. It follows the table density (normal, compact, ultra) and corner-style settings. The original table is still available as **Tiny backends**; the new card is visible in the factory layout (Tiny backends is hidden there), while existing saved layouts keep their current cards and can enable the new one in the layout editor.
|
||||||
|
- **Process card redesigned:** chips for platform, threads, start and uptime, Load rows with usage meters (CPU, RSS, file descriptors, cgroup), a Memory section with an anonymous/file-backed split bar, and a 2×2 grid of history charts (CPU, RSS, file descriptors, OS threads); cache memory is no longer shown there (see RAM cache). It takes over the existing `process_diagnostics` slot, so saved layouts keep its position, size and visibility. The previous card is still available as the optional **Process simple** (`process_simple`, hidden by default).
|
||||||
|
- **Docker vhosts:** the collapsible sections (Configure vhost, Protocols, Access control, timeouts, health checks) now align arrow, title and hint to the left with a common title column. Traffic statistics are expanded by default for every enabled app (a section you collapse stays collapsed). A new **Hide disabled** button hides all unpublished vhosts from the view at once; **Show hidden** brings them back.
|
||||||
|
- **404 hosts:** cards use the same host chips → response layout as redirects, with listeners and certificate on a quiet meta line; redirect and 404 cards are separated by the same gap as vhost cards.
|
||||||
|
- **Cache panel:** the pager follows the corner-style setting (outer corners only) and stays on one row (the heading wraps instead) with the arrow after "Next"; the "transformed" badge stays visible next to long object URLs (the URL is shortened instead). Decision counters show a thin share bar under each counter (relative to the largest) and a compact "+N" toggle; the pager no longer shows doubled arrows; the folded Decision counters ("+N") keep their open/closed state across automatic refreshes.
|
||||||
|
- **Dashboard cache outcomes:** the legend under the bar shows only the request counts; percentages are shown once, inside the bar and in the headline (tooltips give both).
|
||||||
|
- **Build:** `SHA256SUMS` uses repository-relative paths again.
|
||||||
|
|
||||||
|
## 0.36.6 — redirect priority, copy and SNI documentation
|
||||||
|
|
||||||
|
- **Deploy script:** `update.sh` / `install.sh` retry the GeoIP download (transient 502/503/504, timeouts) up to four times and, if it still fails, warn and continue with the installed GeoIP data instead of aborting the update with `HTTP Error 504`.
|
||||||
|
- **Redirect priority fix:** the most specific host name now wins between vhosts, redirects and 404 hosts. A wildcard redirect such as `*.gru.one.pl` (or `gru.one.pl`) no longer shadows an exact vhost like `autodiscover.gru.one.pl` on the same listener; the same applies to wildcard 404 hosts.
|
||||||
|
- **Redirect cards:** redirects are shown as host chips → target with a quiet meta line (rules, listeners, certificate) instead of nested boxes, and stack cleanly on narrow screens.
|
||||||
|
- **Certificate mismatch on save:** when the selected certificate does not cover the vhost names, saving now asks whether to force it (and enables "Force an incompatible certificate") instead of failing with a vague error; the server error names the certificate and explains the fix.
|
||||||
|
- **Certificates by SNI explained:** the vhost certificate hint and `docs/CERTIFICATES.md` now describe that a vhost left on "Global SNI / default certificate" gets the certificate per requested name from the stored certificates' SNI names (filled from their SAN entries), so one vhost can serve domains with different certificates. The editor also tells you when no single certificate covers all names but each name is covered. The "Legacy SNI names" field is now called "SNI names".
|
||||||
|
- **Copy:** vhosts, redirects, 404 hosts, streams and ACL profiles have a **Copy** button next to Edit. It opens a new form filled from the original with the ID and name cleared (the ID is generated from the new name or host again) and the values that must be unique cleared too: host names and certificate for vhosts, redirects and 404 hosts, the listen address for streams. Nothing is saved until you press Save.
|
||||||
|
- **Build without GitHub:** the official Swagger UI 5.33.1 files (`swagger-ui-dist`, Apache-2.0) are vendored in `third_party/swagger-ui-5.33.1/` and verified against `SHA256SUMS` on every build, so `bash dev.sh release` needs no network for Swagger UI. Setting `SWAGGER_UI_DOWNLOAD_URL` or `SWAGGER_UI_ARCHIVE_SHA256` still selects an explicit archive.
|
||||||
|
- **Build:** the pinned Swagger UI archive is now reused from a previous build directory (or `target/swagger-ui-cache`, or `SWAGGER_UI_CACHE_DIR`) instead of being downloaded again on every version bump, and a download is retried up to four times. A flaky GitHub connection no longer fails `bash dev.sh release` right after a version change.
|
||||||
|
|
||||||
|
## 0.36.5 — automatic certificate choice and clearer host conflicts
|
||||||
|
|
||||||
|
- **Certificate auto-selection:** when creating a vhost, redirect or 404 host, RustProxy picks the stored certificate that covers every host name you enter (an exact name beats a wildcard, the narrowest certificate wins). A hint names the chosen certificate and the selector still lets you pick another one or the global default. Existing entries are never changed.
|
||||||
|
- **Clearer host conflicts:** the "overlapping frontend bindings for hostname …" error now names the entry that already uses the host (`vhost`, `redirect` or `404 host` plus its ID) and the conflicting listener address.
|
||||||
|
- **Clearer validation errors:** duplicate or invalid IDs, repeated hostnames, missing hostnames or listeners, invalid location IDs, regular expressions, exact paths and duplicate location matchers now say which value or location is wrong and how to fix it.
|
||||||
|
|
||||||
|
## 0.36.4 — cache readability and editor UX polish
|
||||||
|
|
||||||
|
- **Cache → Domains and effectiveness:** decision counters are one compact line (`name count`, top three, the rest in a `+N` tooltip) instead of a wall of chips; the rest of the table keeps its 0.36.3 look.
|
||||||
|
- **Cache → Static response transformation:** the "Transformations by type" and "Vhost effectiveness" tables use the same presentation as Domains and effectiveness (right-aligned tabular numbers, emphasised vhost name, non-zero errors in red, reduction meter) and the skip reasons are compact `name count` lines.
|
||||||
|
- **Cache → Stored objects:** the tier is a badge in the same style as the other badges (follows the corner-radius setting), numeric columns are right-aligned, long targets and cache keys are truncated (full text on hover, click still copies), rows highlight on hover, and the Previous / range / Next pager is a single segmented control.
|
||||||
|
- **Docker vhosts:** the per-service link is now **Open** (same wording as in Vhosts); the expandable per-application forms and their protocol, ACL, timeout and health sections have a chevron, keyboard focus outline and a framed panel when open.
|
||||||
|
- **Automatic IDs:** when creating a vhost, web service, stream, redirect, 404 host or ACL profile, the ID is generated from the name (for vhosts, from the first host name) as a unique slug; the ID field comes after the name (after the hosts for vhosts) and is shown as a plain value with a **Change** link — the input only appears when you ask for it (or when validation needs it) — `example.com` becomes `example-com`, Polish and other accented letters are folded to ASCII, and `-2`, `-3`… is appended when the ID is taken. Typing in the ID field switches to a manual ID; a "Generate automatically" button (or clearing the field) restores it. Existing IDs are never changed.
|
||||||
|
- **Vhost, Web service, Stream, Redirect and ACL forms:** section tabs wrap onto a second line instead of scrolling horizontally, and use a readable 12 px underlined tab style with a clear active state and hover; form action bars are aligned consistently.
|
||||||
|
|
||||||
|
## 0.36.3 — settings, cache and dashboard layout polish
|
||||||
|
|
||||||
|
- **More chart styles:** Settings → Appearance → Chart style now offers eight additional presets: **Sunset**, **Aurora**, **Ocean**, **Forest**, **Pastel**, **Terminal** (green phosphor, square lines), **Blueprint** (thin technical lines on a blue tint) and **Colorblind safe** (Okabe–Ito palette with dashed grid). The `chart_style` preference accepts the new values (`sunset`, `aurora`, `ocean`, `forest`, `pastel`, `terminal`, `blueprint`, `colorblind`); existing values and the default are unchanged.
|
||||||
|
- **Cache → Domains and effectiveness:** numeric columns are right-aligned with tabular digits, HIT / MISS / BYPASS counts use the same colours as the Dashboard cache bar, the hit ratio has a small meter, domain names are emphasised and decision-counter chips are quieter.
|
||||||
|
- **Docker vhosts:** collapsed service rows are compact on desktop (publish switch, name with image/ports, and health with actions in one row instead of a stacked column).
|
||||||
|
- **Cache and Static response transformation pages:** the Cache → General tab is split into *Behavior* and *Memory and limits* groups (the fallback TTL and bypass options moved up next to the other behavior switches), switch descriptions sit directly under their checkbox, the memory/transform preset cards have a compact header, and numeric fields use a responsive grid (up to four per row) instead of two fixed columns.
|
||||||
|
- **Settings layout:** Settings → Appearance now uses one-setting-per-row layout (label and help on the left, control on the right, checkboxes on the right edge) instead of an uneven two-column grid, section headings no longer duplicate their first setting (`Charts`, `Tables and logs`) and the small eyebrow labels were dropped. Sub-tabs (Rules & headers, Cache, HTTP connections, Logging, …) are a horizontal tab bar on desktop instead of a vertical stack of full-width buttons, and the main settings menu has thin separators between its groups.
|
||||||
|
- **Compact stat cards:** Dashboard cards that consist of a title, a value and one secondary line (Requests, Active exchanges, Payload transferred, Errors, Process memory, HTTP/2 requests, backend and stream cards, …) now show that line to the right of the value, which makes them about 20 px lower. WebSocket tunnels shows a plain `H1 · H2 · H3` split (full names in the tooltip) HTTP/2 shows `H2 connections` and HTTP/3 now shows `H3 connections` (active HTTP/3 connections); the labels were shortened in `langs/en_US.json` and `langs/pl_pl.json`.
|
||||||
|
- **WebSocket tunnels card:** the *Established* count moved from its own text line to the right of the main value (it wraps below only when the card is too narrow), so the card is one line shorter.
|
||||||
|
- **Cache outcomes bar:** the HIT / MISS / BYPASS / OTHER segments of the Dashboard cache bar now show their percentage inside the bar (bar height 12 → 20 px with tighter margins, so the card keeps its height). A label that does not fit a narrow segment is hidden automatically and re-evaluated on resize; the legend below keeps the exact count and percentage.
|
||||||
|
- **CSS cleanup:** removed orphaned panel styles: the legacy `body[data-density=…]` rules (46 selectors across 7 modules; density is controlled only by `html[data-table-density]` and `html[data-dashboard-density]`), the unused `.badge` branch of the corner-style selector list, references to undefined custom properties (`--radius`, `--surface`, `--line-strong`; their fallbacks are now inlined) and duplicated `.path-permissions-*` blocks. A static audit found no other unused classes, ids, keyframes or custom properties. Computed styles of all panels at 402 px and 1280 px (several corner/density/icon settings) are identical before and after; the bundle is about 3 KB smaller.
|
||||||
|
- **Upgrade notes:** interface-only release; no configuration or database migration is required. New interface labels were added to `langs/en_US.json` and `langs/pl_pl.json` (`appearanceChartsTitle`, `appearanceTablesTitle`, `cacheSectionBehavior`, `cacheSectionMemory`, `h3ActiveConnections`, `chartStyleSunset` … `chartStyleColorblind`) and the WebSocket / HTTP/2 labels were shortened.
|
||||||
|
|
||||||
|
## 0.36.2 — icon semantics review and optional logo animation
|
||||||
|
|
||||||
|
- **SVG icon review:** every panel control was re-checked against its function. Fixes include **Unlock layout** (was the sign-out icon, now an open padlock), **Save and lock** (padlock), **Dashboard presets** (layers instead of edit), **Docker agents settings** (was a pin because "docker" matched "dock"), cache/transform memory presets (were the undo icon because "preset" matched "reset"), **Previous/Next** in Cache and Stored logs (chevrons instead of database/list), **Close** in the health and history dialogs (close instead of activity/history), revision history **Reload/Restore/Compare** (refresh/undo/compare), **Sign in** (sign-in instead of check), **Sign out all sessions** (sign-out instead of trash), **Run operation** (play), **Purge / Ban** and **Active BANs** (ban), **Clean cold cache** and **Clear** (eraser), **Download now** (download), token generation/rotation (key), **Repeated values / Count repeats** (hash), **Open Logs** (list), **Configure fields** (sliders), **About** (info), automatic renewal enable/disable (toggles) and Live logs **Pause/Resume** (pause/play). Expand/collapse toggles now follow their current state.
|
||||||
|
- **Distinct section icons:** Streams (transfer arrows), Listeners (plug), GeoIP updates (map pin) and Administration (users) no longer share icons with Vhosts, HTTP connections and Settings. The settings cog, pin, undo and sign-in/sign-out glyphs were redrawn to be more recognizable.
|
||||||
|
- **Icon fixes:** English labels whose translation equals the key (for example **Edit**) no longer fall back to the generic placeholder icon; buttons labelled with a single symbol (`×`, `−`, `↑`, `←` …) keep the native glyph instead of showing a duplicate SVG; protocol badges and history calendar days no longer receive icons.
|
||||||
|
- **Mobile/Safari chart fixes:** with SVG icons enabled, the **Hide** / **Show** buttons of the Traffic-by-interface charts made the Overview jump and flicker on every refresh. Root causes: the chart lookup (`row.querySelector("svg")`) could return the button's icon instead of the chart (axis labels were then drawn inside the button); the periodic label update rebuilt the button and its icon each time; and descendant chart rules (`.network-interface-row svg`, `.stat-chart svg`, `.system-card svg` …) stretched icons to full chart size. Charts are now addressed with `svg:not(.ui-icon)`, the axis renderer ignores icons, refreshes update only the text node, chart rules exclude `.ui-icon`, and small buttons use an 11 px icon so their height never changes. A measured 28-refresh run kept the container height constant (previously a 13 px swing) with zero icon rebuilds.
|
||||||
|
- **Badge consistency:** the upstream protocol badges in the Vhost policy row (for example `origin · H2 compatible → H2`) now use the same geometry as the other policy pills (9 px mono, 1 px letter-spacing, 4×8 padding, transparent background, square corners in the original corner style, 20 px height); only the colour differs (muted = not observed, green = observed). The **Transformed** marker in the Cache objects table now uses pill typography (1 px letter-spacing, square corners). ACL picker tags (selected countries/IPs) now use the same chip look as host chips (square corners, 10 px mono, same padding and remove button) instead of a rounded pill with a different font. A style comparison of every pill/badge/chip family and of controls sharing a row found no further deviations besides intentional ones (ACL rule action labels that mimic form fields, quick enable/disable toggles, the sort-direction arrow).
|
||||||
|
- **Spacing review:** a rendered scan of every panel, settings page and dialog (402 px and 1280 px wide) found descriptive text and blocks that touched the next element. Added spacing for TLS policy (description → TLS 1.2/1.3 switches, PQC help → documentation link, which is now styled as a link), Rules & headers, HTTP connections and Logging sub-tabs, Cache and Transform live-activity cards, empty-state notes under tables, Logs footnote, Dashboard presets footer, GeoIP and cache-cleanup field groups, htaccess/PHP help dialogs and several settings help paragraphs. The rules add space only where the layout had none.
|
||||||
|
- **Mobile layout:** the mobile **Menu** button no longer shows two hamburger icons, and **System uptime** wraps inside the System load card instead of overflowing its right edge.
|
||||||
|
- **Logo animation:** new **Settings → Appearance → Interface → Animate logo** option (account-scoped `logo_animation`, **off by default**) plays short, subtle random effects on the sidebar logo; it pauses in hidden tabs and respects reduced-motion preferences.
|
||||||
|
- **Upgrade notes:** no configuration or database migration is required; `logo_animation` is an additive preference field that defaults to `false` for existing accounts.
|
||||||
|
|
||||||
|
## 0.36.1 — interface icon polish and on-demand live logs
|
||||||
|
|
||||||
|
- **SVG icon polish:** HTTP/2 and HTTP/3 tabs now use the same protocol icon; listener HTTP/2/HTTP/3 limit links use the settings cog; **Apply preset** uses a preset/wand icon; the top **Logs** menu receives its missing icon. Compact Overview layout controls keep their native `+ / − / ← / → / ×` glyphs instead of receiving duplicate SVGs, and UI icon sizing is protected from chart SVG rules.
|
||||||
|
- **WebSocket tunnels:** **Active tunnels** is now a compact clickable badge without an SVG icon.
|
||||||
|
- **Overview metric cards:** removed the redundant **Recent samples / Ostatnie próbki** footer from the lower metric tiles; the footer is shown only when a metric has real detail text to present.
|
||||||
|
- **Appearance copy:** removed the experimental/default-state sentence from the SVG icon option help text. The option remains account-scoped and off by default.
|
||||||
|
- **On-demand live logs:** the panel status SSE uses `/api/events?...&logs=false`; `/api/logs` refreshes only while the Live Logs window is open or docked and actively following the bounded buffer. Closing, pausing or expanding the history view stops periodic log reads; reopening/resuming triggers an immediate refresh. Existing `/api/events` API consumers remain compatible because the new `logs` query parameter defaults to `true`.
|
||||||
|
- **OPNsense certificate deploy:** the source release now includes the previously referenced `rustproxy-acme.conf.example` and `actions_rustproxy_cert.conf`; the integration guide explicitly requires certificate scope plus `allow_deploy` for the deployment credential.
|
||||||
|
- **Preference persistence fix:** expanded the per-administrator UI preference document limit from 4 KiB to 64 KiB. Customized Overview layouts can exceed the old limit once widget widths are stored; affected saves previously surfaced as `auth.unavailable` even though authentication was healthy.
|
||||||
|
- **Upgrade notes:** the SQLite schema is migrated automatically to expand administrator preference storage; proxy configuration is unchanged.
|
||||||
|
|
||||||
|
## 0.36.0 — leaner administration polling
|
||||||
|
|
||||||
|
- **Experimental interface icons:** **Settings → Appearance → Interface → Show SVG interface icons** adds semantic inline SVG icons to navigation, action buttons, tabs and dialogs. The account-scoped option is **off by default**, keeps text labels as the accessible source of meaning, follows the current theme via `currentColor`, and can be disabled without changing the original control layout or behavior.
|
||||||
|
- **Polling API:** `GET /api/cache`, `GET /api/transform` and `GET /api/traffic` now accept `view=live` for lightweight periodic refreshes. The default response remains the complete backward-compatible payload. Live responses carry the configuration `revision`, and the panel automatically reloads static data when that revision changes.
|
||||||
|
- **Panel network load:** Cache and Transform polling no longer retransmit static settings/Vhost configuration on every refresh; Traffic polling omits the static Vhost list. Certificate API credentials are fetched only when the corresponding Settings tab is opened.
|
||||||
|
- **Refresh behavior:** configuration writes and operational actions refresh status without also forcing a full `/api/logs` snapshot. Initial load, explicit log refresh and the existing live-log/fallback path retain log coverage.
|
||||||
|
- **API contract:** OpenAPI documents the polling views and revision fields; regression tests cover compatibility, invalid views and the panel refresh contract.
|
||||||
|
- **Upgrade notes:** no configuration or database changes are required. Existing API clients that do not send `view=live` retain their previous response shapes, with the additive `revision` field on Cache, Transform and Traffic observations.
|
||||||
|
|
||||||
|
## 0.35.5 — Overview polish and cache build fix
|
||||||
|
|
||||||
|
- **Overview charts:** the cache-results heading now matches the other chart headings and its HIT/MISS/BYPASS legend stays on one line; system uptime is shown in the System load header; the backend count is aligned with Open connections; the redundant Requests per second footer was removed from the PHP requests tile.
|
||||||
|
- **PL/EN status labels:** the sidebar healthy-state label is now **SYSTEM OK**, and the application self-check uses the shorter **API** label.
|
||||||
|
**Upgrade notes:** no configuration or database changes are required.
|
||||||
|
|
||||||
|
## 0.35.4 — Dashboard status and control cleanup
|
||||||
|
|
||||||
|
- **Overview samples:** the history sample selector is right-aligned on desktop and mobile and now saves immediately in the browser; the separate Remember choice button is removed.
|
||||||
|
- **Application self-check:** the old runtime/control-plane sidebar block is replaced by a color-coded application status indicator. Clicking it shows checks for the control API, listener restart state, backend readiness and logging pipeline issues.
|
||||||
|
- **Dashboard footer:** removed the low-value dropped-log counter; API latency and update availability remain.
|
||||||
|
- **Navigation cleanup:** removed the static `RUNTIME / Rust / Tokio / Hyper / Axum / Tower / SQLite` text from the sidebar.
|
||||||
|
- **Dashboard layout editing:** blocks are now moved by dragging the ⠿ handle with a live drop placeholder (mouse, pen and touch), smooth movement of neighbouring blocks, edge auto-scroll and **Esc** to cancel; arrow keys on the handle move a block from the keyboard. Custom layouts no longer back-fill gaps automatically, so blocks stay where they were placed; the new **Fill gaps** action packs them on request. The edit toolbar is a single compact row, so the layout keeps its shape while unlocked.
|
||||||
|
- **Interface fixes:** the WebSocket tunnels title no longer overlaps its **Active tunnels** button; summary tiles on the Cache, Traffic and Transform pages fill each row instead of leaving empty columns; identifier fields (username, certificate, Docker agent and redirect IDs) no longer trigger an invalid-pattern error in current browsers; several layout-editor tooltips showed raw translation keys.
|
||||||
|
- **Cache under turnover:** returning freed memory to the operating system is now rate-limited (at most four times per second, plus the regular maintenance pass) instead of running after every eviction. With a full HOT cache and constant replacement this measured about 2.3× higher throughput with the default system allocator (2,900 → 7,000 req/s, one core) at about 7% higher resident memory.
|
||||||
|
- **Cache hot path:** popular objects no longer re-link the LRU list on every hit, HOT/COLD index lookups use the already-uniform cache digest instead of a second SipHash pass, and the request-collapsing bypass marker for uncacheable URLs keeps working when its table is full.
|
||||||
|
- **Per-response work:** configured `Server` and cache `Via` values are parsed once per worker instead of on every response, and cache diagnostic headers avoid temporary allocations.
|
||||||
|
- **Upgrade notes:** no configuration or database changes are required.
|
||||||
|
|
||||||
|
## 0.35.3 — Overview layout fixes
|
||||||
|
|
||||||
|
- **Standard Overview layout restored:** the quick-health tiles added in 0.35.2 (healthy backends, error share, requests rejected by limits, nearest certificate expiry) are now hidden by default and placed at the end of the Overview, so they no longer shift the standard block layout. Enable them in the layout editor when needed.
|
||||||
|
- **Fixed:** the "Saved default" status next to the history sample count was always visible and looked like a fresh save notification. It now appears only for an unsaved temporary choice or briefly after **Remember choice**.
|
||||||
|
- **Upgrade notes:** no configuration changes are required. If 0.35.2 already changed your Overview, use **Unlock layout → Reset layout** to restore the default arrangement, or hide the tiles in the layout editor.
|
||||||
|
|
||||||
## 0.35.2 — memory fix, health check options and Overview tiles
|
## 0.35.2 — memory fix, health check options and Overview tiles
|
||||||
|
|
||||||
- **Memory use restored:** with a large HOT cache, 0.35.1 used about 25% more memory than 0.35.0 (for example 1.1–1.2 GiB instead of about 800 MiB with a 600 MiB cache). The default build is back to the 0.35.0 memory profile. The faster alternative allocator is available as an optional build feature for servers with spare RAM; the Performance guide explains the trade-off with measurements.
|
- **Memory use restored:** with a large HOT cache, 0.35.1 used about 25% more memory than 0.35.0 (for example 1.1–1.2 GiB instead of about 800 MiB with a 600 MiB cache). The default build is back to the 0.35.0 memory profile. The faster alternative allocator is available as an optional build feature for servers with spare RAM; the Performance guide explains the trade-off with measurements.
|
||||||
- **Health check port:** active TCP and HTTP health checks can probe a different port than the one receiving traffic, for example a status port 8079 for a backend serving on port 80. It can be set for a whole Vhost or per backend; host, TLS and address restrictions stay the same.
|
- **Health check port:** active TCP and HTTP health checks can probe a different port than the one receiving traffic, for example a status port 8079 for a backend serving on port 80. It can be set for a whole Vhost or per backend; host, TLS and address restrictions stay the same.
|
||||||
- **Expected response text:** HTTP health checks can require a text fragment in the response body, so a status page that answers 200 while reporting a degraded state marks the backend as down.
|
- **Expected response text:** HTTP health checks can require a text fragment in the response body, so a status page that answers 200 while reporting a degraded state marks the backend as down.
|
||||||
- **New Overview tiles:** healthy backends (with the names of failing ones), error share, requests rejected by limits and the nearest certificate expiry are shown on the Overview by default.
|
- **New Overview tiles:** healthy backends (with the names of failing ones), error share, requests rejected by limits and the nearest certificate expiry.
|
||||||
- **Certificate expiry metrics:** Prometheus now exports the expiry date and remaining days of every installed certificate, including manually uploaded ones, for alerting.
|
- **Certificate expiry metrics:** Prometheus now exports the expiry date and remaining days of every installed certificate, including manually uploaded ones, for alerting.
|
||||||
- **Safari:** the Overview no longer jumps when you scroll near the Backends table while live values refresh.
|
- **Safari:** the Overview no longer jumps when you scroll near the Backends table while live values refresh.
|
||||||
- **Fixed:** network interface traffic below 1 KiB/s is shown as a whole number of bytes instead of a long decimal.
|
- **Fixed:** network interface traffic below 1 KiB/s is shown as a whole number of bytes instead of a long decimal.
|
||||||
|
|||||||
Generated
+1
-1
@@ -1943,7 +1943,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-proxy"
|
name = "rust-proxy"
|
||||||
version = "0.35.2"
|
version = "0.36.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rust-proxy"
|
name = "rust-proxy"
|
||||||
version = "0.35.2"
|
version = "0.36.8"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.99.1"
|
rust-version = "1.99.1"
|
||||||
description = "RustProxy - independent reverse proxy with local administration"
|
description = "RustProxy - independent reverse proxy with local administration"
|
||||||
|
|||||||
+579
-563
File diff suppressed because it is too large.
Load diff
Regular → Executable
File mode changed.
+6
-3
@@ -20,21 +20,24 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot
|
|||||||
| `POST /api/config/geoip/update` | Run a GeoIP download/check/reload immediately using already-saved GeoIP settings; does not change revision. |
|
| `POST /api/config/geoip/update` | Run a GeoIP download/check/reload immediately using already-saved GeoIP settings; does not change revision. |
|
||||||
| `GET /api/config/history` · `POST /api/config/history/restore` | Review/restore retained configuration snapshots. |
|
| `GET /api/config/history` · `POST /api/config/history/restore` | Review/restore retained configuration snapshots. |
|
||||||
| `GET /api/status` · `GET /api/backends` | Operational status and backend observations. |
|
| `GET /api/status` · `GET /api/backends` | Operational status and backend observations. |
|
||||||
| `GET /api/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. |
|
| `GET /api/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. `GET` supports the lightweight `view=live` polling view. |
|
||||||
|
| `GET /api/transform` | Transform observations and resource usage. Supports the lightweight `view=live` polling view. |
|
||||||
| `GET /api/logs` · `GET /api/live-logs` | Recent and streaming request logs. |
|
| `GET /api/logs` · `GET /api/live-logs` | Recent and streaming request logs. |
|
||||||
| `GET /api/file-logs` · `GET /api/file-logs/query` | Stored-log exploration. |
|
| `GET /api/file-logs` · `GET /api/file-logs/query` | Stored-log exploration. |
|
||||||
| `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. |
|
| `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. Traffic supports the lightweight `view=live` polling view. |
|
||||||
| `GET /api/metrics/json` · `GET /api/metrics/summary` | Full JSON snapshot (includes all Prometheus samples and `summary`) or compact computed `summary` only, both pull-only. [Monitoring](MONITORING.md). |
|
| `GET /api/metrics/json` · `GET /api/metrics/summary` | Full JSON snapshot (includes all Prometheus samples and `summary`) or compact computed `summary` only, both pull-only. [Monitoring](MONITORING.md). |
|
||||||
| `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. |
|
| `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. |
|
||||||
| `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. |
|
| `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. |
|
||||||
| `POST /api/certificates/inspect` | Read DNS SAN coverage from a managed/external public PEM path for the editor; admin browser session only. |
|
| `POST /api/certificates/inspect` | Read DNS SAN coverage from a managed/external public PEM path for the editor; admin browser session only. |
|
||||||
| `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. |
|
| `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. |
|
||||||
| `GET/PUT /api/preferences` | Personal UI settings. |
|
| `GET/PUT /api/preferences` | Personal UI settings, including theme/layout and optional `interface_icons` and `logo_animation` (both default `false`). |
|
||||||
| `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. |
|
| `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. |
|
||||||
| `GET /docs/openapi.json` | Protected machine-readable API specification. |
|
| `GET /docs/openapi.json` | Protected machine-readable API specification. |
|
||||||
|
|
||||||
Some paths have additional DELETE/PATCH operations, job-status resources, query parameters or restricted actions. Use OpenAPI for the **actual fields/methods**, not guesses based on a table. `GET /metrics` here is not a replacement for the separate Prometheus exporter.
|
Some paths have additional DELETE/PATCH operations, job-status resources, query parameters or restricted actions. Use OpenAPI for the **actual fields/methods**, not guesses based on a table. `GET /metrics` here is not a replacement for the separate Prometheus exporter.
|
||||||
|
|
||||||
|
For browser polling, `GET /api/cache?view=live`, `GET /api/transform?view=live` and `GET /api/traffic?view=live` omit static response members that do not need to be retransmitted on every interval. Their `revision` identifies the active configuration; when it changes, clients should fetch the corresponding endpoint without `view=live` and refresh their cached static fields. Requests without `view=live` remain the complete compatibility form. Cache live responses contain metrics/dashboard/bans, Transform live responses contain resources/metrics, and Traffic live responses retain current settings/GeoIP/analytics while omitting the static Vhost list. Unknown `view` values are rejected.
|
||||||
|
|
||||||
Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed file or an absolute external path and returns `{ "cert_file": "...", "covered_hosts": ["example.com"] }`. It applies the existing certificate-file trust and size checks and reads no private key. It makes no configuration or permission changes. This POST requires the signed-in administrator's normal Origin, request-marker and CSRF checks; general RO/RW API tokens and certificate-only credentials cannot call it.
|
Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed file or an absolute external path and returns `{ "cert_file": "...", "covered_hosts": ["example.com"] }`. It applies the existing certificate-file trust and size checks and reads no private key. It makes no configuration or permission changes. This POST requires the signed-in administrator's normal Origin, request-marker and CSRF checks; general RO/RW API tokens and certificate-only credentials cannot call it.
|
||||||
|
|
||||||
## Access and authentication
|
## Access and authentication
|
||||||
|
|||||||
+12
-1
@@ -61,6 +61,17 @@ When another service owns the certificate, add a manual certificate with the act
|
|||||||
/etc/letsencrypt/live/example.com/privkey.pem
|
/etc/letsencrypt/live/example.com/privkey.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### One vhost, several certificates (SNI selection)
|
||||||
|
|
||||||
|
A vhost normally uses one certificate for all of its names. When a single vhost serves domains that have **different** certificates (for example several `autodiscover.<domain>` names, each with its own certificate), leave the vhost certificate on **Global SNI / default certificate**. RustProxy then picks the certificate for every TLS connection by the requested server name (SNI) from the stored certificates whose **SNI names** include it. Wildcard names match one label (`*.example.com` matches `a.example.com`, not `a.b.example.com`).
|
||||||
|
|
||||||
|
1. Open **Certificates**, edit the certificate and open **Existing files / advanced**.
|
||||||
|
2. Check **SNI names**: it is filled automatically from the certificate's SAN entries and can be edited.
|
||||||
|
3. Repeat for each certificate and keep the vhost on **Global SNI / default certificate**.
|
||||||
|
|
||||||
|
While you add host names to a new vhost, the editor selects a stored certificate automatically when one certificate covers every name. When no single certificate does but each name is covered by some stored certificate, it tells you to keep Global SNI. Selecting a certificate explicitly in the vhost overrides the SNI names for that vhost's hosts; if it does not cover them, saving asks whether to force it.
|
||||||
|
|
||||||
|
|
||||||
### Permission commands and automatic SNI names
|
### Permission commands and automatic SNI names
|
||||||
|
|
||||||
Select **External files (absolute paths)** and enter both paths. A permission-command block is generated **below the certificate and private-key fields** and changes as the paths change. It uses the supplied paths, resolves symlink targets on the server when run, removes group/other write permission on the resolved PEM files and other-user read permission on the private key, then grants traversal of the relevant parent directories and read access to the files for `rustproxy`. Install the Linux `acl` tools if needed and review/run the commands as an administrator on that server. The panel generates commands; it does not execute them or change filesystem ownership.
|
Select **External files (absolute paths)** and enter both paths. A permission-command block is generated **below the certificate and private-key fields** and changes as the paths change. It uses the supplied paths, resolves symlink targets on the server when run, removes group/other write permission on the resolved PEM files and other-user read permission on the private key, then grants traversal of the relevant parent directories and read access to the files for `rustproxy`. Install the Linux `acl` tools if needed and review/run the commands as an administrator on that server. The panel generates commands; it does not execute them or change filesystem ownership.
|
||||||
@@ -69,7 +80,7 @@ The paths must be absolute, without `.`/`..` components or control characters, a
|
|||||||
|
|
||||||
The existing trusted-path rules still apply: real PEM files and ancestors must be owned by root or the RustProxy service user, ancestors must not be group/world-writable, and a private key must not be world-readable or group/world-writable. ACLs alone do not make an untrusted path acceptable. Container mounts and mandatory access-control rules need matching deployment settings; see [Installation](INSTALL.md#linux-debrpm-read-only-access-to-external-paths).
|
The existing trusted-path rules still apply: real PEM files and ancestors must be owned by root or the RustProxy service user, ancestors must not be group/world-writable, and a private key must not be world-readable or group/world-writable. ACLs alone do not make an untrusted path acceptable. Container mounts and mandatory access-control rules need matching deployment settings; see [Installation](INSTALL.md#linux-debrpm-read-only-access-to-external-paths).
|
||||||
|
|
||||||
After a public certificate becomes readable, the editor automatically fills **Legacy SNI names** from its DNS Subject Alternative Name (SAN) entries. Wildcard SANs are retained; filenames and a certificate's Common Name are not used to invent DNS names. Inspection applies to managed certificate filenames and external paths, reads only the public PEM, and does not activate or save the certificate. The certificate/key pair is still validated when applied.
|
After a public certificate becomes readable, the editor automatically fills **SNI names** from its DNS Subject Alternative Name (SAN) entries. Wildcard SANs are retained; filenames and a certificate's Common Name are not used to invent DNS names. Inspection applies to managed certificate filenames and external paths, reads only the public PEM, and does not activate or save the certificate. The certificate/key pair is still validated when applied.
|
||||||
|
|
||||||
Automatic reads preserve manually edited names, intentional clearing and existing saved mappings. Results from an earlier path or form are ignored. Use **Read names from certificate** to retry after fixing filesystem permissions or to deliberately replace the current field with the certificate's names. Prefer explicit certificate assignment in the intended Vhost; auto-filled legacy SNI names do not create Vhosts or replace their assignments.
|
Automatic reads preserve manually edited names, intentional clearing and existing saved mappings. Results from an earlier path or form are ignored. Use **Read names from certificate** to retry after fixing filesystem permissions or to deliberately replace the current field with the certificate's names. Prefer explicit certificate assignment in the intended Vhost; auto-filled legacy SNI names do not create Vhosts or replace their assignments.
|
||||||
|
|
||||||
|
|||||||
+26
-2
@@ -14,7 +14,7 @@ page has its own stylesheet.
|
|||||||
|
|
||||||
The Overview contains live counters, charts and optional backend, cache, PHP, Docker and Stream cards. Under **Settings → Appearance → Dashboard layout** or the Overview editor, configure visibility, order, card width, density and the number of history samples. A hidden card stops consuming its optional browser-side visualization work but does not turn off its underlying proxy feature.
|
The Overview contains live counters, charts and optional backend, cache, PHP, Docker and Stream cards. Under **Settings → Appearance → Dashboard layout** or the Overview editor, configure visibility, order, card width, density and the number of history samples. A hidden card stops consuming its optional browser-side visualization work but does not turn off its underlying proxy feature.
|
||||||
|
|
||||||
Quick-health tiles shown by default since 0.35.2:
|
Optional quick-health tiles (since 0.35.2, hidden by default; enable them in the layout editor or **Settings → Appearance → Dashboard layout**):
|
||||||
|
|
||||||
| Tile | Shows |
|
| Tile | Shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -23,12 +23,36 @@ Quick-health tiles shown by default since 0.35.2:
|
|||||||
| **Rejected by limits / s** | Requests and connections refused by `--max-connections` / `--max-inflight`; sustained values mean the limits are too low for the load. |
|
| **Rejected by limits / s** | Requests and connections refused by `--max-connections` / `--max-inflight`; sustained values mean the limits are too low for the load. |
|
||||||
| **Nearest certificate expiry** | Days until the earliest installed certificate expires; the bar is full at 90 days or more. |
|
| **Nearest certificate expiry** | Days until the earliest installed certificate expires; the bar is full at 90 days or more. |
|
||||||
|
|
||||||
Existing layouts receive these tiles at the end of the Overview; hide or move them in the layout editor.
|
Enabled tiles are added at the end of the Overview, so the standard block layout does not change.
|
||||||
|
|
||||||
|
The Overview history sample count is saved in the browser immediately when the selection changes; no separate confirmation button is required.
|
||||||
|
|
||||||
|
The sidebar application-status indicator runs a lightweight self-check from the live status already used by the dashboard. Green means healthy, amber means attention is needed, and red means a problem was detected; click it to see API, listener, backend and logging details.
|
||||||
|
|
||||||
The unlocked Overview layout exposes a single **Save and lock** action (plus Cancel), removing the redundant lock-and-save button.
|
The unlocked Overview layout exposes a single **Save and lock** action (plus Cancel), removing the redundant lock-and-save button.
|
||||||
|
|
||||||
|
### Arranging blocks
|
||||||
|
|
||||||
|
After **Unlock layout**, every block shows a compact toolbar: the **⠿** handle, width **− / +**, and **×** to hide it (wider blocks also show **← / →**).
|
||||||
|
|
||||||
|
- **Drag:** grab the ⠿ handle (mouse, pen or touch). The block lifts and follows the pointer, and a dashed frame of the same width shows exactly where it will land; neighbouring blocks glide aside instead of jumping. Release to drop, or press **Esc** to return the block to its previous place. Dragging near the top or bottom edge of the window scrolls the page.
|
||||||
|
- **Keyboard:** focus the ⠿ handle and use the arrow keys to move the block one position.
|
||||||
|
- **Fill gaps:** a saved custom layout keeps blocks in exactly the order you arranged them, so a wide block that does not fit at the end of a row leaves a visible gap instead of other blocks silently moving into it. Use **Fill gaps** to pack blocks into empty space once; nothing is stored until **Save and lock**.
|
||||||
|
|
||||||
**Dashboard Presets** supports up to **16 named layouts per administrator**. Save, load, rename, overwrite and delete presets; a factory layout can be restored. Presets preserve the dashboard arrangement, not the live traffic values. Interface theme, scale and other display settings are also account-scoped and do not alter proxy routing or rewrite shared configuration.
|
**Dashboard Presets** supports up to **16 named layouts per administrator**. Save, load, rename, overwrite and delete presets; a factory layout can be restored. Presets preserve the dashboard arrangement, not the live traffic values. Interface theme, scale and other display settings are also account-scoped and do not alter proxy routing or rewrite shared configuration.
|
||||||
|
|
||||||
|
### SVG interface icons
|
||||||
|
|
||||||
|
Under **Settings → Appearance → Interface**, enable **Show SVG interface icons** to add semantic line icons to the main navigation, action buttons, tabs and dialogs. The preference is stored per administrator as `interface_icons: true|false` in `/api/preferences` and is **off by default** for both new and upgraded accounts.
|
||||||
|
|
||||||
|
The icons are inline SVGs bundled with RustProxy; no web font, CDN or external icon library is loaded. They use `currentColor`, so the active theme, danger/primary states and disabled-state colors remain authoritative. Text labels and existing ARIA labels remain the source of accessible meaning; the SVGs are `aria-hidden`. Dynamic controls are reclassified when their state changes, so actions such as sort direction, save/loading, show/hide and refresh keep a matching icon. Turning the option off removes the injected icons and restores the original text-only presentation without restarting the panel.
|
||||||
|
|
||||||
|
Controls whose label is already a single symbol (`×`, `−`, `+`, `←`, `→`, `↑`, `↓`) keep that glyph and do not receive a second SVG. Verbs take precedence over the surrounding feature: a **Close** button in the health or history dialog shows a close icon, **Previous/Next** use chevrons, and **Unlock layout** uses an open padlock.
|
||||||
|
|
||||||
|
### Logo animation
|
||||||
|
|
||||||
|
Under **Settings → Appearance → Interface**, enable **Animate logo** to let the sidebar `r/p` logo play a short, subtle effect (rotating slash, letter flip, wave, glow, sheen, glitch or wobble) at random intervals of roughly 10–25 seconds and when the pointer moves over it. The preference is stored per administrator as `logo_animation: true|false` in `/api/preferences` and is **off by default**. Animations pause while the browser tab is hidden and are suppressed entirely when the operating system or browser requests reduced motion.
|
||||||
|
|
||||||
## Editing configuration safely
|
## Editing configuration safely
|
||||||
|
|
||||||
1. Open the relevant workspace and inspect existing state before editing.
|
1. Open the relevant workspace and inspect existing state before editing.
|
||||||
|
|||||||
+3
-1
@@ -76,11 +76,12 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se
|
|||||||
## Monitoring and logs
|
## Monitoring and logs
|
||||||
|
|
||||||
- Overview dashboard with requests per second, bandwidth, status codes, error share, cache hit ratio, protocol mix, system load, backend health, requests rejected by limits and nearest certificate expiry.
|
- Overview dashboard with requests per second, bandwidth, status codes, error share, cache hit ratio, protocol mix, system load, backend health, requests rejected by limits and nearest certificate expiry.
|
||||||
- Customizable dashboard layouts and saved presets.
|
- Customizable dashboard layouts (drag-and-drop with a live drop placeholder, keyboard moves, per-block width, explicit gap filling) and saved presets.
|
||||||
- Per-Vhost, per-backend, per-Web-Service and per-Stream statistics.
|
- Per-Vhost, per-backend, per-Web-Service and per-Stream statistics.
|
||||||
- Live request log in the browser and in the terminal (`--livelogs`).
|
- Live request log in the browser and in the terminal (`--livelogs`).
|
||||||
- Stored JSON access logs with search syntax, per-Vhost log files and an error log.
|
- Stored JSON access logs with search syntax, per-Vhost log files and an error log.
|
||||||
- Prometheus exporter with token/CIDR protection, JSON metrics and a compact summary endpoint.
|
- Prometheus exporter with token/CIDR protection, JSON metrics and a compact summary endpoint.
|
||||||
|
- Lightweight revision-aware polling views for Cache, Traffic and Transform keep the administration panel responsive without repeatedly transferring static configuration.
|
||||||
- Grafana dashboard and Nagios checks included.
|
- Grafana dashboard and Nagios checks included.
|
||||||
|
|
||||||

|

|
||||||
@@ -94,6 +95,7 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se
|
|||||||
- Nginx Proxy Manager import with a dry-run plan.
|
- Nginx Proxy Manager import with a dry-run plan.
|
||||||
- Managed install/update scripts with rollback, DEB/RPM packages, Docker image and an OPNsense plugin.
|
- Managed install/update scripts with rollback, DEB/RPM packages, Docker image and an OPNsense plugin.
|
||||||
- Panel in English and Polish, light and dark themes, adjustable density and corner style.
|
- Panel in English and Polish, light and dark themes, adjustable density and corner style.
|
||||||
|
- Optional account-scoped semantic SVG icons for navigation, actions, tabs and dialogs; off by default.
|
||||||
|
|
||||||
## Administration and API
|
## Administration and API
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ bash dev.sh test
|
|||||||
bash dev.sh release
|
bash dev.sh release
|
||||||
```
|
```
|
||||||
|
|
||||||
`dev.sh release` builds the application; it is not equivalent to a successful production rollout. Cargo dependencies and Swagger assets must be available to the build environment. An example systemd unit is in `deploy/`.
|
`dev.sh release` builds the application; it is not equivalent to a successful production rollout. Cargo dependencies must be available to the build environment; Swagger UI 5.33.1 is vendored in `third_party/swagger-ui-5.33.1/` and needs no download (set `SWAGGER_UI_DOWNLOAD_URL` or `SWAGGER_UI_ARCHIVE_SHA256` to build from an explicit archive instead). An example systemd unit is in `deploy/`.
|
||||||
|
|
||||||
For a first Linux/systemd installation:
|
For a first Linux/systemd installation:
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ RustProxy has several independent logging paths. Choose the one matching the que
|
|||||||
|
|
||||||
## Live Logs
|
## Live Logs
|
||||||
|
|
||||||
|
The administration panel does not fetch `/api/logs` while Live Logs is closed. Opening or docking the Live Logs window starts the bounded live snapshot refresh; closing it stops that polling. Pausing or expanding the history buffer also suspends periodic `/api/logs` reads until live viewing resumes. The main `/api/events` status stream is kept independent by using `logs=false` in the panel.
|
||||||
|
|
||||||
Use **Logs → Live Logs** in the authenticated panel, or locally:
|
Use **Logs → Live Logs** in the authenticated panel, or locally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+10
-1
@@ -70,7 +70,16 @@ Choose mimalloc only when all of these apply:
|
|||||||
- throughput under that turnover matters more than memory;
|
- throughput under that turnover matters more than memory;
|
||||||
- the server has at least 30% RAM headroom above the configured HOT cache size.
|
- the server has at least 30% RAM headroom above the configured HOT cache size.
|
||||||
|
|
||||||
Otherwise keep the default. The system allocator returns memory from evicted cache entries to the operating system right away, so process memory stays close to the configured cache size.
|
Otherwise keep the default. The system allocator returns memory from evicted cache entries to the operating system shortly after eviction, so process memory stays close to the configured cache size.
|
||||||
|
|
||||||
|
**Since 0.35.4** the default build returns freed memory to the operating system at most four times per second (plus the regular cache maintenance pass) instead of after every eviction. Most of the system allocator's slowdown under turnover came from these repeated trims, so the gap to mimalloc is now much smaller. Measured on the same 2 vCPU machine, one core, 256 MiB HOT cache filled with 100 KiB objects and every request a new URL:
|
||||||
|
|
||||||
|
| Build | RSS after load | Throughput under cache turnover |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 0.35.3, system allocator | 595 MiB | 2,900 req/s |
|
||||||
|
| 0.35.4, system allocator | 639 MiB (+7%) | 7,000 req/s (2.4×) |
|
||||||
|
|
||||||
|
The mimalloc rows above were measured with 0.35.2 and a different object mix; re-measure on your own workload before choosing the optional build.
|
||||||
|
|
||||||
## Production tuning
|
## Production tuning
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ Guides for operating RustProxy: installation, publishing services, security, cer
|
|||||||
- [Backup and recovery](BACKUP.md) — encrypted backups, restore and the Secret Store.
|
- [Backup and recovery](BACKUP.md) — encrypted backups, restore and the Secret Store.
|
||||||
- [Migration](MIGRATION.md) — upgrades and Nginx Proxy Manager import.
|
- [Migration](MIGRATION.md) — upgrades and Nginx Proxy Manager import.
|
||||||
- [Docker discovery](DOCKER_DISCOVERY.md) — Docker agent and generated Vhosts.
|
- [Docker discovery](DOCKER_DISCOVERY.md) — Docker agent and generated Vhosts.
|
||||||
- [Administration API](API.md) — authentication, tokens, endpoints and configuration sections.
|
- [Administration API](API.md) — authentication, tokens, endpoints, lightweight polling views and configuration sections.
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ A simple mechanism for automatically sending a certificate generated by `os-acme
|
|||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `opnsense-rustproxy-cert-deploy.sh` - sends the certificate and private key to RustProxy.
|
- `opnsense-rustproxy-cert-deploy.sh` - sends the certificate and private key to RustProxy.
|
||||||
- `rustproxy-acme.conf` - connection settings, API token, and certificate file paths.
|
- `rustproxy-acme.conf.example` - example connection settings, API token, and certificate file paths.
|
||||||
- `actions_rustproxy_cert.conf` - optional `configd` action that allows the script to be executed from Cron in OPNsense.
|
- `actions_rustproxy_cert.conf` - optional `configd` action that allows the script to be executed from Cron in OPNsense.
|
||||||
|
|
||||||
## 1. Installation
|
## 1. Installation
|
||||||
@@ -50,13 +50,15 @@ KEY_FILE='/path/to/private.key'
|
|||||||
|
|
||||||
## 3. RustProxy API
|
## 3. RustProxy API
|
||||||
|
|
||||||
Create a certificate API key in RustProxy and enter its values in the configuration:
|
Create a certificate API key in **Settings -> Certificate API** and enter its values in the configuration:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
RUSTPROXY_KEY_ID
|
RUSTPROXY_KEY_ID
|
||||||
RUSTPROXY_TOKEN
|
RUSTPROXY_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The credential must explicitly allow `CERTIFICATE_ID` and **Also allow deploy** (`allow_deploy`) must be enabled. A reload-only credential will be rejected with HTTP 403.
|
||||||
|
|
||||||
The script sends the certificate to:
|
The script sends the certificate to:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -88,7 +90,7 @@ cp actions_rustproxy_cert.conf /usr/local/opnsense/service/conf/actions.d/
|
|||||||
service configd restart
|
service configd restart
|
||||||
```
|
```
|
||||||
|
|
||||||
Then go to **System -> Settings -> Cron** and add a scheduled execution of the `Deploy ACME certificate to RustProxy` action.
|
Verify the action first with `configctl rustproxy_cert deploy`. Then go to **System -> Settings -> Cron** and add a scheduled execution of the `Deploy ACME certificate to RustProxy` action.
|
||||||
|
|
||||||
The script deliberately reconciles on every run instead of trusting a local cache. The server is the source of truth: an identical RustProxy-owned certificate generation is a no-op, a missing id is created, and changed material is rotated atomically. This also repairs drift when a certificate entry or generation was removed outside OPNsense.
|
The script deliberately reconciles on every run instead of trusting a local cache. The server is the source of truth: an identical RustProxy-owned certificate generation is a no-op, a missing id is created, and changed material is rotated atomically. This also repairs drift when a certificate entry or generation was removed outside OPNsense.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[deploy]
|
||||||
|
command:/usr/local/sbin/opnsense-rustproxy-cert-deploy.sh
|
||||||
|
parameters:
|
||||||
|
type:script_output
|
||||||
|
message:deploy ACME certificate to RustProxy
|
||||||
|
description:Deploy ACME certificate to RustProxy
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# RustProxy administration endpoint reachable from OPNsense.
|
||||||
|
RUSTPROXY_URL='https://rustproxy.example.lan:9090'
|
||||||
|
|
||||||
|
# Certificate API credential created in RustProxy Settings -> Certificate API.
|
||||||
|
# The credential must allow this CERTIFICATE_ID and have deploy permission enabled.
|
||||||
|
RUSTPROXY_KEY_ID='opnsense-acme'
|
||||||
|
RUSTPROXY_TOKEN='REPLACE_WITH_CERTIFICATE_API_TOKEN'
|
||||||
|
|
||||||
|
# Stable RustProxy certificate id to create/update.
|
||||||
|
CERTIFICATE_ID='shared-cert'
|
||||||
|
|
||||||
|
# os-acme-client certificate material. Replace CERT_UUID with the actual UUID.
|
||||||
|
CERT_FILE='/var/etc/acme-client/certs/CERT_UUID/fullchain.pem'
|
||||||
|
KEY_FILE='/var/etc/acme-client/keys/CERT_UUID/private.key'
|
||||||
|
|
||||||
|
# Optional: trust a private CA used by the RustProxy administration listener.
|
||||||
|
# RUSTPROXY_CACERT='/usr/local/etc/ssl/rustproxy-ca.pem'
|
||||||
|
|
||||||
|
# Optional: connect to a fixed address while keeping TLS verification for the URL hostname.
|
||||||
|
# Format: hostname:port:address
|
||||||
|
# RUSTPROXY_RESOLVE='rustproxy.example.lan:9090:192.0.2.20'
|
||||||
@@ -77,10 +77,10 @@ ENV_FILE=integrations/routeros-oci-build/.env \
|
|||||||
bash integrations/routeros-oci-build/build-routeros-oci.sh push
|
bash integrations/routeros-oci-build/build-routeros-oci.sh push
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, with RustProxy version `0.35.2` and the defaults this publishes:
|
For example, with RustProxy version `0.36.8` and the defaults this publishes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
zot.example.net/network/rustproxy:0.35.2
|
zot.example.net/network/rustproxy:0.36.8
|
||||||
zot.example.net/network/rustproxy:latest
|
zot.example.net/network/rustproxy:latest
|
||||||
zot.example.net/network/rustproxy:routeros
|
zot.example.net/network/rustproxy:routeros
|
||||||
```
|
```
|
||||||
|
|||||||
+92
-16
@@ -253,7 +253,8 @@
|
|||||||
"certificateSafety": "Certificates secure TLS connections, not routing. Assign a certificate to a vhost. Changes affect new handshakes.",
|
"certificateSafety": "Certificates secure TLS connections, not routing. Assign a certificate to a vhost. Changes affect new handshakes.",
|
||||||
"wsActive": "WebSocket tunnels",
|
"wsActive": "WebSocket tunnels",
|
||||||
"h2Requests": "HTTP/2 requests",
|
"h2Requests": "HTTP/2 requests",
|
||||||
"h2ActiveConnections": "Active H2 connections",
|
"h2ActiveConnections": "H2 connections",
|
||||||
|
"h3ActiveConnections": "H3 connections",
|
||||||
"tlsFailures": "TLS handshake errors",
|
"tlsFailures": "TLS handshake errors",
|
||||||
"tlsListener": "TLS listener",
|
"tlsListener": "TLS listener",
|
||||||
"notEnabled": "Disabled",
|
"notEnabled": "Disabled",
|
||||||
@@ -395,6 +396,7 @@
|
|||||||
"vhImport": "Load into editor",
|
"vhImport": "Load into editor",
|
||||||
"vhMigrationHint": "Migration is saved atomically. Routes sharing one host and one backend are grouped; ambiguous host ownership is rejected.",
|
"vhMigrationHint": "Migration is saved atomically. Routes sharing one host and one backend are grouped; ambiguous host ownership is rejected.",
|
||||||
"vhEditor": "Vhost configuration",
|
"vhEditor": "Vhost configuration",
|
||||||
|
"vhCancel": "Cancel",
|
||||||
"vhReset": "Discard changes",
|
"vhReset": "Discard changes",
|
||||||
"vhSafetyHint": "Private IP binding is not authentication. Keep firewall/ACL rules. HSTS preload is opt-in; affinity cookies are not login sessions.",
|
"vhSafetyHint": "Private IP binding is not authentication. Keep firewall/ACL rules. HSTS preload is opt-in; affinity cookies are not login sessions.",
|
||||||
"vhId": "Vhost ID",
|
"vhId": "Vhost ID",
|
||||||
@@ -637,13 +639,13 @@
|
|||||||
"certCheckDomains": "Domains to verify (comma-separated)",
|
"certCheckDomains": "Domains to verify (comma-separated)",
|
||||||
"certAcmeLater": "Issued certificates are imported into the library and assigned per vhost.",
|
"certAcmeLater": "Issued certificates are imported into the library and assigned per vhost.",
|
||||||
"certAdvanced": "Existing files / advanced",
|
"certAdvanced": "Existing files / advanced",
|
||||||
"certLegacyNames": "Legacy SNI names (optional; prefer per-vhost assignment)",
|
"certLegacyNames": "SNI names (optional; used when a vhost is set to Global SNI / default certificate)",
|
||||||
"certAssigned": "Assigned vhosts",
|
"certAssigned": "Assigned vhosts",
|
||||||
"certUnassigned": "Not assigned",
|
"certUnassigned": "Not assigned",
|
||||||
"certInUse": "Certificate is in use. Reassign vhosts and change the default before removal.",
|
"certInUse": "Certificate is in use. Reassign vhosts and change the default before removal.",
|
||||||
"certDuplicate": "This certificate ID exists. Use a new ID for safe rotation.",
|
"certDuplicate": "This certificate ID exists. Use a new ID for safe rotation.",
|
||||||
"certManage": "Manage certificate library",
|
"certManage": "Manage certificate library",
|
||||||
"certSelectHint": "Select an imported certificate. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.",
|
"certSelectHint": "Select an imported certificate to use it for every name of this vhost. Leave \"Global SNI / default certificate\" to let RustProxy pick the certificate per requested name (SNI) from the stored certificates whose SNI names or SAN entries include it — useful when one vhost serves domains with different certificates. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.",
|
||||||
"vhTinyauth": "Authentication",
|
"vhTinyauth": "Authentication",
|
||||||
"vhTabAuth": "Authentication",
|
"vhTabAuth": "Authentication",
|
||||||
"taUrl": "Tinyauth URL / verification endpoint",
|
"taUrl": "Tinyauth URL / verification endpoint",
|
||||||
@@ -1677,6 +1679,7 @@
|
|||||||
"dockerSearchPlaceholder": "Search service, host, alias or agent...",
|
"dockerSearchPlaceholder": "Search service, host, alias or agent...",
|
||||||
"dockerShowHidden": "Show Hidden",
|
"dockerShowHidden": "Show Hidden",
|
||||||
"dockerHideHidden": "Hide Hidden",
|
"dockerHideHidden": "Hide Hidden",
|
||||||
|
"dockerHideDisabled": "Hide disabled",
|
||||||
"dockerHideVhost": "Hide from view",
|
"dockerHideVhost": "Hide from view",
|
||||||
"dockerUnhideVhost": "Show in view",
|
"dockerUnhideVhost": "Show in view",
|
||||||
"dockerCertificate": "Frontend certificate",
|
"dockerCertificate": "Frontend certificate",
|
||||||
@@ -1725,7 +1728,16 @@
|
|||||||
"dockerAliasesShort": "aliases",
|
"dockerAliasesShort": "aliases",
|
||||||
"dockerAgentDetails": "Agent details",
|
"dockerAgentDetails": "Agent details",
|
||||||
"dockerServices": "Services",
|
"dockerServices": "Services",
|
||||||
"dockerOpenApp": "Open app",
|
"dockerOpenApp": "Open",
|
||||||
|
"autoIdHint": "Generated automatically from the name. Edit it to set your own ID.",
|
||||||
|
"autoIdReset": "Generate automatically",
|
||||||
|
"autoIdChange": "Change",
|
||||||
|
"autoIdPending": "Generated from the name",
|
||||||
|
"autoIdBadge": "auto",
|
||||||
|
"vhCertificateUncoveredPrompt": "Certificate \"{certificate}\" does not cover: {hosts}.\n\nSave anyway and force this certificate for these names? Browsers will show a name-mismatch warning. Choose Cancel to pick a matching certificate instead.",
|
||||||
|
"copyEntry": "Copy",
|
||||||
|
"autoCertSniHint": "No single certificate covers all names, but each name is covered by a stored certificate. Keep \"Global SNI / default certificate\" and RustProxy will pick the right certificate for each name (SNI).",
|
||||||
|
"autoCertHint": "Certificate \"{id}\" was selected automatically because it covers all host names. You can choose another one.",
|
||||||
"dockerLiveAccessLogsHelp": "Live HTTP access logs for this Docker vhost, not the container stdout/stderr.",
|
"dockerLiveAccessLogsHelp": "Live HTTP access logs for this Docker vhost, not the container stdout/stderr.",
|
||||||
"networkInterfacesHistory": "Traffic by interface",
|
"networkInterfacesHistory": "Traffic by interface",
|
||||||
"networkInterfaceRate": "RX / TX",
|
"networkInterfaceRate": "RX / TX",
|
||||||
@@ -1877,9 +1889,9 @@
|
|||||||
"tunnelKillReasonDefault": "admin panel",
|
"tunnelKillReasonDefault": "admin panel",
|
||||||
"listenerH2c": "h2c cleartext HTTP/2 (prior knowledge + HTTP/1.1 Upgrade)",
|
"listenerH2c": "h2c cleartext HTTP/2 (prior knowledge + HTTP/1.1 Upgrade)",
|
||||||
"vhWebsocketRfc8441": "RFC 8441 Extended CONNECT over HTTP/2",
|
"vhWebsocketRfc8441": "RFC 8441 Extended CONNECT over HTTP/2",
|
||||||
"wsH1": "H1 Upgrade",
|
"wsH1": "H1",
|
||||||
"wsH2Rfc8441": "H2 RFC 8441",
|
"wsH2Rfc8441": "H2",
|
||||||
"wsH3Rfc9220": "H3 RFC 9220",
|
"wsH3Rfc9220": "H3",
|
||||||
"wsEstablished": "Established",
|
"wsEstablished": "Established",
|
||||||
"httpProtocolMix": "HTTP protocol mix",
|
"httpProtocolMix": "HTTP protocol mix",
|
||||||
"wsProtocolMix": "WebSocket protocol mix",
|
"wsProtocolMix": "WebSocket protocol mix",
|
||||||
@@ -2280,6 +2292,28 @@
|
|||||||
"vhPoolRecycleSeconds": "Maximum upstream pool age (seconds, 0 = off)",
|
"vhPoolRecycleSeconds": "Maximum upstream pool age (seconds, 0 = off)",
|
||||||
"vhHttp1MaxBuffer": "HTTP/1 upstream max read buffer (bytes)",
|
"vhHttp1MaxBuffer": "HTTP/1 upstream max read buffer (bytes)",
|
||||||
"vhPoolRecycleHelp": "Recycling replaces only the Hyper client generation for each backend; active exchanges continue normally. Defaults bound long-lived pool/TLS buffer retention without reloading the vhost or cache. Request recycling accepts 0 or 1000..10000000; age accepts 0 or 60..604800.",
|
"vhPoolRecycleHelp": "Recycling replaces only the Hyper client generation for each backend; active exchanges continue normally. Defaults bound long-lived pool/TLS buffer retention without reloading the vhost or cache. Request recycling accepts 0 or 1000..10000000; age accepts 0 or 60..604800.",
|
||||||
|
"backendTinyTitle": "Tiny backends",
|
||||||
|
"backendTinyDescription": "Compact backend diagnostics table with search and sorting (the original 14-column view).",
|
||||||
|
"backendOverviewTitle": "Backends",
|
||||||
|
"backendOverviewDescription": "Backend health with status badges, error share, latency trend and expandable client-generation details; search, sorting, filters and column selection.",
|
||||||
|
"backendFilterAll": "All",
|
||||||
|
"backendFilterOther": "Other",
|
||||||
|
"backendFilterLabel": "Filter backends by status",
|
||||||
|
"backendSummary": "{up} of {total} up",
|
||||||
|
"backendLastEvent": "Last event",
|
||||||
|
"backendEventFailed": "Failed",
|
||||||
|
"backendEventRecovered": "Recovered",
|
||||||
|
"backendEventNone": "No failures",
|
||||||
|
"backendAgo": "{value} ago",
|
||||||
|
"backendDetailsToggle": "Show client generation details",
|
||||||
|
"processOverviewTitle": "Process",
|
||||||
|
"processOverviewDescription": "Process CPU, file descriptors, memory split and CPU/RSS history (cache memory is on the RAM cache card).",
|
||||||
|
"processClassicTitle": "Process simple",
|
||||||
|
"processLoadTitle": "Load",
|
||||||
|
"processMemoryTitle": "Memory",
|
||||||
|
"processFdHistory": "File descriptors history",
|
||||||
|
"processThreadHistory": "OS threads history",
|
||||||
|
"backendRequestsTrend": "Requests per refresh",
|
||||||
"backendSearch": "Search backends",
|
"backendSearch": "Search backends",
|
||||||
"backendSearchPlaceholder": "Search vhost, backend or state",
|
"backendSearchPlaceholder": "Search vhost, backend or state",
|
||||||
"backendSortBy": "Sort by",
|
"backendSortBy": "Sort by",
|
||||||
@@ -2360,6 +2394,14 @@
|
|||||||
"chartStyleMonochrome": "Monochrome",
|
"chartStyleMonochrome": "Monochrome",
|
||||||
"chartStyleWarm": "Warm",
|
"chartStyleWarm": "Warm",
|
||||||
"chartStyleCool": "Cool",
|
"chartStyleCool": "Cool",
|
||||||
|
"chartStyleSunset": "Sunset",
|
||||||
|
"chartStyleAurora": "Aurora",
|
||||||
|
"chartStyleOcean": "Ocean",
|
||||||
|
"chartStyleForest": "Forest",
|
||||||
|
"chartStylePastel": "Pastel",
|
||||||
|
"chartStyleTerminal": "Terminal",
|
||||||
|
"chartStyleBlueprint": "Blueprint",
|
||||||
|
"chartStyleColorblind": "Colorblind safe",
|
||||||
"settingsSearch": "Find settings",
|
"settingsSearch": "Find settings",
|
||||||
"settingsSearchPlaceholder": "Search settings, sections and options...",
|
"settingsSearchPlaceholder": "Search settings, sections and options...",
|
||||||
"settingsSearchResults": "Matching sections",
|
"settingsSearchResults": "Matching sections",
|
||||||
@@ -2368,6 +2410,10 @@
|
|||||||
"appearanceInterfaceSection": "INTERFACE",
|
"appearanceInterfaceSection": "INTERFACE",
|
||||||
"appearanceInterfaceTitle": "Interface",
|
"appearanceInterfaceTitle": "Interface",
|
||||||
"appearanceChartsSection": "CHARTS",
|
"appearanceChartsSection": "CHARTS",
|
||||||
|
"cacheSectionBehavior": "Behavior",
|
||||||
|
"cacheSectionMemory": "Memory and limits",
|
||||||
|
"appearanceChartsTitle": "Charts",
|
||||||
|
"appearanceTablesTitle": "Tables and logs",
|
||||||
"appearanceDataSection": "TABLES / LOGS",
|
"appearanceDataSection": "TABLES / LOGS",
|
||||||
"appearanceDashboardSection": "DASHBOARD",
|
"appearanceDashboardSection": "DASHBOARD",
|
||||||
"resetSection": "Reset section",
|
"resetSection": "Reset section",
|
||||||
@@ -2402,6 +2448,10 @@
|
|||||||
"buttonStyleNormal": "Normal",
|
"buttonStyleNormal": "Normal",
|
||||||
"buttonStyleCompact": "Compact",
|
"buttonStyleCompact": "Compact",
|
||||||
"buttonStyleHelp": "Normal uses the refreshed controls; Compact restores the smaller source-style buttons across all actions and dialog tabs.",
|
"buttonStyleHelp": "Normal uses the refreshed controls; Compact restores the smaller source-style buttons across all actions and dialog tabs.",
|
||||||
|
"interfaceIcons": "Show SVG interface icons",
|
||||||
|
"interfaceIconsHelp": "Adds semantic SVG icons to navigation, actions, tabs and dialogs.",
|
||||||
|
"logoAnimation": "Animate logo",
|
||||||
|
"logoAnimationHelp": "Plays short, subtle animations of the sidebar logo from time to time and on hover.",
|
||||||
"entityEnableHint": "Controls whether this item is active.",
|
"entityEnableHint": "Controls whether this item is active.",
|
||||||
"setupCommandExample": "Standard service installation example:",
|
"setupCommandExample": "Standard service installation example:",
|
||||||
"certificateRuntimeTitle": "ACME runtime",
|
"certificateRuntimeTitle": "ACME runtime",
|
||||||
@@ -2641,7 +2691,7 @@
|
|||||||
"backendWidgetInFlight": "In flight",
|
"backendWidgetInFlight": "In flight",
|
||||||
"backendWidgetErrorsRate": "Recent errors/s",
|
"backendWidgetErrorsRate": "Recent errors/s",
|
||||||
"backendWidgetErrorPercent": "Recent failed requests",
|
"backendWidgetErrorPercent": "Recent failed requests",
|
||||||
"backendWidgetProbeCount": "Probed healthy origins",
|
"backendWidgetProbeCount": "Health Origins",
|
||||||
"backendWidgetH2c": "H2C origins",
|
"backendWidgetH2c": "H2C origins",
|
||||||
"backendWidgetModeUnknown": "Unknown",
|
"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.",
|
"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.",
|
||||||
@@ -2749,7 +2799,7 @@
|
|||||||
"overviewLayoutTypeFilter": "Filter blocks by type",
|
"overviewLayoutTypeFilter": "Filter blocks by type",
|
||||||
"overviewLayoutUnlock": "Unlock layout",
|
"overviewLayoutUnlock": "Unlock layout",
|
||||||
"overviewLayoutLock": "Lock and save",
|
"overviewLayoutLock": "Lock and save",
|
||||||
"overviewLayoutInlineHint": "Layout unlocked: reorder blocks within groups of similar size, hide with ×, then save or cancel. The grid aligns card heights automatically.",
|
"overviewLayoutInlineHint": "Layout unlocked: grab a block by its ⠿ handle and drag it — the dashed frame shows where it will land (Esc cancels). Resize with − / +; arrow keys on the handle move a block from the keyboard. “Fill gaps” packs blocks into empty space once.",
|
||||||
"overviewLayoutInlineDrag": "Drag to move",
|
"overviewLayoutInlineDrag": "Drag to move",
|
||||||
"overviewLayoutInlineSave": "Save and lock",
|
"overviewLayoutInlineSave": "Save and lock",
|
||||||
"overviewLayoutInlineCancel": "Cancel changes",
|
"overviewLayoutInlineCancel": "Cancel changes",
|
||||||
@@ -2840,11 +2890,35 @@
|
|||||||
"overviewTransformErrors": "Errors",
|
"overviewTransformErrors": "Errors",
|
||||||
"overviewMeterNoLimit": "No configured limit",
|
"overviewMeterNoLimit": "No configured limit",
|
||||||
"overviewCacheHitsMisses": "HIT {hits}, MISS {misses}",
|
"overviewCacheHitsMisses": "HIT {hits}, MISS {misses}",
|
||||||
"overviewSamplesRemember": "Remember choice",
|
|
||||||
"overviewSamplesSaved": "Saved default",
|
|
||||||
"overviewSamplesTemporary": "For this visit only",
|
|
||||||
"overviewSamplesStorageUnavailable": "Could not save in this browser",
|
"overviewSamplesStorageUnavailable": "Could not save in this browser",
|
||||||
"overviewSamplesTitle": "Overview chart samples",
|
"overviewSamplesTitle": "Overview chart samples",
|
||||||
|
"appHealthTitle": "Application status",
|
||||||
|
"appHealthSelfCheck": "SELF-CHECK",
|
||||||
|
"appHealthChecking": "Checking application status",
|
||||||
|
"appHealthOk": "All systems operational",
|
||||||
|
"appHealthWarning": "Attention required",
|
||||||
|
"appHealthError": "Problem detected",
|
||||||
|
"appHealthSidebarChecking": "CHECKING",
|
||||||
|
"appHealthSidebarOk": "APPLICATION OK",
|
||||||
|
"appHealthSidebarWarning": "APPLICATION WARNING",
|
||||||
|
"appHealthSidebarError": "APPLICATION ERROR",
|
||||||
|
"appHealthUpdated": "Updated {time}",
|
||||||
|
"appHealthNoStatus": "No current status",
|
||||||
|
"appHealthApi": "API",
|
||||||
|
"appHealthApiOk": "Live application status is responding.",
|
||||||
|
"appHealthApiError": "No current status from the application.",
|
||||||
|
"appHealthListeners": "Listeners",
|
||||||
|
"appHealthListenersOk": "Active listeners match the desired configuration.",
|
||||||
|
"appHealthListenersRestart": "Listener changes are pending; restart RustProxy to apply them.",
|
||||||
|
"appHealthBackends": "Backends",
|
||||||
|
"appHealthBackendsNone": "No backend pools are configured.",
|
||||||
|
"appHealthBackendsOk": "All active backend pools are ready.",
|
||||||
|
"appHealthBackendsWarning": "{down} down, {checking} checking: {members}.",
|
||||||
|
"appHealthBackendsError": "Unavailable pools: {pools}.",
|
||||||
|
"appHealthBackendsAffected": "Affected backends: {members}.",
|
||||||
|
"appHealthLogging": "Logging",
|
||||||
|
"appHealthLoggingOk": "Logging pipeline reports no queue drops or write/send errors.",
|
||||||
|
"appHealthLoggingWarning": "{dropped} queued entries dropped, {errors} write/send errors since start.",
|
||||||
"webMetricStatus": "WWW HTTP status",
|
"webMetricStatus": "WWW HTTP status",
|
||||||
"webMetricStatusHelp": "Completed Web Server status classes, all-time since reload.",
|
"webMetricStatusHelp": "Completed Web Server status classes, all-time since reload.",
|
||||||
"webMetricKind": "Static vs PHP",
|
"webMetricKind": "Static vs PHP",
|
||||||
@@ -2964,11 +3038,13 @@
|
|||||||
"certificateInspectKept": "Certificate read; your SNI names were preserved.",
|
"certificateInspectKept": "Certificate read; your SNI names were preserved.",
|
||||||
"certificateInspectNoNames": "Certificate contains no DNS SAN names.",
|
"certificateInspectNoNames": "Certificate contains no DNS SAN names.",
|
||||||
"certificateInspectFailed": "Cannot read certificate. Check the file and its permissions, then read again.",
|
"certificateInspectFailed": "Cannot read certificate. Check the file and its permissions, then read again.",
|
||||||
"pathPermissionsOptions": "Options and help"
|
"pathPermissionsOptions": "Options and help",
|
||||||
},
|
|
||||||
"overviewLayoutNarrow": "Decrease card width",
|
"overviewLayoutNarrow": "Decrease card width",
|
||||||
"overviewLayoutWiden": "Increase card width",
|
"overviewLayoutWiden": "Increase card width",
|
||||||
"overviewLayoutWidth": "Card width in 12-column grid",
|
"overviewLayoutWidth": "Card width in 12-column grid",
|
||||||
"overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert.",
|
"dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again.",
|
||||||
"dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again."
|
"overviewLayoutInlineDragging": "Dragging: drop the block on the dashed frame. Esc restores its previous position.",
|
||||||
|
"overviewLayoutInlineCompact": "Fill gaps",
|
||||||
|
"overviewLayoutInlineCompactHint": "Move blocks into empty grid space once (you can still cancel before saving)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+92
-16
@@ -253,7 +253,8 @@
|
|||||||
"certificateSafety": "Certyfikaty samopodpisane służą do testów. Certyfikat nie tworzy trasy. Zmiana dotyczy nowych połączeń TLS.",
|
"certificateSafety": "Certyfikaty samopodpisane służą do testów. Certyfikat nie tworzy trasy. Zmiana dotyczy nowych połączeń TLS.",
|
||||||
"wsActive": "Tunele WebSocket",
|
"wsActive": "Tunele WebSocket",
|
||||||
"h2Requests": "Żądania HTTP/2",
|
"h2Requests": "Żądania HTTP/2",
|
||||||
"h2ActiveConnections": "Aktywne połączenia H2",
|
"h2ActiveConnections": "Połączenia H2",
|
||||||
|
"h3ActiveConnections": "Połączenia H3",
|
||||||
"tlsFailures": "Błędy handshake TLS",
|
"tlsFailures": "Błędy handshake TLS",
|
||||||
"tlsListener": "Listener TLS",
|
"tlsListener": "Listener TLS",
|
||||||
"notEnabled": "Wyłączony",
|
"notEnabled": "Wyłączony",
|
||||||
@@ -395,6 +396,7 @@
|
|||||||
"vhImport": "Wczytaj do edytora",
|
"vhImport": "Wczytaj do edytora",
|
||||||
"vhMigrationHint": "Migracja jest atomowa. Trasy tych samych hostów i backendu są grupowane; niejednoznaczne przypisanie hostów jest odrzucane.",
|
"vhMigrationHint": "Migracja jest atomowa. Trasy tych samych hostów i backendu są grupowane; niejednoznaczne przypisanie hostów jest odrzucane.",
|
||||||
"vhEditor": "Konfiguracja vhosta",
|
"vhEditor": "Konfiguracja vhosta",
|
||||||
|
"vhCancel": "Anuluj",
|
||||||
"vhReset": "Odrzuć zmiany",
|
"vhReset": "Odrzuć zmiany",
|
||||||
"vhSafetyHint": "Prywatny IP nie zastępuje uwierzytelniania. Zachowaj firewall i ACL. Preload HSTS jest opcjonalny; cookie affinity nie jest sesją logowania.",
|
"vhSafetyHint": "Prywatny IP nie zastępuje uwierzytelniania. Zachowaj firewall i ACL. Preload HSTS jest opcjonalny; cookie affinity nie jest sesją logowania.",
|
||||||
"vhId": "Identyfikator vhosta",
|
"vhId": "Identyfikator vhosta",
|
||||||
@@ -637,13 +639,13 @@
|
|||||||
"certCheckDomains": "Domeny do sprawdzenia (po przecinku)",
|
"certCheckDomains": "Domeny do sprawdzenia (po przecinku)",
|
||||||
"certAcmeLater": "Wydane certyfikaty trafiaja do biblioteki i sa przypisywane do vhostow.",
|
"certAcmeLater": "Wydane certyfikaty trafiaja do biblioteki i sa przypisywane do vhostow.",
|
||||||
"certAdvanced": "Istniejące pliki / zaawansowane",
|
"certAdvanced": "Istniejące pliki / zaawansowane",
|
||||||
"certLegacyNames": "Stare nazwy SNI (opcjonalne; zalecane przypisanie w vhoście)",
|
"certLegacyNames": "Nazwy SNI (opcjonalne; używane, gdy vhost ma Globalne SNI / certyfikat domyślny)",
|
||||||
"certAssigned": "Przypisane vhosty",
|
"certAssigned": "Przypisane vhosty",
|
||||||
"certUnassigned": "Nieprzypisany",
|
"certUnassigned": "Nieprzypisany",
|
||||||
"certInUse": "Certyfikat jest używany. Zmień przypisania vhostów i certyfikat domyślny przed usunięciem.",
|
"certInUse": "Certyfikat jest używany. Zmień przypisania vhostów i certyfikat domyślny przed usunięciem.",
|
||||||
"certDuplicate": "Ten identyfikator certyfikatu istnieje. Do bezpiecznej rotacji użyj nowego.",
|
"certDuplicate": "Ten identyfikator certyfikatu istnieje. Do bezpiecznej rotacji użyj nowego.",
|
||||||
"certManage": "Zarządzaj biblioteką certyfikatów",
|
"certManage": "Zarządzaj biblioteką certyfikatów",
|
||||||
"certSelectHint": "Wybierz zaimportowany certyfikat. Import i rotacja są w zakładce Certyfikaty. Przypisania są sprawdzane względem nazw vhosta.",
|
"certSelectHint": "Wybierz zaimportowany certyfikat, aby użyć go dla wszystkich nazw tego vhosta. Zostaw \"Globalne SNI / certyfikat domyślny\", a RustProxy sam dobierze certyfikat do żądanej nazwy (SNI) spośród zapisanych certyfikatów, których nazwy SNI lub wpisy SAN ją zawierają — przydatne, gdy jeden vhost obsługuje domeny z różnymi certyfikatami. Import i rotacja są w zakładce Certyfikaty. Przypisania są sprawdzane względem nazw vhosta.",
|
||||||
"vhTinyauth": "Uwierzytelnianie",
|
"vhTinyauth": "Uwierzytelnianie",
|
||||||
"vhTabAuth": "Uwierzytelnianie",
|
"vhTabAuth": "Uwierzytelnianie",
|
||||||
"taUrl": "URL Tinyauth / punkt weryfikacji",
|
"taUrl": "URL Tinyauth / punkt weryfikacji",
|
||||||
@@ -1677,6 +1679,7 @@
|
|||||||
"dockerSearchPlaceholder": "Szukaj usługi, hosta, aliasu lub agenta...",
|
"dockerSearchPlaceholder": "Szukaj usługi, hosta, aliasu lub agenta...",
|
||||||
"dockerShowHidden": "Pokaż ukryte",
|
"dockerShowHidden": "Pokaż ukryte",
|
||||||
"dockerHideHidden": "Ukryj ukryte",
|
"dockerHideHidden": "Ukryj ukryte",
|
||||||
|
"dockerHideDisabled": "Ukryj wyłączone",
|
||||||
"dockerHideVhost": "Ukryj z widoku",
|
"dockerHideVhost": "Ukryj z widoku",
|
||||||
"dockerUnhideVhost": "Pokaż w widoku",
|
"dockerUnhideVhost": "Pokaż w widoku",
|
||||||
"dockerCertificate": "Certyfikat frontendu",
|
"dockerCertificate": "Certyfikat frontendu",
|
||||||
@@ -1725,7 +1728,16 @@
|
|||||||
"dockerAliasesShort": "aliasów",
|
"dockerAliasesShort": "aliasów",
|
||||||
"dockerAgentDetails": "Szczegóły agenta",
|
"dockerAgentDetails": "Szczegóły agenta",
|
||||||
"dockerServices": "Usługi",
|
"dockerServices": "Usługi",
|
||||||
"dockerOpenApp": "Otwórz aplikację",
|
"dockerOpenApp": "Otwórz",
|
||||||
|
"autoIdHint": "Generowane automatycznie z nazwy. Zmień, aby ustawić własne ID.",
|
||||||
|
"autoIdReset": "Generuj automatycznie",
|
||||||
|
"autoIdChange": "Zmień",
|
||||||
|
"autoIdPending": "Generowane z nazwy",
|
||||||
|
"autoIdBadge": "auto",
|
||||||
|
"vhCertificateUncoveredPrompt": "Certyfikat \"{certificate}\" nie obejmuje: {hosts}.\n\nZapisać mimo to i wymusić ten certyfikat dla tych nazw? Przeglądarki pokażą ostrzeżenie o niezgodności nazwy. Wybierz Anuluj, aby wskazać pasujący certyfikat.",
|
||||||
|
"autoCertSniHint": "Żaden pojedynczy certyfikat nie obejmuje wszystkich nazw, ale każda nazwa jest objęta jakimś zapisanym certyfikatem. Zostaw \"Globalne SNI / certyfikat domyślny\", a RustProxy dobierze właściwy certyfikat dla każdej nazwy (SNI).",
|
||||||
|
"copyEntry": "Kopiuj",
|
||||||
|
"autoCertHint": "Certyfikat \"{id}\" został wybrany automatycznie, bo pokrywa wszystkie nazwy hostów. Możesz wybrać inny.",
|
||||||
"dockerLiveAccessLogsHelp": "Logi żądań HTTP dla tego Docker vhosta, nie stdout/stderr kontenera.",
|
"dockerLiveAccessLogsHelp": "Logi żądań HTTP dla tego Docker vhosta, nie stdout/stderr kontenera.",
|
||||||
"networkInterfacesHistory": "Ruch według interfejsów",
|
"networkInterfacesHistory": "Ruch według interfejsów",
|
||||||
"networkInterfaceRate": "RX / TX",
|
"networkInterfaceRate": "RX / TX",
|
||||||
@@ -1877,9 +1889,9 @@
|
|||||||
"tunnelKillReasonDefault": "panel administracyjny",
|
"tunnelKillReasonDefault": "panel administracyjny",
|
||||||
"listenerH2c": "h2c HTTP/2 bez TLS (prior knowledge + Upgrade z HTTP/1.1)",
|
"listenerH2c": "h2c HTTP/2 bez TLS (prior knowledge + Upgrade z HTTP/1.1)",
|
||||||
"vhWebsocketRfc8441": "RFC 8441 Extended CONNECT przez HTTP/2",
|
"vhWebsocketRfc8441": "RFC 8441 Extended CONNECT przez HTTP/2",
|
||||||
"wsH1": "H1 Upgrade",
|
"wsH1": "H1",
|
||||||
"wsH2Rfc8441": "H2 RFC 8441",
|
"wsH2Rfc8441": "H2",
|
||||||
"wsH3Rfc9220": "H3 RFC 9220",
|
"wsH3Rfc9220": "H3",
|
||||||
"wsEstablished": "Utworzone",
|
"wsEstablished": "Utworzone",
|
||||||
"httpProtocolMix": "Miks protokołów HTTP",
|
"httpProtocolMix": "Miks protokołów HTTP",
|
||||||
"wsProtocolMix": "Miks protokołów WebSocket",
|
"wsProtocolMix": "Miks protokołów WebSocket",
|
||||||
@@ -2280,6 +2292,28 @@
|
|||||||
"vhPoolRecycleSeconds": "Maks. wiek puli upstream (sekundy, 0 = wył.)",
|
"vhPoolRecycleSeconds": "Maks. wiek puli upstream (sekundy, 0 = wył.)",
|
||||||
"vhHttp1MaxBuffer": "Maks. bufor odczytu HTTP/1 upstream (bajty)",
|
"vhHttp1MaxBuffer": "Maks. bufor odczytu HTTP/1 upstream (bajty)",
|
||||||
"vhPoolRecycleHelp": "Odświeżanie podmienia tylko generację klienta Hyper danego backendu; aktywne wymiany kończą się normalnie. Domyślne limity ograniczają retencję pamięci puli/TLS bez przeładowania vhosta ani cache. Limit żądań: 0 lub 1000..10000000; limit czasu: 0 lub 60..604800.",
|
"vhPoolRecycleHelp": "Odświeżanie podmienia tylko generację klienta Hyper danego backendu; aktywne wymiany kończą się normalnie. Domyślne limity ograniczają retencję pamięci puli/TLS bez przeładowania vhosta ani cache. Limit żądań: 0 lub 1000..10000000; limit czasu: 0 lub 60..604800.",
|
||||||
|
"backendTinyTitle": "Backendy (kompaktowe)",
|
||||||
|
"backendTinyDescription": "Kompaktowa tabela diagnostyki backendów z wyszukiwaniem i sortowaniem (dotychczasowy widok 14 kolumn).",
|
||||||
|
"backendOverviewTitle": "Backendy",
|
||||||
|
"backendOverviewDescription": "Stan backendów ze znacznikami statusu, udziałem błędów, trendem opóźnień i rozwijanymi szczegółami generacji klienta; wyszukiwanie, sortowanie, filtry i wybór kolumn.",
|
||||||
|
"backendFilterAll": "Wszystkie",
|
||||||
|
"backendFilterOther": "Inne",
|
||||||
|
"backendFilterLabel": "Filtruj backendy według statusu",
|
||||||
|
"backendSummary": "{up} z {total} działa",
|
||||||
|
"backendLastEvent": "Ostatnie zdarzenie",
|
||||||
|
"backendEventFailed": "Awaria",
|
||||||
|
"backendEventRecovered": "Przywrócony",
|
||||||
|
"backendEventNone": "Brak awarii",
|
||||||
|
"backendAgo": "{value} temu",
|
||||||
|
"backendDetailsToggle": "Pokaż szczegóły generacji klienta",
|
||||||
|
"processOverviewTitle": "Proces",
|
||||||
|
"processOverviewDescription": "Obciążenie procesu, deskryptory plików, podział pamięci oraz historia CPU i RSS (pamięć cache jest w karcie RAM cache).",
|
||||||
|
"processClassicTitle": "Proces prosty",
|
||||||
|
"processLoadTitle": "Obciążenie",
|
||||||
|
"processMemoryTitle": "Pamięć",
|
||||||
|
"processFdHistory": "Historia deskryptorów",
|
||||||
|
"processThreadHistory": "Historia wątków systemowych",
|
||||||
|
"backendRequestsTrend": "Żądania w kolejnych odświeżeniach",
|
||||||
"backendSearch": "Szukaj backendów",
|
"backendSearch": "Szukaj backendów",
|
||||||
"backendSearchPlaceholder": "Szukaj vhosta, backendu lub stanu",
|
"backendSearchPlaceholder": "Szukaj vhosta, backendu lub stanu",
|
||||||
"backendSortBy": "Sortuj według",
|
"backendSortBy": "Sortuj według",
|
||||||
@@ -2360,6 +2394,14 @@
|
|||||||
"chartStyleMonochrome": "Monochromatyczny",
|
"chartStyleMonochrome": "Monochromatyczny",
|
||||||
"chartStyleWarm": "Ciepły",
|
"chartStyleWarm": "Ciepły",
|
||||||
"chartStyleCool": "Chłodny",
|
"chartStyleCool": "Chłodny",
|
||||||
|
"chartStyleSunset": "Zachód słońca",
|
||||||
|
"chartStyleAurora": "Zorza",
|
||||||
|
"chartStyleOcean": "Ocean",
|
||||||
|
"chartStyleForest": "Las",
|
||||||
|
"chartStylePastel": "Pastelowy",
|
||||||
|
"chartStyleTerminal": "Terminal",
|
||||||
|
"chartStyleBlueprint": "Rysunek techniczny",
|
||||||
|
"chartStyleColorblind": "Przyjazny daltonistom",
|
||||||
"settingsSearch": "Znajdź ustawienia",
|
"settingsSearch": "Znajdź ustawienia",
|
||||||
"settingsSearchPlaceholder": "Szukaj ustawień, sekcji i opcji...",
|
"settingsSearchPlaceholder": "Szukaj ustawień, sekcji i opcji...",
|
||||||
"settingsSearchResults": "Pasujące sekcje",
|
"settingsSearchResults": "Pasujące sekcje",
|
||||||
@@ -2368,6 +2410,10 @@
|
|||||||
"appearanceInterfaceSection": "INTERFEJS",
|
"appearanceInterfaceSection": "INTERFEJS",
|
||||||
"appearanceInterfaceTitle": "Interfejs",
|
"appearanceInterfaceTitle": "Interfejs",
|
||||||
"appearanceChartsSection": "WYKRESY",
|
"appearanceChartsSection": "WYKRESY",
|
||||||
|
"cacheSectionBehavior": "Działanie",
|
||||||
|
"cacheSectionMemory": "Pamięć i limity",
|
||||||
|
"appearanceChartsTitle": "Wykresy",
|
||||||
|
"appearanceTablesTitle": "Tabele i logi",
|
||||||
"appearanceDataSection": "TABELE / LOGI",
|
"appearanceDataSection": "TABELE / LOGI",
|
||||||
"appearanceDashboardSection": "DASHBOARD",
|
"appearanceDashboardSection": "DASHBOARD",
|
||||||
"resetSection": "Resetuj sekcję",
|
"resetSection": "Resetuj sekcję",
|
||||||
@@ -2402,6 +2448,10 @@
|
|||||||
"buttonStyleNormal": "Normalny",
|
"buttonStyleNormal": "Normalny",
|
||||||
"buttonStyleCompact": "Kompaktowy",
|
"buttonStyleCompact": "Kompaktowy",
|
||||||
"buttonStyleHelp": "Normalny używa odświeżonych przycisków; Kompaktowy przywraca mniejsze przyciski ze źródłowego interfejsu we wszystkich akcjach i zakładkach modali.",
|
"buttonStyleHelp": "Normalny używa odświeżonych przycisków; Kompaktowy przywraca mniejsze przyciski ze źródłowego interfejsu we wszystkich akcjach i zakładkach modali.",
|
||||||
|
"interfaceIcons": "Pokaż ikony SVG interfejsu",
|
||||||
|
"interfaceIconsHelp": "Dodaje semantyczne ikony SVG do nawigacji, akcji, zakładek i okien dialogowych.",
|
||||||
|
"logoAnimation": "Animuj logo",
|
||||||
|
"logoAnimationHelp": "Od czasu do czasu (i po najechaniu kursorem) odtwarza krótkie, subtelne animacje logo w panelu bocznym.",
|
||||||
"entityEnableHint": "Określa, czy ten element jest aktywny.",
|
"entityEnableHint": "Określa, czy ten element jest aktywny.",
|
||||||
"setupCommandExample": "Przykład dla standardowej instalacji usługi:",
|
"setupCommandExample": "Przykład dla standardowej instalacji usługi:",
|
||||||
"certificateRuntimeTitle": "Środowisko ACME",
|
"certificateRuntimeTitle": "Środowisko ACME",
|
||||||
@@ -2641,7 +2691,7 @@
|
|||||||
"backendWidgetInFlight": "W toku",
|
"backendWidgetInFlight": "W toku",
|
||||||
"backendWidgetErrorsRate": "Błędy/s",
|
"backendWidgetErrorsRate": "Błędy/s",
|
||||||
"backendWidgetErrorPercent": "Błędne żądania w ostatnim okresie",
|
"backendWidgetErrorPercent": "Błędne żądania w ostatnim okresie",
|
||||||
"backendWidgetProbeCount": "Zdrowe backendy z sondami",
|
"backendWidgetProbeCount": "Zdrowe backendy",
|
||||||
"backendWidgetH2c": "Backendy h2c",
|
"backendWidgetH2c": "Backendy h2c",
|
||||||
"backendWidgetModeUnknown": "Nieznane",
|
"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.",
|
"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.",
|
||||||
@@ -2749,7 +2799,7 @@
|
|||||||
"overviewLayoutTypeFilter": "Filtruj bloki według typu",
|
"overviewLayoutTypeFilter": "Filtruj bloki według typu",
|
||||||
"overviewLayoutUnlock": "Odblokuj układ",
|
"overviewLayoutUnlock": "Odblokuj układ",
|
||||||
"overviewLayoutLock": "Zablokuj i zapisz",
|
"overviewLayoutLock": "Zablokuj i zapisz",
|
||||||
"overviewLayoutInlineHint": "Układ odblokowany: przesuwaj bloki w obrębie grup podobnych rozmiarów, ukrywaj przyciskiem ×, a potem zapisz lub anuluj. Siatka automatycznie wyrównuje wysokości.",
|
"overviewLayoutInlineHint": "Układ odblokowany: chwyć blok za uchwyt ⠿ i przeciągnij — przerywana ramka pokazuje, gdzie wyląduje (Esc anuluje). Szerokość zmieniasz przyciskami − / +, strzałki na uchwycie przesuwają blok z klawiatury. „Uzupełnij luki” jednorazowo dosuwa bloki w puste miejsca.",
|
||||||
"overviewLayoutInlineDrag": "Przeciągnij, aby przesunąć",
|
"overviewLayoutInlineDrag": "Przeciągnij, aby przesunąć",
|
||||||
"overviewLayoutInlineSave": "Zapisz i zablokuj",
|
"overviewLayoutInlineSave": "Zapisz i zablokuj",
|
||||||
"overviewLayoutInlineCancel": "Anuluj zmiany",
|
"overviewLayoutInlineCancel": "Anuluj zmiany",
|
||||||
@@ -2840,11 +2890,35 @@
|
|||||||
"overviewTransformErrors": "Błędy",
|
"overviewTransformErrors": "Błędy",
|
||||||
"overviewMeterNoLimit": "Brak skonfigurowanego limitu",
|
"overviewMeterNoLimit": "Brak skonfigurowanego limitu",
|
||||||
"overviewCacheHitsMisses": "HIT {hits}, MISS {misses}",
|
"overviewCacheHitsMisses": "HIT {hits}, MISS {misses}",
|
||||||
"overviewSamplesRemember": "Zapamiętaj wybór",
|
|
||||||
"overviewSamplesSaved": "Ustawienie zapisane",
|
|
||||||
"overviewSamplesTemporary": "Tylko do odświeżenia strony",
|
|
||||||
"overviewSamplesStorageUnavailable": "Nie można zapisać w przeglądarce",
|
"overviewSamplesStorageUnavailable": "Nie można zapisać w przeglądarce",
|
||||||
"overviewSamplesTitle": "Próbki wykresów na stronie głównej",
|
"overviewSamplesTitle": "Próbki wykresów na stronie głównej",
|
||||||
|
"appHealthTitle": "Status aplikacji",
|
||||||
|
"appHealthSelfCheck": "SELF-CHECK",
|
||||||
|
"appHealthChecking": "Sprawdzanie statusu aplikacji",
|
||||||
|
"appHealthOk": "Wszystkie systemy działają",
|
||||||
|
"appHealthWarning": "Wymaga uwagi",
|
||||||
|
"appHealthError": "Wykryto problem",
|
||||||
|
"appHealthSidebarChecking": "SPRAWDZANIE",
|
||||||
|
"appHealthSidebarOk": "SYSTEM OK",
|
||||||
|
"appHealthSidebarWarning": "APLIKACJA: UWAGA",
|
||||||
|
"appHealthSidebarError": "APLIKACJA: BŁĄD",
|
||||||
|
"appHealthUpdated": "Aktualizacja: {time}",
|
||||||
|
"appHealthNoStatus": "Brak aktualnego statusu",
|
||||||
|
"appHealthApi": "API",
|
||||||
|
"appHealthApiOk": "Status live aplikacji odpowiada.",
|
||||||
|
"appHealthApiError": "Brak aktualnego statusu aplikacji.",
|
||||||
|
"appHealthListeners": "Listenery",
|
||||||
|
"appHealthListenersOk": "Aktywne listenery są zgodne z żądaną konfiguracją.",
|
||||||
|
"appHealthListenersRestart": "Zmiany listenerów czekają na zastosowanie; uruchom RustProxy ponownie.",
|
||||||
|
"appHealthBackends": "Backendy",
|
||||||
|
"appHealthBackendsNone": "Brak skonfigurowanych pul backendów.",
|
||||||
|
"appHealthBackendsOk": "Wszystkie aktywne pule backendów są gotowe.",
|
||||||
|
"appHealthBackendsWarning": "Niedostępne: {down}, sprawdzane: {checking}: {members}.",
|
||||||
|
"appHealthBackendsError": "Niedostępne pule: {pools}.",
|
||||||
|
"appHealthBackendsAffected": "Dotyczy backendów: {members}.",
|
||||||
|
"appHealthLogging": "Logowanie",
|
||||||
|
"appHealthLoggingOk": "Kolejki logowania bez odrzuceń i błędów zapisu/wysyłki.",
|
||||||
|
"appHealthLoggingWarning": "Odrzucone wpisy kolejki: {dropped}, błędy zapisu/wysyłki od startu: {errors}.",
|
||||||
"webMetricStatus": "Statusy HTTP WWW",
|
"webMetricStatus": "Statusy HTTP WWW",
|
||||||
"webMetricStatusHelp": "Klasy odpowiedzi serwera WWW od ostatniego resetu.",
|
"webMetricStatusHelp": "Klasy odpowiedzi serwera WWW od ostatniego resetu.",
|
||||||
"webMetricKind": "Static / PHP",
|
"webMetricKind": "Static / PHP",
|
||||||
@@ -2964,11 +3038,13 @@
|
|||||||
"certificateInspectKept": "Odczytano certyfikat; zachowano wpisane nazwy SNI.",
|
"certificateInspectKept": "Odczytano certyfikat; zachowano wpisane nazwy SNI.",
|
||||||
"certificateInspectNoNames": "Certyfikat nie zawiera nazw DNS w SAN.",
|
"certificateInspectNoNames": "Certyfikat nie zawiera nazw DNS w SAN.",
|
||||||
"certificateInspectFailed": "Nie można odczytać certyfikatu. Sprawdź plik i jego uprawnienia, a następnie ponów odczyt.",
|
"certificateInspectFailed": "Nie można odczytać certyfikatu. Sprawdź plik i jego uprawnienia, a następnie ponów odczyt.",
|
||||||
"pathPermissionsOptions": "Opcje i pomoc"
|
"pathPermissionsOptions": "Opcje i pomoc",
|
||||||
},
|
|
||||||
"overviewLayoutNarrow": "Zmniejsz szerokość karty",
|
"overviewLayoutNarrow": "Zmniejsz szerokość karty",
|
||||||
"overviewLayoutWiden": "Zwiększ szerokość karty",
|
"overviewLayoutWiden": "Zwiększ szerokość karty",
|
||||||
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn",
|
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn",
|
||||||
"overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany.",
|
"dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie.",
|
||||||
"dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie."
|
"overviewLayoutInlineDragging": "Przeciąganie: upuść blok w miejscu ramki. Esc przywraca poprzednią pozycję.",
|
||||||
|
"overviewLayoutInlineCompact": "Uzupełnij luki",
|
||||||
|
"overviewLayoutInlineCompactHint": "Dosuń bloki w puste miejsca siatki (jednorazowo, przed zapisaniem możesz to anulować)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -13,7 +13,7 @@ from scripts.version import project_version
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
MANAGED = [
|
MANAGED = [
|
||||||
'src', 'tests', 'scripts', 'tools', 'integrations', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'grafana',
|
'src', 'tests', 'scripts', 'tools', 'integrations', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'grafana', 'third_party',
|
||||||
'.github', '.cargo', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'rust-toolchain.toml', 'UPDATE_CHECK.toml',
|
'.github', '.cargo', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'rust-toolchain.toml', 'UPDATE_CHECK.toml',
|
||||||
'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'README.md', 'LICENSE',
|
'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'README.md', 'LICENSE',
|
||||||
'CHANGELOG.md', 'AGENTS.md', '.env.example', '.gitignore', '.dockerignore',
|
'CHANGELOG.md', 'AGENTS.md', '.env.example', '.gitignore', '.dockerignore',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Named, administrator-scoped Overview presets. Keep this separate from the
|
-- Named, administrator-scoped Overview presets. Keep this separate from the
|
||||||
-- existing 4096-byte appearance preferences document and proxy configuration.
|
-- appearance preferences document and proxy configuration.
|
||||||
CREATE TABLE IF NOT EXISTS dashboard_presets (
|
CREATE TABLE IF NOT EXISTS dashboard_presets (
|
||||||
administrator_id INTEGER NOT NULL CHECK (administrator_id = 1),
|
administrator_id INTEGER NOT NULL CHECK (administrator_id = 1),
|
||||||
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64),
|
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64),
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Expand administrator UI preference storage. Current dashboard layouts can exceed
|
||||||
|
-- the original 4096-byte limit once per-widget widths are persisted.
|
||||||
|
CREATE TABLE admin_preferences_v2 (
|
||||||
|
administrator_id INTEGER PRIMARY KEY CHECK (administrator_id = 1),
|
||||||
|
document TEXT NOT NULL CHECK (length(document) BETWEEN 2 AND 65536),
|
||||||
|
FOREIGN KEY (administrator_id) REFERENCES administrator(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO admin_preferences_v2(administrator_id, document)
|
||||||
|
SELECT administrator_id, document FROM admin_preferences;
|
||||||
|
|
||||||
|
DROP TABLE admin_preferences;
|
||||||
|
ALTER TABLE admin_preferences_v2 RENAME TO admin_preferences;
|
||||||
Executable → Regular
+108
-1
@@ -14,6 +14,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
@@ -25,6 +26,7 @@ import zlib
|
|||||||
VERSION = "5.33.1"
|
VERSION = "5.33.1"
|
||||||
DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip"
|
DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip"
|
||||||
PREFIX = f"swagger-ui-{VERSION}/"
|
PREFIX = f"swagger-ui-{VERSION}/"
|
||||||
|
VENDORED = Path(__file__).resolve().parents[2] / "third_party" / f"swagger-ui-{VERSION}"
|
||||||
MAX_ARCHIVE = 64 * 1024 * 1024
|
MAX_ARCHIVE = 64 * 1024 * 1024
|
||||||
ASSETS = {
|
ASSETS = {
|
||||||
"swagger-ui-bundle.js": ("dist/swagger-ui-bundle.js", 16 * 1024 * 1024),
|
"swagger-ui-bundle.js": ("dist/swagger-ui-bundle.js", 16 * 1024 * 1024),
|
||||||
@@ -82,11 +84,29 @@ class HttpsRedirects(HTTPRedirectHandler):
|
|||||||
|
|
||||||
|
|
||||||
def download(destination: Path) -> None:
|
def download(destination: Path) -> None:
|
||||||
|
"""Download with a few retries; github.com occasionally times out or returns a gateway error."""
|
||||||
|
last: Exception | None = None
|
||||||
|
for attempt in range(4):
|
||||||
|
try:
|
||||||
|
_download_once(destination)
|
||||||
|
return
|
||||||
|
except AssetError:
|
||||||
|
raise
|
||||||
|
except (OSError, ValueError) as error: # URLError/HTTPError/timeouts are OSError subclasses
|
||||||
|
last = error
|
||||||
|
if attempt < 3:
|
||||||
|
delay = (3, 6, 12)[attempt]
|
||||||
|
print(f"Swagger download failed ({error}); retrying in {delay}s ({attempt + 1}/3).", file=sys.stderr)
|
||||||
|
time.sleep(delay)
|
||||||
|
raise AssetError(f"Swagger download failed after 4 attempts: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def _download_once(destination: Path) -> None:
|
||||||
"""TLS verification uses Python's default trust store; never executes a shell."""
|
"""TLS verification uses Python's default trust store; never executes a shell."""
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
opener = build_opener(HttpsRedirects())
|
opener = build_opener(HttpsRedirects())
|
||||||
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build"})
|
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build"})
|
||||||
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
|
with opener.open(request, timeout=60) as response, destination.open("wb") as output:
|
||||||
length = response.headers.get("Content-Length")
|
length = response.headers.get("Content-Length")
|
||||||
if length and int(length) > MAX_ARCHIVE:
|
if length and int(length) > MAX_ARCHIVE:
|
||||||
raise AssetError("Swagger archive exceeds the 64 MiB limit.")
|
raise AssetError("Swagger archive exceeds the 64 MiB limit.")
|
||||||
@@ -162,6 +182,75 @@ def atomic_write(path: Path, data: bytes) -> None:
|
|||||||
temporary.unlink(missing_ok=True)
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def cached_archives(out_dir: Path, cache: Path) -> list[Path]:
|
||||||
|
"""Previously downloaded copies of the pinned archive.
|
||||||
|
|
||||||
|
Cargo gives every package version its own OUT_DIR, so a version bump would download the same
|
||||||
|
pinned archive again. Reuse an archive from the shared cache or from another build directory,
|
||||||
|
after the usual validation below.
|
||||||
|
"""
|
||||||
|
candidates: list[Path] = []
|
||||||
|
override = os.environ.get("SWAGGER_UI_CACHE_DIR", "").strip()
|
||||||
|
if override:
|
||||||
|
candidates.append(Path(override) / cache.name)
|
||||||
|
parents = out_dir.parents
|
||||||
|
if len(parents) > 3 and parents[1].name == "build":
|
||||||
|
candidates.append(parents[3] / "swagger-ui-cache" / cache.name)
|
||||||
|
try:
|
||||||
|
siblings = sorted(parents[1].glob("*/out/" + cache.name), key=lambda item: item.stat().st_mtime, reverse=True)
|
||||||
|
except OSError:
|
||||||
|
siblings = []
|
||||||
|
candidates.extend(siblings)
|
||||||
|
return [item for item in dict.fromkeys(candidates) if item != cache and item.is_file()]
|
||||||
|
|
||||||
|
|
||||||
|
def remember_archive(out_dir: Path, cache: Path) -> None:
|
||||||
|
"""Best-effort copy of a validated archive into the shared cache directory."""
|
||||||
|
parents = out_dir.parents
|
||||||
|
if len(parents) > 3 and parents[1].name == "build":
|
||||||
|
shared = parents[3] / "swagger-ui-cache"
|
||||||
|
try:
|
||||||
|
shared.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not (shared / cache.name).exists():
|
||||||
|
shutil.copyfile(cache, shared / cache.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def read_vendored() -> tuple[dict[str, bytes], str, Path] | None:
|
||||||
|
"""Assets vendored in third_party/ (official swagger-ui-dist files); verified against SHA256SUMS.
|
||||||
|
|
||||||
|
Returns None when the directory is absent so the pinned download still works for older trees.
|
||||||
|
A present but inconsistent directory is an error, never a silent fallback.
|
||||||
|
"""
|
||||||
|
manifest = VENDORED / "SHA256SUMS"
|
||||||
|
if not manifest.is_file():
|
||||||
|
return None
|
||||||
|
expected: dict[str, str] = {}
|
||||||
|
for line in manifest.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
digest, _, name = line.partition(" ")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", digest) or not name or "/" in name or name.startswith("."):
|
||||||
|
raise AssetError("Vendored Swagger SHA256SUMS is malformed.")
|
||||||
|
expected[name] = digest
|
||||||
|
files: dict[str, bytes] = {}
|
||||||
|
for name in ("swagger-ui-bundle.js", "swagger-ui.css", "LICENSE", "NOTICE", "swagger-ui-bundle.js.LICENSE.txt"):
|
||||||
|
path = VENDORED / name
|
||||||
|
if name not in expected or not path.is_file() or path.is_symlink():
|
||||||
|
raise AssetError(f"Vendored Swagger UI is incomplete: {name} is missing or not listed in SHA256SUMS.")
|
||||||
|
data = path.read_bytes()
|
||||||
|
if not 0 < len(data) <= 16 * 1024 * 1024 or hashlib.sha256(data).hexdigest() != expected[name]:
|
||||||
|
raise AssetError(f"Vendored Swagger UI file failed its SHA-256 check: {name}.")
|
||||||
|
data.decode("utf-8")
|
||||||
|
files[name] = data
|
||||||
|
package = {name: files[name] for name in ASSETS}
|
||||||
|
licenses = [label.encode() + b"\n" + b"-" * 40 + b"\n" + files[name] for label, name in (("LICENSE", "LICENSE"), ("NOTICE", "NOTICE"), ("dist/swagger-ui-bundle.js.LICENSE.txt", "swagger-ui-bundle.js.LICENSE.txt"))]
|
||||||
|
assets = {"swagger-ui-bundle.js": package["swagger-ui-bundle.js"], "swagger-ui.css": package["swagger-ui.css"],
|
||||||
|
"licenses.txt": b"Swagger UI " + VERSION.encode() + b"\n\n" + b"\n\n".join(licenses)}
|
||||||
|
return assets, hashlib.sha256(manifest.read_bytes()).hexdigest(), manifest
|
||||||
|
|
||||||
|
|
||||||
def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
||||||
*, offline: bool = False) -> list[Path]:
|
*, offline: bool = False) -> list[Path]:
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -169,10 +258,27 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
|||||||
if isinstance(source, Path):
|
if isinstance(source, Path):
|
||||||
archive = source
|
archive = source
|
||||||
assets, digest = read_assets(archive, checksum)
|
assets, digest = read_assets(archive, checksum)
|
||||||
|
elif source == DOWNLOAD_URL and checksum is None and (vendored := read_vendored()) is not None:
|
||||||
|
assets, digest, archive = vendored
|
||||||
|
print(f"Using the vendored Swagger UI {VERSION} from {VENDORED}.", file=sys.stderr)
|
||||||
elif source == DOWNLOAD_URL:
|
elif source == DOWNLOAD_URL:
|
||||||
archive = cache
|
archive = cache
|
||||||
|
reused = None
|
||||||
if cache.exists():
|
if cache.exists():
|
||||||
assets, digest = read_assets(cache, checksum)
|
assets, digest = read_assets(cache, checksum)
|
||||||
|
else:
|
||||||
|
for candidate in cached_archives(out_dir, cache):
|
||||||
|
try:
|
||||||
|
reused = (read_assets(candidate, checksum), candidate)
|
||||||
|
break
|
||||||
|
except (OSError, ValueError, KeyError, RuntimeError, zipfile.BadZipFile, zlib.error):
|
||||||
|
continue
|
||||||
|
if reused is not None:
|
||||||
|
(assets, digest), candidate = reused
|
||||||
|
shutil.copyfile(candidate, cache)
|
||||||
|
print(f"Reusing the cached Swagger UI archive from {candidate}.", file=sys.stderr)
|
||||||
|
elif cache.exists():
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
if offline:
|
if offline:
|
||||||
raise AssetError("Swagger assets are not cached and CARGO_NET_OFFLINE is set. " + HELP)
|
raise AssetError("Swagger assets are not cached and CARGO_NET_OFFLINE is set. " + HELP)
|
||||||
@@ -185,6 +291,7 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
|||||||
os.replace(temporary, cache) # Cache only an already-validated archive.
|
os.replace(temporary, cache) # Cache only an already-validated archive.
|
||||||
finally:
|
finally:
|
||||||
temporary.unlink(missing_ok=True)
|
temporary.unlink(missing_ok=True)
|
||||||
|
remember_archive(out_dir, cache)
|
||||||
else:
|
else:
|
||||||
raise AssetError("Unrecognized Swagger source.")
|
raise AssetError("Unrecognized Swagger source.")
|
||||||
destination = out_dir / "swagger-ui"
|
destination = out_dir / "swagger-ui"
|
||||||
|
|||||||
Executable → Regular
+27
-1
@@ -28,6 +28,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -41,6 +42,12 @@ GEOIP_SOURCES = {
|
|||||||
'user-country-ipv6.csv': 'https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv',
|
'user-country-ipv6.csv': 'https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv',
|
||||||
}
|
}
|
||||||
GEOIP_MAX_BYTES = 256 * 1024 * 1024
|
GEOIP_MAX_BYTES = 256 * 1024 * 1024
|
||||||
|
GEOIP_FETCH_ATTEMPTS = 4
|
||||||
|
GEOIP_RETRY_DELAYS = (3, 6, 12)
|
||||||
|
|
||||||
|
|
||||||
|
class GeoipUnavailable(RuntimeError):
|
||||||
|
"""The GeoIP CSV download failed after retries; the deployment itself can continue."""
|
||||||
|
|
||||||
|
|
||||||
def run(args: list[str], **kwargs) -> subprocess.CompletedProcess:
|
def run(args: list[str], **kwargs) -> subprocess.CompletedProcess:
|
||||||
@@ -93,7 +100,7 @@ def digest(path: Path) -> str:
|
|||||||
return result.hexdigest()
|
return result.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def fetch_https(url: str, max_bytes: int) -> bytes:
|
def _fetch_https_once(url: str, max_bytes: int) -> bytes:
|
||||||
if urllib.parse.urlparse(url).scheme != 'https':
|
if urllib.parse.urlparse(url).scheme != 'https':
|
||||||
raise ValueError('GeoIP downloads require HTTPS.')
|
raise ValueError('GeoIP downloads require HTTPS.')
|
||||||
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer'})
|
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer'})
|
||||||
@@ -114,6 +121,25 @@ def fetch_https(url: str, max_bytes: int) -> bytes:
|
|||||||
return bytes(result)
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_https(url: str, max_bytes: int) -> bytes:
|
||||||
|
"""Download with retries; transient gateway/network failures (502/503/504, timeouts) are retried."""
|
||||||
|
last: BaseException | None = None
|
||||||
|
for attempt in range(GEOIP_FETCH_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
return _fetch_https_once(url, max_bytes)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
last = error
|
||||||
|
if error.code not in (408, 425, 429) and error.code < 500:
|
||||||
|
break
|
||||||
|
except (urllib.error.URLError, TimeoutError, ConnectionError, ssl.SSLError) as error:
|
||||||
|
last = error
|
||||||
|
if attempt + 1 < GEOIP_FETCH_ATTEMPTS:
|
||||||
|
delay = GEOIP_RETRY_DELAYS[min(attempt, len(GEOIP_RETRY_DELAYS) - 1)]
|
||||||
|
print(f'GeoIP download failed ({last}); retrying in {delay}s ({attempt + 1}/{GEOIP_FETCH_ATTEMPTS - 1}).', file=sys.stderr)
|
||||||
|
time.sleep(delay)
|
||||||
|
raise GeoipUnavailable(f'GeoIP download from {url} failed: {last}')
|
||||||
|
|
||||||
|
|
||||||
def acquire_geoip_update_lock(data: Path, uid: int | None = None, gid: int | None = None) -> int:
|
def acquire_geoip_update_lock(data: Path, uid: int | None = None, gid: int | None = None) -> int:
|
||||||
"""Serialize installer/manual GeoIP refresh with the in-process worker."""
|
"""Serialize installer/manual GeoIP refresh with the in-process worker."""
|
||||||
path = data / '.geoip-update.lock'
|
path = data / '.geoip-update.lock'
|
||||||
|
|||||||
Executable → Regular
+8
@@ -365,7 +365,12 @@ def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: boo
|
|||||||
if refresh_geoip:
|
if refresh_geoip:
|
||||||
# Shared lock with the runtime worker prevents concurrent upgrades.
|
# Shared lock with the runtime worker prevents concurrent upgrades.
|
||||||
geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid)
|
geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid)
|
||||||
|
try:
|
||||||
geoip_stage, geoip_names = prepare_geoip_update(data, prefix)
|
geoip_stage, geoip_names = prepare_geoip_update(data, prefix)
|
||||||
|
except GeoipUnavailable as error:
|
||||||
|
# GeoIP is refreshed best-effort: the update must not be blocked by a flaky download.
|
||||||
|
print(f'WARNING: GeoIP refresh skipped, keeping the installed country data: {error}', file=sys.stderr)
|
||||||
|
geoip_stage, geoip_names = None, []
|
||||||
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
|
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
|
||||||
stop_for_managed_update(data)
|
stop_for_managed_update(data)
|
||||||
try:
|
try:
|
||||||
@@ -434,7 +439,10 @@ def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bo
|
|||||||
prefix.mkdir(parents=True, mode=0o755, exist_ok=True); os.chmod(prefix, 0o755)
|
prefix.mkdir(parents=True, mode=0o755, exist_ok=True); os.chmod(prefix, 0o755)
|
||||||
data.mkdir(parents=True, mode=0o700, exist_ok=True); os.chmod(data, 0o700); os.chown(data, account.pw_uid, account.pw_gid)
|
data.mkdir(parents=True, mode=0o700, exist_ok=True); os.chmod(data, 0o700); os.chown(data, account.pw_uid, account.pw_gid)
|
||||||
if install_geoip:
|
if install_geoip:
|
||||||
|
try:
|
||||||
download_geoip(data, account.pw_uid, account.pw_gid)
|
download_geoip(data, account.pw_uid, account.pw_gid)
|
||||||
|
except GeoipUnavailable as error:
|
||||||
|
print(f'WARNING: GeoIP download skipped ({error}). Country allow/block lists will use data fetched later by the GeoIP updater, or rerun update.sh.', file=sys.stderr)
|
||||||
release, version = stage(binary, prefix, record['user'], sha)
|
release, version = stage(binary, prefix, record['user'], sha)
|
||||||
record.update(release=release, version=version, sha256=sha)
|
record.update(release=release, version=version, sha256=sha)
|
||||||
switch(prefix, release)
|
switch(prefix, release)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
|||||||
flat = module.parent.with_suffix(".rs")
|
flat = module.parent.with_suffix(".rs")
|
||||||
if flat.is_file():
|
if flat.is_file():
|
||||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
||||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "LICENSE", "docs/FEATURES.md", "docs/PERFORMANCE.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/INSTALL.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
|
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/icons.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "LICENSE", "docs/FEATURES.md", "docs/PERFORMANCE.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/INSTALL.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
|
||||||
if not (root / path).is_file():
|
if not (root / path).is_file():
|
||||||
raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}")
|
raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}")
|
||||||
for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"):
|
for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"):
|
||||||
|
|||||||
+61
-22
@@ -171,6 +171,7 @@ async fn metrics_summary(State(state): State<Arc<App>>) -> Response {
|
|||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
struct EventsQuery {
|
struct EventsQuery {
|
||||||
interval: Option<u64>,
|
interval: Option<u64>,
|
||||||
|
logs: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn events(
|
async fn events(
|
||||||
@@ -179,6 +180,7 @@ async fn events(
|
|||||||
Query(query): Query<EventsQuery>,
|
Query(query): Query<EventsQuery>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let interval = query.interval.unwrap_or(3);
|
let interval = query.interval.unwrap_or(3);
|
||||||
|
let include_logs = query.logs.unwrap_or(true);
|
||||||
if ![1, 2, 3, 5, 10].contains(&interval) {
|
if ![1, 2, 3, 5, 10].contains(&interval) {
|
||||||
return Err(ApiError(
|
return Err(ApiError(
|
||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
@@ -198,8 +200,8 @@ async fn events(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let stream = futures_util::stream::unfold(
|
let stream = futures_util::stream::unfold(
|
||||||
(state, secret, permit, false, interval),
|
(state, secret, permit, false, interval, include_logs),
|
||||||
|(state, secret, permit, delayed, interval)| async move {
|
|(state, secret, permit, delayed, interval, include_logs)| async move {
|
||||||
if delayed {
|
if delayed {
|
||||||
tokio::time::sleep(Duration::from_secs(interval)).await;
|
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||||
}
|
}
|
||||||
@@ -218,13 +220,16 @@ async fn events(
|
|||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
let payload =
|
let payload = if include_logs {
|
||||||
json!({"status":live_status_payload(&state), "logs":logs_payload(&state)})
|
json!({"status":live_status_payload(&state), "logs":logs_payload(&state)})
|
||||||
|
} else {
|
||||||
|
json!({"status":live_status_payload(&state)})
|
||||||
|
}
|
||||||
.to_string();
|
.to_string();
|
||||||
let event = Event::default().event("snapshot").data(payload);
|
let event = Event::default().event("snapshot").data(payload);
|
||||||
Some((
|
Some((
|
||||||
Ok::<Event, Infallible>(event),
|
Ok::<Event, Infallible>(event),
|
||||||
(state, secret, permit, true, interval),
|
(state, secret, permit, true, interval, include_logs),
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -237,11 +242,22 @@ async fn events(
|
|||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
struct TrafficQuery {
|
struct TrafficQuery {
|
||||||
vhost: Option<String>,
|
vhost: Option<String>,
|
||||||
|
view: Option<String>,
|
||||||
}
|
}
|
||||||
async fn traffic_status(
|
async fn traffic_status(
|
||||||
State(state): State<Arc<App>>,
|
State(state): State<Arc<App>>,
|
||||||
Query(query): Query<TrafficQuery>,
|
Query(query): Query<TrafficQuery>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let live = match query.view.as_deref() {
|
||||||
|
None => false,
|
||||||
|
Some("live") => true,
|
||||||
|
_ => {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid traffic view".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
let snapshot = state.config.load_full();
|
let snapshot = state.config.load_full();
|
||||||
if query.vhost.as_ref().is_some_and(|id| {
|
if query.vhost.as_ref().is_some_and(|id| {
|
||||||
id != "*"
|
id != "*"
|
||||||
@@ -258,14 +274,8 @@ async fn traffic_status(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let scope = query.vhost.as_deref().unwrap_or("*");
|
let scope = query.vhost.as_deref().unwrap_or("*");
|
||||||
let vhosts = snapshot
|
let mut payload = json!({
|
||||||
.envelope
|
"revision": snapshot.envelope.revision,
|
||||||
.config
|
|
||||||
.vhosts
|
|
||||||
.iter()
|
|
||||||
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Ok(Json(json!({
|
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
"since": "process_start",
|
"since": "process_start",
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -273,9 +283,19 @@ async fn traffic_status(
|
|||||||
"capture_country": snapshot.envelope.config.logging.capture_country
|
"capture_country": snapshot.envelope.config.logging.capture_country
|
||||||
},
|
},
|
||||||
"geoip": state.geoip.load().view(),
|
"geoip": state.geoip.load().view(),
|
||||||
"analytics": state.traffic.view(Some(scope)),
|
"analytics": state.traffic.view(Some(scope))
|
||||||
"vhosts": vhosts
|
});
|
||||||
})))
|
if live {
|
||||||
|
return Ok(Json(payload));
|
||||||
|
}
|
||||||
|
payload["vhosts"] = json!(snapshot
|
||||||
|
.envelope
|
||||||
|
.config
|
||||||
|
.vhosts
|
||||||
|
.iter()
|
||||||
|
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
|
||||||
|
.collect::<Vec<_>>());
|
||||||
|
Ok(Json(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Deserialize)]
|
#[derive(Default, Deserialize)]
|
||||||
@@ -284,11 +304,22 @@ struct CacheQuery {
|
|||||||
vhost: Option<String>,
|
vhost: Option<String>,
|
||||||
offset: Option<usize>,
|
offset: Option<usize>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
|
view: Option<String>,
|
||||||
}
|
}
|
||||||
async fn cache_status(
|
async fn cache_status(
|
||||||
State(state): State<Arc<App>>,
|
State(state): State<Arc<App>>,
|
||||||
Query(query): Query<CacheQuery>,
|
Query(query): Query<CacheQuery>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let live = match query.view.as_deref() {
|
||||||
|
None => false,
|
||||||
|
Some("live") => true,
|
||||||
|
_ => {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid cache view".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
let snapshot = state.config.load_full();
|
let snapshot = state.config.load_full();
|
||||||
if query
|
if query
|
||||||
.vhost
|
.vhost
|
||||||
@@ -300,16 +331,24 @@ async fn cache_status(
|
|||||||
"unknown vhost cache scope".into(),
|
"unknown vhost cache scope".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let scopes: Vec<_> = snapshot.envelope.config.vhosts.iter().map(|vhost| {
|
|
||||||
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
|
|
||||||
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
|
|
||||||
}).collect();
|
|
||||||
let dashboard = state.cache.dashboard(
|
let dashboard = state.cache.dashboard(
|
||||||
query.vhost.as_deref(),
|
query.vhost.as_deref(),
|
||||||
query.offset.unwrap_or(0).min(16384),
|
query.offset.unwrap_or(0).min(16384),
|
||||||
query.limit.unwrap_or(50),
|
query.limit.unwrap_or(50),
|
||||||
);
|
);
|
||||||
Ok(Json(
|
let mut payload = json!({
|
||||||
json!({"settings":snapshot.envelope.config.cache, "metrics":state.cache.view(), "vhosts":scopes, "dashboard":dashboard, "bans":state.cache.bans()}),
|
"revision": snapshot.envelope.revision,
|
||||||
))
|
"metrics": state.cache.view(),
|
||||||
|
"dashboard": dashboard,
|
||||||
|
"bans": state.cache.bans()
|
||||||
|
});
|
||||||
|
if live {
|
||||||
|
return Ok(Json(payload));
|
||||||
|
}
|
||||||
|
payload["settings"] = json!(snapshot.envelope.config.cache);
|
||||||
|
payload["vhosts"] = json!(snapshot.envelope.config.vhosts.iter().map(|vhost| {
|
||||||
|
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
|
||||||
|
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
|
||||||
|
}).collect::<Vec<_>>());
|
||||||
|
Ok(Json(payload))
|
||||||
}
|
}
|
||||||
+263
-41
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "RustProxy Administration API",
|
"title": "RustProxy Administration API",
|
||||||
"version": "0.35.2",
|
"version": "0.36.8",
|
||||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
@@ -443,10 +443,31 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.",
|
"description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["provider", "method"],
|
"required": [
|
||||||
|
"provider",
|
||||||
|
"method"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"provider": {"type": "string", "enum": ["basic", "tinyauth", "authelia", "authentik", "pocket_id", "custom"]},
|
"provider": {
|
||||||
"method": {"type": "string", "enum": ["basic", "forward_auth", "oidc"], "description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."}
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"basic",
|
||||||
|
"tinyauth",
|
||||||
|
"authelia",
|
||||||
|
"authentik",
|
||||||
|
"pocket_id",
|
||||||
|
"custom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"basic",
|
||||||
|
"forward_auth",
|
||||||
|
"oidc"
|
||||||
|
],
|
||||||
|
"description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"OidcConfig": {
|
"OidcConfig": {
|
||||||
@@ -454,20 +475,76 @@
|
|||||||
"description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.",
|
"description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {"type": "boolean", "default": false},
|
"enabled": {
|
||||||
"issuer_url": {"type": "string", "format": "uri", "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"},
|
"type": "boolean",
|
||||||
"backend_url": {"type": "string", "format": "uri", "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"},
|
"default": false
|
||||||
"redirect_url": {"type": "string", "format": "uri", "description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"},
|
},
|
||||||
"client_id": {"type": "string", "maxLength": 256},
|
"issuer_url": {
|
||||||
"client_secret": {"type": "string", "maxLength": 2048, "description": "Confidential OIDC client secret; stored in Vhost configuration"},
|
"type": "string",
|
||||||
"session_secret": {"type": "string", "minLength": 32, "maxLength": 4096, "description": "High-entropy secret for encrypted session and temporary state cookies"},
|
"format": "uri",
|
||||||
"scopes": {"type": "string", "default": "openid profile email"},
|
"description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"
|
||||||
"allow_private": {"type": "boolean", "default": false},
|
},
|
||||||
"address_allowlist": {"type": "array", "items": {"type": "string"}},
|
"backend_url": {
|
||||||
"tls_ca_pem": {"type": "string", "nullable": true},
|
"type": "string",
|
||||||
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
|
"format": "uri",
|
||||||
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
|
"description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"
|
||||||
"session_ttl_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "default": 3600}
|
},
|
||||||
|
"redirect_url": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"
|
||||||
|
},
|
||||||
|
"client_id": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 256
|
||||||
|
},
|
||||||
|
"client_secret": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"description": "Confidential OIDC client secret; stored in Vhost configuration"
|
||||||
|
},
|
||||||
|
"session_secret": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 32,
|
||||||
|
"maxLength": 4096,
|
||||||
|
"description": "High-entropy secret for encrypted session and temporary state cookies"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "openid profile email"
|
||||||
|
},
|
||||||
|
"allow_private": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"address_allowlist": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tls_ca_pem": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"connect_timeout_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 30,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"timeout_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 60,
|
||||||
|
"default": 10
|
||||||
|
},
|
||||||
|
"session_ttl_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 60,
|
||||||
|
"maximum": 86400,
|
||||||
|
"default": 3600
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ForwardAuthConfig": {
|
"ForwardAuthConfig": {
|
||||||
@@ -475,17 +552,70 @@
|
|||||||
"description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.",
|
"description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {"type": "boolean", "default": false},
|
"enabled": {
|
||||||
"url": {"type": "string", "maxLength": 2048, "description": "Internal service/outpost origin or the provider's verifier endpoint"},
|
"type": "boolean",
|
||||||
"login_url": {"type": "string", "maxLength": 2048, "description": "Required public HTTPS portal URL for Authelia; ignored for authentik"},
|
"default": false
|
||||||
"allow_private": {"type": "boolean", "default": false},
|
},
|
||||||
"address_allowlist": {"type": "array", "maxItems": 32, "items": {"type": "string"}},
|
"url": {
|
||||||
"tls_ca_pem": {"type": "string", "nullable": true},
|
"type": "string",
|
||||||
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
|
"maxLength": 2048,
|
||||||
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
|
"description": "Internal service/outpost origin or the provider's verifier endpoint"
|
||||||
"request_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"},
|
},
|
||||||
"response_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"},
|
"login_url": {
|
||||||
"redirect_forbidden": {"type": "boolean", "default": false, "description": "Authelia only: accept trusted portal Location on explicit 403 if provided"}
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"description": "Required public HTTPS portal URL for Authelia; ignored for authentik"
|
||||||
|
},
|
||||||
|
"allow_private": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"address_allowlist": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 32,
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tls_ca_pem": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"connect_timeout_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 30,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"timeout_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 60,
|
||||||
|
"default": 10
|
||||||
|
},
|
||||||
|
"request_headers": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 32,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"
|
||||||
|
},
|
||||||
|
"response_headers": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 32,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"
|
||||||
|
},
|
||||||
|
"redirect_forbidden": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Authelia only: accept trusted portal Location on explicit 403 if provided"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Vhost": {
|
"Vhost": {
|
||||||
@@ -591,8 +721,13 @@
|
|||||||
"$ref": "#/components/schemas/ForwardAuthConfig",
|
"$ref": "#/components/schemas/ForwardAuthConfig",
|
||||||
"description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia."
|
"description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia."
|
||||||
},
|
},
|
||||||
"oidc": {"$ref": "#/components/schemas/OidcConfig"},
|
"oidc": {
|
||||||
"authentication": {"$ref": "#/components/schemas/AuthenticationChoice", "description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."},
|
"$ref": "#/components/schemas/OidcConfig"
|
||||||
|
},
|
||||||
|
"authentication": {
|
||||||
|
"$ref": "#/components/schemas/AuthenticationChoice",
|
||||||
|
"description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."
|
||||||
|
},
|
||||||
"cache": {
|
"cache": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1618,6 +1753,11 @@
|
|||||||
"Cache": {
|
"Cache": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"revision": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Configuration revision used for this snapshot."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {},
|
"properties": {},
|
||||||
@@ -3672,7 +3812,15 @@
|
|||||||
"neon",
|
"neon",
|
||||||
"monochrome",
|
"monochrome",
|
||||||
"warm",
|
"warm",
|
||||||
"cool"
|
"cool",
|
||||||
|
"sunset",
|
||||||
|
"aurora",
|
||||||
|
"ocean",
|
||||||
|
"forest",
|
||||||
|
"pastel",
|
||||||
|
"terminal",
|
||||||
|
"blueprint",
|
||||||
|
"colorblind"
|
||||||
],
|
],
|
||||||
"default": "default",
|
"default": "default",
|
||||||
"description": "Account-scoped visual presentation preset applied consistently to administration charts."
|
"description": "Account-scoped visual presentation preset applied consistently to administration charts."
|
||||||
@@ -3682,6 +3830,16 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"description": "Account-scoped optional X/Y scales on sampled time-series charts. Shows the same Y range used to draw each SVG and X sample indices rather than inferred elapsed time; does not change adaptive scaling."
|
"description": "Account-scoped optional X/Y scales on sampled time-series charts. Shows the same Y range used to draw each SVG and X sample indices rather than inferred elapsed time; does not change adaptive scaling."
|
||||||
},
|
},
|
||||||
|
"interface_icons": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Account-scoped administration UI preference. When enabled, semantic inline SVG icons are added to navigation, actions, tabs and dialogs while preserving text labels and accessibility."
|
||||||
|
},
|
||||||
|
"logo_animation": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Account-scoped administration UI preference. When enabled, the sidebar logo plays short, subtle animations at random intervals and on hover. Paused while the tab is hidden and suppressed when the browser requests reduced motion."
|
||||||
|
},
|
||||||
"button_style": {
|
"button_style": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -3795,8 +3953,10 @@
|
|||||||
"connection_chart",
|
"connection_chart",
|
||||||
"cache_diagnostics",
|
"cache_diagnostics",
|
||||||
"process_diagnostics",
|
"process_diagnostics",
|
||||||
|
"process_simple",
|
||||||
"network_diagnostics",
|
"network_diagnostics",
|
||||||
"backend_table",
|
"backend_table",
|
||||||
|
"backend_tiny",
|
||||||
"extra_cold_queue_memory",
|
"extra_cold_queue_memory",
|
||||||
"extra_cold_storage",
|
"extra_cold_storage",
|
||||||
"wide_transform_activity",
|
"wide_transform_activity",
|
||||||
@@ -3880,8 +4040,10 @@
|
|||||||
"connection_chart",
|
"connection_chart",
|
||||||
"cache_diagnostics",
|
"cache_diagnostics",
|
||||||
"process_diagnostics",
|
"process_diagnostics",
|
||||||
|
"process_simple",
|
||||||
"network_diagnostics",
|
"network_diagnostics",
|
||||||
"backend_table"
|
"backend_table",
|
||||||
|
"backend_tiny"
|
||||||
],
|
],
|
||||||
"description": "Account-scoped order for configurable Overview telemetry cards."
|
"description": "Account-scoped order for configurable Overview telemetry cards."
|
||||||
},
|
},
|
||||||
@@ -3972,8 +4134,10 @@
|
|||||||
"connection_chart",
|
"connection_chart",
|
||||||
"cache_diagnostics",
|
"cache_diagnostics",
|
||||||
"process_diagnostics",
|
"process_diagnostics",
|
||||||
|
"process_simple",
|
||||||
"network_diagnostics",
|
"network_diagnostics",
|
||||||
"backend_table",
|
"backend_table",
|
||||||
|
"backend_tiny",
|
||||||
"extra_cold_queue_memory",
|
"extra_cold_queue_memory",
|
||||||
"extra_cold_storage",
|
"extra_cold_storage",
|
||||||
"wide_transform_activity",
|
"wide_transform_activity",
|
||||||
@@ -4790,6 +4954,11 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"revision": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Configuration revision used for this snapshot."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"$ref": "#/components/schemas/TransformSettings"
|
"$ref": "#/components/schemas/TransformSettings"
|
||||||
},
|
},
|
||||||
@@ -6194,6 +6363,11 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"revision": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Configuration revision used for this snapshot."
|
||||||
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -9872,14 +10046,14 @@
|
|||||||
},
|
},
|
||||||
"/api/cache": {
|
"/api/cache": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Cache metrics, domains and paginated objects",
|
"summary": "Cache metrics, domains and paginated objects; optional lightweight live view",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Cache"
|
"Cache"
|
||||||
],
|
],
|
||||||
"operationId": "get_api_cache",
|
"operationId": "get_api_cache",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful response",
|
"description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, live metrics, dashboard objects/domains and BAN rules.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -9960,6 +10134,18 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"default": 50
|
"default": 50
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "view",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"live"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": [
|
"security": [
|
||||||
@@ -9974,14 +10160,14 @@
|
|||||||
},
|
},
|
||||||
"/api/transform": {
|
"/api/transform": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Transformation runtime metrics and per-vhost effectiveness",
|
"summary": "Transformation runtime metrics and per-vhost effectiveness; optional lightweight live view",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Transform"
|
"Transform"
|
||||||
],
|
],
|
||||||
"operationId": "get_api_transform",
|
"operationId": "get_api_transform",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful response",
|
"description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, resources and live metrics.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -10031,6 +10217,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "view",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"live"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"AdminSession": []
|
"AdminSession": []
|
||||||
@@ -10373,7 +10573,7 @@
|
|||||||
},
|
},
|
||||||
"/api/events": {
|
"/api/events": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Live SSE status and log snapshots",
|
"summary": "Live SSE status snapshots with optional logs",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Monitoring"
|
"Monitoring"
|
||||||
],
|
],
|
||||||
@@ -10430,7 +10630,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "SSE snapshot events contain compact volatile status and bounded logs. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.",
|
"description": "SSE snapshot events always contain compact volatile status. Bounded recent logs are included by default for backward compatibility and can be disabled per viewer with logs=false; the administration panel disables them until Live Logs is open/docked and polls /api/logs only while that view is active. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "interval",
|
"name": "interval",
|
||||||
@@ -10448,6 +10648,16 @@
|
|||||||
],
|
],
|
||||||
"default": 3
|
"default": 3
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "logs",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"description": "Include the bounded live log snapshot in each SSE event. Defaults to true for API compatibility; the panel uses false and fetches logs only while Live Logs is active.",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -12884,7 +13094,7 @@
|
|||||||
},
|
},
|
||||||
"/api/traffic": {
|
"/api/traffic": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Bounded per-vhost traffic analytics",
|
"summary": "Bounded per-vhost traffic analytics; optional lightweight live view",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Traffic"
|
"Traffic"
|
||||||
],
|
],
|
||||||
@@ -12899,11 +13109,23 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "*"
|
"default": "*"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "view",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"description": "Set to live for polling. Omits the static vhost list; refetch the full view when revision changes.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"live"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful response",
|
"description": "Full response by default. view=live omits the static vhost list while retaining revision, current settings, GeoIP state and analytics.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|||||||
+34
-5
@@ -1,6 +1,34 @@
|
|||||||
async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
|
#[derive(Default, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
struct TransformQuery {
|
||||||
|
view: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transform_status(
|
||||||
|
State(state): State<Arc<App>>,
|
||||||
|
Query(query): Query<TransformQuery>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let live = match query.view.as_deref() {
|
||||||
|
None => false,
|
||||||
|
Some("live") => true,
|
||||||
|
_ => {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid transform view".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
let snapshot = state.config.load_full();
|
let snapshot = state.config.load_full();
|
||||||
let global = &snapshot.envelope.config.transform;
|
let global = &snapshot.envelope.config.transform;
|
||||||
|
let resources = crate::transform::resource_usage(global);
|
||||||
|
let metrics = state.transform_metrics.view();
|
||||||
|
if live {
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"revision": snapshot.envelope.revision,
|
||||||
|
"resources": resources,
|
||||||
|
"metrics": metrics
|
||||||
|
})));
|
||||||
|
}
|
||||||
let vhosts: Vec<_> = snapshot
|
let vhosts: Vec<_> = snapshot
|
||||||
.envelope
|
.envelope
|
||||||
.config
|
.config
|
||||||
@@ -14,12 +42,13 @@ async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Json(json!({
|
Ok(Json(json!({
|
||||||
|
"revision": snapshot.envelope.revision,
|
||||||
"settings": global,
|
"settings": global,
|
||||||
"resources": crate::transform::resource_usage(global),
|
"resources": resources,
|
||||||
"metrics": state.transform_metrics.view(),
|
"metrics": metrics,
|
||||||
"vhosts": vhosts
|
"vhosts": vhosts
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Deserialize)]
|
#[derive(Default, Deserialize)]
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ struct AdminPreferences {
|
|||||||
chart_style: String,
|
chart_style: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
chart_axes: bool,
|
chart_axes: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
interface_icons: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
logo_animation: bool,
|
||||||
#[serde(default = "default_admin_button_style")]
|
#[serde(default = "default_admin_button_style")]
|
||||||
button_style: String,
|
button_style: String,
|
||||||
#[serde(default = "default_admin_corner_style")]
|
#[serde(default = "default_admin_corner_style")]
|
||||||
@@ -180,6 +184,8 @@ impl Default for AdminPreferences {
|
|||||||
time_format: default_admin_time_format(),
|
time_format: default_admin_time_format(),
|
||||||
chart_style: default_admin_chart_style(),
|
chart_style: default_admin_chart_style(),
|
||||||
chart_axes: false,
|
chart_axes: false,
|
||||||
|
interface_icons: false,
|
||||||
|
logo_animation: false,
|
||||||
button_style: default_admin_button_style(),
|
button_style: default_admin_button_style(),
|
||||||
corner_style: default_admin_corner_style(),
|
corner_style: default_admin_corner_style(),
|
||||||
table_density: default_admin_table_density(),
|
table_density: default_admin_table_density(),
|
||||||
@@ -297,6 +303,14 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
|
|||||||
| "monochrome"
|
| "monochrome"
|
||||||
| "warm"
|
| "warm"
|
||||||
| "cool"
|
| "cool"
|
||||||
|
| "sunset"
|
||||||
|
| "aurora"
|
||||||
|
| "ocean"
|
||||||
|
| "forest"
|
||||||
|
| "pastel"
|
||||||
|
| "terminal"
|
||||||
|
| "blueprint"
|
||||||
|
| "colorblind"
|
||||||
) {
|
) {
|
||||||
return Err(ApiError(
|
return Err(ApiError(
|
||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
|
|||||||
"wide_compression_activity",
|
"wide_compression_activity",
|
||||||
"cache",
|
"cache",
|
||||||
"backend_table",
|
"backend_table",
|
||||||
|
"process_diagnostics",
|
||||||
] {
|
] {
|
||||||
assert!(order.contains(&id.to_owned()), "not registered: {id}");
|
assert!(order.contains(&id.to_owned()), "not registered: {id}");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -28,6 +29,8 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
|
|||||||
for id in [
|
for id in [
|
||||||
"bandwidth",
|
"bandwidth",
|
||||||
"backend_health",
|
"backend_health",
|
||||||
|
"backend_tiny",
|
||||||
|
"process_simple",
|
||||||
"extra_process_cpu",
|
"extra_process_cpu",
|
||||||
"stream_traffic",
|
"stream_traffic",
|
||||||
"wide_cache_activity",
|
"wide_cache_activity",
|
||||||
@@ -41,6 +44,34 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
|
|||||||
assert!(hidden.iter().all(|id| order.contains(id)));
|
assert!(hidden.iter().all(|id| order.contains(id)));
|
||||||
assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok());
|
assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok());
|
||||||
assert!(!AdminPreferences::default().chart_axes);
|
assert!(!AdminPreferences::default().chart_axes);
|
||||||
|
assert!(!AdminPreferences::default().interface_icons);
|
||||||
|
assert!(!AdminPreferences::default().logo_animation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn optional_logo_animation_preference_is_backward_compatible_and_off_by_default() {
|
||||||
|
let legacy: AdminPreferences = serde_json::from_str(r#"{"theme":"light"}"#).unwrap();
|
||||||
|
assert!(!legacy.logo_animation);
|
||||||
|
let mut enabled = legacy.clone();
|
||||||
|
enabled.logo_animation = true;
|
||||||
|
assert!(validate_admin_preferences(&enabled).is_ok());
|
||||||
|
let stored = serde_json::to_string(&enabled).unwrap();
|
||||||
|
assert!(stored.contains("\"logo_animation\":true"));
|
||||||
|
let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap();
|
||||||
|
assert!(recovered.logo_animation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn optional_interface_icons_preference_is_backward_compatible_and_off_by_default() {
|
||||||
|
let legacy: AdminPreferences = serde_json::from_str(r#"{"theme":"light"}"#).unwrap();
|
||||||
|
assert!(!legacy.interface_icons);
|
||||||
|
let mut enabled = legacy.clone();
|
||||||
|
enabled.interface_icons = true;
|
||||||
|
assert!(validate_admin_preferences(&enabled).is_ok());
|
||||||
|
let stored = serde_json::to_string(&enabled).unwrap();
|
||||||
|
assert!(stored.contains("\"interface_icons\":true"));
|
||||||
|
let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap();
|
||||||
|
assert!(recovered.interface_icons);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -278,3 +309,22 @@ fn dashboard_width_preferences_preserve_legacy_and_allow_custom_grids() {
|
|||||||
.contains_key("not_a_card"));
|
.contains_key("not_a_card"));
|
||||||
assert!(validate_admin_preferences(&prefs).is_ok());
|
assert!(validate_admin_preferences(&prefs).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn customized_dashboard_preferences_fit_persistent_storage_limit() {
|
||||||
|
let mut prefs = AdminPreferences {
|
||||||
|
dashboard_widget_sizes: Some(std::collections::BTreeMap::new()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for widget in DASHBOARD_WIDGETS.iter() {
|
||||||
|
prefs
|
||||||
|
.dashboard_widget_sizes
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert(widget.id.clone(), 12);
|
||||||
|
}
|
||||||
|
assert!(validate_admin_preferences(&prefs).is_ok());
|
||||||
|
let document = serde_json::to_string(&prefs).unwrap();
|
||||||
|
assert!(document.len() > 4096);
|
||||||
|
assert!(document.len() <= crate::db::ADMIN_PREFERENCES_MAX_BYTES);
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ fn admin_app_javascript() -> &'static str {
|
|||||||
");",
|
");",
|
||||||
include_str!("web/app/overview-samples.js"),
|
include_str!("web/app/overview-samples.js"),
|
||||||
include_str!("web/app/core.js"),
|
include_str!("web/app/core.js"),
|
||||||
|
include_str!("web/app/icons.js"),
|
||||||
|
include_str!("web/app/logo-animation.js"),
|
||||||
include_str!("web/app/path-permissions.js"),
|
include_str!("web/app/path-permissions.js"),
|
||||||
include_str!("web/app/session-resume.js"),
|
include_str!("web/app/session-resume.js"),
|
||||||
include_str!("web/app/refresh.js"),
|
include_str!("web/app/refresh.js"),
|
||||||
|
|||||||
@@ -2146,6 +2146,8 @@ mod tests {
|
|||||||
assert_eq!(initial["time_format"], "24h");
|
assert_eq!(initial["time_format"], "24h");
|
||||||
assert_eq!(initial["chart_style"], "default");
|
assert_eq!(initial["chart_style"], "default");
|
||||||
assert_eq!(initial["chart_axes"], false);
|
assert_eq!(initial["chart_axes"], false);
|
||||||
|
assert_eq!(initial["interface_icons"], false);
|
||||||
|
assert_eq!(initial["logo_animation"], false);
|
||||||
assert_eq!(initial["button_style"], "normal");
|
assert_eq!(initial["button_style"], "normal");
|
||||||
assert_eq!(initial["corner_style"], "default");
|
assert_eq!(initial["corner_style"], "default");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2183,6 +2185,8 @@ mod tests {
|
|||||||
assert_eq!(legacy["time_format"], "24h");
|
assert_eq!(legacy["time_format"], "24h");
|
||||||
assert_eq!(legacy["chart_style"], "default");
|
assert_eq!(legacy["chart_style"], "default");
|
||||||
assert_eq!(legacy["chart_axes"], false);
|
assert_eq!(legacy["chart_axes"], false);
|
||||||
|
assert_eq!(legacy["interface_icons"], false);
|
||||||
|
assert_eq!(legacy["logo_animation"], false);
|
||||||
assert_eq!(legacy["button_style"], "normal");
|
assert_eq!(legacy["button_style"], "normal");
|
||||||
assert_eq!(legacy["corner_style"], "default");
|
assert_eq!(legacy["corner_style"], "default");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2205,6 +2209,8 @@ mod tests {
|
|||||||
"time_format":"12h",
|
"time_format":"12h",
|
||||||
"chart_style":"neon",
|
"chart_style":"neon",
|
||||||
"chart_axes":true,
|
"chart_axes":true,
|
||||||
|
"interface_icons":true,
|
||||||
|
"logo_animation":true,
|
||||||
"button_style":"compact",
|
"button_style":"compact",
|
||||||
"corner_style":"rounded",
|
"corner_style":"rounded",
|
||||||
"table_density":"ultra",
|
"table_density":"ultra",
|
||||||
@@ -3515,6 +3521,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
||||||
let bad_sse = router
|
let bad_sse = router
|
||||||
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
request("/api/events?interval=4")
|
request("/api/events?interval=4")
|
||||||
.header("cookie", &cookie)
|
.header("cookie", &cookie)
|
||||||
@@ -3524,6 +3531,104 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let bad_sse_logs = router
|
||||||
|
.oneshot(
|
||||||
|
request("/api/events?logs=maybe")
|
||||||
|
.header("cookie", &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bad_sse_logs.status(), StatusCode::BAD_REQUEST);
|
||||||
|
store.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn polling_views_omit_static_configuration_and_reject_unknown_modes() {
|
||||||
|
let (router, store, _dir, secret, _) = fixture().await;
|
||||||
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||||
|
|
||||||
|
for (path, omitted, retained) in [
|
||||||
|
(
|
||||||
|
"/api/cache?view=live",
|
||||||
|
&["settings", "vhosts"][..],
|
||||||
|
&["revision", "metrics", "dashboard", "bans"][..],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/transform?view=live",
|
||||||
|
&["settings", "vhosts"][..],
|
||||||
|
&["revision", "resources", "metrics"][..],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/traffic?view=live",
|
||||||
|
&["vhosts"][..],
|
||||||
|
&["revision", "settings", "geoip", "analytics"][..],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let response = router
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
request(path)
|
||||||
|
.header("cookie", &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
|
||||||
|
let value = json_body(response).await;
|
||||||
|
for &field in omitted {
|
||||||
|
assert!(
|
||||||
|
value.get(field).is_none(),
|
||||||
|
"{path} unexpectedly contains {field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for &field in retained {
|
||||||
|
assert!(value.get(field).is_some(), "{path} is missing {field}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, required) in [
|
||||||
|
("/api/cache", &["settings", "vhosts"][..]),
|
||||||
|
("/api/transform", &["settings", "vhosts"][..]),
|
||||||
|
("/api/traffic", &["vhosts"][..]),
|
||||||
|
] {
|
||||||
|
let response = router
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
request(path)
|
||||||
|
.header("cookie", &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
|
||||||
|
let value = json_body(response).await;
|
||||||
|
assert!(value.get("revision").is_some());
|
||||||
|
for &field in required {
|
||||||
|
assert!(value.get(field).is_some(), "{path} is missing {field}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in [
|
||||||
|
"/api/cache?view=unknown",
|
||||||
|
"/api/transform?view=unknown",
|
||||||
|
"/api/traffic?view=unknown",
|
||||||
|
] {
|
||||||
|
let response = router
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
request(path)
|
||||||
|
.header("cookie", &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "GET {path}");
|
||||||
|
}
|
||||||
store.close().await;
|
store.close().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ window.rustProxyBackendDashboard = (() => {
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
if (!visible(id)) continue;
|
if (!visible(id)) continue;
|
||||||
set(`backend-dashboard-${id.slice("backend_".length)}`, "-");
|
set(`backend-dashboard-${id.slice("backend_".length)}`, "-");
|
||||||
set(`backend-dashboard-${id.slice("backend_".length)}-detail`, t("backendWidgetUnavailable"));
|
set(`backend-dashboard-${id.slice("backend_".length)}-detail`, id === "backend_latency" ? `${t("backendWidgetProbeCount")} -` : t("backendWidgetUnavailable"));
|
||||||
}
|
}
|
||||||
for (const id of ["backend-dashboard-in-rate", "backend-dashboard-out-rate"]) set(id, "-");
|
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"])
|
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"])
|
||||||
@@ -191,7 +191,7 @@ window.rustProxyBackendDashboard = (() => {
|
|||||||
}
|
}
|
||||||
if (visible("backend_latency")) {
|
if (visible("backend_latency")) {
|
||||||
set("backend-dashboard-latency", latency === null ? "-" : `${fmt(latency, 1)} ms`);
|
set("backend-dashboard-latency", latency === null ? "-" : `${fmt(latency, 1)} ms`);
|
||||||
set("backend-dashboard-latency-detail", `${t("backendWidgetProbeCount")}: ${probes.length}`);
|
set("backend-dashboard-latency-detail", `${t("backendWidgetProbeCount")} ${probes.length}`);
|
||||||
paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency));
|
paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency));
|
||||||
}
|
}
|
||||||
if (visible("backend_traffic")) {
|
if (visible("backend_traffic")) {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ window.rustProxyChartScale = (() => {
|
|||||||
{ top = 0.08, bottom = 0.92, clipped = false, seriesScales = null } = {}) {
|
{ top = 0.08, bottom = 0.92, clipped = false, seriesScales = null } = {}) {
|
||||||
const node = typeof target === "string" ? document.getElementById(target) : target;
|
const node = typeof target === "string" ? document.getElementById(target) : target;
|
||||||
const svg = node?.tagName?.toLowerCase() === "svg" ? node : node?.ownerSVGElement;
|
const svg = node?.tagName?.toLowerCase() === "svg" ? node : node?.ownerSVGElement;
|
||||||
if (!svg) return;
|
if (!svg || svg.classList.contains("ui-icon")) return;
|
||||||
svg.rustProxyAxisData = {
|
svg.rustProxyAxisData = {
|
||||||
peak: Number.isFinite(peakValue) && peakValue > 0 ? peakValue : 1,
|
peak: Number.isFinite(peakValue) && peakValue > 0 ? peakValue : 1,
|
||||||
unit, count: Number.isInteger(count) && count > 0 ? count : 0,
|
unit, count: Number.isInteger(count) && count > 0 ? count : 0,
|
||||||
@@ -127,7 +127,7 @@ window.rustProxyChartScale = (() => {
|
|||||||
}
|
}
|
||||||
function refreshAxes() {
|
function refreshAxes() {
|
||||||
const show = document.documentElement.dataset.chartAxes === "true";
|
const show = document.documentElement.dataset.chartAxes === "true";
|
||||||
for (const svg of document.querySelectorAll("svg")) {
|
for (const svg of document.querySelectorAll("svg:not(.ui-icon)")) {
|
||||||
if (show && svg.rustProxyAxisData) renderAxes(svg);
|
if (show && svg.rustProxyAxisData) renderAxes(svg);
|
||||||
else if (!show && svg.parentElement?.classList.contains("rp-chart-axis-host")) {
|
else if (!show && svg.parentElement?.classList.contains("rp-chart-axis-host")) {
|
||||||
const host = svg.parentElement;
|
const host = svg.parentElement;
|
||||||
|
|||||||
+200
-13
@@ -44,7 +44,7 @@ async function loadLanguages() {
|
|||||||
$("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
$("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
||||||
$("language").value = languagePreference === "auto" ? "auto" : language;
|
$("language").value = languagePreference === "auto" ? "auto" : language;
|
||||||
}
|
}
|
||||||
let latestStatus = null, certificateInventory = new Map();
|
let latestStatus = null, certificateInventory = new Map(), appHealthUpdatedAt = null;
|
||||||
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
||||||
function translate() {
|
function translate() {
|
||||||
clearTimeout(toastTimer); hideToast();
|
clearTimeout(toastTimer); hideToast();
|
||||||
@@ -59,16 +59,25 @@ function translate() {
|
|||||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||||
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
||||||
syncSettingsDirtyIndicators();
|
syncSettingsDirtyIndicators();
|
||||||
|
window.rustProxyIcons?.refresh?.();
|
||||||
}
|
}
|
||||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
function toastNode() {
|
||||||
|
let node = document.getElementById("toast");
|
||||||
|
if (!node) { // the node lived inside a dialog that was rebuilt; recreate it instead of failing every later toast
|
||||||
|
node = document.createElement("div"); node.id = "toast"; node.className = "toast"; node.hidden = true;
|
||||||
|
node.setAttribute("role", "status"); node.setAttribute("aria-live", "polite"); node.setAttribute("popover", "manual"); document.body.append(node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
function hideToast() { const node = toastNode(); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||||
function raiseToast() {
|
function raiseToast() {
|
||||||
const node = $("toast"); if (node.hidden) return;
|
const node = toastNode(); if (node.hidden) return;
|
||||||
const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body;
|
const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body;
|
||||||
if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover();
|
if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover();
|
||||||
if (node.parentElement !== parent) parent.append(node);
|
if (node.parentElement !== parent) parent.append(node);
|
||||||
if (typeof node.showPopover === "function") node.showPopover();
|
if (typeof node.showPopover === "function") node.showPopover();
|
||||||
}
|
}
|
||||||
function toast(message, error = false) { clearTimeout(toastTimer); const node = $("toast"); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); }
|
function toast(message, error = false) { clearTimeout(toastTimer); const node = toastNode(); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); }
|
||||||
function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); }
|
function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); }
|
||||||
function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; }
|
function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; }
|
||||||
function clearFieldError(target) {
|
function clearFieldError(target) {
|
||||||
@@ -133,7 +142,7 @@ function apiErrorMessage(path, response, value) {
|
|||||||
const area = apiArea(path);
|
const area = apiArea(path);
|
||||||
return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail;
|
return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail;
|
||||||
}
|
}
|
||||||
new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = $("toast"); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] });
|
new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = toastNode(); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] });
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const started = performance.now(), trackLatency = path.startsWith("/api/");
|
const started = performance.now(), trackLatency = path.startsWith("/api/");
|
||||||
try {
|
try {
|
||||||
@@ -179,6 +188,68 @@ function uptime(value) {
|
|||||||
}
|
}
|
||||||
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
|
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
|
||||||
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
|
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
|
||||||
|
function appHealthChecks(status) {
|
||||||
|
if (!connected || !status) return [{ level: "error", label: t("appHealthApi"), detail: t("appHealthApiError") }];
|
||||||
|
const checks = [{ level: "ok", label: t("appHealthApi"), detail: t("appHealthApiOk") }];
|
||||||
|
if (status.listeners?.restart_required) {
|
||||||
|
checks.push({ level: "warning", label: t("appHealthListeners"), detail: t("appHealthListenersRestart") });
|
||||||
|
} else {
|
||||||
|
checks.push({ level: "ok", label: t("appHealthListeners"), detail: t("appHealthListenersOk") });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pools = Array.isArray(status.backend_pools) ? status.backend_pools : [];
|
||||||
|
const members = pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member })));
|
||||||
|
const down = members.filter(member => member.status === "down");
|
||||||
|
const checking = members.filter(member => member.status === "checking");
|
||||||
|
const unavailablePools = pools.filter(pool => pool.ready === false && (pool.members || []).some(member => ["up", "down", "checking"].includes(member.status))).map(pool => pool.id).filter(Boolean);
|
||||||
|
const affected = [...down, ...checking].slice(0, 5).map(member => `${member.pool}/${member.id}`).join(", ");
|
||||||
|
if (unavailablePools.length) {
|
||||||
|
let detail = t("appHealthBackendsError").replace("{pools}", unavailablePools.slice(0, 5).join(", ") + (unavailablePools.length > 5 ? " …" : ""));
|
||||||
|
if (affected) detail += ` ${t("appHealthBackendsAffected").replace("{members}", affected + (down.length + checking.length > 5 ? " …" : ""))}`;
|
||||||
|
checks.push({ level: "error", label: t("appHealthBackends"), detail });
|
||||||
|
} else if (down.length || checking.length) {
|
||||||
|
checks.push({ level: "warning", label: t("appHealthBackends"), detail: t("appHealthBackendsWarning").replace("{down}", String(down.length)).replace("{checking}", String(checking.length)).replace("{members}", affected + (down.length + checking.length > 5 ? " …" : "")) });
|
||||||
|
} else {
|
||||||
|
checks.push({ level: "ok", label: t("appHealthBackends"), detail: pools.length ? t("appHealthBackendsOk") : t("appHealthBackendsNone") });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sink = status.logging?.sinks || {}, errorFile = status.logging?.error_file || {};
|
||||||
|
const loggingDropped = (Number(sink.queue_dropped) || 0) + (Number(errorFile.queue_dropped) || 0);
|
||||||
|
const loggingErrors = (Number(sink.errors) || 0) + (Number(errorFile.errors) || 0);
|
||||||
|
if (loggingDropped || loggingErrors) {
|
||||||
|
checks.push({ level: "warning", label: t("appHealthLogging"), detail: t("appHealthLoggingWarning").replace("{dropped}", String(loggingDropped)).replace("{errors}", String(loggingErrors)) });
|
||||||
|
} else {
|
||||||
|
checks.push({ level: "ok", label: t("appHealthLogging"), detail: t("appHealthLoggingOk") });
|
||||||
|
}
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
function appHealthLevel(checks) {
|
||||||
|
if (!checks.length) return "checking";
|
||||||
|
if (checks.some(check => check.level === "error")) return "error";
|
||||||
|
if (checks.some(check => check.level === "warning")) return "warning";
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
function setAppHealthClass(node, level) {
|
||||||
|
if (!node) return;
|
||||||
|
node.classList.remove("is-checking", "is-ok", "is-warning", "is-error");
|
||||||
|
node.classList.add(`is-${level}`);
|
||||||
|
}
|
||||||
|
function renderAppHealth(status = latestStatus) {
|
||||||
|
const checks = appHealthChecks(status), level = appHealthLevel(checks);
|
||||||
|
const labelKeys = { checking: "appHealthSidebarChecking", ok: "appHealthSidebarOk", warning: "appHealthSidebarWarning", error: "appHealthSidebarError" };
|
||||||
|
const summaryKeys = { checking: "appHealthChecking", ok: "appHealthOk", warning: "appHealthWarning", error: "appHealthError" };
|
||||||
|
const button = $("app-health-open"), label = $("app-health-label"), summaryRow = $("app-health-summary-row"), summary = $("app-health-summary");
|
||||||
|
setAppHealthClass(button, level); setAppHealthClass(summaryRow, level);
|
||||||
|
if (label) { label.dataset.i18n = labelKeys[level]; label.textContent = t(labelKeys[level]); }
|
||||||
|
if (summary) { summary.dataset.i18n = summaryKeys[level]; summary.textContent = t(summaryKeys[level]); }
|
||||||
|
const updated = $("app-health-updated");
|
||||||
|
if (updated) updated.textContent = appHealthUpdatedAt ? t("appHealthUpdated").replace("{time}", appHealthUpdatedAt.toLocaleTimeString(locale())) : t("appHealthNoStatus");
|
||||||
|
const list = $("app-health-checks");
|
||||||
|
if (list) list.replaceChildren(...checks.map(check => {
|
||||||
|
const row = el("div", undefined, `app-health-check is-${check.level}`), dot = el("i"), copy = el("div");
|
||||||
|
dot.setAttribute("aria-hidden", "true"); copy.append(el("strong", check.label), el("p", check.detail, "fine")); row.append(dot, copy); return row;
|
||||||
|
}));
|
||||||
|
}
|
||||||
function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
|
function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
|
||||||
const label = input?.closest("label"); if (!label) return () => { };
|
const label = input?.closest("label"); if (!label) return () => { };
|
||||||
label.classList.add("vh-enable-control", "vh-wide");
|
label.classList.add("vh-enable-control", "vh-wide");
|
||||||
@@ -257,7 +328,7 @@ function renderStatusNow(s, systemSample = false) {
|
|||||||
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
|
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
|
||||||
};
|
};
|
||||||
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
||||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
|
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); $("app-health-slot").hidden = false; appHealthUpdatedAt = new Date(); renderAppHealth(s); const m = s.metrics;
|
||||||
$("metric-ws").textContent = m.websocket_active || 0;
|
$("metric-ws").textContent = m.websocket_active || 0;
|
||||||
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
|
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
|
||||||
$("metric-ws-h2").textContent = m.websocket_h2_active || 0;
|
$("metric-ws-h2").textContent = m.websocket_h2_active || 0;
|
||||||
@@ -299,7 +370,7 @@ function renderStatusNow(s, systemSample = false) {
|
|||||||
function renderFooter(s) {
|
function renderFooter(s) {
|
||||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||||
const footer = $("footer-status");
|
const footer = $("footer-status");
|
||||||
footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`));
|
footer.replaceChildren(document.createTextNode(apiLatency));
|
||||||
const update = s.software_update;
|
const update = s.software_update;
|
||||||
if (update?.update_available && update.latest_version) {
|
if (update?.update_available && update.latest_version) {
|
||||||
footer.append(document.createTextNode(" / "));
|
footer.append(document.createTextNode(" / "));
|
||||||
@@ -319,7 +390,7 @@ let lastLogs = null;
|
|||||||
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
|
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
|
||||||
async function refresh(force = false) {
|
async function refresh(force = false) {
|
||||||
if (!authenticated) return;
|
if (!authenticated) return;
|
||||||
if (force) return Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback")]);
|
if (force) return rustProxyRefresh.manual("status-full");
|
||||||
return rustProxyRefresh.manual("status-fallback");
|
return rustProxyRefresh.manual("status-fallback");
|
||||||
}
|
}
|
||||||
function renderLiveStatus(value) { renderStatus(value); }
|
function renderLiveStatus(value) { renderStatus(value); }
|
||||||
@@ -333,15 +404,16 @@ function startLive() {
|
|||||||
if (liveEvents && liveInterval === seconds) return;
|
if (liveEvents && liveInterval === seconds) return;
|
||||||
stopLive();
|
stopLive();
|
||||||
liveInterval = seconds;
|
liveInterval = seconds;
|
||||||
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}`);
|
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}&logs=false`);
|
||||||
liveEvents = stream;
|
liveEvents = stream;
|
||||||
stream.addEventListener("snapshot", event => {
|
stream.addEventListener("snapshot", event => {
|
||||||
if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return;
|
if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data);
|
const payload = JSON.parse(event.data);
|
||||||
if (!payload.status || !payload.logs) throw new Error("Invalid SSE payload");
|
if (!payload.status) throw new Error("Invalid SSE payload");
|
||||||
liveConnected = true; liveFallback = false;
|
liveConnected = true; liveFallback = false;
|
||||||
renderLiveStatus(payload.status); renderLogs(payload.logs);
|
renderLiveStatus(payload.status);
|
||||||
|
if (payload.logs) renderLogs(payload.logs);
|
||||||
window.rustProxyRefresh?.sync();
|
window.rustProxyRefresh?.sync();
|
||||||
} catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); }
|
} catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); }
|
||||||
});
|
});
|
||||||
@@ -421,12 +493,12 @@ function lock(notify = true) {
|
|||||||
window.rustProxyInlineLayout?.cancel?.();
|
window.rustProxyInlineLayout?.cancel?.();
|
||||||
window.rustProxyRefresh?.stop();
|
window.rustProxyRefresh?.stop();
|
||||||
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
||||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; $("app-health-slot").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||||
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
||||||
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
||||||
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
||||||
document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); });
|
document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); });
|
||||||
renderSession(); syncAuthForm();
|
appHealthUpdatedAt = null; renderAppHealth(null); renderSession(); syncAuthForm();
|
||||||
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +578,8 @@ function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
|||||||
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||||
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
||||||
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
||||||
|
$("app-health-open")?.addEventListener("click", () => { renderAppHealth(connected ? latestStatus : null); const dialog = $("app-health-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||||
|
$("app-health-close")?.addEventListener("click", () => $("app-health-dialog").close());
|
||||||
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||||
$("about-close").addEventListener("click", () => $("about-dialog").close());
|
$("about-close").addEventListener("click", () => $("about-dialog").close());
|
||||||
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
|
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
|
||||||
@@ -602,3 +676,116 @@ $("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
|||||||
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
||||||
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
||||||
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
|
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
|
||||||
|
|
||||||
|
// Auto-generated identifiers: a new entry's ID follows its name (or first host) as a unique slug until the user edits the ID by hand.
|
||||||
|
const rustProxyAutoId = (() => {
|
||||||
|
const FOLD = { "ł": "l", "đ": "d", "ø": "o", "ß": "ss", "æ": "ae", "œ": "oe", "þ": "th", "ð": "d" };
|
||||||
|
const states = new WeakMap();
|
||||||
|
function slugify(value, max = 64) {
|
||||||
|
let slug = String(value ?? "").trim().toLowerCase().replace(/^\*\./, "").replace(/[łđøßæœþð]/g, ch => FOLD[ch]).normalize("NFKD").replace(/[̀-ͯ]/g, "");
|
||||||
|
slug = slug.replace(/[^a-z0-9_-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-_]+/, "").slice(0, max).replace(/[-_]+$/, "");
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
function unique(base, taken) {
|
||||||
|
if (!base) return "";
|
||||||
|
const used = new Set([...taken].map(value => String(value).toLowerCase()));
|
||||||
|
if (!used.has(base)) return base;
|
||||||
|
for (let n = 2; n < 10000; n++) { const suffix = `-${n}`, candidate = base.slice(0, 64 - suffix.length).replace(/[-_]+$/, "") + suffix; if (!used.has(candidate)) return candidate; }
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const locked = input => input.readOnly || input.disabled;
|
||||||
|
function suggestion(state) { return unique(slugify(state.text()), state.taken()); }
|
||||||
|
// The ID is shown as a plain value with a "Change" link; the input only appears after the user asks for it (or when validation needs it).
|
||||||
|
function paint(input, state) {
|
||||||
|
const isLocked = locked(input), wanted = suggestion(state), value = input.value.trim(), compact = !state.open || isLocked;
|
||||||
|
state.holder.classList.toggle("is-compact", compact);
|
||||||
|
state.value.textContent = value || t("autoIdPending");
|
||||||
|
state.value.classList.toggle("is-pending", !value);
|
||||||
|
state.badge.hidden = !state.auto || !value; state.badge.textContent = t("autoIdBadge");
|
||||||
|
state.change.hidden = isLocked; state.change.textContent = t("autoIdChange");
|
||||||
|
state.row.hidden = !compact;
|
||||||
|
state.hint.hidden = compact || !state.auto; state.hint.textContent = t("autoIdHint");
|
||||||
|
state.reset.hidden = compact || state.auto || state.resettable === false || !wanted || wanted === value; state.reset.textContent = t("autoIdReset");
|
||||||
|
}
|
||||||
|
function regenerate(input, state) { if (!state.auto || locked(input)) return; input.value = suggestion(state); input.setCustomValidity?.(""); paint(input, state); }
|
||||||
|
// options: { elements: inputs whose changes drive the ID, text: () => source string, taken: () => existing IDs }
|
||||||
|
function attach(input, options) {
|
||||||
|
let state = states.get(input);
|
||||||
|
if (!state) {
|
||||||
|
state = { auto: true, open: false, holder: input.closest("label") || input.parentElement, hint: document.createElement("small"), reset: document.createElement("button"), row: document.createElement("div"), value: document.createElement("code"), badge: document.createElement("span"), change: document.createElement("button") };
|
||||||
|
state.holder.classList.add("auto-id-field");
|
||||||
|
state.hint.className = "fine auto-id-hint"; state.reset.type = "button"; state.reset.className = "quiet auto-id-reset";
|
||||||
|
state.row.className = "auto-id-row"; state.value.className = "auto-id-value"; state.badge.className = "auto-id-badge"; state.change.type = "button"; state.change.className = "quiet auto-id-change";
|
||||||
|
state.row.append(state.value, state.badge, state.change); state.holder.append(state.row, state.hint, state.reset);
|
||||||
|
input.addEventListener("input", event => { if (!event.isTrusted) return; state.auto = input.value.trim() === ""; if (state.auto) regenerate(input, state); else paint(input, state); });
|
||||||
|
input.addEventListener("invalid", () => { if (!state.open) { state.open = true; paint(input, state); } });
|
||||||
|
input.addEventListener("blur", () => { if (input.value.trim() && input.validity.valid) { state.open = false; paint(input, state); } });
|
||||||
|
state.change.addEventListener("click", () => { state.open = true; paint(input, state); input.focus(); input.select?.(); });
|
||||||
|
state.reset.addEventListener("mousedown", event => event.preventDefault());
|
||||||
|
state.reset.addEventListener("click", () => { state.auto = true; regenerate(input, state); input.dispatchEvent(new Event("input", { bubbles: true })); });
|
||||||
|
states.set(input, state);
|
||||||
|
for (const source of options.elements || []) source.addEventListener("input", () => { const current = states.get(input); if (current) regenerate(input, current); });
|
||||||
|
}
|
||||||
|
state.text = options.text || (() => (options.elements || []).map(node => node.value).find(value => value.trim()) || "");
|
||||||
|
state.taken = options.taken || (() => []); state.resettable = options.resettable;
|
||||||
|
state.auto = !locked(input) && input.value.trim() === ""; state.open = false;
|
||||||
|
paint(input, state);
|
||||||
|
return { refresh() { const current = states.get(input); if (current) regenerate(input, current); } };
|
||||||
|
}
|
||||||
|
return { slugify, unique, attach };
|
||||||
|
})();
|
||||||
|
window.rustProxyAutoId = rustProxyAutoId;
|
||||||
|
|
||||||
|
// Automatic certificate choice: for a new entry, pick the stored certificate that covers every host name (exact names beat wildcards); the user can still choose another one.
|
||||||
|
const rustProxyAutoCertificate = (() => {
|
||||||
|
const states = new WeakMap();
|
||||||
|
const covers = (pattern, host) => {
|
||||||
|
pattern = String(pattern).toLowerCase(); host = String(host).toLowerCase();
|
||||||
|
if (pattern === host) return true;
|
||||||
|
if (host.startsWith("*.") || !pattern.startsWith("*.")) return false;
|
||||||
|
const suffix = pattern.slice(1);
|
||||||
|
return host.length > suffix.length && host.endsWith(suffix) && !host.slice(0, -suffix.length).includes(".");
|
||||||
|
};
|
||||||
|
function suggest(hosts, certificates) {
|
||||||
|
const names = [...new Set((hosts || []).map(host => String(host).trim().toLowerCase()).filter(Boolean))];
|
||||||
|
if (!names.length) return "";
|
||||||
|
let best = "", bestScore = -Infinity;
|
||||||
|
for (const cert of certificates || []) {
|
||||||
|
const coverage = certificateInventory.get(cert.id)?.covered_hosts?.length ? certificateInventory.get(cert.id).covered_hosts : (cert.covered_hosts || []);
|
||||||
|
let score = 0, all = true;
|
||||||
|
for (const host of names) {
|
||||||
|
if (coverage.some(pattern => String(pattern).toLowerCase() === host)) score += 2;
|
||||||
|
else if (coverage.some(pattern => covers(pattern, host))) score += 1;
|
||||||
|
else { all = false; break; }
|
||||||
|
}
|
||||||
|
if (!all) continue;
|
||||||
|
score -= coverage.length / 1000;
|
||||||
|
if (score > bestScore) { best = cert.id; bestScore = score; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
// options: { hosts: () => host names, certificates: () => certificate objects, auto: boolean, anchor: element after which the hint is placed }
|
||||||
|
function attach(select, options) {
|
||||||
|
let state = states.get(select);
|
||||||
|
if (!state) {
|
||||||
|
state = { hint: document.createElement("small") };
|
||||||
|
state.hint.className = "fine auto-cert-hint"; state.hint.hidden = true;
|
||||||
|
(options.anchor || select).after(state.hint);
|
||||||
|
select.addEventListener("change", event => { if (!event.isTrusted) return; const current = states.get(select); current.auto = false; current.hint.hidden = true; });
|
||||||
|
states.set(select, state);
|
||||||
|
}
|
||||||
|
state.options = options; state.auto = options.auto !== false && !select.value; state.hint.hidden = true;
|
||||||
|
const refresh = hosts => {
|
||||||
|
const current = states.get(select);
|
||||||
|
if (!current.auto) return;
|
||||||
|
const list = hosts || current.options.hosts();
|
||||||
|
const best = suggest(list, current.options.certificates());
|
||||||
|
if (select.value !== best) { select.value = best; select.dispatchEvent(new Event("change", { bubbles: true })); }
|
||||||
|
const certificates = current.options.certificates(), splitCoverage = !best && list.length > 1 && list.every(host => certificates.some(cert => suggest([host], [cert])));
|
||||||
|
current.hint.hidden = !best && !splitCoverage; current.hint.textContent = best ? t("autoCertHint").replace("{id}", best) : (splitCoverage ? t("autoCertSniHint") : "");
|
||||||
|
};
|
||||||
|
return { refresh };
|
||||||
|
}
|
||||||
|
return { attach, suggest, covers };
|
||||||
|
})();
|
||||||
|
window.rustProxyAutoCertificate = rustProxyAutoCertificate;
|
||||||
@@ -9,9 +9,9 @@ window.rustProxyInlineLayout = (() => {
|
|||||||
const hiddenList = $("overview-inline-hidden-list");
|
const hiddenList = $("overview-inline-hidden-list");
|
||||||
const hiddenCount = $("overview-inline-hidden-count");
|
const hiddenCount = $("overview-inline-hidden-count");
|
||||||
const modalButton = $("overview-layout-open");
|
const modalButton = $("overview-layout-open");
|
||||||
|
const compactButton = $("overview-inline-compact");
|
||||||
let draft = null;
|
let draft = null;
|
||||||
let original = null;
|
let original = null;
|
||||||
let dragging = null;
|
|
||||||
let opening = false;
|
let opening = false;
|
||||||
let saving = false;
|
let saving = false;
|
||||||
|
|
||||||
@@ -72,10 +72,7 @@ window.rustProxyInlineLayout = (() => {
|
|||||||
if (widen) widen.disabled = at === widths.length - 1;
|
if (widen) widen.disabled = at === widths.length - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function clearDrag() {
|
function clearDrag() { if (pointerDrag) settle(false); }
|
||||||
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) {
|
function move(id, target, after = false) {
|
||||||
if (!draft || id === target) return;
|
if (!draft || id === target) return;
|
||||||
if (!visibleIds().includes(id) || !visibleIds().includes(target)) return;
|
if (!visibleIds().includes(id) || !visibleIds().includes(target)) return;
|
||||||
@@ -118,7 +115,7 @@ window.rustProxyInlineLayout = (() => {
|
|||||||
const title = label(id);
|
const title = label(id);
|
||||||
const bar = el("div", undefined, "overview-inline-toolbar");
|
const bar = el("div", undefined, "overview-inline-toolbar");
|
||||||
const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { });
|
const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { });
|
||||||
grip.draggable = true;
|
grip.setAttribute("aria-describedby", "overview-inline-hint");
|
||||||
grip.dataset.overviewDragHandle = id;
|
grip.dataset.overviewDragHandle = id;
|
||||||
const sizeControls = el("span", undefined, "overview-inline-width");
|
const sizeControls = el("span", undefined, "overview-inline-width");
|
||||||
const widthValue = el("span", "", "overview-inline-width-value");
|
const widthValue = el("span", "", "overview-inline-width-value");
|
||||||
@@ -142,6 +139,7 @@ window.rustProxyInlineLayout = (() => {
|
|||||||
actions.hidden = false;
|
actions.hidden = false;
|
||||||
hint.hidden = false;
|
hint.hidden = false;
|
||||||
hint.textContent = t("overviewLayoutInlineHint");
|
hint.textContent = t("overviewLayoutInlineHint");
|
||||||
|
if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint");
|
||||||
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
|
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
|
||||||
if (modalButton) modalButton.disabled = true;
|
if (modalButton) modalButton.disabled = true;
|
||||||
for (const node of nodes()) toolbar(node);
|
for (const node of nodes()) toolbar(node);
|
||||||
@@ -195,40 +193,185 @@ window.rustProxyInlineLayout = (() => {
|
|||||||
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
|
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
|
||||||
}
|
}
|
||||||
function cancel() { if (draft && !saving) finish(false); }
|
function cancel() { if (draft && !saving) finish(false); }
|
||||||
function dropTarget(event) {
|
// Pointer-driven sorting with a live placeholder. The grid never back-fills
|
||||||
const widget = event.target?.closest?.("[data-dashboard-widget]");
|
// holes while a block is lifted, so the placeholder shows exactly where the
|
||||||
return widget && grid.contains(widget) && !widget.hidden && visibleIds().includes(widget.dataset.dashboardWidget) ? widget : null;
|
// block lands; neighbours glide (FLIP) instead of jumping.
|
||||||
|
const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90;
|
||||||
|
const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||||
|
let pointerDrag = null;
|
||||||
|
function flip(mutate) {
|
||||||
|
const before = new Map(nodes().filter(node => !node.hidden && node !== pointerDrag?.node)
|
||||||
|
.map(node => [node, node.getBoundingClientRect()]));
|
||||||
|
mutate();
|
||||||
|
if (reducedMotion()) return;
|
||||||
|
for (const [node, rect] of before) {
|
||||||
|
const after = node.getBoundingClientRect();
|
||||||
|
const dx = rect.left - after.left, dy = rect.top - after.top;
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
node.animate?.([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
|
||||||
|
{ duration: 160, easing: "cubic-bezier(.2, .7, .3, 1)" });
|
||||||
}
|
}
|
||||||
function positionAfter(widget, event) {
|
|
||||||
const rect = widget.getBoundingClientRect();
|
|
||||||
return event.clientX >= rect.left + rect.width / 2;
|
|
||||||
}
|
}
|
||||||
grid?.addEventListener("dragstart", event => {
|
function placeholderTarget(x, y) {
|
||||||
|
const drag = pointerDrag;
|
||||||
|
const hit = document.elementFromPoint(x, y);
|
||||||
|
const widget = hit?.closest?.("[data-dashboard-widget]");
|
||||||
|
if (widget && grid.contains(widget) && widget !== drag.node && !widget.hidden) return { node: widget };
|
||||||
|
if (hit === drag.placeholder || drag.placeholder.contains(hit)) return null;
|
||||||
|
if (hit !== grid) return null;
|
||||||
|
// Empty grid area (gap or hole at a row end): attach to the nearest block in that row.
|
||||||
|
const candidates = nodes().filter(node => node !== drag.node && !node.hidden);
|
||||||
|
let best = null, bestDistance = Infinity;
|
||||||
|
for (const node of candidates) {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
if (y < rect.top - 8 || y > rect.bottom + 8) continue;
|
||||||
|
const distance = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
|
||||||
|
if (distance < bestDistance) { bestDistance = distance; best = { node, after: x > rect.left + rect.width / 2 }; }
|
||||||
|
}
|
||||||
|
if (best) return best;
|
||||||
|
const last = candidates.at(-1);
|
||||||
|
if (last && y > last.getBoundingClientRect().bottom) return { node: last, after: true };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function updatePlaceholder(x, y) {
|
||||||
|
const drag = pointerDrag;
|
||||||
|
if (performance.now() < drag.cooldownUntil) return;
|
||||||
|
const target = placeholderTarget(x, y);
|
||||||
|
if (!target) return;
|
||||||
|
const children = [...grid.children];
|
||||||
|
const placeholderIndex = children.indexOf(drag.placeholder), targetIndex = children.indexOf(target.node);
|
||||||
|
// Entering a block swaps the placeholder past it; this is stable and never oscillates.
|
||||||
|
const after = target.after ?? placeholderIndex < targetIndex;
|
||||||
|
const reference = after ? target.node.nextSibling : target.node;
|
||||||
|
if (reference === drag.placeholder || (after ? target.node.nextSibling === drag.placeholder : target.node.previousSibling === drag.placeholder)) return;
|
||||||
|
flip(() => grid.insertBefore(drag.placeholder, reference));
|
||||||
|
drag.cooldownUntil = performance.now() + MOVE_COOLDOWN_MS;
|
||||||
|
}
|
||||||
|
function autoScroll() {
|
||||||
|
const drag = pointerDrag;
|
||||||
|
if (!drag?.active) return;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
let speed = 0;
|
||||||
|
if (drag.y < EDGE) speed = -Math.ceil((EDGE - drag.y) / 4);
|
||||||
|
else if (drag.y > height - EDGE) speed = Math.ceil((drag.y - (height - EDGE)) / 4);
|
||||||
|
if (speed) {
|
||||||
|
const scroller = document.scrollingElement || document.documentElement;
|
||||||
|
const previous = scroller.scrollTop;
|
||||||
|
scroller.scrollTop += speed;
|
||||||
|
if (scroller.scrollTop !== previous) updatePlaceholder(drag.x, drag.y);
|
||||||
|
}
|
||||||
|
drag.frame = requestAnimationFrame(autoScroll);
|
||||||
|
}
|
||||||
|
function liftBlock() {
|
||||||
|
const drag = pointerDrag;
|
||||||
|
enterFreeLayout();
|
||||||
|
paint();
|
||||||
|
const rect = drag.node.getBoundingClientRect();
|
||||||
|
const placeholder = el("div", undefined, "overview-drop-placeholder");
|
||||||
|
placeholder.dataset.dashboardSpan = drag.node.dataset.dashboardSpan || "2";
|
||||||
|
placeholder.style.minHeight = `${Math.round(rect.height)}px`;
|
||||||
|
placeholder.setAttribute("aria-hidden", "true");
|
||||||
|
drag.origin = drag.node.nextSibling;
|
||||||
|
drag.placeholder = placeholder;
|
||||||
|
drag.offsetX = drag.startX - rect.left;
|
||||||
|
drag.offsetY = drag.startY - rect.top;
|
||||||
|
drag.width = rect.width;
|
||||||
|
drag.height = rect.height;
|
||||||
|
drag.active = true;
|
||||||
|
drag.cooldownUntil = 0;
|
||||||
|
grid.insertBefore(placeholder, drag.node);
|
||||||
|
grid.classList.add("is-overview-sorting");
|
||||||
|
drag.node.classList.add("is-overview-lifted");
|
||||||
|
Object.assign(drag.node.style, {
|
||||||
|
position: "fixed", left: "0px", top: "0px", width: `${rect.width}px`, height: `${rect.height}px`,
|
||||||
|
zIndex: "60", pointerEvents: "none", margin: "0",
|
||||||
|
});
|
||||||
|
moveLifted();
|
||||||
|
hint.textContent = t("overviewLayoutInlineDragging");
|
||||||
|
drag.frame = requestAnimationFrame(autoScroll);
|
||||||
|
}
|
||||||
|
function moveLifted() {
|
||||||
|
const drag = pointerDrag;
|
||||||
|
drag.node.style.transform = `translate(${drag.x - drag.offsetX}px, ${drag.y - drag.offsetY}px) rotate(.6deg) scale(1.015)`;
|
||||||
|
}
|
||||||
|
function settle(commit) {
|
||||||
|
const drag = pointerDrag;
|
||||||
|
pointerDrag = null;
|
||||||
|
if (!drag) return;
|
||||||
|
window.removeEventListener("pointermove", onPointerMove);
|
||||||
|
window.removeEventListener("pointerup", onPointerUp);
|
||||||
|
window.removeEventListener("pointercancel", onPointerCancel);
|
||||||
|
window.removeEventListener("keydown", onDragKey, true);
|
||||||
|
if (!drag.active) return;
|
||||||
|
cancelAnimationFrame(drag.frame);
|
||||||
|
for (const key of ["position", "left", "top", "width", "height", "zIndex", "pointerEvents", "margin", "transform"]) drag.node.style[key] = "";
|
||||||
|
drag.node.classList.remove("is-overview-lifted");
|
||||||
|
grid.classList.remove("is-overview-sorting");
|
||||||
|
if (commit) drag.placeholder.replaceWith(drag.node);
|
||||||
|
else { drag.placeholder.remove(); grid.insertBefore(drag.node, drag.origin?.parentNode === grid ? drag.origin : null); }
|
||||||
|
hint.textContent = t("overviewLayoutInlineHint");
|
||||||
|
if (commit) {
|
||||||
|
const domOrder = nodes().map(node => node.dataset.dashboardWidget).filter(id => !draft.hidden.has(id));
|
||||||
|
const iter = domOrder.values();
|
||||||
|
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||||
|
}
|
||||||
|
paint();
|
||||||
|
drag.node.querySelector(".overview-inline-grip")?.focus({ preventScroll: true });
|
||||||
|
drag.node.animate?.([{ boxShadow: "0 0 0 3px var(--accent)" }, { boxShadow: "0 0 0 0 transparent" }], { duration: 450 });
|
||||||
|
}
|
||||||
|
function onPointerMove(event) {
|
||||||
|
const drag = pointerDrag;
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||||
|
drag.x = event.clientX; drag.y = event.clientY;
|
||||||
|
if (!drag.active) {
|
||||||
|
if (Math.hypot(drag.x - drag.startX, drag.y - drag.startY) < DRAG_THRESHOLD) return;
|
||||||
|
liftBlock();
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
moveLifted();
|
||||||
|
updatePlaceholder(drag.x, drag.y);
|
||||||
|
}
|
||||||
|
function onPointerUp(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(true); }
|
||||||
|
function onPointerCancel(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(false); }
|
||||||
|
function onDragKey(event) { if (event.key === "Escape" && pointerDrag) { event.preventDefault(); event.stopPropagation(); settle(false); } }
|
||||||
|
grid?.addEventListener("pointerdown", event => {
|
||||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||||
if (!draft || !handle || !grid.contains(handle)) { event.preventDefault(); return; }
|
if (!draft || saving || pointerDrag || !handle || !grid.contains(handle) || event.button !== 0) return;
|
||||||
dragging = handle.dataset.overviewDragHandle;
|
const node = nodeFor(handle.dataset.overviewDragHandle);
|
||||||
event.dataTransfer?.setData("text/plain", dragging);
|
if (!node) return;
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
|
||||||
nodeFor(dragging)?.classList.add("is-overview-dragging");
|
|
||||||
});
|
|
||||||
grid?.addEventListener("dragover", event => {
|
|
||||||
if (!draft || !dragging) return;
|
|
||||||
const target = dropTarget(event);
|
|
||||||
if (!target || target.dataset.dashboardWidget === dragging) return;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const node of nodes()) node.classList.remove("is-overview-drop-before", "is-overview-drop-after");
|
pointerDrag = { node, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, active: false };
|
||||||
target.classList.add(positionAfter(target, event) ? "is-overview-drop-after" : "is-overview-drop-before");
|
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
window.addEventListener("pointerup", onPointerUp);
|
||||||
|
window.addEventListener("pointercancel", onPointerCancel);
|
||||||
|
window.addEventListener("keydown", onDragKey, true);
|
||||||
});
|
});
|
||||||
grid?.addEventListener("drop", event => {
|
grid?.addEventListener("keydown", event => {
|
||||||
if (!draft || !dragging) return;
|
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||||
|
if (!draft || !handle) return;
|
||||||
|
const delta = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key];
|
||||||
|
if (!delta) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const target = dropTarget(event), from = dragging;
|
nudge(handle.dataset.overviewDragHandle, delta);
|
||||||
clearDrag();
|
nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus();
|
||||||
if (target && target.dataset.dashboardWidget !== from)
|
|
||||||
move(from, target.dataset.dashboardWidget, positionAfter(target, event));
|
|
||||||
});
|
});
|
||||||
grid?.addEventListener("dragend", clearDrag);
|
function compact() {
|
||||||
|
// Explicit, one-off gap filling: reads the dense packing the browser would
|
||||||
|
// produce and adopts it as the new order, so nothing moves unexpectedly later.
|
||||||
|
if (!draft) return;
|
||||||
|
enterFreeLayout();
|
||||||
|
paint();
|
||||||
|
flip(() => {
|
||||||
|
grid.style.gridAutoFlow = "row dense";
|
||||||
|
const placed = nodes().filter(node => !node.hidden).map(node => ({ id: node.dataset.dashboardWidget, rect: node.getBoundingClientRect() }));
|
||||||
|
grid.style.gridAutoFlow = "";
|
||||||
|
placed.sort((a, b) => Math.round(a.rect.top) - Math.round(b.rect.top) || a.rect.left - b.rect.left);
|
||||||
|
const iter = placed.map(item => item.id).values();
|
||||||
|
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||||
|
paint();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
compactButton?.addEventListener("click", compact);
|
||||||
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
|
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
|
||||||
saveButton?.addEventListener("click", () => { void save(); });
|
saveButton?.addEventListener("click", () => { void save(); });
|
||||||
cancelButton?.addEventListener("click", cancel);
|
cancelButton?.addEventListener("click", cancel);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ window.rustProxyDashboardLayout = (() => {
|
|||||||
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") {
|
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") {
|
||||||
const card = el("article", undefined, "overview-layout-panel-preview");
|
const card = el("article", undefined, "overview-layout-panel-preview");
|
||||||
card.append(el("strong", label(id)));
|
card.append(el("strong", label(id)));
|
||||||
const chart = id === "backend_table" ? null : original.querySelector?.("svg");
|
const chart = id === "backend_tiny" || id === "backend_table" ? null : original.querySelector?.("svg");
|
||||||
if (chart) {
|
if (chart) {
|
||||||
const graphic = chart.cloneNode(true);
|
const graphic = chart.cloneNode(true);
|
||||||
for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id");
|
for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id");
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
const UI_ICON_NS = "http://www.w3.org/2000/svg";
|
||||||
|
const UI_ICON_SHAPES = Object.freeze({
|
||||||
|
activity: [["path", { d: "M3 12h4l2-6 4 12 2-6h6" }]],
|
||||||
|
alert: [["path", { d: "M12 3 2.8 20h18.4L12 3Z" }], ["path", { d: "M12 9v4" }], ["circle", { cx: "12", cy: "17", r: ".7", fill: "currentColor", stroke: "none" }]],
|
||||||
|
archive: [["rect", { x: "3", y: "5", width: "18", height: "4", rx: "1" }], ["path", { d: "M5 9v10h14V9M9 13h6" }]],
|
||||||
|
ban: [["circle", { cx: "12", cy: "12", r: "9" }], ["path", { d: "m5.6 5.6 12.8 12.8" }]],
|
||||||
|
book: [["path", { d: "M4 4.5h5.5A2.5 2.5 0 0 1 12 7v13a3 3 0 0 0-3-3H4V4.5ZM20 4.5h-5.5A2.5 2.5 0 0 0 12 7v13a3 3 0 0 1 3-3h5V4.5Z" }]],
|
||||||
|
box: [["path", { d: "m12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z" }], ["path", { d: "m4 7.5 8 4.5 8-4.5M12 12v9" }]],
|
||||||
|
certificate: [["rect", { x: "4", y: "3", width: "16", height: "14", rx: "2" }], ["path", { d: "M8 7h8M8 10h5M9 17l-1 4 4-2 4 2-1-4" }]],
|
||||||
|
check: [["path", { d: "m5 12 4 4L19 6" }]],
|
||||||
|
chevronDown: [["path", { d: "m6 9 6 6 6-6" }]],
|
||||||
|
chevronLeft: [["path", { d: "m15 6-6 6 6 6" }]],
|
||||||
|
chevronRight: [["path", { d: "m9 6 6 6-6 6" }]],
|
||||||
|
chevronUp: [["path", { d: "m6 15 6-6 6 6" }]],
|
||||||
|
code: [["path", { d: "m9 7-5 5 5 5M15 7l5 5-5 5M13 5l-2 14" }]],
|
||||||
|
command: [["rect", { x: "4", y: "4", width: "16", height: "16", rx: "4" }], ["path", { d: "M8 12h8M12 8v8" }]],
|
||||||
|
collapse: [["path", { d: "M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7" }]],
|
||||||
|
compare: [["rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }], ["path", { d: "M12 4v16M6 9h3M6 13h3M15 11h3M15 15h3" }]],
|
||||||
|
copy: [["rect", { x: "8", y: "8", width: "11", height: "11", rx: "2" }], ["path", { d: "M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" }]],
|
||||||
|
cpu: [["rect", { x: "7", y: "7", width: "10", height: "10", rx: "2" }], ["path", { d: "M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4M10 10h4v4h-4z" }]],
|
||||||
|
database: [["ellipse", { cx: "12", cy: "5", rx: "7", ry: "3" }], ["path", { d: "M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" }]],
|
||||||
|
download: [["path", { d: "M12 3v12m0 0-4-4m4 4 4-4M4 19h16" }]],
|
||||||
|
edit: [["path", { d: "M4 20h4l11-11-4-4L4 16v4ZM13.5 6.5l4 4" }]],
|
||||||
|
eraser: [["path", { d: "m7 20-3.3-3.3a1 1 0 0 1 0-1.4l9.6-9.6a1 1 0 0 1 1.4 0l5.6 5.6a1 1 0 0 1 0 1.4L13 20H7ZM21 20H7M5.5 11.5l7 7" }]],
|
||||||
|
expand: [["path", { d: "M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" }]],
|
||||||
|
external: [["path", { d: "M14 4h6v6M20 4l-9 9" }], ["path", { d: "M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" }]],
|
||||||
|
eye: [["path", { d: "M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6S2.5 12 2.5 12Z" }], ["circle", { cx: "12", cy: "12", r: "2.5" }]],
|
||||||
|
eyeOff: [["path", { d: "M3 3l18 18M10.6 6.2A10 10 0 0 1 12 6c6 0 9.5 6 9.5 6a15 15 0 0 1-3 3.6M6.1 6.2C3.8 8 2.5 12 2.5 12s3.5 6 9.5 6a10 10 0 0 0 3.2-.5M9.9 9.9a3 3 0 0 0 4.2 4.2" }]],
|
||||||
|
file: [["path", { d: "M6 3h8l4 4v14H6V3Z" }], ["path", { d: "M14 3v5h5" }]],
|
||||||
|
fileX: [["path", { d: "M6 3h8l4 4v14H6V3ZM14 3v5h5M9 13l6 6M15 13l-6 6" }]],
|
||||||
|
filter: [["path", { d: "M3 5h18l-7 8v6l-4 2v-8L3 5Z" }]],
|
||||||
|
globe: [["circle", { cx: "12", cy: "12", r: "9" }], ["path", { d: "M3 12h18M12 3c2.5 2.7 3.5 5.7 3.5 9S14.5 18.3 12 21M12 3C9.5 5.7 8.5 8.7 8.5 12S9.5 18.3 12 21" }]],
|
||||||
|
grid: [["rect", { x: "4", y: "4", width: "6", height: "6", rx: "1" }], ["rect", { x: "14", y: "4", width: "6", height: "6", rx: "1" }], ["rect", { x: "4", y: "14", width: "6", height: "6", rx: "1" }], ["rect", { x: "14", y: "14", width: "6", height: "6", rx: "1" }]],
|
||||||
|
hash: [["path", { d: "M4 9h16M4 15h16M10 3 8 21M16 3l-2 18" }]],
|
||||||
|
history: [["path", { d: "M4 8V4m0 0h4M4 4l3 3a8 8 0 1 1-2.2 7" }], ["path", { d: "M12 8v5l3 2" }]],
|
||||||
|
info: [["circle", { cx: "12", cy: "12", r: "9" }], ["path", { d: "M12 11v5" }], ["circle", { cx: "12", cy: "7.8", r: ".8", fill: "currentColor", stroke: "none" }]],
|
||||||
|
key: [["circle", { cx: "8", cy: "12", r: "4" }], ["path", { d: "M12 12h9M18 12v3M15 12v2" }]],
|
||||||
|
layers: [["path", { d: "m12 3 9 5-9 5-9-5 9-5Z" }], ["path", { d: "m3 12.5 9 5 9-5M3 16.5l9 5 9-5" }]],
|
||||||
|
list: [["path", { d: "M9 6h11M9 12h11M9 18h11" }], ["circle", { cx: "4.5", cy: "6", r: ".8", fill: "currentColor", stroke: "none" }], ["circle", { cx: "4.5", cy: "12", r: ".8", fill: "currentColor", stroke: "none" }], ["circle", { cx: "4.5", cy: "18", r: ".8", fill: "currentColor", stroke: "none" }]],
|
||||||
|
loader: [["path", { d: "M20 12a8 8 0 1 1-2.3-5.7" }]],
|
||||||
|
lock: [["rect", { x: "5", y: "10", width: "14", height: "10", rx: "2" }], ["path", { d: "M8 10V7a4 4 0 0 1 8 0v3" }]],
|
||||||
|
login: [["path", { d: "M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 8l4 4-4 4M14 12H3" }]],
|
||||||
|
logout: [["path", { d: "M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3M16 8l4 4-4 4M20 12H9" }]],
|
||||||
|
mapPin: [["path", { d: "M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11Z" }], ["circle", { cx: "12", cy: "10", r: "2.5" }]],
|
||||||
|
menu: [["path", { d: "M4 7h16M4 12h16M4 17h16" }]],
|
||||||
|
network: [["rect", { x: "9", y: "3", width: "6", height: "5", rx: "1" }], ["rect", { x: "3", y: "16", width: "6", height: "5", rx: "1" }], ["rect", { x: "15", y: "16", width: "6", height: "5", rx: "1" }], ["path", { d: "M12 8v4M6 16v-4h12v4" }]],
|
||||||
|
palette: [["path", { d: "M12 3a9 9 0 1 0 0 18h1.5a2 2 0 0 0 0-4H12a2 2 0 0 1 0-4h4a5 5 0 0 0 0-10h-4Z" }], ["circle", { cx: "7", cy: "10", r: "1", fill: "currentColor", stroke: "none" }], ["circle", { cx: "9", cy: "6.5", r: "1", fill: "currentColor", stroke: "none" }], ["circle", { cx: "14", cy: "6", r: "1", fill: "currentColor", stroke: "none" }]],
|
||||||
|
pause: [["path", { d: "M8 5v14M16 5v14" }]],
|
||||||
|
pin: [["path", { d: "M8 3h8M10 3v5l-3 4h10l-3-4V3M12 12v9" }]],
|
||||||
|
play: [["path", { d: "m8 5 11 7-11 7V5Z" }]],
|
||||||
|
plug: [["path", { d: "M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-12 0V8ZM12 17v4" }]],
|
||||||
|
plus: [["path", { d: "M12 5v14M5 12h14" }]],
|
||||||
|
refresh: [["path", { d: "M20 7v5h-5M4 17v-5h5" }], ["path", { d: "M18.5 9A7 7 0 0 0 6.2 6.2L4 8M5.5 15A7 7 0 0 0 17.8 17.8L20 16" }]],
|
||||||
|
route: [["circle", { cx: "6", cy: "6", r: "2" }], ["circle", { cx: "18", cy: "18", r: "2" }], ["path", { d: "M8 6h4a4 4 0 0 1 4 4v0a4 4 0 0 1-4 4H8a2 2 0 0 0-2 2v0" }]],
|
||||||
|
save: [["path", { d: "M5 4h12l2 2v14H5V4Z" }], ["path", { d: "M8 4v6h8V4M8 20v-6h8v6" }]],
|
||||||
|
search: [["circle", { cx: "10.5", cy: "10.5", r: "6.5" }], ["path", { d: "m15.5 15.5 5 5" }]],
|
||||||
|
settings: [["path", { d: "M18.9 9.9L21.3 10.3L21.3 13.7L18.9 14.1L18.4 15.4L19.8 17.4L17.4 19.8L15.4 18.4L14.1 18.9L13.7 21.3L10.3 21.3L9.9 18.9L8.6 18.4L6.6 19.8L4.2 17.4L5.6 15.4L5.1 14.1L2.7 13.7L2.7 10.3L5.1 9.9L5.6 8.6L4.2 6.6L6.6 4.2L8.6 5.6L9.9 5.1L10.3 2.7L13.7 2.7L14.1 5.1L15.4 5.6L17.4 4.2L19.8 6.6L18.4 8.6Z" }], ["circle", { cx: "12", cy: "12", r: "3" }]],
|
||||||
|
shield: [["path", { d: "M12 3 20 6v6c0 5-3.3 8-8 9-4.7-1-8-4-8-9V6l8-3Z" }], ["path", { d: "m8.5 12 2.2 2.2 4.8-5" }]],
|
||||||
|
sliders: [["path", { d: "M4 7h6M14 7h6M4 17h10M18 17h2M10 4v6M14 14v6" }]],
|
||||||
|
sortDown: [["path", { d: "M8 5v14M5 16l3 3 3-3M14 8h5M14 12h4M14 16h3" }]],
|
||||||
|
sortUp: [["path", { d: "M8 19V5M5 8l3-3 3 3M14 8h3M14 12h4M14 16h5" }]],
|
||||||
|
transfer: [["path", { d: "M4 8h15m0 0-4-4m4 4-4 4M20 16H5m0 0 4-4m-4 4 4 4" }]],
|
||||||
|
trash: [["path", { d: "M4 7h16M9 3h6l1 4H8l1-4ZM7 7l1 14h8l1-14M10 11v6M14 11v6" }]],
|
||||||
|
toggleOff: [["rect", { x: "3", y: "7", width: "18", height: "10", rx: "5" }], ["circle", { cx: "8", cy: "12", r: "3", fill: "currentColor", stroke: "none" }]],
|
||||||
|
toggleOn: [["rect", { x: "3", y: "7", width: "18", height: "10", rx: "5" }], ["circle", { cx: "16", cy: "12", r: "3", fill: "currentColor", stroke: "none" }]],
|
||||||
|
undo: [["path", { d: "M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 0 11H11" }]],
|
||||||
|
unlock: [["rect", { x: "5", y: "10", width: "14", height: "10", rx: "2" }], ["path", { d: "M8 10V7a4 4 0 0 1 7.8-1.2" }]],
|
||||||
|
upload: [["path", { d: "M12 16V4m0 0-4 4m4-4 4 4M4 20h16" }]],
|
||||||
|
users: [["circle", { cx: "9", cy: "9", r: "3" }], ["path", { d: "M3 20v-2a5 5 0 0 1 10 0v2M16 8a3 3 0 0 1 0 6M16 15a5 5 0 0 1 5 5" }]],
|
||||||
|
wand: [["path", { d: "m4 20 12-12 3 3L7 23 4 20ZM14 4l1-3 1 3 3 1-3 1-1 3-1-3-3-1 3-1ZM19 14l.7-2 .8 2 2 .8-2 .7-.8 2-.7-2-2-.7 2-.8Z" }]],
|
||||||
|
x: [["path", { d: "M6 6l12 12M18 6 6 18" }]],
|
||||||
|
});
|
||||||
|
|
||||||
|
const UI_PANEL_ICONS = Object.freeze({
|
||||||
|
overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box",
|
||||||
|
redirects: "route", notfound: "fileX", streams: "transfer", "acl-profiles": "shield",
|
||||||
|
tls: "certificate", traffic: "activity", cache: "database", transform: "wand", settings: "settings",
|
||||||
|
});
|
||||||
|
const UI_SETTINGS_TAB_ICONS = Object.freeze({
|
||||||
|
appearance: "palette", listeners: "plug", connections: "globe", "docker-agents": "box",
|
||||||
|
policies: "shield", cache: "database", transform: "wand", forwarding: "route", pages: "file",
|
||||||
|
logging: "list", geoip: "mapPin", prometheus: "activity", administration: "users",
|
||||||
|
"system-info": "cpu", "certificate-api": "key", "tls-policy": "lock", "manual-config": "code", security: "shield",
|
||||||
|
});
|
||||||
|
const UI_VHOST_TAB_ICONS = Object.freeze({
|
||||||
|
details: "sliders", backends: "network", health: "activity", locations: "route", auth: "key", ssl: "lock",
|
||||||
|
cache: "database", compression: "archive", transform: "wand", security: "shield", pages: "file", advanced: "code",
|
||||||
|
});
|
||||||
|
const UI_TRANSLATED_ICON_KEYS = Object.freeze([
|
||||||
|
["trash", ["remove", "delete", "draftDiscard", "cacheRemoveBan", "cachePurgeUrl", "certificateApiRevoke", "certificateApiRevokeAll", "apiTokenRevoke", "revokeAll"]],
|
||||||
|
["x", ["close", "cancelAction", "overviewLayoutInlineCancel"]],
|
||||||
|
["save", ["certAssignApply", "saveCertificate", "savePassword", "dashboardPresetsSave", "overviewLayoutInlineSave"]],
|
||||||
|
["check", ["apply", "applyFilters", "savePolicy", "continueAction", "dockerApplyService"]],
|
||||||
|
["plus", ["addCertificate", "vhNew", "webServiceNew", "redirectNew", "notfoundNew", "streamNew", "aclProfileNew", "phpProfileAdd", "redirectAddRule", "streamAddListener", "aclProfileAddIpException", "apiTokenCreate"]],
|
||||||
|
["refresh", ["refresh", "refreshNow", "reload", "reloadAudit", "reloadCertificates", "tunnelsReload", "dockerRefresh", "cacheRefresh", "sessionRetry"]],
|
||||||
|
["undo", ["configHistoryRestoreAction", "dashboardPresetsFactory", "draftRestore"]],
|
||||||
|
["eyeOff", ["hideMetric", "overviewLayoutEditorHide"]],
|
||||||
|
["edit", ["edit", "certificateApiEdit"]],
|
||||||
|
["key", ["certificateApiRegenerate"]],
|
||||||
|
["download", ["export"]],
|
||||||
|
["upload", ["acmeImport", "aclProfileImportRules"]],
|
||||||
|
["eye", ["certificateReadNames", "dockerShowHidden", "overviewLayoutEditorShow"]],
|
||||||
|
["settings", ["settings", "vhManage", "dockerAgentsSettings"]],
|
||||||
|
["book", ["webServiceHtaccessDocs", "webServicePhpDocsButton"]],
|
||||||
|
["pause", ["logPause"]],
|
||||||
|
["pin", ["logPin"]],
|
||||||
|
["history", ["configHistory"]],
|
||||||
|
["chevronLeft", ["previous"]],
|
||||||
|
["chevronRight", ["next"]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function uiIconSvg(name) {
|
||||||
|
const definition = UI_ICON_SHAPES[name] || UI_ICON_SHAPES.command;
|
||||||
|
const svg = document.createElementNS(UI_ICON_NS, "svg");
|
||||||
|
svg.classList.add("ui-icon"); svg.dataset.uiIcon = name; svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false");
|
||||||
|
svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "1.8");
|
||||||
|
svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round");
|
||||||
|
for (const [tag, attrs] of definition) {
|
||||||
|
const node = document.createElementNS(UI_ICON_NS, tag);
|
||||||
|
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
|
||||||
|
svg.append(node);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
function uiIconStrings(target) {
|
||||||
|
return [target.textContent, target.getAttribute("aria-label"), target.title].filter(Boolean).map(value => String(value).trim().toLocaleLowerCase());
|
||||||
|
}
|
||||||
|
function uiIconMatchesTranslation(target, keys) {
|
||||||
|
if (typeof t !== "function") return false;
|
||||||
|
const strings = uiIconStrings(target); if (!strings.length) return false;
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = String(t(key) || "").trim(), translated = raw.toLocaleLowerCase();
|
||||||
|
// t() returns the key itself when a translation is missing; compare case-sensitively
|
||||||
|
// so an English label such as "Edit" for the key "edit" still counts as translated.
|
||||||
|
if (!translated || raw === key) continue;
|
||||||
|
if (strings.some(value => value === translated || value.startsWith(`${translated} `) || value.startsWith(`${translated}:`))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Explicit choices for controls whose ids/i18n keys would otherwise collide with
|
||||||
|
// broader keyword rules (for example "unlock" vs "lock", "presets" vs "reset",
|
||||||
|
// "docker" vs "dock", or a domain noun such as "cache" before a verb such as "next").
|
||||||
|
const UI_ID_ICONS = Object.freeze({
|
||||||
|
"about-open": "info", "login-submit": "login",
|
||||||
|
"overview-layout-unlock": "unlock", "overview-inline-save": "lock", "overview-inline-compact": "grid",
|
||||||
|
"overview-layout-open": "layers", "overview-layout-open-settings": "layers",
|
||||||
|
"overview-presets-close": "x", "overview-presets-save": "save", "overview-presets-factory": "undo",
|
||||||
|
"docker-settings-open": "settings", "settings-vhost-open": "network", "settings-file-logs-open": "list",
|
||||||
|
"log-format-open": "sliders", "file-log-tab-counts": "hash", "refresh-reset": "undo",
|
||||||
|
"acme-submit": "certificate", "geoip-update-now": "download",
|
||||||
|
"cache-panel-cleanup": "eraser", "cache-invalidation-open": "ban", "cache-bans-open": "ban",
|
||||||
|
"prom-token-generate": "key", "certificate-api-save": "key", "revoke-sessions": "logout",
|
||||||
|
"acl-profile-reset": "eraser", "reset-certificate": "eraser",
|
||||||
|
"config-history-compare-open": "compare", "logs-load-history": "history",
|
||||||
|
});
|
||||||
|
const UI_I18N_ICONS = Object.freeze({
|
||||||
|
apply: "check", fileLogsCountRepeats: "hash", cacheRunInvalidation: "play",
|
||||||
|
previous: "chevronLeft", next: "chevronRight", cachePrevious: "chevronLeft", cacheNext: "chevronRight",
|
||||||
|
renewalEnable: "toggleOn", renewalDisable: "toggleOff", tunnelKill: "ban", certAssignVhosts: "network",
|
||||||
|
});
|
||||||
|
// Buttons whose visible label is already a single symbol (×, −, +, ←, →) keep the
|
||||||
|
// native glyph instead of receiving a second, competing SVG.
|
||||||
|
const UI_GLYPH_LABEL = /^[×✕−+←→↑↓⠿]$/;
|
||||||
|
function uiIconForTarget(target) {
|
||||||
|
const manual = target.dataset.uiIconName;
|
||||||
|
if (manual === "none") return null;
|
||||||
|
if (manual && UI_ICON_SHAPES[manual]) return manual;
|
||||||
|
if (target.matches(".chart-style-choice")) return null;
|
||||||
|
if (target.matches(".overview-inline-grip, .overview-inline-narrow, .overview-inline-widen, .overview-inline-up, .overview-inline-down, .overview-inline-hide, .overview-layout-narrow, .overview-layout-widen, .overview-layout-up, .overview-layout-down, .overview-layout-hide")) return null;
|
||||||
|
if (target.matches("#tunnels-open, .vhost-backend-protocol-badge, .config-history-day, .draft-dock-toggle, .mobile-menu-toggle")) return null;
|
||||||
|
if (UI_GLYPH_LABEL.test(String(target.textContent || "").trim())) return null;
|
||||||
|
if (UI_ID_ICONS[target.id]) return UI_ID_ICONS[target.id];
|
||||||
|
if (target.matches("#web-service-preset-apply, [data-cache-memory-preset], [data-cache-cold-preset], [data-transform-preset]")) return "wand";
|
||||||
|
if (target.matches("#logs-pause")) return uiIconMatchesTranslation(target, ["logResume"]) ? "play" : "pause";
|
||||||
|
if (target.matches("#docker-metrics-toggle-all, #dashboard-widget-expand")) return target.getAttribute("aria-expanded") === "true" ? "collapse" : "expand";
|
||||||
|
if (target.matches(".docker-open-logs")) return "list";
|
||||||
|
if (target.matches(".docker-view-toggle")) return uiIconMatchesTranslation(target, ["dockerUnhideVhost"]) ? "eye" : "eyeOff";
|
||||||
|
if (target.matches(".vhost-quick-toggle.is-enable")) return "toggleOn";
|
||||||
|
if (target.matches(".vhost-quick-toggle.is-disable")) return "toggleOff";
|
||||||
|
if (target.matches(".listener-h2-settings-link, .listener-h3-settings-link")) return "settings";
|
||||||
|
if (target.dataset.panel && UI_PANEL_ICONS[target.dataset.panel]) return UI_PANEL_ICONS[target.dataset.panel];
|
||||||
|
if (target.dataset.settingsTab && UI_SETTINGS_TAB_ICONS[target.dataset.settingsTab]) return UI_SETTINGS_TAB_ICONS[target.dataset.settingsTab];
|
||||||
|
if (target.dataset.tab && UI_VHOST_TAB_ICONS[target.dataset.tab]) return UI_VHOST_TAB_ICONS[target.dataset.tab];
|
||||||
|
if (target.dataset.certificateTab) return ({ installed: "certificate", renewal: "refresh", generate: "plus" })[target.dataset.certificateTab] || "certificate";
|
||||||
|
if (target.dataset.trafficTab) return ({ overview: "grid", clients: "users", requests: "list", delivery: "activity" })[target.dataset.trafficTab] || "activity";
|
||||||
|
if (target.dataset.httpSettingsTab) return "network";
|
||||||
|
if (target.dataset.policySettingsTab) return target.dataset.policySettingsTab === "headers" || target.dataset.policySettingsTab === "response" ? "list" : "shield";
|
||||||
|
if (target.dataset.cacheSettingsTab) return ({ general: "database", resilience: "shield", cold: "archive", diagnostics: "activity" })[target.dataset.cacheSettingsTab] || "database";
|
||||||
|
if (target.dataset.loggingSettingsTab) return ({ outputs: "list", format: "code", errors: "alert" })[target.dataset.loggingSettingsTab] || "list";
|
||||||
|
if (target.dataset.dashboardWidgetAction === "toggle") return target.classList.contains("is-hidden") ? "eyeOff" : "eye";
|
||||||
|
if (target.dataset.dashboardWidgetAction === "up") return "chevronLeft";
|
||||||
|
if (target.dataset.dashboardWidgetAction === "down") return "chevronRight";
|
||||||
|
const i18nKey = target.dataset.i18n || target.dataset.action || target.dataset.presetAction || "";
|
||||||
|
if (UI_I18N_ICONS[i18nKey]) return UI_I18N_ICONS[i18nKey];
|
||||||
|
for (const [key, icon] of Object.entries(UI_I18N_ICONS)) if (uiIconMatchesTranslation(target, [key]) && !["previous", "next", "apply"].includes(key)) return icon;
|
||||||
|
|
||||||
|
const signature = [target.id, target.dataset.i18n, target.dataset.i18nAria, target.dataset.action, target.className]
|
||||||
|
.filter(Boolean).join(" ").toLocaleLowerCase();
|
||||||
|
const aria = String(target.getAttribute("aria-label") || "").toLocaleLowerCase();
|
||||||
|
if (/mobile-menu|menu-toggle/.test(signature)) return "menu";
|
||||||
|
if (/is-saving|saving/.test(signature)) return "loader";
|
||||||
|
if (/sort-direction|sort-button/.test(signature)) return /descending|desc/.test(aria) ? "sortDown" : "sortUp";
|
||||||
|
if (/scroll-prev|move-earlier|layout-up|-prev\b/.test(signature)) return "chevronLeft";
|
||||||
|
if (/scroll-next|move-later|layout-down|-next\b/.test(signature)) return "chevronRight";
|
||||||
|
// Verbs win over domain nouns: a "Close" button inside the health or history
|
||||||
|
// dialog is still a close action.
|
||||||
|
if (/close|cancel/.test(signature)) return "x";
|
||||||
|
if (/sign-in|login/.test(signature)) return "login";
|
||||||
|
if (/logout|sign-out/.test(signature)) return "logout";
|
||||||
|
if (/unlock/.test(signature)) return "unlock";
|
||||||
|
if (/collapse|narrow/.test(signature)) return "collapse";
|
||||||
|
if (/expand|widen/.test(signature)) return "expand";
|
||||||
|
if (/compare|diff/.test(signature)) return "compare";
|
||||||
|
if (/health/.test(signature) && !/certificate/.test(signature)) return "activity";
|
||||||
|
if (/copy/.test(signature)) return "copy";
|
||||||
|
if (/export|download/.test(signature)) return "download";
|
||||||
|
if (/import|upload/.test(signature)) return "upload";
|
||||||
|
if (/search|find/.test(signature)) return "search";
|
||||||
|
if (/filter/.test(signature)) return "filter";
|
||||||
|
if (/inspect|preview|read-names|readnames|show-hidden|visibility|view-toggle/.test(signature)) return "eye";
|
||||||
|
if (/restore|factory/.test(signature)) return "undo";
|
||||||
|
if (/(^|[^p])reset/.test(signature)) return "undo";
|
||||||
|
if (/refresh|reload|retry|renew|update-now|updatenow/.test(signature)) return "refresh";
|
||||||
|
if (/history|revision/.test(signature)) return "history";
|
||||||
|
if (/revoke|remove|delete|discard|purge|kill/.test(signature)) return "trash";
|
||||||
|
if (/preset/.test(signature)) return "layers";
|
||||||
|
if (/new|add|create|generate|issue/.test(signature)) return "plus";
|
||||||
|
if (/save/.test(signature)) return "save";
|
||||||
|
if (/apply|submit|continue/.test(signature)) return "check";
|
||||||
|
if (/edit/.test(signature)) return "edit";
|
||||||
|
if (/pause/.test(signature)) return "pause";
|
||||||
|
if (/logs-pin|logpin|(^|[\s-])pin($|[\s-])|(^|[\s-])dock($|[\s-])/.test(signature)) return "pin";
|
||||||
|
if (/start|run-operation|cleanup-start/.test(signature)) return "play";
|
||||||
|
if (/(^|[\s-])lock($|[\s-])/.test(signature)) return "lock";
|
||||||
|
if (/certificate-api|api-token|token|secret|key/.test(signature)) return "key";
|
||||||
|
if (/certificate|acme|tls/.test(signature)) return "certificate";
|
||||||
|
if (/security|acl|policy|audit/.test(signature)) return "shield";
|
||||||
|
if (/settings|manage|configuration/.test(signature)) return "settings";
|
||||||
|
if (/docker/.test(signature)) return "box";
|
||||||
|
if (/vhost|backend|stream|tunnel|listener/.test(signature)) return "network";
|
||||||
|
if (/redirect|forward/.test(signature)) return "route";
|
||||||
|
if (/notfound|404/.test(signature)) return "fileX";
|
||||||
|
if (/cache|cold/.test(signature)) return "database";
|
||||||
|
if (/transform|minif/.test(signature)) return "wand";
|
||||||
|
if (/traffic|metric|prometheus/.test(signature)) return "activity";
|
||||||
|
if (/log/.test(signature)) return "list";
|
||||||
|
if (/docs|help|documentation/.test(signature)) return "book";
|
||||||
|
if (/php|json|advanced|code/.test(signature)) return "code";
|
||||||
|
if (/layout|dashboard|overview/.test(signature)) return "grid";
|
||||||
|
if (/about|info/.test(signature)) return "info";
|
||||||
|
if (target.matches("a[href]")) return target.href?.startsWith(location.origin) ? "file" : "external";
|
||||||
|
for (const [icon, keys] of UI_TRANSLATED_ICON_KEYS) if (uiIconMatchesTranslation(target, keys)) return icon;
|
||||||
|
if (aria && typeof t === "function") {
|
||||||
|
if (aria.startsWith(String(t("remove")).toLocaleLowerCase())) return "trash";
|
||||||
|
if (aria.startsWith(String(t("copyValue")).toLocaleLowerCase())) return "copy";
|
||||||
|
if (aria === String(t("dashboardWidgetsMoveEarlier")).toLocaleLowerCase()) return "chevronLeft";
|
||||||
|
if (aria === String(t("dashboardWidgetsMoveLater")).toLocaleLowerCase()) return "chevronRight";
|
||||||
|
}
|
||||||
|
return "command";
|
||||||
|
}
|
||||||
|
const UI_ICON_TARGET_SELECTOR = "button, a.primary, a.outline, a.quiet, #logs-menu > summary";
|
||||||
|
function uiIconTargetIsEligible(target) {
|
||||||
|
return target instanceof Element && target.matches(UI_ICON_TARGET_SELECTOR);
|
||||||
|
}
|
||||||
|
function uiDecorateIconTarget(target) {
|
||||||
|
if (!uiIconTargetIsEligible(target)) return;
|
||||||
|
const existing = target.querySelector(":scope > svg.ui-icon");
|
||||||
|
if (document.documentElement.dataset.interfaceIcons !== "true") { existing?.remove(); return; }
|
||||||
|
const name = uiIconForTarget(target);
|
||||||
|
if (!name) { existing?.remove(); return; }
|
||||||
|
if (existing?.dataset.uiIcon === name) return;
|
||||||
|
existing?.remove(); target.prepend(uiIconSvg(name));
|
||||||
|
}
|
||||||
|
function refreshUiIcons(root = document) {
|
||||||
|
if (root instanceof Element && uiIconTargetIsEligible(root)) uiDecorateIconTarget(root);
|
||||||
|
root.querySelectorAll?.(UI_ICON_TARGET_SELECTOR).forEach(uiDecorateIconTarget);
|
||||||
|
}
|
||||||
|
let uiIconObserverActive = false;
|
||||||
|
function setUiIconsEnabled(value) {
|
||||||
|
const enabled = value === true;
|
||||||
|
document.documentElement.dataset.interfaceIcons = String(enabled);
|
||||||
|
if (enabled) {
|
||||||
|
if (!uiIconObserverActive) {
|
||||||
|
uiIconObserver.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-label", "aria-expanded", "aria-pressed", "data-i18n", "data-action"] });
|
||||||
|
uiIconObserverActive = true;
|
||||||
|
}
|
||||||
|
refreshUiIcons();
|
||||||
|
} else {
|
||||||
|
if (uiIconObserverActive) { uiIconObserver.disconnect(); uiIconObserverActive = false; }
|
||||||
|
document.querySelectorAll("svg.ui-icon").forEach(node => node.remove());
|
||||||
|
}
|
||||||
|
const input = $("ui-interface-icons"); if (input) input.checked = enabled;
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
const uiIconObserver = new MutationObserver(records => {
|
||||||
|
if (document.documentElement.dataset.interfaceIcons !== "true") return;
|
||||||
|
const targets = new Set();
|
||||||
|
for (const record of records) {
|
||||||
|
const element = record.target instanceof Element ? record.target : record.target.parentElement;
|
||||||
|
const action = element?.closest?.(UI_ICON_TARGET_SELECTOR); if (action) targets.add(action);
|
||||||
|
for (const node of record.addedNodes) {
|
||||||
|
if (!(node instanceof Element)) continue;
|
||||||
|
if (uiIconTargetIsEligible(node)) targets.add(node);
|
||||||
|
node.querySelectorAll?.(UI_ICON_TARGET_SELECTOR).forEach(item => targets.add(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets.forEach(uiDecorateIconTarget);
|
||||||
|
});
|
||||||
|
window.rustProxyIcons = { refresh: refreshUiIcons, setEnabled: setUiIconsEnabled, iconFor: uiIconForTarget };
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Optional, account-scoped subtle animation of the sidebar r/p logo
|
||||||
|
// (Settings → Appearance → Interface → Animate logo). Off by default.
|
||||||
|
// Effects are short (≤1.2 s), run at random intervals, pause while the tab is
|
||||||
|
// hidden and are skipped entirely when the system asks for reduced motion.
|
||||||
|
const LOGO_ANIMATION_EFFECTS = Object.freeze(["spin-slash", "flip", "wave", "glow", "sheen", "glitch", "wobble"]);
|
||||||
|
const LOGO_ANIMATION_DURATION_MS = 1400;
|
||||||
|
let logoAnimationTimer = null, logoAnimationReset = null, logoAnimationLast = "", logoAnimationHoverBound = false;
|
||||||
|
function logoAnimationMark() { return document.querySelector(".sidebar a.brand > .brand-mark") || document.querySelector("a.brand > .brand-mark"); }
|
||||||
|
function logoAnimationReducedMotion() { return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; }
|
||||||
|
function prepareLogoAnimationMark(mark) {
|
||||||
|
if (mark.dataset.logoPrepared === "true") return;
|
||||||
|
// Wrap the bare "r" and "p" text nodes so each letter can be transformed on its own.
|
||||||
|
for (const node of [...mark.childNodes]) {
|
||||||
|
if (node.nodeType !== Node.TEXT_NODE || !node.textContent.trim()) continue;
|
||||||
|
const letter = document.createElement("b"); letter.className = "brand-letter"; letter.textContent = node.textContent.trim();
|
||||||
|
node.replaceWith(letter);
|
||||||
|
}
|
||||||
|
mark.dataset.logoPrepared = "true";
|
||||||
|
}
|
||||||
|
function playLogoAnimation(effect) {
|
||||||
|
const mark = logoAnimationMark();
|
||||||
|
if (!mark || document.documentElement.dataset.logoAnimation !== "true" || document.hidden || logoAnimationReducedMotion() || mark.dataset.logoEffect) return;
|
||||||
|
prepareLogoAnimationMark(mark);
|
||||||
|
const choices = LOGO_ANIMATION_EFFECTS.filter(name => name !== logoAnimationLast);
|
||||||
|
const next = LOGO_ANIMATION_EFFECTS.includes(effect) ? effect : choices[Math.floor(Math.random() * choices.length)];
|
||||||
|
logoAnimationLast = next; mark.dataset.logoEffect = next;
|
||||||
|
clearTimeout(logoAnimationReset);
|
||||||
|
logoAnimationReset = setTimeout(() => { delete mark.dataset.logoEffect; }, LOGO_ANIMATION_DURATION_MS);
|
||||||
|
}
|
||||||
|
function scheduleLogoAnimation() {
|
||||||
|
clearTimeout(logoAnimationTimer);
|
||||||
|
if (document.documentElement.dataset.logoAnimation !== "true") return;
|
||||||
|
const delay = 9000 + Math.random() * 14000;
|
||||||
|
logoAnimationTimer = setTimeout(() => { playLogoAnimation(); scheduleLogoAnimation(); }, delay);
|
||||||
|
}
|
||||||
|
function setLogoAnimationEnabled(value) {
|
||||||
|
const enabled = value === true;
|
||||||
|
document.documentElement.dataset.logoAnimation = String(enabled);
|
||||||
|
const input = $("ui-logo-animation"); if (input) input.checked = enabled;
|
||||||
|
const mark = logoAnimationMark();
|
||||||
|
if (!enabled) {
|
||||||
|
clearTimeout(logoAnimationTimer); clearTimeout(logoAnimationReset); logoAnimationTimer = null;
|
||||||
|
if (mark) delete mark.dataset.logoEffect;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mark) prepareLogoAnimationMark(mark);
|
||||||
|
if (!logoAnimationHoverBound) {
|
||||||
|
mark?.closest("a.brand")?.addEventListener("mouseenter", () => playLogoAnimation());
|
||||||
|
logoAnimationHoverBound = !!mark;
|
||||||
|
}
|
||||||
|
if (!logoAnimationTimer) { scheduleLogoAnimation(); setTimeout(() => playLogoAnimation(), 600); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && document.documentElement.dataset.logoAnimation === "true") scheduleLogoAnimation(); });
|
||||||
|
window.rustProxyLogoAnimation = { setEnabled: setLogoAnimationEnabled, play: playLogoAnimation, effects: LOGO_ANIMATION_EFFECTS };
|
||||||
@@ -219,7 +219,11 @@ window.rustProxyExtraOverview = (() => {
|
|||||||
donut("extra-cache-ratio", slices?.[0], slices?.[1]);
|
donut("extra-cache-ratio", slices?.[0], slices?.[1]);
|
||||||
}
|
}
|
||||||
const detail = $(`overview-extra-${id}-detail`);
|
const detail = $(`overview-extra-${id}-detail`);
|
||||||
if (detail && id !== "extra_cache_hit_rate") detail.textContent = metric.detail || t("overviewExtraRecentSamples");
|
if (detail && id !== "extra_cache_hit_rate") {
|
||||||
|
const detailText = metric.detail || "";
|
||||||
|
detail.textContent = detailText;
|
||||||
|
detail.hidden = !detailText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); lastSegments.clear(); window.rustProxyChartScale.reset("extra:"); }
|
function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); lastSegments.clear(); window.rustProxyChartScale.reset("extra:"); }
|
||||||
|
|||||||
@@ -2,55 +2,40 @@ window.rustProxyOverviewSamples = (() => {
|
|||||||
const allowed = new Set([60, 120, 300, 600]);
|
const allowed = new Set([60, 120, 300, 600]);
|
||||||
const storageKey = "rustproxy.overview.liveChartSamples";
|
const storageKey = "rustproxy.overview.liveChartSamples";
|
||||||
const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60;
|
const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60;
|
||||||
let saved = allowed.has(factory) ? factory : 60;
|
let limit = allowed.has(factory) ? factory : 60;
|
||||||
try {
|
try {
|
||||||
const stored = Number(localStorage.getItem(storageKey));
|
const stored = Number(localStorage.getItem(storageKey));
|
||||||
if (allowed.has(stored)) saved = stored;
|
if (allowed.has(stored)) limit = stored;
|
||||||
} catch { /* Private browsing or storage policy: temporary selection still works. */ }
|
} catch { /* Private browsing or storage policy: selection still works for this visit. */ }
|
||||||
let limit = saved;
|
|
||||||
const callbacks = new Set();
|
const callbacks = new Set();
|
||||||
const getLimit = () => limit;
|
const getLimit = () => limit;
|
||||||
const trim = array => {
|
const trim = array => {
|
||||||
if (Array.isArray(array) && array.length > limit) array.splice(0, array.length - limit);
|
if (Array.isArray(array) && array.length > limit) array.splice(0, array.length - limit);
|
||||||
return array;
|
return array;
|
||||||
};
|
};
|
||||||
function sync(messageKey = limit === saved ? "overviewSamplesSaved" : "overviewSamplesTemporary") {
|
function sync() {
|
||||||
const select = document.getElementById("overview-live-samples");
|
const select = document.getElementById("overview-live-samples");
|
||||||
if (select) select.value = String(limit);
|
if (select) select.value = String(limit);
|
||||||
const state = document.getElementById("overview-samples-state");
|
|
||||||
if (state) {
|
|
||||||
state.dataset.i18n = messageKey;
|
|
||||||
state.textContent = t(messageKey);
|
|
||||||
}
|
|
||||||
const save = document.getElementById("overview-samples-save");
|
|
||||||
if (save) save.disabled = limit === saved;
|
|
||||||
}
|
}
|
||||||
function setLimit(value, persist = false) {
|
function setLimit(value, persist = false) {
|
||||||
const count = Number(value);
|
const count = Number(value);
|
||||||
if (!allowed.has(count)) return false;
|
if (!allowed.has(count)) return false;
|
||||||
|
const changed = count !== limit;
|
||||||
|
limit = count;
|
||||||
if (persist) {
|
if (persist) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(storageKey, String(count));
|
localStorage.setItem(storageKey, String(count));
|
||||||
saved = count;
|
|
||||||
} catch {
|
} catch {
|
||||||
const changed = count !== limit;
|
if (typeof toast === "function") toast(t("overviewSamplesStorageUnavailable"), true);
|
||||||
limit = count;
|
|
||||||
if (changed) for (const callback of callbacks) callback(limit);
|
|
||||||
sync("overviewSamplesStorageUnavailable");
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const changed = count !== limit;
|
|
||||||
limit = count;
|
|
||||||
if (changed) for (const callback of callbacks) callback(limit);
|
if (changed) for (const callback of callbacks) callback(limit);
|
||||||
sync();
|
sync();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function init() {
|
function init() {
|
||||||
const select = document.getElementById("overview-live-samples");
|
const select = document.getElementById("overview-live-samples");
|
||||||
const save = document.getElementById("overview-samples-save");
|
select?.addEventListener("change", () => setLimit(select.value, true));
|
||||||
select?.addEventListener("change", () => setLimit(select.value));
|
|
||||||
save?.addEventListener("click", () => setLimit(select.value, true));
|
|
||||||
sync();
|
sync();
|
||||||
}
|
}
|
||||||
if (document.readyState !== "complete") document.addEventListener("DOMContentLoaded", init, { once: true });
|
if (document.readyState !== "complete") document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const ADMIN_THEMES = new Set(["dark", "ultra_dark", "light", "cream"]);
|
const ADMIN_THEMES = new Set(["dark", "ultra_dark", "light", "cream"]);
|
||||||
const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]);
|
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_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool", "sunset", "aurora", "ocean", "forest", "pastel", "terminal", "blueprint", "colorblind"]);
|
||||||
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
|
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
|
||||||
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
|
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
|
||||||
const ADMIN_CORNER_STYLES = new Set(["default", "square", "subtle", "rounded", "extra"]);
|
const ADMIN_CORNER_STYLES = new Set(["default", "square", "subtle", "rounded", "extra"]);
|
||||||
@@ -13,10 +13,10 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([
|
|||||||
])]);
|
])]);
|
||||||
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
|
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
|
||||||
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
|
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
|
||||||
chart_axes: false,
|
chart_axes: false, interface_icons: false, logo_animation: false,
|
||||||
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null,
|
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null,
|
||||||
});
|
});
|
||||||
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
|
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool", sunset: "chartStyleSunset", aurora: "chartStyleAurora", ocean: "chartStyleOcean", forest: "chartStyleForest", pastel: "chartStylePastel", terminal: "chartStyleTerminal", blueprint: "chartStyleBlueprint", colorblind: "chartStyleColorblind" });
|
||||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
||||||
|
|
||||||
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
||||||
@@ -123,6 +123,8 @@ function applyAdminChartAxes(value) {
|
|||||||
window.rustProxyChartScale?.refreshAxes?.();
|
window.rustProxyChartScale?.refreshAxes?.();
|
||||||
return enabled;
|
return enabled;
|
||||||
}
|
}
|
||||||
|
function applyAdminInterfaceIcons(value) { return window.rustProxyIcons?.setEnabled?.(value === true) ?? false; }
|
||||||
|
function applyAdminLogoAnimation(value) { return window.rustProxyLogoAnimation?.setEnabled?.(value === true) ?? false; }
|
||||||
function applyAdminButtonStyle(value) {
|
function applyAdminButtonStyle(value) {
|
||||||
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
|
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
|
||||||
const select = $("ui-button-style"); if (select) select.value = style; return style;
|
const select = $("ui-button-style"); if (select) select.value = style; return style;
|
||||||
@@ -287,7 +289,7 @@ function applyAdminPreferences(value, cache = true) {
|
|||||||
...value,
|
...value,
|
||||||
refresh: normalizeRefreshPreferences(value.refresh),
|
refresh: normalizeRefreshPreferences(value.refresh),
|
||||||
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
|
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
|
||||||
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
|
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), interface_icons: applyAdminInterfaceIcons(value.interface_icons), logo_animation: applyAdminLogoAnimation(value.logo_animation), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
|
||||||
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes,
|
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes,
|
||||||
};
|
};
|
||||||
adminPreferences = normalized;
|
adminPreferences = normalized;
|
||||||
@@ -335,7 +337,7 @@ function updateAdminPreferences(patch) {
|
|||||||
const next = { ...current, ...patch };
|
const next = { ...current, ...patch };
|
||||||
next.refresh = normalizeRefreshPreferences(next.refresh);
|
next.refresh = normalizeRefreshPreferences(next.refresh);
|
||||||
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
|
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
|
||||||
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
|
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.interface_icons = next.interface_icons === true; next.logo_animation = next.logo_animation === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
|
||||||
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes);
|
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes);
|
||||||
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
|
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
|
||||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||||
@@ -343,7 +345,7 @@ function updateAdminPreferences(patch) {
|
|||||||
});
|
});
|
||||||
adminPreferencesWrite = task.catch(() => undefined); return task;
|
adminPreferencesWrite = task.catch(() => undefined); return task;
|
||||||
}
|
}
|
||||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
|
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); applyAdminInterfaceIcons(false); applyAdminLogoAnimation(false); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
|
||||||
function renderRefreshPreferences(value = REFRESH_DEFAULTS) {
|
function renderRefreshPreferences(value = REFRESH_DEFAULTS) {
|
||||||
const refresh = normalizeRefreshPreferences(value);
|
const refresh = normalizeRefreshPreferences(value);
|
||||||
const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled;
|
const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled;
|
||||||
@@ -419,6 +421,22 @@ $("ui-chart-axes")?.addEventListener("change", () => {
|
|||||||
applyAdminChartAxes(previous);
|
applyAdminChartAxes(previous);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
$("ui-interface-icons")?.addEventListener("change", () => {
|
||||||
|
const control = $("ui-interface-icons"), previous = adminPreferences?.interface_icons === true, next = control.checked;
|
||||||
|
applyAdminInterfaceIcons(next); if (adminPreferences) adminPreferences.interface_icons = next;
|
||||||
|
void saveAppearancePreference({ interface_icons: next }, control, () => {
|
||||||
|
if (adminPreferences) adminPreferences.interface_icons = previous;
|
||||||
|
applyAdminInterfaceIcons(previous);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$("ui-logo-animation")?.addEventListener("change", () => {
|
||||||
|
const control = $("ui-logo-animation"), previous = adminPreferences?.logo_animation === true, next = control.checked;
|
||||||
|
applyAdminLogoAnimation(next); if (adminPreferences) adminPreferences.logo_animation = next;
|
||||||
|
void saveAppearancePreference({ logo_animation: next }, control, () => {
|
||||||
|
if (adminPreferences) adminPreferences.logo_animation = previous;
|
||||||
|
applyAdminLogoAnimation(previous);
|
||||||
|
});
|
||||||
|
});
|
||||||
$("ui-button-style")?.addEventListener("change", () => {
|
$("ui-button-style")?.addEventListener("change", () => {
|
||||||
const control = $("ui-button-style"), previous = adminPreferences?.button_style || "normal", next = normalizeAdminButtonStyle(control.value);
|
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); });
|
applyAdminButtonStyle(next); if (adminPreferences) adminPreferences.button_style = next; void saveAppearancePreference({ button_style: next }, control, () => { if (adminPreferences) adminPreferences.button_style = previous; applyAdminButtonStyle(previous); });
|
||||||
@@ -450,7 +468,7 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
|
|||||||
}
|
}
|
||||||
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
|
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
|
||||||
const patches = {
|
const patches = {
|
||||||
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
|
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", interface_icons: false, logo_animation: false, button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
|
||||||
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null },
|
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null },
|
||||||
};
|
};
|
||||||
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
||||||
|
|||||||
@@ -167,10 +167,10 @@ const rustProxyRefresh = (() => {
|
|||||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||||
const value = await api("/api/status?view=live", { signal });
|
const value = await api("/api/status?view=live", { signal });
|
||||||
if (isCurrent()) renderLiveStatus(value);
|
if (isCurrent()) renderLiveStatus(value);
|
||||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }
|
}, onError: error => { if (error?.status !== 401) { connected = false; $("connection-label").textContent = t("disconnected"); $("signal").classList.add("status-error"); $("app-health-slot").hidden = false; renderAppHealth(null); } }
|
||||||
});
|
});
|
||||||
register("logs-fallback", {
|
register("logs-fallback", {
|
||||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
interval: () => options.overview_seconds, active: () => !!window.rustProxyLogs?.active?.(), run: async ({ signal, isCurrent }) => {
|
||||||
const value = await api("/api/logs", { signal });
|
const value = await api("/api/logs", { signal });
|
||||||
if (isCurrent()) renderLogs(value);
|
if (isCurrent()) renderLogs(value);
|
||||||
}
|
}
|
||||||
@@ -199,6 +199,6 @@ const rustProxyRefresh = (() => {
|
|||||||
window.rustProxyRefresh = rustProxyRefresh;
|
window.rustProxyRefresh = rustProxyRefresh;
|
||||||
|
|
||||||
$("overview-refresh")?.addEventListener("click", () => {
|
$("overview-refresh")?.addEventListener("click", () => {
|
||||||
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback"), rustProxyRefresh.manual("system-info")]);
|
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("system-info")]);
|
||||||
});
|
});
|
||||||
$("system-info-refresh")?.addEventListener("click", () => void rustProxyRefresh.manual("system-info"));
|
$("system-info-refresh")?.addEventListener("click", () => void rustProxyRefresh.manual("system-info"));
|
||||||
@@ -48,7 +48,7 @@ function syncCertificateFileSource() {
|
|||||||
for (const input of [cert, key]) {
|
for (const input of [cert, key]) {
|
||||||
input.maxLength = external ? 4096 : 96;
|
input.maxLength = external ? 4096 : 96;
|
||||||
if (external) input.removeAttribute("pattern");
|
if (external) input.removeAttribute("pattern");
|
||||||
else input.pattern = "[A-Za-z0-9_.-]+";
|
else input.pattern = "[A-Za-z0-9_.\\-]+";
|
||||||
}
|
}
|
||||||
cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem";
|
cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem";
|
||||||
key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key";
|
key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key";
|
||||||
@@ -226,7 +226,7 @@ $("reset-certificate").addEventListener("click", () => { $("certificate-form").r
|
|||||||
syncCertificateFileSource();
|
syncCertificateFileSource();
|
||||||
|
|
||||||
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, lastCacheOutcomeRates = null,
|
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, lastCacheOutcomeRates = null,
|
||||||
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
|
processCpuSamples = [], processRssSamples = [], processFdSamples = [], processThreadSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
|
||||||
const hiddenNetworkInterfaces = (() => {
|
const hiddenNetworkInterfaces = (() => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]");
|
const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]");
|
||||||
@@ -257,6 +257,68 @@ function setOptionalMetric(id, supported, text) {
|
|||||||
const row = node.closest("div"); if (row) row.hidden = !supported;
|
const row = node.closest("div"); if (row) row.hidden = !supported;
|
||||||
if (supported) node.textContent = text;
|
if (supported) node.textContent = text;
|
||||||
}
|
}
|
||||||
|
function setProcessMeter(id, used, limit) {
|
||||||
|
const meter = $(id)?.closest("div")?.querySelector(".pr-meter"); if (!meter) return;
|
||||||
|
const usedNumber = Number(used), limitNumber = Number(limit);
|
||||||
|
const ok = used != null && limit != null && Number.isFinite(usedNumber) && Number.isFinite(limitNumber) && limitNumber > 0;
|
||||||
|
meter.hidden = !ok; if (!ok) return;
|
||||||
|
const percent = Math.max(0, Math.min(100, usedNumber / limitNumber * 100));
|
||||||
|
const bar = meter.firstElementChild; bar.style.width = `${percent}%`; bar.classList.toggle("is-high", percent >= 85);
|
||||||
|
}
|
||||||
|
function renderProcessOverview(d, status, live, systemSample) {
|
||||||
|
if (!$("po-cpu")) return;
|
||||||
|
if (systemSample) {
|
||||||
|
const memory = status.memory_accounting || {};
|
||||||
|
const platform = [d.platform, d.environment].filter(Boolean).join(" / ");
|
||||||
|
setOptionalMetric("po-platform", !!d.platform && d.platform !== "Unsupported", platform);
|
||||||
|
setOptionalMetric("po-threads", d.process_threads != null, fmt(d.process_threads));
|
||||||
|
setOptionalMetric("po-start", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : formatDateTime(Number(d.process_start_time_seconds) * 1000));
|
||||||
|
setOptionalMetric("po-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
|
||||||
|
setOptionalMetric("po-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`);
|
||||||
|
setProcessMeter("po-cpu", d.process_cpu_percent, 100);
|
||||||
|
setOptionalMetric("po-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
|
||||||
|
setProcessMeter("po-rss-total", d.process_rss_bytes, d.cgroup_memory_max_bytes);
|
||||||
|
const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited"));
|
||||||
|
setOptionalMetric("po-fds", fds != null, fds);
|
||||||
|
setProcessMeter("po-fds", d.open_fds, d.max_fds);
|
||||||
|
const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
|
||||||
|
setOptionalMetric("po-cgroup", cgroupMemory != null, cgroupMemory);
|
||||||
|
setProcessMeter("po-cgroup", d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes);
|
||||||
|
setOptionalMetric("po-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
|
||||||
|
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
|
||||||
|
setOptionalMetric("po-rss-file", rssFile != null, rssFile);
|
||||||
|
setOptionalMetric("po-non-cache", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
|
||||||
|
const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
|
||||||
|
setOptionalMetric("po-virtual", virtualMemory != null, virtualMemory);
|
||||||
|
const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value));
|
||||||
|
setOptionalMetric("po-data-swap", dataSwap != null, dataSwap);
|
||||||
|
const split = $("po-rss-split"), anon = Number(d.process_rss_anon_bytes), file = Number(d.process_rss_file_bytes);
|
||||||
|
const known = d.process_rss_anon_bytes != null && d.process_rss_file_bytes != null && anon + file > 0;
|
||||||
|
split.hidden = !known;
|
||||||
|
if (known) { split.firstElementChild.style.width = `${anon / (anon + file) * 100}%`; split.lastElementChild.style.width = `${file / (anon + file) * 100}%`; }
|
||||||
|
}
|
||||||
|
$("po-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
|
||||||
|
window.rustProxyChartScale.axes("po-cpu-sparkline", Math.max(1, ...processCpuSamples), "percent",
|
||||||
|
processCpuSamples.length, { top: 5 / 60, bottom: 55 / 60 });
|
||||||
|
setChartText($("po-cpu-chart-value"), live.process_cpu_percent == null ? "" : `${fmt(live.process_cpu_percent, 1)}%`);
|
||||||
|
$("po-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
|
||||||
|
window.rustProxyChartScale.axes("po-rss-sparkline", Math.max(1, ...processRssSamples), "bytes",
|
||||||
|
processRssSamples.length, { top: 5 / 60, bottom: 55 / 60 });
|
||||||
|
setChartText($("po-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
|
||||||
|
$("po-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
|
||||||
|
$("po-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
|
||||||
|
const openFdsNow = live.open_fds ?? d.open_fds, threadsNow = live.process_threads ?? d.process_threads;
|
||||||
|
$("po-fd-sparkline").setAttribute("points", sparklinePoints(processFdSamples));
|
||||||
|
window.rustProxyChartScale.axes("po-fd-sparkline", Math.max(1, ...processFdSamples), "count",
|
||||||
|
processFdSamples.length, { top: 5 / 60, bottom: 55 / 60 });
|
||||||
|
setChartText($("po-fd-chart-value"), openFdsNow == null ? "" : fmt(openFdsNow));
|
||||||
|
$("po-fd-sparkline").closest(".mini-live-chart").hidden = openFdsNow == null;
|
||||||
|
$("po-thread-sparkline").setAttribute("points", sparklinePoints(processThreadSamples));
|
||||||
|
window.rustProxyChartScale.axes("po-thread-sparkline", Math.max(1, ...processThreadSamples), "count",
|
||||||
|
processThreadSamples.length, { top: 5 / 60, bottom: 55 / 60 });
|
||||||
|
setChartText($("po-thread-chart-value"), threadsNow == null ? "" : fmt(threadsNow));
|
||||||
|
$("po-thread-sparkline").closest(".mini-live-chart").hidden = threadsNow == null;
|
||||||
|
}
|
||||||
function knownPair(left, right, format, zeroRight = null) {
|
function knownPair(left, right, format, zeroRight = null) {
|
||||||
if (left == null && right == null) return null;
|
if (left == null && right == null) return null;
|
||||||
if (left == null) return format(right);
|
if (left == null) return format(right);
|
||||||
@@ -282,6 +344,15 @@ function svgNode(tag, attrs = {}) {
|
|||||||
|
|
||||||
function setChartText(node, value) {
|
function setChartText(node, value) {
|
||||||
const text = String(value);
|
const text = String(value);
|
||||||
|
// Controls decorated by the optional SVG icons hold [svg.ui-icon, text]; keep the icon
|
||||||
|
// and update only the text node so periodic refreshes do not rebuild the button.
|
||||||
|
const icon = node.querySelector(":scope > svg.ui-icon");
|
||||||
|
if (icon) {
|
||||||
|
const last = node.lastChild;
|
||||||
|
if (last?.nodeType === 3) { if (last.data !== text) last.data = text; }
|
||||||
|
else node.append(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (node.firstChild?.nodeType === 3 && node.firstChild === node.lastChild) {
|
if (node.firstChild?.nodeType === 3 && node.firstChild === node.lastChild) {
|
||||||
if (node.firstChild.data !== text) node.firstChild.data = text;
|
if (node.firstChild.data !== text) node.firstChild.data = text;
|
||||||
} else if (node.textContent !== text) {
|
} else if (node.textContent !== text) {
|
||||||
@@ -346,9 +417,12 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
|
|||||||
setChartText(row.querySelector(".network-interface-hide"), t("hideMetric"));
|
setChartText(row.querySelector(".network-interface-hide"), t("hideMetric"));
|
||||||
row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55");
|
row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55");
|
||||||
row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55");
|
row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55");
|
||||||
window.rustProxyChartScale.axes(row.querySelector("svg"), peak, "bytes/s", history.rx.length,
|
// The Hide button precedes the chart and may carry an injected svg.ui-icon:
|
||||||
|
// address the chart explicitly so axes/labels never attach to the icon.
|
||||||
|
const chart = row.querySelector("svg:not(.ui-icon)");
|
||||||
|
window.rustProxyChartScale.axes(chart, peak, "bytes/s", history.rx.length,
|
||||||
{ top: 5 / 60, bottom: 55 / 60 });
|
{ top: 5 / 60, bottom: 55 / 60 });
|
||||||
row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
|
chart.setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
|
||||||
children.push(row);
|
children.push(row);
|
||||||
}
|
}
|
||||||
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
|
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
|
||||||
@@ -395,15 +469,33 @@ function cacheOutcomeMix(totals) {
|
|||||||
const counts = [hits, misses, bypasses, requests - cacheTotal];
|
const counts = [hits, misses, bypasses, requests - cacheTotal];
|
||||||
return { counts, percentages: counts.map(value => value / requests * 100), requests };
|
return { counts, percentages: counts.map(value => value / requests * 100), requests };
|
||||||
}
|
}
|
||||||
|
// Percent labels live inside the bar segments; a label that does not fit is hidden
|
||||||
|
// (the legend carries the request counts; tooltips give both).
|
||||||
|
function fitCacheMixLabels() {
|
||||||
|
const bar = $("cache-mix-bar");
|
||||||
|
if (!bar) return;
|
||||||
|
bar.querySelectorAll(":scope > span").forEach(segment => {
|
||||||
|
segment.classList.remove("is-tight");
|
||||||
|
segment.classList.toggle("is-tight", segment.textContent !== "" && segment.scrollWidth > segment.clientWidth);
|
||||||
|
});
|
||||||
|
if (!bar.dataset.fitObserved && typeof ResizeObserver === "function") {
|
||||||
|
bar.dataset.fitObserved = "1";
|
||||||
|
new ResizeObserver(() => fitCacheMixLabels()).observe(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
function renderCacheMix(mix, totalRequests) {
|
function renderCacheMix(mix, totalRequests) {
|
||||||
const names = ["hit", "miss", "bypass", "other"];
|
const names = ["hit", "miss", "bypass", "other"];
|
||||||
const unavailable = t(totalRequests === 0 ? "cacheMixWaiting" : "cacheMixInvalid");
|
const unavailable = t(totalRequests === 0 ? "cacheMixWaiting" : "cacheMixInvalid");
|
||||||
names.forEach((name, i) => {
|
names.forEach((name, i) => {
|
||||||
const pct = mix ? mix.percentages[i] : 0;
|
const pct = mix ? mix.percentages[i] : 0;
|
||||||
$(`cache-mix-${name}`).style.width = `${pct}%`;
|
const segment = $(`cache-mix-${name}`);
|
||||||
|
segment.style.width = `${pct}%`;
|
||||||
|
segment.textContent = mix && pct > 0 ? `${fmt(pct, pct < 10 ? 1 : 0)}%` : "";
|
||||||
|
const detail = mix ? `${name.toUpperCase()}: ${fmt(mix.counts[i])} (${fmt(pct, 1)}%)` : "";
|
||||||
$(`cache-mix-${name}-count`).textContent = mix ? fmt(mix.counts[i]) : "-";
|
$(`cache-mix-${name}-count`).textContent = mix ? fmt(mix.counts[i]) : "-";
|
||||||
$(`cache-mix-${name}-percent`).textContent = mix ? `${fmt(pct, 1)}%` : "-";
|
segment.title = detail; $(`cache-mix-${name}-key`).title = detail;
|
||||||
});
|
});
|
||||||
|
fitCacheMixLabels();
|
||||||
$("cache-mix-headline").textContent = mix ? `${fmt(mix.percentages[0], 1)}% HIT` : "-";
|
$("cache-mix-headline").textContent = mix ? `${fmt(mix.percentages[0], 1)}% HIT` : "-";
|
||||||
const caption = $("cache-mix-caption");
|
const caption = $("cache-mix-caption");
|
||||||
const validTotal = Number.isSafeInteger(totalRequests) && totalRequests >= 0;
|
const validTotal = Number.isSafeInteger(totalRequests) && totalRequests >= 0;
|
||||||
@@ -424,6 +516,7 @@ function renderCacheMix(mix, totalRequests) {
|
|||||||
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
|
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
|
||||||
const d = status.system || {}, live = status.live_system || d, c = status.cache || {}, m = status.metrics || {};
|
const d = status.system || {}, live = status.live_system || d, c = status.cache || {}, m = status.metrics || {};
|
||||||
$("metric-h3").textContent = fmt(m.http3_requests);
|
$("metric-h3").textContent = fmt(m.http3_requests);
|
||||||
|
$("metric-h3-active").textContent = fmt(m.http3_active_connections || 0);
|
||||||
const cacheHits = c.hits == null ? null : Number(c.hits), cacheMisses = c.misses == null ? null : Number(c.misses);
|
const cacheHits = c.hits == null ? null : Number(c.hits), cacheMisses = c.misses == null ? null : Number(c.misses);
|
||||||
const cacheEvents = cacheHits !== null && cacheMisses !== null ? cacheHits + cacheMisses : 0;
|
const cacheEvents = cacheHits !== null && cacheMisses !== null ? cacheHits + cacheMisses : 0;
|
||||||
$("metric-cache-ratio").textContent = cacheEvents > 0 ? `${fmt(cacheHits / cacheEvents * 100, 1)}%` : "-";
|
$("metric-cache-ratio").textContent = cacheEvents > 0 ? `${fmt(cacheHits / cacheEvents * 100, 1)}%` : "-";
|
||||||
@@ -435,8 +528,6 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
|||||||
$("process-summary").hidden = d.process_threads == null;
|
$("process-summary").hidden = d.process_threads == null;
|
||||||
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
|
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
|
||||||
setOptionalMetric("system-cpu", d.cpu_percent != null, `${fmt(d.cpu_percent, 1)}%`);
|
setOptionalMetric("system-cpu", d.cpu_percent != null, `${fmt(d.cpu_percent, 1)}%`);
|
||||||
const sourceSupported = !!d.source && d.source !== "unsupported";
|
|
||||||
$("system-source").hidden = !sourceSupported; if (sourceSupported) $("system-source").textContent = t("realSample");
|
|
||||||
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
|
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
|
||||||
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
|
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
|
||||||
const ramMeter = $("memory-meter"), ramFill = $("memory-meter-fill");
|
const ramMeter = $("memory-meter"), ramFill = $("memory-meter-fill");
|
||||||
@@ -457,7 +548,9 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
|||||||
setOptionalMetric("system-swap", systemSwap != null, systemSwap);
|
setOptionalMetric("system-swap", systemSwap != null, systemSwap);
|
||||||
const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0;
|
const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0;
|
||||||
setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : "");
|
setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : "");
|
||||||
setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : uptime(d.system_uptime_seconds));
|
const systemUptime = $("system-uptime"), systemUptimeMeta = systemUptime?.closest(".system-uptime-meta");
|
||||||
|
if (systemUptimeMeta) systemUptimeMeta.hidden = d.system_uptime_seconds == null;
|
||||||
|
if (systemUptime && d.system_uptime_seconds != null) systemUptime.textContent = uptime(d.system_uptime_seconds);
|
||||||
}
|
}
|
||||||
$("system-chart").hidden = live.cpu_percent == null; if (appendSample && live.cpu_percent != null) { systemSamples.push(Number(live.cpu_percent)); systemSamples = systemSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
$("system-chart").hidden = live.cpu_percent == null; if (appendSample && live.cpu_percent != null) { systemSamples.push(Number(live.cpu_percent)); systemSamples = systemSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||||
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
|
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
|
||||||
@@ -497,6 +590,9 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
|||||||
if (appendSample) {
|
if (appendSample) {
|
||||||
if (live.process_cpu_percent != null) { processCpuSamples.push(Number(live.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
if (live.process_cpu_percent != null) { processCpuSamples.push(Number(live.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||||
if (live.process_rss_bytes != null) { processRssSamples.push(Number(live.process_rss_bytes)); processRssSamples = processRssSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
if (live.process_rss_bytes != null) { processRssSamples.push(Number(live.process_rss_bytes)); processRssSamples = processRssSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||||
|
const openFds = live.open_fds ?? d.open_fds, threadCount = live.process_threads ?? d.process_threads;
|
||||||
|
if (openFds != null) { processFdSamples.push(Number(openFds)); processFdSamples = processFdSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||||
|
if (threadCount != null) { processThreadSamples.push(Number(threadCount)); processThreadSamples = processThreadSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||||
}
|
}
|
||||||
const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples);
|
const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples);
|
||||||
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
|
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
|
||||||
@@ -568,6 +664,7 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
|||||||
setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
|
setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
|
||||||
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
|
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
|
||||||
$("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
|
$("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
|
||||||
|
renderProcessOverview(d, status, live, systemSample);
|
||||||
updateCacheMemorySizing();
|
updateCacheMemorySizing();
|
||||||
const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null;
|
const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null;
|
||||||
if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`);
|
if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`);
|
||||||
@@ -597,6 +694,7 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
|||||||
$("system-sample-age").hidden = age == null;
|
$("system-sample-age").hidden = age == null;
|
||||||
if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`;
|
if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`;
|
||||||
$("system-sample-age").classList.toggle("status-error", age != null && age > 10);
|
$("system-sample-age").classList.toggle("status-error", age != null && age > 10);
|
||||||
|
if ($("po-sample-age")) { $("po-sample-age").hidden = age == null; if (age != null) $("po-sample-age").textContent = `${fmt(age)} s`; $("po-sample-age").classList.toggle("status-error", age != null && age > 10); }
|
||||||
const l = status.listeners || {};
|
const l = status.listeners || {};
|
||||||
$("listener-pending").hidden = !l.restart_required;
|
$("listener-pending").hidden = !l.restart_required;
|
||||||
$("listener-pending").textContent = t("restartPending");
|
$("listener-pending").textContent = t("restartPending");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
(() => {
|
(() => {
|
||||||
let loading = false, offset = 0, generation = 0;
|
let loading = false, offset = 0, generation = 0, staticData = null, staticRevision = null;
|
||||||
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
|
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
|
||||||
const liveChartSamples = { hits: [], misses: [], bypasses: [] };
|
const liveChartSamples = { hits: [], misses: [], bypasses: [] };
|
||||||
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
|
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
|
||||||
@@ -67,6 +67,22 @@
|
|||||||
return groups.filter(row => cacheEnabledFor(data, row) === wanted);
|
return groups.filter(row => cacheEnabledFor(data, row) === wanted);
|
||||||
};
|
};
|
||||||
const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; };
|
const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; };
|
||||||
|
|
||||||
|
// Decision counters: label + count + share bar; top three visible, the rest folded into a details toggle.
|
||||||
|
// Folded counters keep their open/closed state across auto-refresh re-renders.
|
||||||
|
const openReasonRows = new Set();
|
||||||
|
function reasonsCell(reasons, rowKey = "") {
|
||||||
|
const td = el("td", undefined, "cache-reasons"), entries = Object.entries(reasons || {}).sort((a, b) => b[1] - a[1]);
|
||||||
|
if (!entries.length) { td.append(el("span", "-", "cache-reason-none")); return td; }
|
||||||
|
const max = entries[0][1] || 1, line = ([key, count]) => {
|
||||||
|
const item = el("div", undefined, "cache-reason-line"), bar = el("i"); bar.style.width = `${Math.max(4, Math.round(count / max * 100))}%`;
|
||||||
|
const track = el("span", undefined, "cache-reason-track"); track.append(bar);
|
||||||
|
item.append(el("span", reason(key), "cache-reason-name"), el("b", number(count), "cache-reason-count"), track); item.title = key; return item;
|
||||||
|
};
|
||||||
|
entries.slice(0, 3).forEach(entry => td.append(line(entry)));
|
||||||
|
if (entries.length > 3) { const more = el("details", undefined, "cache-reason-more"); more.open = openReasonRows.has(rowKey); more.addEventListener("toggle", () => { if (more.open) openReasonRows.add(rowKey); else openReasonRows.delete(rowKey); }); more.append(el("summary", `+${entries.length - 3}`)); entries.slice(3).forEach(entry => more.append(line(entry))); td.append(more); }
|
||||||
|
return td;
|
||||||
|
}
|
||||||
function resetLiveChart(selected = scope()) {
|
function resetLiveChart(selected = scope()) {
|
||||||
liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null;
|
liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null;
|
||||||
for (const values of Object.values(liveChartSamples)) values.length = 0;
|
for (const values of Object.values(liveChartSamples)) values.length = 0;
|
||||||
@@ -162,8 +178,13 @@
|
|||||||
$("cp-expired").textContent = `${number(m.evictions)} / ${number(m.expired)}`;
|
$("cp-expired").textContent = `${number(m.evictions)} / ${number(m.expired)}`;
|
||||||
$("cp-rejected").textContent = `${t("cacheRejected")}: ${number(m.rejected_fills)} · ${t("cacheMemoryPressureShort")}: ${number(m.memory_pressure_rejections)} · ${t("cacheNukeLimitShort")}: ${number(m.hot_eviction_limited)}`;
|
$("cp-rejected").textContent = `${t("cacheRejected")}: ${number(m.rejected_fills)} · ${t("cacheMemoryPressureShort")}: ${number(m.memory_pressure_rejections)} · ${t("cacheNukeLimitShort")}: ${number(m.hot_eviction_limited)}`;
|
||||||
$("cache-domain-rows").replaceChildren(...shownGroups.map(row => {
|
$("cache-domain-rows").replaceChildren(...shownGroups.map(row => {
|
||||||
const tr = el("tr"); for (const value of [row.vhost || t("cacheLegacy"), row.domain, number(row.hot_entries), number(row.cold_entries), fmtBytes(row.resident_bytes), fmtBytes(row.cold_bytes), `${number(row.hits)} / ${number(row.misses)} / ${number(row.bypasses)}`, `${fmt((row.hit_ratio || 0) * 100, 1)}%`]) tr.append(el("td", value));
|
const tr = el("tr"); for (const value of [row.vhost || t("cacheLegacy"), row.domain, number(row.hot_entries), number(row.cold_entries), fmtBytes(row.resident_bytes), fmtBytes(row.cold_bytes)]) tr.append(el("td", value));
|
||||||
const td = el("td", undefined, "cache-reasons"); for (const [key, count] of Object.entries(row.reasons || {}).sort((a, b) => b[1] - a[1])) { const tag = el("span", `${reason(key)}: ${number(count)}`, "cache-reason"); tag.title = key; td.append(tag); } tr.append(td); return tr;
|
// HIT / MISS / BYPASS: coloured counts that match the dashboard cache bar, plus a hit-ratio meter.
|
||||||
|
const outcomes = el("td", undefined, "cache-outcomes-cell");
|
||||||
|
[[row.hits, "cache-outcome-hit"], [row.misses, "cache-outcome-miss"], [row.bypasses, "cache-outcome-bypass"]].forEach(([count, cls], index) => { if (index) outcomes.append(el("span", "/", "cache-outcome-sep")); outcomes.append(el("span", number(count), cls)); });
|
||||||
|
const ratioPercent = Math.max(0, Math.min(100, (row.hit_ratio || 0) * 100)), ratio = el("td", undefined, "cache-ratio-cell"), meter = el("span", undefined, "cache-ratio-meter"), fill = el("i"); fill.style.width = `${ratioPercent}%`; meter.append(fill); ratio.append(el("span", `${fmt(ratioPercent, 1)}%`, "cache-ratio-value"), meter);
|
||||||
|
tr.append(outcomes, ratio);
|
||||||
|
tr.append(reasonsCell(row.reasons, `${row.vhost || ""}\u0000${row.domain || ""}`)); return tr;
|
||||||
}));
|
}));
|
||||||
const domainEmpty = $("cache-domains-empty");
|
const domainEmpty = $("cache-domains-empty");
|
||||||
domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains");
|
domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains");
|
||||||
@@ -172,11 +193,17 @@
|
|||||||
const tr = el("tr"), resident = Number(row.resident_bytes) || 0, disk = Number(row.storage_bytes) || 0, body = Number(row.body_bytes) || 0, storage = resident || disk;
|
const tr = el("tr"), resident = Number(row.resident_bytes) || 0, disk = Number(row.storage_bytes) || 0, body = Number(row.body_bytes) || 0, storage = resident || disk;
|
||||||
tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain));
|
tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain));
|
||||||
const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue);
|
const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue);
|
||||||
if (row.transformed) target.append(el("span", t("transformBadge"), "cache-transform-badge"));
|
if (row.transformed) {
|
||||||
|
// Keep the badge outside the ellipsised text so a long URL never clips it.
|
||||||
|
const wrap = el("div", undefined, "cache-target-wrap");
|
||||||
|
wrap.append(el("span", targetValue, "cache-target-text"), el("span", t("transformBadge"), "cache-transform-badge"));
|
||||||
|
target.replaceChildren(wrap);
|
||||||
|
}
|
||||||
tr.append(target);
|
tr.append(target);
|
||||||
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
|
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
|
||||||
tr.append(makeCopyCell(keyTags));
|
tr.append(makeCopyCell(keyTags));
|
||||||
for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
|
tr.append(el("td", undefined)); tr.lastChild.append(el("span", row.tier === "cold" ? t("cacheCold") : t("cacheHot"), `cache-tier-pill ${row.tier === "cold" ? "is-cold" : "is-hot"}`));
|
||||||
|
for (const value of [fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
|
||||||
const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr;
|
const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr;
|
||||||
}));
|
}));
|
||||||
const bans = data.bans || [];
|
const bans = data.bans || [];
|
||||||
@@ -229,7 +256,16 @@
|
|||||||
const query = new URLSearchParams({ offset: String(offset), limit: "50" });
|
const query = new URLSearchParams({ offset: String(offset), limit: "50" });
|
||||||
if (scope() !== "*") query.set("vhost", scope());
|
if (scope() !== "*") query.set("vhost", scope());
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/cache?${query}`, { signal });
|
const base = `/api/cache?${query}`;
|
||||||
|
if (staticData) query.set("view", "live");
|
||||||
|
let data = await api(`/api/cache?${query}`, { signal });
|
||||||
|
if (staticData && data.revision !== staticRevision) data = await api(base, { signal });
|
||||||
|
if (data.settings && Array.isArray(data.vhosts)) {
|
||||||
|
staticData = { settings: data.settings, vhosts: data.vhosts };
|
||||||
|
staticRevision = data.revision;
|
||||||
|
} else if (staticData) {
|
||||||
|
data = { ...staticData, ...data };
|
||||||
|
}
|
||||||
if (isCurrent() && token === generation) render(data);
|
if (isCurrent() && token === generation) render(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||||
@@ -343,7 +379,7 @@
|
|||||||
$("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
$("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
||||||
window.rustProxyCachePanel = {
|
window.rustProxyCachePanel = {
|
||||||
refresh, configure, reason, reset() {
|
refresh, configure, reason, reset() {
|
||||||
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans();
|
generation++; offset = 0; staticData = null; staticRevision = null; resetLiveChart(); closeInvalidation(); closeBans();
|
||||||
$("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren();
|
$("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren();
|
||||||
$("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status");
|
$("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,5 +243,4 @@ curl --fail --show-error --request POST \
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
renderCertificateApi();
|
renderCertificateApi();
|
||||||
loadCertificateApi();
|
|
||||||
})();
|
})();
|
||||||
+208
-14
@@ -257,20 +257,6 @@ html[data-theme=cream] {
|
|||||||
--scrollbar-thumb-hover: #978a76;
|
--scrollbar-thumb-hover: #978a76;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=dense] {
|
|
||||||
--ui-gap: 4px;
|
|
||||||
--ui-card: 7px;
|
|
||||||
--ui-font: 11.5px;
|
|
||||||
--ui-control: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=comfortable] {
|
|
||||||
--ui-gap: 13px;
|
|
||||||
--ui-card: 17px;
|
|
||||||
--ui-font: 14px;
|
|
||||||
--ui-control: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-button-style=compact] body {
|
html[data-button-style=compact] body {
|
||||||
--button-padding: 3px 7px;
|
--button-padding: 3px 7px;
|
||||||
--button-font-size: 10px;
|
--button-font-size: 10px;
|
||||||
@@ -333,6 +319,62 @@ html[data-button-style=compact] body {
|
|||||||
--sw4: #70d7ff;
|
--sw4: #70d7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-style-choice[data-chart-preset=sunset] {
|
||||||
|
--sw1: #ff7a59;
|
||||||
|
--sw2: #ffb347;
|
||||||
|
--sw3: #e8509b;
|
||||||
|
--sw4: #9b6bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-style-choice[data-chart-preset=aurora] {
|
||||||
|
--sw1: #4ff0b0;
|
||||||
|
--sw2: #6ec8ff;
|
||||||
|
--sw3: #b78cff;
|
||||||
|
--sw4: #ff7ad9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-style-choice[data-chart-preset=ocean] {
|
||||||
|
--sw1: #3fa7ff;
|
||||||
|
--sw2: #2fd0c0;
|
||||||
|
--sw3: #6f8cff;
|
||||||
|
--sw4: #9be7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-style-choice[data-chart-preset=forest] {
|
||||||
|
--sw1: #7bc96f;
|
||||||
|
--sw2: #d6c45a;
|
||||||
|
--sw3: #4fae9a;
|
||||||
|
--sw4: #c98f5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-style-choice[data-chart-preset=pastel] {
|
||||||
|
--sw1: #f4b6c2;
|
||||||
|
--sw2: #b5e0c2;
|
||||||
|
--sw3: #b4c8f5;
|
||||||
|
--sw4: #e3c8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-style-choice[data-chart-preset=terminal] {
|
||||||
|
--sw1: #6bff7a;
|
||||||
|
--sw2: #e5ff6b;
|
||||||
|
--sw3: #4fd0a0;
|
||||||
|
--sw4: #ffb347;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-style-choice[data-chart-preset=blueprint] {
|
||||||
|
--sw1: #cfe6ff;
|
||||||
|
--sw2: #8fc4ff;
|
||||||
|
--sw3: #ffffff;
|
||||||
|
--sw4: #7aa8e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-style-choice[data-chart-preset=colorblind] {
|
||||||
|
--sw1: #e69f00;
|
||||||
|
--sw2: #56b4e9;
|
||||||
|
--sw3: #009e73;
|
||||||
|
--sw4: #cc79a7;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-chart-style=default] {
|
html[data-chart-style=default] {
|
||||||
--chart-accent: var(--accent);
|
--chart-accent: var(--accent);
|
||||||
--chart-secondary: var(--green);
|
--chart-secondary: var(--green);
|
||||||
@@ -485,6 +527,158 @@ html[data-chart-style=warm] {
|
|||||||
--chart-svg-filter: saturate(1.1) brightness(1.04);
|
--chart-svg-filter: saturate(1.1) brightness(1.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-chart-style=sunset] {
|
||||||
|
--chart-linecap: round;
|
||||||
|
--chart-linejoin: round;
|
||||||
|
--chart-accent: #ff7a59;
|
||||||
|
--chart-secondary: #ffb347;
|
||||||
|
--chart-tertiary: #e8509b;
|
||||||
|
--chart-quaternary: #9b6bff;
|
||||||
|
--chart-quinary: #ffd479;
|
||||||
|
--chart-senary: #ff9fb2;
|
||||||
|
--chart-septenary: #c08ad8;
|
||||||
|
--chart-octonary: #8f6aa8;
|
||||||
|
--chart-surface: color-mix(in srgb, var(--panel2) 90%, #3a1428 10%);
|
||||||
|
--chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #2e1022 8%);
|
||||||
|
--chart-line-width: 2.5;
|
||||||
|
--chart-grid-opacity: .36;
|
||||||
|
--chart-grid-dash: 1 0;
|
||||||
|
--chart-svg-filter: saturate(1.12) brightness(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-chart-style=aurora] {
|
||||||
|
--chart-linecap: round;
|
||||||
|
--chart-linejoin: round;
|
||||||
|
--chart-accent: #4ff0b0;
|
||||||
|
--chart-secondary: #6ec8ff;
|
||||||
|
--chart-tertiary: #b78cff;
|
||||||
|
--chart-quaternary: #ff7ad9;
|
||||||
|
--chart-quinary: #c8ff7a;
|
||||||
|
--chart-senary: #7affea;
|
||||||
|
--chart-septenary: #8fa0ff;
|
||||||
|
--chart-octonary: #d0a8ff;
|
||||||
|
--chart-surface: color-mix(in srgb, var(--panel2) 91%, #0a2630 9%);
|
||||||
|
--chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #082028 8%);
|
||||||
|
--chart-line-width: 2.4;
|
||||||
|
--chart-grid-opacity: .4;
|
||||||
|
--chart-grid-dash: 2 5;
|
||||||
|
--chart-svg-filter: saturate(1.2) brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-chart-style=ocean] {
|
||||||
|
--chart-linecap: round;
|
||||||
|
--chart-linejoin: round;
|
||||||
|
--chart-accent: #3fa7ff;
|
||||||
|
--chart-secondary: #2fd0c0;
|
||||||
|
--chart-tertiary: #6f8cff;
|
||||||
|
--chart-quaternary: #9be7ff;
|
||||||
|
--chart-quinary: #52e0a8;
|
||||||
|
--chart-senary: #78b8ff;
|
||||||
|
--chart-septenary: #4a7fb5;
|
||||||
|
--chart-octonary: #5f9ea8;
|
||||||
|
--chart-surface: color-mix(in srgb, var(--panel2) 90%, #061c33 10%);
|
||||||
|
--chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #05182b 8%);
|
||||||
|
--chart-line-width: 2.2;
|
||||||
|
--chart-grid-opacity: .4;
|
||||||
|
--chart-grid-dash: 2 5;
|
||||||
|
--chart-svg-filter: saturate(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-chart-style=forest] {
|
||||||
|
--chart-linecap: round;
|
||||||
|
--chart-linejoin: round;
|
||||||
|
--chart-accent: #7bc96f;
|
||||||
|
--chart-secondary: #d6c45a;
|
||||||
|
--chart-tertiary: #4fae9a;
|
||||||
|
--chart-quaternary: #c98f5c;
|
||||||
|
--chart-quinary: #a4d86a;
|
||||||
|
--chart-senary: #e0a84f;
|
||||||
|
--chart-septenary: #7aa86a;
|
||||||
|
--chart-octonary: #8c7a5a;
|
||||||
|
--chart-surface: color-mix(in srgb, var(--panel2) 91%, #11230f 9%);
|
||||||
|
--chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #0e1d0d 8%);
|
||||||
|
--chart-line-width: 2.3;
|
||||||
|
--chart-grid-opacity: .34;
|
||||||
|
--chart-grid-dash: 1 0;
|
||||||
|
--chart-svg-filter: saturate(.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-chart-style=pastel] {
|
||||||
|
--chart-linecap: round;
|
||||||
|
--chart-linejoin: round;
|
||||||
|
--chart-accent: #f4b6c2;
|
||||||
|
--chart-secondary: #b5e0c2;
|
||||||
|
--chart-tertiary: #b4c8f5;
|
||||||
|
--chart-quaternary: #e3c8f0;
|
||||||
|
--chart-quinary: #f6dba4;
|
||||||
|
--chart-senary: #a8dcdc;
|
||||||
|
--chart-septenary: #d2b8e8;
|
||||||
|
--chart-octonary: #b9c2cc;
|
||||||
|
--chart-surface: color-mix(in srgb, var(--panel2) 90%, #fff 10%);
|
||||||
|
--chart-surface-alt: color-mix(in srgb, var(--panel) 93%, #fff 7%);
|
||||||
|
--chart-line-width: 3;
|
||||||
|
--chart-grid-opacity: .44;
|
||||||
|
--chart-grid-dash: 1 0;
|
||||||
|
--chart-svg-filter: saturate(.82) brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-chart-style=terminal] {
|
||||||
|
--chart-linecap: square;
|
||||||
|
--chart-linejoin: miter;
|
||||||
|
--chart-accent: #6bff7a;
|
||||||
|
--chart-secondary: #e5ff6b;
|
||||||
|
--chart-tertiary: #4fd0a0;
|
||||||
|
--chart-quaternary: #ffb347;
|
||||||
|
--chart-quinary: #a6ff9a;
|
||||||
|
--chart-senary: #d4ff9a;
|
||||||
|
--chart-septenary: #58b868;
|
||||||
|
--chart-octonary: #7a8f58;
|
||||||
|
--chart-surface: color-mix(in srgb, var(--panel2) 88%, #031a07 12%);
|
||||||
|
--chart-surface-alt: color-mix(in srgb, var(--panel) 90%, #021405 10%);
|
||||||
|
--chart-line-width: 1.6;
|
||||||
|
--chart-grid-opacity: .7;
|
||||||
|
--chart-grid-dash: 1 3;
|
||||||
|
--chart-svg-filter: saturate(1.1) contrast(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-chart-style=blueprint] {
|
||||||
|
--chart-linecap: square;
|
||||||
|
--chart-linejoin: miter;
|
||||||
|
--chart-accent: #cfe6ff;
|
||||||
|
--chart-secondary: #8fc4ff;
|
||||||
|
--chart-tertiary: #ffffff;
|
||||||
|
--chart-quaternary: #7aa8e0;
|
||||||
|
--chart-quinary: #a8d0ff;
|
||||||
|
--chart-senary: #e8f2ff;
|
||||||
|
--chart-septenary: #6a90c0;
|
||||||
|
--chart-octonary: #506c94;
|
||||||
|
--chart-surface: color-mix(in srgb, var(--panel2) 84%, #0a2a5c 16%);
|
||||||
|
--chart-surface-alt: color-mix(in srgb, var(--panel) 88%, #08224a 12%);
|
||||||
|
--chart-line-width: 1.3;
|
||||||
|
--chart-grid-opacity: .78;
|
||||||
|
--chart-grid-dash: 4 4;
|
||||||
|
--chart-svg-filter: saturate(.9) contrast(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-chart-style=colorblind] {
|
||||||
|
--chart-linecap: round;
|
||||||
|
--chart-linejoin: round;
|
||||||
|
--chart-accent: #e69f00;
|
||||||
|
--chart-secondary: #56b4e9;
|
||||||
|
--chart-tertiary: #009e73;
|
||||||
|
--chart-quaternary: #cc79a7;
|
||||||
|
--chart-quinary: #f0e442;
|
||||||
|
--chart-senary: #0072b2;
|
||||||
|
--chart-septenary: #d55e00;
|
||||||
|
--chart-octonary: #bdbdbd;
|
||||||
|
--chart-surface: color-mix(in srgb, var(--panel2) 94%, #000 6%);
|
||||||
|
--chart-surface-alt: color-mix(in srgb, var(--panel) 94%, #000 6%);
|
||||||
|
--chart-line-width: 2.6;
|
||||||
|
--chart-grid-opacity: .7;
|
||||||
|
--chart-grid-dash: 3 4;
|
||||||
|
--chart-svg-filter: contrast(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
html[data-chart-style] {
|
html[data-chart-style] {
|
||||||
--traffic-h1: var(--chart-accent);
|
--traffic-h1: var(--chart-accent);
|
||||||
--traffic-h2: var(--chart-secondary);
|
--traffic-h2: var(--chart-secondary);
|
||||||
|
|||||||
@@ -87,6 +87,53 @@ button:disabled {
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-inline-end: 6px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-interface-icons="true"] svg.ui-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav>.ui-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-interface-icons="true"] .nav>svg.ui-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backend-sort-direction>.ui-icon,
|
||||||
|
.dashboard-widget-scroll-button>.ui-icon,
|
||||||
|
.host-chip button>.ui-icon,
|
||||||
|
.acl-picker-remove>.ui-icon,
|
||||||
|
.draft-dock-remove>.ui-icon,
|
||||||
|
.vhost-backend-popup-close>.ui-icon,
|
||||||
|
.dashboard-widget-toggle>.ui-icon {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.is-saving>.ui-icon[data-ui-icon="loader"] {
|
||||||
|
animation: ui-icon-spin .8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ui-icon-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-interface-icons="true"] .nav>span:first-of-type,
|
||||||
[hidden],
|
[hidden],
|
||||||
#file-log-view-counts[hidden],
|
#file-log-view-counts[hidden],
|
||||||
#file-log-view-entries[hidden],
|
#file-log-view-entries[hidden],
|
||||||
@@ -711,7 +758,8 @@ progress::-moz-progress-bar {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pool-search {
|
#pool-search,
|
||||||
|
#bo-search {
|
||||||
width: calc(min(250px, 30vw));
|
width: calc(min(250px, 30vw));
|
||||||
min-width: 170px;
|
min-width: 170px;
|
||||||
}
|
}
|
||||||
@@ -968,6 +1016,8 @@ dialog .vh-form,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#vhost-list,
|
#vhost-list,
|
||||||
|
#redirect-list,
|
||||||
|
#notfound-list,
|
||||||
#web-service-list,
|
#web-service-list,
|
||||||
#stream-list,
|
#stream-list,
|
||||||
.vh-www-choices,
|
.vh-www-choices,
|
||||||
@@ -1020,43 +1070,10 @@ dialog .vh-form,
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] {
|
|
||||||
--ui-gap: 2px;
|
|
||||||
--ui-card: 5px;
|
|
||||||
--ui-font: 10.75px;
|
|
||||||
--ui-control: 22px;
|
|
||||||
line-height: 1.32;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] main {
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .card-heading {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .card-heading h2,
|
|
||||||
html[data-dashboard-density=compact] #panel-overview .dashboard-overview-grid>.card .card-heading h2 {
|
html[data-dashboard-density=compact] #panel-overview .dashboard-overview-grid>.card .card-heading h2 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .outline,
|
|
||||||
body[data-density=compact] .primary,
|
|
||||||
body[data-density=compact] .quiet,
|
|
||||||
body[data-density=compact] button,
|
|
||||||
body[data-density=compact] input:not([type=checkbox]):not([type=radio]),
|
|
||||||
body[data-density=compact] select,
|
|
||||||
body[data-density=compact] textarea {
|
|
||||||
padding: 3px 7px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .page-footer {
|
|
||||||
padding: 7px 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-interface-row-head {
|
.network-interface-row-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1104,7 +1121,7 @@ body[data-density=compact] .page-footer {
|
|||||||
font: 10px var(--mono);
|
font: 10px var(--mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.network-interface-row svg {
|
.network-interface-row svg:not(.ui-icon) {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
@@ -1204,6 +1221,29 @@ body[data-density=compact] .page-footer {
|
|||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
font: 9px var(--mono);
|
font: 9px var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small controls: a smaller icon must not change the button height when the
|
||||||
|
optional SVG icons are toggled or re-applied during a refresh (Safari jumped). */
|
||||||
|
.network-interface-hide>.ui-icon,
|
||||||
|
.network-interface-hidden .quiet>.ui-icon {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
margin-inline-end: 4px;
|
||||||
|
vertical-align: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-interface-icons="true"] .network-interface-hide>svg.ui-icon,
|
||||||
|
html[data-interface-icons="true"] .network-interface-hidden .quiet>svg.ui-icon {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-interface-charts {
|
||||||
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.network-interface-row+.network-interface-row,
|
.network-interface-row+.network-interface-row,
|
||||||
@@ -1409,9 +1449,16 @@ body[data-density=compact] .page-footer {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-repository span:first-child {
|
||||||
|
margin-right: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.about-repository span:last-child {
|
.about-repository span:last-child {
|
||||||
|
margin-left: auto;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font: 10px var(--mono);
|
font: 10px var(--mono);
|
||||||
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1516,14 +1563,6 @@ html[data-table-density=ultra] :is(table th, table td) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-permissions-commands {
|
|
||||||
max-height: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-permissions-actions {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-permissions-options>summary {
|
.path-permissions-options>summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1545,6 +1584,7 @@ html[data-table-density=ultra] :is(table th, table td) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.path-permissions-commands {
|
.path-permissions-commands {
|
||||||
|
max-height: 180px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1555,3 +1595,12 @@ html[data-table-density=ultra] :is(table th, table td) {
|
|||||||
.path-permissions-actions {
|
.path-permissions-actions {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
/* Safety net: whatever chart/card rule is added later, injected UI icons stay inline,
|
||||||
|
fixed-size and out of layout flow (they used to stretch inside chart cards on mobile Safari). */
|
||||||
|
html[data-interface-icons="true"] :is(button, a, summary)>svg.ui-icon {
|
||||||
|
display: inline-block;
|
||||||
|
position: static;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
+252
-36
@@ -175,6 +175,37 @@ nav {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-health-button {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-button i {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-button.is-checking,
|
||||||
|
.app-health-summary.is-checking { color: var(--muted); }
|
||||||
|
.app-health-button.is-ok,
|
||||||
|
.app-health-summary.is-ok,
|
||||||
|
.app-health-check.is-ok { color: var(--green); }
|
||||||
|
.app-health-button.is-warning,
|
||||||
|
.app-health-summary.is-warning,
|
||||||
|
.app-health-check.is-warning { color: var(--notice-text); }
|
||||||
|
.app-health-button.is-error,
|
||||||
|
.app-health-summary.is-error,
|
||||||
|
.app-health-check.is-error { color: var(--red); }
|
||||||
|
|
||||||
|
.app-health-button:hover { opacity: .82; }
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -216,11 +247,71 @@ nav {
|
|||||||
padding-top: 32px;
|
padding-top: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-bottom p {
|
.app-health-dialog {
|
||||||
font-size: 11px;
|
width: min(620px, calc(100vw - 24px));
|
||||||
line-height: 1.9;
|
}
|
||||||
color: var(--text-subtle);
|
|
||||||
margin: 13px 0 26px;
|
.app-health-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-summary>i {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-summary strong {
|
||||||
|
color: currentColor;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-summary .fine {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-checks {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-check {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 9px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--neutral-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-check>i {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-check strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-health-check .fine {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -513,37 +604,6 @@ main {
|
|||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .shell {
|
|
||||||
grid-template-columns: 164px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .sidebar {
|
|
||||||
padding: 14px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .nav,
|
|
||||||
body[data-density=compact] .stat {
|
|
||||||
padding: 6px 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .topbar {
|
|
||||||
height: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .page-intro {
|
|
||||||
padding: 7px 0 6px;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .page-intro h1 {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .page-intro .lead {
|
|
||||||
font-size: 10px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-log-menu>summary,
|
.top-log-menu>summary,
|
||||||
.backend-column-picker>summary,
|
.backend-column-picker>summary,
|
||||||
.file-log-search-help>summary {
|
.file-log-search-help>summary {
|
||||||
@@ -779,3 +839,159 @@ body[data-density=compact] .page-intro .lead {
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Optional subtle logo animation (Settings → Appearance → Animate logo). */
|
||||||
|
.brand-mark .brand-letter {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-logo-animation="true"] a.brand>.brand-mark {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow .4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-logo-animation="true"] a.brand>.brand-mark>.brand-letter,
|
||||||
|
html[data-logo-animation="true"] a.brand>.brand-mark>span {
|
||||||
|
display: inline-block;
|
||||||
|
transform-origin: 50% 55%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="spin-slash"]>span {
|
||||||
|
animation: brand-logo-spin 1.1s cubic-bezier(.55, 0, .25, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="flip"]>.brand-letter {
|
||||||
|
animation: brand-logo-flip .9s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="flip"]>.brand-letter:last-child {
|
||||||
|
animation-delay: .18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="wave"]>* {
|
||||||
|
animation: brand-logo-wave .7s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="wave"]>:nth-child(2) {
|
||||||
|
animation-delay: .1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="wave"]>:nth-child(3) {
|
||||||
|
animation-delay: .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="glow"] {
|
||||||
|
animation: brand-logo-glow 1.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="sheen"]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(105deg, transparent 30%, color-mix(in srgb, var(--logo-accent, var(--accent)) 28%, transparent) 50%, transparent 70%);
|
||||||
|
animation: brand-logo-sheen 1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="glitch"]>* {
|
||||||
|
animation: brand-logo-glitch .45s steps(2, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect="wobble"]>span {
|
||||||
|
animation: brand-logo-wobble .9s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes brand-logo-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes brand-logo-flip {
|
||||||
|
to {
|
||||||
|
transform: rotateY(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes brand-logo-wave {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes brand-logo-glow {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 12px 1px color-mix(in srgb, var(--logo-accent, var(--accent)) 45%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes brand-logo-sheen {
|
||||||
|
from {
|
||||||
|
transform: translateX(-110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(110%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes brand-logo-glitch {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: none;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
transform: translateX(-1px);
|
||||||
|
text-shadow: 1px 0 var(--logo-secondary, var(--brand-secondary)), -1px 0 var(--logo-accent, var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translateX(1px);
|
||||||
|
text-shadow: -1px 0 var(--logo-secondary, var(--brand-secondary)), 1px 0 var(--logo-accent, var(--accent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes brand-logo-wobble {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
transform: rotate(-16deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
55% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
transform: rotate(-4deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|
||||||
|
a.brand>.brand-mark[data-logo-effect],
|
||||||
|
a.brand>.brand-mark[data-logo-effect]>*,
|
||||||
|
a.brand>.brand-mark[data-logo-effect]::after {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
+452
-104
@@ -15,6 +15,16 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
margin-bottom: var(--ui-gap);
|
margin-bottom: var(--ui-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-grid.cache-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid.cache-stats>.stat {
|
||||||
|
flex: 1 1 190px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -74,7 +84,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
stroke-dasharray: var(--chart-grid-dash, 2 5);
|
stroke-dasharray: var(--chart-grid-dash, 2 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Axis labels are HTML overlays, not stretched SVG text. */
|
|
||||||
.rp-chart-axis-host {
|
.rp-chart-axis-host {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flow-root;
|
display: flow-root;
|
||||||
@@ -82,7 +91,10 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-chart-axis-host>svg { display: block; vertical-align: top; }
|
.rp-chart-axis-host>svg {
|
||||||
|
display: block;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
.rp-chart-axis-y {
|
.rp-chart-axis-y {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -103,9 +115,20 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
background: color-mix(in srgb, var(--panel) 82%, transparent);
|
background: color-mix(in srgb, var(--panel) 82%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-chart-axis-top { top: var(--rp-axis-top, 8%); transform: translateY(-30%); }
|
.rp-chart-axis-top {
|
||||||
.rp-chart-axis-middle { top: 50%; transform: translateY(-50%); }
|
top: var(--rp-axis-top, 8%);
|
||||||
.rp-chart-axis-bottom { top: var(--rp-axis-bottom, 92%); transform: translateY(-70%); }
|
transform: translateY(-30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-chart-axis-middle {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-chart-axis-bottom {
|
||||||
|
top: var(--rp-axis-bottom, 92%);
|
||||||
|
transform: translateY(-70%);
|
||||||
|
}
|
||||||
|
|
||||||
.rp-chart-axis-x {
|
.rp-chart-axis-x {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -121,12 +144,29 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-chart-axis-x .rp-chart-axis-start { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
.rp-chart-axis-x .rp-chart-axis-start {
|
||||||
.rp-chart-axis-x .rp-chart-axis-end { min-width: 0; text-align: right; }
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-chart-axis-x .rp-chart-axis-end {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.rp-chart-axis-compact .rp-chart-axis-y,
|
.rp-chart-axis-compact .rp-chart-axis-y,
|
||||||
.rp-chart-axis-multiple .rp-chart-axis-y,
|
.rp-chart-axis-multiple .rp-chart-axis-y,
|
||||||
.rp-chart-axis-series[hidden] { display: none; visibility: hidden; }
|
.rp-chart-axis-series[hidden] {
|
||||||
.rp-chart-axis-compact .rp-chart-axis-x { font-size: 8px; gap: 2px; }
|
display: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-chart-axis-compact .rp-chart-axis-x {
|
||||||
|
font-size: 8px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.rp-chart-axis-series {
|
.rp-chart-axis-series {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -138,8 +178,13 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.rp-chart-axis-y .rp-chart-axis-middle { display: none; }
|
.rp-chart-axis-y .rp-chart-axis-middle {
|
||||||
.rp-chart-axis-x { font-size: 8px; }
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-chart-axis-x {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-footer {
|
.chart-footer {
|
||||||
@@ -235,7 +280,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-live-chart svg {
|
.mini-live-chart svg:not(.ui-icon) {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
@@ -279,13 +324,19 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
min-width: 640px;
|
min-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pool-table td:nth-child(n/**/+4) {
|
.pool-table td:nth-child(n
|
||||||
|
|
||||||
|
/**/
|
||||||
|
+4) {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir=rtl] table,
|
[dir=rtl] table,
|
||||||
.pool-table th:nth-child(n/**/+4),
|
.pool-table th:nth-child(n
|
||||||
|
|
||||||
|
/**/
|
||||||
|
+4),
|
||||||
.logging-builder-action .small-label {
|
.logging-builder-action .small-label {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -310,11 +361,11 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-card svg {
|
.activity-card svg:not(.ui-icon) {
|
||||||
height: 105px;
|
height: 105px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-card svg {
|
.system-card svg:not(.ui-icon) {
|
||||||
height: 80px;
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,11 +383,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
|||||||
max-height: 205px;
|
max-height: 205px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=dense] .stat strong {
|
|
||||||
font-size: 22px;
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.traffic-chart-grid,
|
.traffic-chart-grid,
|
||||||
.logging-grid {
|
.logging-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -417,7 +463,7 @@ body[data-density=dense] .stat strong {
|
|||||||
padding: 7px 10px 0;
|
padding: 7px 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vhost-chart svg {
|
.vhost-chart svg:not(.ui-icon) {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 54px;
|
height: 54px;
|
||||||
@@ -499,37 +545,12 @@ body[data-density=dense] .stat strong {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .stat strong {
|
|
||||||
font-size: 18px;
|
|
||||||
margin: 1px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .stat p,
|
|
||||||
body[data-density=compact] .stat small,
|
|
||||||
html[data-dashboard-density=normal] #panel-overview .stat small,
|
html[data-dashboard-density=normal] #panel-overview .stat small,
|
||||||
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-header>span,
|
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-header>span,
|
||||||
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-caption {
|
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-caption {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .activity-card svg {
|
|
||||||
height: 84px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .system-card svg {
|
|
||||||
height: 66px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .pool-table td,
|
|
||||||
body[data-density=compact] .pool-table th {
|
|
||||||
padding: 3px 5px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .pool-card .table-scroll {
|
|
||||||
max-height: 170px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-interface-charts {
|
.network-interface-charts {
|
||||||
display: grid;
|
display: grid;
|
||||||
max-height: 260px;
|
max-height: 260px;
|
||||||
@@ -549,11 +570,71 @@ body[data-density=compact] .pool-card .table-scroll {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#panel-overview .stat-with-action>span:first-child {
|
||||||
|
display: block;
|
||||||
|
padding-right: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stat-with-action .stat-corner-action {
|
||||||
|
max-width: 96px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stat-with-action .stat-corner-badge {
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stat-with-action .stat-corner-badge:hover,
|
||||||
|
#panel-overview .stat-with-action .stat-corner-badge:focus-visible {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid.is-unlocked .stat-with-action .stat-corner-action {
|
||||||
|
top: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .backend-latency-stat>span:first-child {
|
||||||
|
display: block;
|
||||||
|
padding-right: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .backend-latency-stat .backend-health-origins {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100px;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 9px var(--mono);
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid.is-unlocked .backend-latency-stat .backend-health-origins {
|
||||||
|
top: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-chart strong {
|
.stat-chart strong {
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-chart svg {
|
.stat-chart svg:not(.ui-icon) {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@@ -658,7 +739,7 @@ body[data-density=compact] .pool-card .table-scroll {
|
|||||||
padding: 14px 15px 12px;
|
padding: 14px 15px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-live-chart svg {
|
.function-live-chart svg:not(.ui-icon) {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
@@ -740,7 +821,7 @@ body[data-density=compact] .pool-card .table-scroll {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-card svg {
|
.connection-card svg:not(.ui-icon) {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -941,7 +1022,7 @@ body[data-density=compact] .pool-card .table-scroll {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg,
|
:is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg:not(.ui-icon),
|
||||||
:is(.traffic-donut, .traffic-bar-chart span) {
|
:is(.traffic-donut, .traffic-bar-chart span) {
|
||||||
filter: var(--chart-svg-filter);
|
filter: var(--chart-svg-filter);
|
||||||
}
|
}
|
||||||
@@ -973,6 +1054,10 @@ html[data-dashboard-density=compact] #panel-overview .stat>span {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-dashboard-density=compact] #panel-overview .stat-cache-outcomes .cache-mix-header>span {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-dashboard-density=compact] #panel-overview .stat strong {
|
html[data-dashboard-density=compact] #panel-overview .stat strong {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
margin: 3px 0 2px;
|
margin: 3px 0 2px;
|
||||||
@@ -997,6 +1082,10 @@ html[data-dashboard-density=ultra] #panel-overview .stat>span {
|
|||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-header>span {
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-dashboard-density=ultra] #panel-overview .stat strong {
|
html[data-dashboard-density=ultra] #panel-overview .stat strong {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
margin: 2px 0 1px;
|
margin: 2px 0 1px;
|
||||||
@@ -1008,15 +1097,15 @@ html[data-dashboard-density=ultra] #panel-overview .stat small {
|
|||||||
line-height: 1.12;
|
line-height: 1.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-dashboard-density=ultra] #panel-overview .stat-chart svg {
|
html[data-dashboard-density=ultra] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-dashboard-density=normal] #panel-overview .stat-chart svg {
|
html[data-dashboard-density=normal] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-dashboard-density=compact] #panel-overview .stat-chart svg {
|
html[data-dashboard-density=compact] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,44 +1243,92 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
position: relative;
|
position: relative;
|
||||||
outline: 1px dashed var(--accent);
|
outline: 1px dashed var(--accent);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
padding-top: 101px;
|
padding-top: 40px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-dragging {
|
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:hover,
|
||||||
opacity: .45;
|
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:focus-within {
|
||||||
|
outline-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-before {
|
#panel-overview .dashboard-overview-grid.is-overview-sorting {
|
||||||
box-shadow: inset 4px 0 var(--accent);
|
grid-auto-flow: row !important;
|
||||||
|
user-select: none;
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-after {
|
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder {
|
||||||
box-shadow: inset -4px 0 var(--accent);
|
grid-column: auto/span 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px dashed var(--accent);
|
||||||
|
border-radius: var(--rp-radius-surface, 9px);
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] {
|
||||||
|
grid-column: auto/span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] {
|
||||||
|
grid-column: auto/span 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] {
|
||||||
|
grid-column: auto/span 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>.is-overview-lifted,
|
||||||
|
#panel-overview .dashboard-overview-grid>.is-overview-lifted * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>.is-overview-lifted {
|
||||||
|
box-shadow: 0 18px 40px rgb(0 0 0 / .28), 0 0 0 2px var(--accent);
|
||||||
|
opacity: .94;
|
||||||
|
transition: none;
|
||||||
|
will-change: transform;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-inline-toolbar {
|
.overview-inline-toolbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 3px;
|
top: 5px;
|
||||||
left: 4px;
|
left: 5px;
|
||||||
right: 4px;
|
right: 5px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .overview-inline-toolbar button {
|
#panel-overview .overview-inline-toolbar button {
|
||||||
min-width: 28px;
|
min-width: 26px;
|
||||||
min-height: 27px;
|
min-height: 26px;
|
||||||
padding: 2px 7px;
|
padding: 2px 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down),
|
||||||
|
#panel-overview .dashboard-overview-grid>[data-dashboard-span="3"] .overview-inline-toolbar :is(.overview-inline-up, .overview-inline-down) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar .overview-inline-hide {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .overview-inline-toolbar .overview-inline-grip {
|
#panel-overview .overview-inline-toolbar .overview-inline-grip {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .overview-inline-toolbar .overview-inline-grip:active {
|
#panel-overview .overview-inline-toolbar .overview-inline-grip:active {
|
||||||
@@ -1570,7 +1707,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
|||||||
border-radius: var(--rp-radius-surface) !important;
|
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):not(.vhost-backend-trigger), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-restore),
|
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):not(.vhost-backend-trigger), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-restore),
|
||||||
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(.nav, .file-log-tab, .vhost-backend-trigger, .backend-column-options, .docker-service-quick-actions a, .docker-service-quick-actions button, .docker-view-toggle, .provider-picker, .renewal-meta-item) {
|
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(.nav, .file-log-tab, .vhost-backend-trigger, .backend-column-options, .docker-service-quick-actions a, .docker-service-quick-actions button, .docker-view-toggle, .provider-picker, .renewal-meta-item) {
|
||||||
border-radius: var(--rp-radius-control) !important;
|
border-radius: var(--rp-radius-control) !important;
|
||||||
}
|
}
|
||||||
@@ -1690,26 +1827,13 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0 0 0 auto;
|
margin: 0 0 0 auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro .overview-samples-toolbar .function-live-chart-samples select {
|
.page-intro .overview-samples-toolbar .function-live-chart-samples select {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro .overview-samples-toolbar button {
|
|
||||||
width: auto;
|
|
||||||
flex: none;
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro .overview-samples-toolbar .fine {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
#panel-overview .stat-donut .overview-donut-info {
|
#panel-overview .stat-donut .overview-donut-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1822,7 +1946,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
|||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .dashboard-overview-grid.is-aligned[data-overview-layout-mode=custom] {
|
#panel-overview .dashboard-overview-grid.is-aligned[data-overview-layout-mode=custom] {
|
||||||
grid-auto-flow: row dense;
|
grid-auto-flow: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="2"] {
|
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="2"] {
|
||||||
@@ -1920,7 +2044,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--rp-radius-surface, var(--radius, 8px));
|
border-radius: var(--rp-radius-surface, 8px);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2038,7 +2162,12 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-family: var(--sans);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .stat-cache-outcomes .cache-mix-caption {
|
#panel-overview .stat-cache-outcomes .cache-mix-caption {
|
||||||
@@ -2054,8 +2183,8 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
|||||||
#panel-overview .stat-cache-outcomes .cache-mix-bar {
|
#panel-overview .stat-cache-outcomes .cache-mix-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 12px;
|
height: 20px;
|
||||||
margin: 7px 0 9px;
|
margin: 3px 0 4px;
|
||||||
border-radius: var(--rp-progress-radius);
|
border-radius: var(--rp-progress-radius);
|
||||||
background: var(--rp-progress-track);
|
background: var(--rp-progress-track);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2065,6 +2194,17 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
|||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 0 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #0c0e11;
|
||||||
|
font: 600 10px/20px var(--mono, ui-monospace, monospace);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .cache-mix-bar>span.is-tight {
|
||||||
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .cache-mix-other {
|
#panel-overview .cache-mix-other {
|
||||||
@@ -2073,16 +2213,18 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
|||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .cache-mix-legend {
|
#panel-overview .cache-mix-legend {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
align-items: center;
|
||||||
gap: 5px 8px;
|
justify-content: space-between;
|
||||||
|
gap: 7px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .cache-mix-legend>span {
|
#panel-overview .cache-mix-legend>span {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 3px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2105,22 +2247,39 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
|||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .cache-mix-legend b {
|
#panel-overview .cache-mix-legend b {
|
||||||
display: flex;
|
margin-left: 1px;
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 500 10px var(--mono);
|
font: 500 9px var(--mono);
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .cache-mix-legend .cache-mix-percent {
|
#panel-overview .system-card .system-uptime-meta,
|
||||||
color: var(--muted);
|
#panel-overview .connection-card .connection-backends-meta {
|
||||||
font: 400 10px var(--mono);
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .system-card .system-uptime-meta b,
|
||||||
|
#panel-overview .connection-card .connection-backends-meta b {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .connection-card .connection-heading-main {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .connection-card .connection-current-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .connection-card .connection-backends-meta {
|
||||||
|
margin-left: auto;
|
||||||
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Live values stay on one line so refreshes do not change card heights. */
|
|
||||||
.mini-live-chart-head> :last-child,
|
.mini-live-chart-head> :last-child,
|
||||||
.network-interface-row-head .network-interface-head-actions {
|
.network-interface-row-head .network-interface-head-actions {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -2133,3 +2292,192 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* WebSocket tunnels: "Established" sits to the right of the main value instead of a third text line. */
|
||||||
|
#panel-overview .stat .stat-value-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
column-gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stat .stat-value-row>strong {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stat .stat-value-row>.stat-side-value {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 0 auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stat .stat-value-row>.stat-side-value b {
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 450;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact stat cards: a card made only of title, value and one secondary line shows that line
|
||||||
|
beside the value (right-aligned) instead of on a line of its own. */
|
||||||
|
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small)) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
column-gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>span:first-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>strong {
|
||||||
|
grid-column: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>small {
|
||||||
|
grid-column: 2;
|
||||||
|
max-width: 14em;
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Backends overview card (0.36.7): follows table density (global table rules), corner style tokens and interface colours */
|
||||||
|
#panel-overview .backend-overview-card.is-no-proxy,
|
||||||
|
#panel-overview #backend-diagnostics-card.is-no-proxy { display: none !important; }
|
||||||
|
.bo-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; min-width: 0; }
|
||||||
|
.bo-summary { color: var(--muted); font: 11px var(--mono); }
|
||||||
|
.bo-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; }
|
||||||
|
.bo-chip { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); background: transparent; color: var(--muted); font: 500 12px/1 var(--sans); cursor: pointer; }
|
||||||
|
.bo-chip:hover { color: var(--text); border-color: var(--border-hover); }
|
||||||
|
.bo-chip[aria-pressed=true] { color: var(--text); background: var(--nav-active-bg); border-color: var(--pill-line); }
|
||||||
|
.bo-chip b { color: var(--text-subtle); font: 600 11px var(--mono); }
|
||||||
|
.bo-chip[aria-pressed=true] b { color: var(--accent); }
|
||||||
|
.bo-chip.is-down b:not(.is-zero) { color: var(--red); }
|
||||||
|
.backend-overview-card .table-scroll { max-height: 520px; overflow: auto; border-top: 1px solid var(--line); }
|
||||||
|
.bo-table { width: 100%; min-width: 1300px; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.bo-table th { position: sticky; top: 0; z-index: 1; padding: 8px 10px; background: var(--panel); color: var(--muted); font: 600 10px/1.2 var(--sans); letter-spacing: .05em; text-align: left; text-transform: uppercase; white-space: nowrap; }
|
||||||
|
.bo-table td { padding: 9px 10px; border-top: 1px solid var(--row-line); vertical-align: middle; white-space: nowrap; }
|
||||||
|
.bo-table .is-num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
|
.bo-table th.is-num { font-family: var(--sans); }
|
||||||
|
.bo-table .is-zero { color: var(--text-subtle); }
|
||||||
|
.bo-row:hover>td { background: var(--panel2); }
|
||||||
|
.bo-row.is-disabled>td:not(:first-child) { opacity: .6; }
|
||||||
|
.bo-row>td:first-child { box-shadow: inset 3px 0 0 var(--line); }
|
||||||
|
.bo-row.is-up>td:first-child { box-shadow: inset 3px 0 0 var(--pill-live-line); }
|
||||||
|
.bo-row.is-down>td:first-child { box-shadow: inset 3px 0 0 var(--red); }
|
||||||
|
.bo-row.is-warn>td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.bo-expand { width: 24px; height: 24px; min-width: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--muted); cursor: pointer; line-height: 1; }
|
||||||
|
.bo-expand::before { content: "›"; display: inline-block; font-size: 15px; transition: transform .15s ease; }
|
||||||
|
.bo-expand[aria-expanded=true]::before { transform: rotate(90deg); }
|
||||||
|
.bo-expand:hover { color: var(--text); border-color: var(--field-line); }
|
||||||
|
.bo-identity { max-width: 360px; }
|
||||||
|
.bo-name { overflow: hidden; color: var(--text); font-weight: 600; text-overflow: ellipsis; }
|
||||||
|
.bo-sub { margin-top: 2px; overflow: hidden; color: var(--muted); font: .88em var(--mono); text-overflow: ellipsis; }
|
||||||
|
.bo-badge { display: inline-flex; align-items: center; gap: 7px; padding: 2px 8px; border: 1px solid var(--field-line); border-radius: var(--rp-radius-control, 0); color: var(--muted); font: .82em var(--mono); letter-spacing: 1px; text-transform: uppercase; }
|
||||||
|
.bo-badge>i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-subtle); }
|
||||||
|
.bo-badge.is-up { border-color: var(--pill-live-line); color: var(--green); }
|
||||||
|
.bo-badge.is-up>i { background: var(--green); }
|
||||||
|
.bo-badge.is-down { border-color: var(--danger-line-strong); color: var(--red); }
|
||||||
|
.bo-badge.is-down>i { background: var(--red); }
|
||||||
|
.bo-badge.is-warn { border-color: var(--warn-line); color: var(--accent); }
|
||||||
|
.bo-badge.is-warn>i { background: var(--accent); }
|
||||||
|
.bo-errors { min-width: 150px; }
|
||||||
|
.bo-meter-top { display: flex; justify-content: space-between; gap: 8px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.bo-meter-top small { color: var(--text-subtle); font-size: .9em; }
|
||||||
|
.bo-meter { height: 2px; margin-top: 5px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
|
||||||
|
.bo-meter>i { display: block; height: 100%; background: var(--accent); border-radius: var(--rp-progress-radius, 0); }
|
||||||
|
.bo-meter>i.is-high { background: var(--red); }
|
||||||
|
.bo-latency { min-width: 200px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.bo-latency>span { display: inline-block; min-width: 62px; }
|
||||||
|
.bo-spark { height: 22px; margin-left: 6px; vertical-align: middle; flex: 0 0 auto; }
|
||||||
|
.bo-spark-latency { width: 120px; }
|
||||||
|
.bo-spark-traffic { width: 96px; margin-left: 0; }
|
||||||
|
.bo-spark-bars { width: 84px; margin-left: 0; }
|
||||||
|
.bo-spark polyline { fill: none; stroke: var(--green); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.bo-row.is-down .bo-spark polyline { stroke: var(--red); }
|
||||||
|
.bo-row.is-warn .bo-spark polyline { stroke: var(--accent); }
|
||||||
|
.bo-event small { display: block; margin-top: 1px; color: var(--text-subtle); font-size: .9em; }
|
||||||
|
.bo-event .is-failed, .bo-detail-item .is-failed { color: var(--red); }
|
||||||
|
.bo-event .is-recovered, .bo-detail-item .is-recovered { color: var(--green); }
|
||||||
|
.bo-detail-row>td { padding: 0 10px 10px 40px; border-top: 0; white-space: normal; }
|
||||||
|
.bo-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; padding: 10px 12px; border: 1px solid var(--row-line); border-radius: var(--rp-radius-surface, 0); background: var(--bg); }
|
||||||
|
.bo-detail-item span { display: block; margin-bottom: 3px; color: var(--text-subtle); font-size: 11px; }
|
||||||
|
.bo-detail-item b { font: 500 12px var(--mono); overflow-wrap: anywhere; }
|
||||||
|
html[data-table-density=normal] .bo-table td { padding-top: 10px; padding-bottom: 10px; }
|
||||||
|
html[data-table-density=ultra] .bo-table th { padding: 4px 8px; }
|
||||||
|
html[data-table-density=ultra] .bo-table td { padding-left: 8px; padding-right: 8px; }
|
||||||
|
html[data-table-density=ultra] .bo-sub { display: none; }
|
||||||
|
html[data-table-density=ultra] .bo-spark { height: 14px; }
|
||||||
|
html[data-table-density=ultra] .bo-spark-latency { width: 80px; }
|
||||||
|
html[data-table-density=ultra] .bo-spark-traffic { width: 64px; }
|
||||||
|
html[data-table-density=ultra] .bo-spark-bars { width: 56px; }
|
||||||
|
html[data-table-density=ultra] .bo-meter { margin-top: 2px; }
|
||||||
|
html[data-table-density=ultra] .bo-event small { display: inline; margin-left: 6px; }
|
||||||
|
html[data-table-density=ultra] .bo-badge { padding: 0 6px; }
|
||||||
|
html[data-table-density=ultra] .bo-chip { padding: 4px 8px; font-size: 11px; }
|
||||||
|
html[data-table-density=compact] .bo-spark { height: 18px; }
|
||||||
|
html[data-table-density=compact] .bo-spark-latency { width: 100px; }
|
||||||
|
html[data-table-density=compact] .bo-spark-traffic { width: 80px; }
|
||||||
|
html[data-table-density=compact] .bo-spark-bars { width: 70px; }
|
||||||
|
html[data-table-density=compact] .bo-chip { padding: 5px 9px; }
|
||||||
|
html[data-table-density=compact] .bo-meter { margin-top: 3px; }
|
||||||
|
|
||||||
|
/* Process overview card (process_diagnostics) */
|
||||||
|
[data-dashboard-widget="process_diagnostics"] { display: flex; flex-direction: column; min-height: 560px; }
|
||||||
|
.pr-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
|
||||||
|
.pr-chip { display: inline-flex; align-items: baseline; gap: 7px; margin: 0; padding: 3px 9px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); font: 11px var(--mono); }
|
||||||
|
.pr-chip dt { color: var(--text-subtle); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.pr-chip dd { margin: 0; font-weight: 500; }
|
||||||
|
.pr-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 22px; margin-top: 6px; }
|
||||||
|
.pr-title { margin: 10px 0 4px; color: var(--muted); font: 600 10px var(--sans); letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.pr-list { margin: 0; }
|
||||||
|
.pr-row { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 10px; padding: 7px 2px; border-top: 1px solid var(--row-line); }
|
||||||
|
.pr-row dt { color: var(--muted); font-size: 11px; }
|
||||||
|
.pr-row dd { margin: 0 0 0 auto; font: 12px var(--mono); font-variant-numeric: tabular-nums; text-align: end; overflow-wrap: anywhere; }
|
||||||
|
.pr-meter { flex: 0 0 100%; height: 2px; margin-top: 3px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
|
||||||
|
.pr-meter b { display: block; width: 0; height: 100%; background: var(--accent); border-radius: var(--rp-progress-radius, 0); }
|
||||||
|
.pr-meter b.is-high { background: var(--red); }
|
||||||
|
.pr-split { display: flex; height: 6px; margin: 2px 0 4px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
|
||||||
|
.pr-split .a { display: block; background: var(--accent); }
|
||||||
|
.pr-split .f { display: block; background: var(--blue); }
|
||||||
|
.pr-charts { flex: 1 1 auto; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 10px; }
|
||||||
|
.pr-charts .mini-live-chart { display: flex; flex-direction: column; margin-top: 0; }
|
||||||
|
.pr-charts .mini-live-chart svg:not(.ui-icon) { flex: 1 1 0; height: 0; min-height: 56px; }
|
||||||
|
.diagnostic-line.pr-line-blue { stroke: var(--blue); }
|
||||||
|
.diagnostic-line.pr-line-muted { stroke: var(--muted); }
|
||||||
|
html[data-table-density=compact] .pr-row { padding-top: 6px; padding-bottom: 6px; }
|
||||||
|
html[data-table-density=ultra] .pr-row { padding-top: 3px; padding-bottom: 3px; }
|
||||||
|
html[data-table-density=ultra] .pr-chip { padding: 1px 7px; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
[data-dashboard-widget="process_diagnostics"] { min-height: 0; }
|
||||||
|
.pr-cols, .pr-charts { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* initial width of the cache outcome segments (set from JS via CSSOM; inline style attributes are blocked by the CSP) */
|
||||||
|
.cache-mix-hit, .cache-mix-miss, .cache-mix-bypass, .cache-mix-other { width: 0; }
|
||||||
|
|
||||||
|
/* Backends overview: trend charts in the requests, latency and traffic cells */
|
||||||
|
.bo-chartcell { display: flex; align-items: center; justify-content: flex-end; gap: 10px; font: 12px var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.bo-requests .bo-chartcell>span { min-width: 44px; text-align: right; }
|
||||||
|
.bo-spark-bars rect { fill: var(--accent); opacity: .75; }
|
||||||
|
.bo-spark .bo-base { stroke: var(--line); stroke-width: 1.5; stroke-linecap: round; }
|
||||||
|
.bo-spark-latency .bo-base { stroke-dasharray: 2 4; }
|
||||||
|
.bo-row .bo-spark-traffic polyline.bo-line-in { stroke: var(--blue); }
|
||||||
|
.bo-row .bo-spark-traffic polyline.bo-line-out { stroke: var(--green); }
|
||||||
|
.bo-traffic { min-width: 230px; }
|
||||||
|
.bo-traffic-text { min-width: 118px; font: 11px/1.55 var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.bo-traffic-text>div { display: flex; justify-content: space-between; gap: 8px; }
|
||||||
|
.bo-traffic-text span { color: var(--text-subtle); }
|
||||||
|
.bo-traffic-text .bo-in b { color: var(--blue); font-weight: 500; }
|
||||||
|
.bo-traffic-text .bo-out b { color: var(--green); font-weight: 500; }
|
||||||
|
.bo-traffic-text b.is-zero { color: var(--text-subtle); }
|
||||||
|
html[data-table-density=ultra] .bo-traffic-text { line-height: 1.25; }
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
.traffic-chart-card,
|
.traffic-chart-card,
|
||||||
#panel-overview .backend-mode-bar span,
|
#panel-overview .backend-mode-bar span,
|
||||||
#panel-overview .stats-grid>.stat,
|
#panel-overview .stats-grid>.stat,
|
||||||
#panel-overview .dashboard-overview-grid>.activity-card svg,
|
#panel-overview .dashboard-overview-grid>.activity-card svg:not(.ui-icon),
|
||||||
#panel-overview .dashboard-overview-grid>[data-dashboard-widget],
|
#panel-overview .dashboard-overview-grid>[data-dashboard-widget],
|
||||||
.dashboard-density-setting>.fine,
|
.dashboard-density-setting>.fine,
|
||||||
.dashboard-density-setting>select,
|
.dashboard-density-setting>select,
|
||||||
@@ -1140,7 +1140,7 @@ html[data-dashboard-density=ultra] #panel-overview .dashboard-overview-grid>.car
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
background: var(--surface, transparent);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-backend-health-editor h3 {
|
.vh-backend-health-editor h3 {
|
||||||
@@ -1483,18 +1483,22 @@ html[data-dashboard-density=ultra] #panel-overview .dashboard-overview-grid>.car
|
|||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same geometry and typography as .pill so protocol indicators sit evenly in the
|
||||||
|
Vhost policy row; only the colour (muted = not observed, green = observed) differs. */
|
||||||
.vhost-backend-protocol-badge {
|
.vhost-backend-protocol-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--pill-line);
|
border: 1px solid var(--pill-line);
|
||||||
border-radius: var(--rp-radius-control, 4px);
|
border-radius: var(--rp-radius-control, 0);
|
||||||
background: var(--panel2);
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 3px 7px;
|
min-height: 0;
|
||||||
font: 10px var(--mono);
|
padding: 4px 8px;
|
||||||
|
font: 9px/10px var(--mono);
|
||||||
|
letter-spacing: 1px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -1504,6 +1508,10 @@ html.app-loading,
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vhost-policy-pills>.vhost-backend-protocol-badge {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
.vhost-backend-protocol-badge[data-observed=true] {
|
.vhost-backend-protocol-badge[data-observed=true] {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: var(--pill-live-line);
|
border-color: var(--pill-live-line);
|
||||||
@@ -1788,12 +1796,12 @@ html.app-loading,
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
gap: 7px;
|
gap: 5px;
|
||||||
padding: 2px 4px 2px 9px;
|
padding: 4px 5px 4px 8px;
|
||||||
border-radius: var(--rp-radius-control, 15px);
|
border-radius: var(--rp-radius-control, 0);
|
||||||
border: 1px solid var(--pill-live-line);
|
border: 1px solid var(--pill-live-line);
|
||||||
background: var(--nav-selected-bg);
|
background: var(--panel);
|
||||||
font-size: 11px;
|
font: 10px var(--mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.acl-picker-tag>span {
|
.acl-picker-tag>span {
|
||||||
@@ -1803,13 +1811,12 @@ html.app-loading,
|
|||||||
|
|
||||||
.acl-picker-tag .acl-picker-remove {
|
.acl-picker-tag .acl-picker-remove {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--rp-radius-control, 50%);
|
border-radius: var(--rp-radius-control, 0);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
min-width: 20px;
|
font-size: 15px;
|
||||||
height: 20px;
|
line-height: 1;
|
||||||
font-size: 16px;
|
padding: 0 3px;
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1941,3 +1948,92 @@ html.app-loading,
|
|||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* Spacing review (0.36.2): descriptive text and blocks that used to touch the next
|
||||||
|
element. Rules only add space where the layout had none; grid/flex gaps are untouched. */
|
||||||
|
.fine+:is(.switch-grid, .settings-tabs, .table-scroll, .soft-card, .appearance-settings-grid, .runtime-information-grid, details),
|
||||||
|
.form-actions+.fine,
|
||||||
|
.table-scroll+:is(.empty, .fine),
|
||||||
|
.empty+.file-logs-pager {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.card-heading, .window-heading)+.fine,
|
||||||
|
h2+.fine {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-help-content :is(p, .fine)+:is(p, .fine) {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-help-content :is(p, .fine, ul, ol, table, pre)+:is(h3, h4) {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-toolbar+.function-live-chart,
|
||||||
|
.function-live-chart+.stats-grid,
|
||||||
|
.soft-card+.soft-card,
|
||||||
|
.field-grid+.field-grid {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-live-chart-summary+.function-live-chart-body {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tls-pqc-control>:is(.fine, a) {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tls-pqc-control {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tls-pqc-control>a {
|
||||||
|
justify-self: start;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-tls-policy .switch-grid {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-settings .fine+.settings-tabs.sub-tabs,
|
||||||
|
.fine+.field-grid,
|
||||||
|
.vh-grid+.fine,
|
||||||
|
.logs-card+.log-footnote,
|
||||||
|
.overview-presets-content+.overview-layout-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-policies>.edit-form+div {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 761px) {
|
||||||
|
#panel-settings .settings-tabs.sub-tabs+:is(div, form) {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Redirect cards (0.36.5): host chips → target flow plus a quiet meta line, no nested boxes */
|
||||||
|
@media (min-width: 761px) { .redirect-card .vhost-card-head { align-items: center; } }
|
||||||
|
.redirect-flow { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.4fr); gap: 12px; align-items: center; margin-top: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--rp-radius-surface, 0); background: var(--bg); }
|
||||||
|
.redirect-flow-from, .redirect-flow-to { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; align-items: center; }
|
||||||
|
.redirect-flow-to { flex-direction: column; align-items: flex-start; gap: 3px; }
|
||||||
|
.redirect-flow-arrow { color: var(--muted); font-size: 16px; line-height: 1; }
|
||||||
|
.redirect-host-chip { max-width: 100%; padding: 2px 6px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); font: 11px/1.45 var(--mono); color: var(--text); overflow-wrap: anywhere; }
|
||||||
|
.redirect-target { font: 12px/1.45 var(--mono); font-weight: 600; color: var(--green); overflow-wrap: anywhere; min-width: 0; }
|
||||||
|
.redirect-target-more { font-size: 11px; color: var(--muted); }
|
||||||
|
.redirect-meta { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-top: 10px; font-size: 11px; color: var(--muted); }
|
||||||
|
.redirect-meta-item { display: inline-flex; gap: 6px; min-width: 0; }
|
||||||
|
.redirect-meta-item b { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.redirect-flow { grid-template-columns: minmax(0, 1fr); gap: 6px; }
|
||||||
|
.redirect-flow-arrow { transform: rotate(90deg); justify-self: start; }
|
||||||
|
}
|
||||||
@@ -81,11 +81,15 @@
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appearance-interface-icons-setting,
|
||||||
|
.appearance-logo-animation-setting,
|
||||||
.appearance-chart-axes-setting {
|
.appearance-chart-axes-setting {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appearance-interface-icons-setting>span,
|
||||||
|
.appearance-logo-animation-setting>span,
|
||||||
.appearance-chart-axes-setting>span {
|
.appearance-chart-axes-setting>span {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -126,7 +130,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .sidebar nav,
|
|
||||||
.certificate-coverage {
|
.certificate-coverage {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
@@ -684,40 +687,6 @@ body[data-density=compact] .sidebar nav,
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .docker-service-row {
|
|
||||||
padding: 6px 8px;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .docker-service-head,
|
|
||||||
body[data-density=compact] .security-stack {
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .docker-agent-overview {
|
|
||||||
gap: 4px 12px;
|
|
||||||
margin: 4px 0 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .docker-agent-details {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .docker-service-editor {
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .docker-service-editor[open]>summary {
|
|
||||||
margin-bottom: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .security-editor {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docker-agent-grid>label {
|
.docker-agent-grid>label {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -1203,6 +1172,7 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global security policy forms */
|
/* Global security policy forms */
|
||||||
.global-policy-form .global-policy-section {
|
.global-policy-form .global-policy-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1212,18 +1182,301 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
|||||||
padding-top: 17px;
|
padding-top: 17px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.global-policy-form .global-policy-section h3 { margin: 0; font-size: 14px; }
|
|
||||||
.global-policy-heading { display: grid; gap: 5px; }
|
.global-policy-form .global-policy-section h3 {
|
||||||
.global-policy-heading p { margin: 0; max-width: 85ch; }
|
margin: 0;
|
||||||
.global-policy-fields { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr); gap: 14px; align-items: start; }
|
font-size: 14px;
|
||||||
.global-rate-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: end; }
|
}
|
||||||
.global-policy-fields label, .global-rate-fields label, .global-policy-section > label:not(.check) { display: grid; gap: 6px; min-width: 0; }
|
|
||||||
.global-policy-section textarea { width: 100%; box-sizing: border-box; }
|
.global-policy-heading {
|
||||||
.global-policy-form .form-actions { margin-top: 20px; }
|
display: grid;
|
||||||
.cache-static-guidance { margin: 9px 0 0; max-width: 88ch; }
|
gap: 5px;
|
||||||
.cache-header-settings-heading { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 24px; margin-bottom: 13px; }
|
}
|
||||||
.transform-operations-title { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 22px; margin-bottom: 14px; }
|
|
||||||
.transform-operations-grid { margin-top: 3px; row-gap: 12px; }
|
.global-policy-heading p {
|
||||||
@media (max-width: 760px) {
|
margin: 0;
|
||||||
.global-policy-fields, .global-rate-fields { grid-template-columns: 1fr; }
|
max-width: 85ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-policy-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-rate-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-policy-fields label,
|
||||||
|
.global-rate-fields label,
|
||||||
|
.global-policy-section>label:not(.check) {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-policy-section textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-policy-form .form-actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-static-guidance {
|
||||||
|
margin: 9px 0 0;
|
||||||
|
max-width: 88ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-header-settings-heading {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 18px;
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-operations-title {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 18px;
|
||||||
|
margin-top: 22px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-operations-grid {
|
||||||
|
margin-top: 3px;
|
||||||
|
row-gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
|
||||||
|
.global-policy-fields,
|
||||||
|
.global-rate-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Settings rows (0.36.3): label and help on the left, control on the right, one setting per row. */
|
||||||
|
#settings-page-appearance .appearance-section-heading .small-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-section-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(170px, 260px);
|
||||||
|
grid-template-areas: "name control" "help control";
|
||||||
|
align-content: center;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 24px;
|
||||||
|
row-gap: 3px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 11px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label>span:first-child {
|
||||||
|
grid-area: name;
|
||||||
|
align-self: end;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label>:is(select, input[type=range]) {
|
||||||
|
grid-area: control;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label>small {
|
||||||
|
grid-area: help;
|
||||||
|
align-self: start;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label:not(:has(>small), .check) {
|
||||||
|
grid-template-areas: "name control";
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label:not(:has(>small), .check)>span:first-child {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .refresh-settings-grid>label.check>span,
|
||||||
|
#settings-page-appearance .dashboard-widget-settings-intro>span {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-scale-control>span:first-child {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox settings: text on the left, the checkbox on the right edge of the row
|
||||||
|
(.check is display:flex !important, so the row is reversed instead of using grid areas). */
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label.check {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
align-self: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label.check>input {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label.check>span {
|
||||||
|
grid-area: text;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label.check>span b {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label.check>span small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-chart-preview {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .dashboard-appearance-grid>.dashboard-widget-settings {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .refresh-settings-grid>label.check {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-areas: "name" "help" "control";
|
||||||
|
row-gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-page-appearance .appearance-settings-grid>label>span:first-child {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings navigation (0.36.3) */
|
||||||
|
@media (min-width: 761px) {
|
||||||
|
/* Sub-tabs are a horizontal tab bar; the vertical rail layout is for the main settings menu only. */
|
||||||
|
#panel-settings .settings-tabs.sub-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-settings .settings-tabs.sub-tabs button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 14px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual groups in the main menu: appearance / traffic / rules / observability / system / security. */
|
||||||
|
#panel-settings #settings-tabs button:is([data-settings-tab=listeners], [data-settings-tab=policies], [data-settings-tab=logging], [data-settings-tab=administration], [data-settings-tab=certificate-api]) {
|
||||||
|
margin-top: 7px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-settings #settings-tabs button:is([data-settings-tab=listeners], [data-settings-tab=policies], [data-settings-tab=logging], [data-settings-tab=administration], [data-settings-tab=certificate-api])::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: 8px;
|
||||||
|
left: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cache and static transformation pages (0.36.3): grouped sections, help attached to its switch. */
|
||||||
|
#cache-form .cache-section-title {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cache-form [data-cache-settings-page=general]>.cache-section-title:first-child {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(#cache-form, #transform-form)>.check:first-of-type,
|
||||||
|
#cache-form [data-cache-settings-page=general]>.check:first-of-type {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(#cache-form, #transform-form) .check+.fine {
|
||||||
|
margin: 3px 0 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(#cache-form, #transform-form) .cache-memory-sizing .card-heading {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(#cache-form, #transform-form) .cache-memory-sizing .card-heading>h3 {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(#cache-form, #transform-form) .field-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr));
|
||||||
|
column-gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#transform-form .switch-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#cache-form #cache-default-ttl {
|
||||||
|
display: block;
|
||||||
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
@@ -430,13 +430,12 @@ html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
padding: 1px 5px;
|
padding: 2px 6px;
|
||||||
border: 1px solid var(--pill-line);
|
border: 1px solid var(--pill-line);
|
||||||
border-radius: var(--rp-radius-control, 3px);
|
border-radius: var(--rp-radius-control, 0);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font: 9px var(--mono);
|
font: 9px var(--mono);
|
||||||
text-transform: uppercase;
|
letter-spacing: 1px;
|
||||||
letter-spacing: .35px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cache-toolbar-message {
|
.cache-toolbar-message {
|
||||||
@@ -574,8 +573,8 @@ html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool
|
|||||||
button.config-history-day.has-revisions {
|
button.config-history-day.has-revisions {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: var(--line-strong, var(--line));
|
border-color: var(--line);
|
||||||
background: var(--surface, var(--bg));
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.config-history-day.has-revisions:hover,
|
button.config-history-day.has-revisions:hover,
|
||||||
@@ -834,7 +833,6 @@ html[data-table-density=ultra] .file-log-inline-detail {
|
|||||||
padding: 5px 7px;
|
padding: 5px 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .mini-metrics div,
|
|
||||||
.acl-policy-mode-card input,
|
.acl-policy-mode-card input,
|
||||||
.docker-service-certificate-bar>.check>input,
|
.docker-service-certificate-bar>.check>input,
|
||||||
html[data-table-density=ultra] .file-log-field-grid {
|
html[data-table-density=ultra] .file-log-field-grid {
|
||||||
@@ -1006,7 +1004,7 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
|||||||
.file-log-advanced-parser {
|
.file-log-advanced-parser {
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
padding: .75rem 1rem;
|
padding: .75rem 1rem;
|
||||||
border-radius: var(--rp-radius-surface, var(--radius, 12px));
|
border-radius: var(--rp-radius-surface, 12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-log-aggregate-form {
|
.file-log-aggregate-form {
|
||||||
@@ -1074,3 +1072,151 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
|||||||
max-height: 68dvh;
|
max-height: 68dvh;
|
||||||
font: 11px/1.5 var(--mono);
|
font: 11px/1.5 var(--mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cache → Domains and effectiveness (0.36.3): numeric columns aligned, outcomes coloured like the dashboard bar. */
|
||||||
|
#cache-domain-scroll .cache-table :is(th, td):nth-child(n+3):nth-child(-n+8) {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cache-domain-scroll .cache-table td:first-child {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#cache-domain-scroll .cache-table td:nth-child(2) {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cache-domain-scroll .cache-table tbody tr:hover {
|
||||||
|
background: var(--panel2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#cache-domain-scroll .cache-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-outcomes-cell {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-outcome-hit {
|
||||||
|
color: var(--chart-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-outcome-miss {
|
||||||
|
color: var(--chart-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-outcome-bypass {
|
||||||
|
color: var(--chart-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-outcome-sep {
|
||||||
|
margin: 0 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: .6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ratio-cell {
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ratio-value {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ratio-meter {
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ratio-meter>i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--chart-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#cache-domain-scroll .cache-reason {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--panel2);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cache → decision counters, stored objects and pager (0.36.4) */
|
||||||
|
#panel-cache .cache-table .cache-reasons { min-width: 200px; max-width: 320px; }
|
||||||
|
.cache-reason-line { display: inline-flex; align-items: baseline; gap: 4px; margin: 0 10px 0 0; font-size: 11px; line-height: 1.5; white-space: nowrap; }
|
||||||
|
.cache-reason-name { color: var(--muted); }
|
||||||
|
.cache-reason-count { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||||
|
.cache-reason-more { color: var(--muted); cursor: help; margin-right: 0; }
|
||||||
|
.cache-reason-none { color: var(--muted); }
|
||||||
|
|
||||||
|
#cache-object-scroll .cache-table td:nth-child(n+6):nth-child(-n+12),
|
||||||
|
#cache-object-scroll .cache-table th:nth-child(n+6):nth-child(-n+12) { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
#cache-object-scroll .cache-table td:nth-child(3),
|
||||||
|
#cache-object-scroll .cache-table td:nth-child(4) { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
|
||||||
|
#cache-object-scroll .cache-table td:nth-child(3):hover,
|
||||||
|
#cache-object-scroll .cache-table td:nth-child(4):hover { white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
#cache-object-scroll .cache-table tbody tr:hover { background: var(--panel2); }
|
||||||
|
.cache-tier-pill { display: inline-flex; align-items: center; padding: 2px 6px; border: 1px solid var(--pill-line); border-radius: var(--rp-radius-control, 0); color: var(--muted); font: 9px var(--mono); letter-spacing: 1px; text-transform: uppercase; }
|
||||||
|
.cache-tier-pill.is-hot { color: var(--green); border-color: var(--pill-live-line); }
|
||||||
|
|
||||||
|
.cache-pager { display: inline-flex; align-items: center; gap: 0; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 6px); overflow: hidden; }
|
||||||
|
.cache-pager button { border: 0; border-radius: 0; min-width: 88px; margin: 0; }
|
||||||
|
.cache-pager button:disabled { opacity: .45; }
|
||||||
|
.cache-pager #cache-page { padding: 0 14px; min-width: 120px; text-align: center; font-variant-numeric: tabular-nums; border-inline: 1px solid var(--line); align-self: stretch; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.cache-pager #cache-next { flex-direction: row-reverse; }
|
||||||
|
|
||||||
|
/* Static transformation tables (0.36.4): same presentation as Cache → Domains and effectiveness */
|
||||||
|
.transform-vhost-table :is(th, td):nth-child(n+2), .transform-type-table :is(th, td):nth-child(2) { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.transform-vhost-table td, .transform-type-table td { vertical-align: middle; }
|
||||||
|
.transform-vhost-table td:first-child { color: var(--text); font-weight: 600; }
|
||||||
|
.transform-type-table td:first-child { color: var(--muted); }
|
||||||
|
.transform-vhost-table tbody tr:hover, .transform-type-table tbody tr:hover { background: var(--panel2); }
|
||||||
|
.transform-zero { color: var(--muted); }
|
||||||
|
.transform-errors-nonzero { color: var(--red, #e5534b); font-weight: 600; }
|
||||||
|
.transform-reason-list { gap: 4px 12px; }
|
||||||
|
.transform-reason-list .cache-reason-line { margin: 0; }
|
||||||
|
|
||||||
|
.cache-ratio-meter, .cache-ratio-meter>i { border-radius: var(--rp-progress-radius, 0); }
|
||||||
|
|
||||||
|
/* Decision counters (0.36.7): name + count on one line, a thin share bar below (relative to the largest counter) */
|
||||||
|
.cache-reasons .cache-reason-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; align-items: baseline; margin: 0 0 6px; white-space: nowrap; }
|
||||||
|
.cache-reasons .cache-reason-name { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cache-reasons .cache-reason-track { grid-column: 1 / -1; display: block; height: 2px; margin-top: 3px; background: var(--line); border-radius: var(--rp-progress-radius, 0); overflow: hidden; }
|
||||||
|
.cache-reasons .cache-reason-track>i { display: block; height: 100%; background: var(--muted); opacity: .7; border-radius: var(--rp-progress-radius, 0); }
|
||||||
|
.cache-reasons .cache-reason-more { cursor: default; }
|
||||||
|
.cache-reasons .cache-reason-more>summary { cursor: pointer; display: inline-flex; align-items: center; gap: 5px; margin: 0 0 6px; padding: 1px 6px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); font: 10px/1.5 var(--mono); color: var(--muted); list-style: none; user-select: none; }
|
||||||
|
.cache-reasons .cache-reason-more>summary::-webkit-details-marker { display: none; }
|
||||||
|
.cache-reasons .cache-reason-more>summary::before { content: "›"; display: inline-block; font-size: 12px; line-height: 1; transition: transform .15s ease; }
|
||||||
|
.cache-reasons .cache-reason-more[open]>summary::before { transform: rotate(90deg); }
|
||||||
|
.cache-reasons .cache-reason-more>summary:hover { color: var(--text); border-color: var(--muted); }
|
||||||
|
|
||||||
|
/* Transformed badge (0.36.7): the URL ellipsises, the badge always stays visible */
|
||||||
|
.cache-target-wrap { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||||
|
.cache-target-text { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cache-target-wrap .cache-transform-badge { flex: 0 0 auto; margin-left: 0; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Cache pager (0.36.7): never wraps inside its frame; the heading wraps instead */
|
||||||
|
#panel-cache .card-heading:has(.cache-pager) { flex-wrap: wrap; row-gap: 8px; }
|
||||||
|
.cache-pager { flex-wrap: nowrap; flex: 0 0 auto; max-width: 100%; margin-left: auto; }
|
||||||
|
.cache-pager button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; flex: 0 0 auto; }
|
||||||
|
.cache-pager #cache-page { flex: 1 1 auto; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Cache pager follows the interface rounding setting: frame and the two outer buttons share the control radius */
|
||||||
|
.cache-pager { overflow: visible; border-radius: var(--rp-radius-control, 0px); }
|
||||||
|
.cache-pager button { border-radius: 0; }
|
||||||
|
.cache-pager>button:first-child { border-radius: max(0px, calc(var(--rp-radius-control, 0px) - 1px)) 0 0 max(0px, calc(var(--rp-radius-control, 0px) - 1px)); }
|
||||||
|
.cache-pager>button:last-child { border-radius: 0 max(0px, calc(var(--rp-radius-control, 0px) - 1px)) max(0px, calc(var(--rp-radius-control, 0px) - 1px)) 0; }
|
||||||
|
.cache-pager button:focus-visible { outline-offset: -2px; }
|
||||||
|
|
||||||
|
/* The global corner-style rule forces the full control radius on every button with !important; the pager is a segmented control, so only its outer corners are rounded */
|
||||||
|
#panel-cache .cache-pager>button { border-radius: 0 !important; }
|
||||||
|
#panel-cache .cache-pager>button:first-child { border-radius: max(0px, calc(var(--rp-radius-control, 0px) - 1px)) 0 0 max(0px, calc(var(--rp-radius-control, 0px) - 1px)) !important; }
|
||||||
|
#panel-cache .cache-pager>button:last-child { border-radius: 0 max(0px, calc(var(--rp-radius-control, 0px) - 1px)) max(0px, calc(var(--rp-radius-control, 0px) - 1px)) 0 !important; }
|
||||||
@@ -286,6 +286,14 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.mobile-nav-open .sidebar-bottom {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
height: 46px;
|
height: 46px;
|
||||||
}
|
}
|
||||||
@@ -294,18 +302,6 @@
|
|||||||
max-width: calc(100vw - 12px);
|
max-width: calc(100vw - 12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .sidebar nav {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .sidebar {
|
|
||||||
padding: 8px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .topbar {
|
|
||||||
height: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .brand {
|
.sidebar .brand {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -702,8 +698,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-intro .overview-samples-toolbar {
|
.page-intro .overview-samples-toolbar {
|
||||||
margin-left: 0;
|
margin-left: auto;
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,7 +819,8 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pool-search {
|
#pool-search,
|
||||||
|
#bo-search {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -889,8 +886,7 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-card>.card-heading,
|
.logs-card>.card-heading {
|
||||||
.about-repository {
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -1286,16 +1282,6 @@
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-density=compact] .docker-service-row {
|
|
||||||
padding: 8px;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-density=compact] .docker-service-head-actions .docker-runtime .pill {
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backend-column-options {
|
.backend-column-options {
|
||||||
width: min(330px, calc(100vw - 32px));
|
width: min(330px, calc(100vw - 32px));
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1457,6 +1443,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width:550px) {
|
@media (max-width:550px) {
|
||||||
|
.stats-grid.cache-stats>.stat {
|
||||||
|
flex-basis: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
.overview-layout-footer {
|
.overview-layout-footer {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -1474,7 +1464,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] {
|
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] {
|
||||||
padding-top: 135px;
|
padding-top: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid.is-unlocked .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder:is([data-dashboard-span="2"], [data-dashboard-span="3"]) {
|
||||||
|
grid-column: auto/span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder:is([data-dashboard-span="4"], [data-dashboard-span="6"], [data-dashboard-span="12"]) {
|
||||||
|
grid-column: 1/-1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1623,6 +1625,10 @@
|
|||||||
padding-right: 11px;
|
padding-right: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#panel-overview .stat-with-action>span:first-child {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-overview .dashboard-overview-grid.is-aligned .stat-with-action .stat-corner-action {
|
#panel-overview .dashboard-overview-grid.is-aligned .stat-with-action .stat-corner-action {
|
||||||
position: static;
|
position: static;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1676,3 +1682,117 @@
|
|||||||
--overview-widget-placement: 1/-1;
|
--overview-widget-placement: 1/-1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
#panel-overview .system-card .card-heading {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .system-card .system-uptime-meta {
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Docker vhosts (0.36.3): compact service rows - switch | name and details | health and actions. */
|
||||||
|
@media (min-width:900px) {
|
||||||
|
.docker-service-head {
|
||||||
|
grid-template-columns: auto minmax(220px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-service-head-actions {
|
||||||
|
grid-column: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-service-head-actions .docker-runtime {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-service-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-service-meta>b {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-service-meta>span {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editors lift (0.36.4): Docker per-app forms and shared vhost/web/stream/redirect/ACL form chrome */
|
||||||
|
.docker-service-editor:not(.docker-service-metrics)>summary,
|
||||||
|
.docker-health-editor>summary, .docker-acl-editor>summary, .docker-protocol-editor>summary, .docker-timeout-editor>summary, .docker-passive-health-editor>summary {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.docker-service-editor>summary::-webkit-details-marker, .docker-health-editor>summary::-webkit-details-marker, .docker-acl-editor>summary::-webkit-details-marker,
|
||||||
|
.docker-protocol-editor>summary::-webkit-details-marker, .docker-timeout-editor>summary::-webkit-details-marker, .docker-passive-health-editor>summary::-webkit-details-marker { display: none; }
|
||||||
|
.docker-service-editor>summary::before, .docker-health-editor>summary::before, .docker-acl-editor>summary::before,
|
||||||
|
.docker-protocol-editor>summary::before, .docker-timeout-editor>summary::before, .docker-passive-health-editor>summary::before {
|
||||||
|
content: "›"; display: inline-block; width: 14px; flex: 0 0 14px; color: var(--muted); font-size: 14px; line-height: 1; transition: transform .15s ease;
|
||||||
|
}
|
||||||
|
.docker-service-editor[open]>summary::before, .docker-health-editor[open]>summary::before, .docker-acl-editor[open]>summary::before,
|
||||||
|
.docker-protocol-editor[open]>summary::before, .docker-timeout-editor[open]>summary::before, .docker-passive-health-editor[open]>summary::before { transform: rotate(90deg); color: var(--text); }
|
||||||
|
.docker-service-editor>summary>span { flex: 0 0 auto; }
|
||||||
|
.docker-service-editor>summary .docker-config-hint { flex: 1; min-width: 0; }
|
||||||
|
.docker-service-editor:not(.docker-service-metrics)>summary:hover>span { color: var(--green); }
|
||||||
|
.docker-service-editor[open]:not(.docker-service-metrics) { border: 1px solid var(--line); border-radius: var(--rp-radius-surface, 6px); background: var(--panel2); padding: 10px 12px 12px; }
|
||||||
|
.docker-service-editor[open]:not(.docker-service-metrics)>.docker-health-editor,
|
||||||
|
.docker-service-editor[open]:not(.docker-service-metrics)>.docker-acl-editor,
|
||||||
|
.docker-service-editor[open]:not(.docker-service-metrics)>.docker-protocol-editor,
|
||||||
|
.docker-service-editor[open]:not(.docker-service-metrics)>.docker-timeout-editor { margin-top: 10px; }
|
||||||
|
.docker-service-editor>summary:focus-visible, .docker-acl-editor>summary:focus-visible, .docker-protocol-editor>summary:focus-visible, .docker-timeout-editor>summary:focus-visible, .docker-health-editor>summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
|
||||||
|
.vh-tabs { gap: 2px; padding: 4px 0 0; border-bottom: 1px solid var(--line); margin-bottom: 12px; }
|
||||||
|
.vh-tabs button { font-size: 12px; padding: 8px 12px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--muted); margin-bottom: -1px; }
|
||||||
|
.vh-tabs button:hover { color: var(--text); background: var(--panel2); }
|
||||||
|
.vh-tabs button.active { color: var(--text); background: transparent; border-bottom-color: var(--green); font-weight: 600; }
|
||||||
|
.vh-tabs button:focus-visible { outline: 2px solid var(--green); outline-offset: -2px; }
|
||||||
|
.vh-form>.card-heading { gap: 10px; align-items: center; }
|
||||||
|
.vh-form .form-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.vh-form .form-actions .primary { order: 0; }
|
||||||
|
.vh-form .form-actions .quiet { order: 1; }
|
||||||
|
.vh-setting-section>h3 { letter-spacing: .01em; }
|
||||||
|
.vh-tabs { flex-wrap: wrap; overflow: visible; row-gap: 0; }
|
||||||
|
@media (max-width:700px) { .vh-tabs button { font-size: 11px; padding: 7px 8px; } }
|
||||||
|
.auto-id-hint, .auto-cert-hint { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.35; }
|
||||||
|
.auto-id-reset { justify-self: start; margin-top: 3px; padding: 2px 8px; font-size: 11px; }
|
||||||
|
.auto-id-hint[hidden], .auto-cert-hint[hidden], .auto-id-reset[hidden] { display: none; }
|
||||||
|
.auto-id-field>span:first-child { color: var(--muted); }
|
||||||
|
.auto-id-field.is-compact>input { display: none; }
|
||||||
|
.auto-id-row { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 2px; }
|
||||||
|
.auto-id-value { font-family: var(--mono, monospace); font-size: 13px; color: var(--text); overflow-wrap: anywhere; }
|
||||||
|
.auto-id-value.is-pending { color: var(--muted); font-style: italic; font-family: inherit; font-size: 12px; }
|
||||||
|
.auto-id-badge { display: inline-flex; align-items: center; padding: 2px 6px; border: 1px solid var(--pill-line); border-radius: var(--rp-radius-control, 0); color: var(--accent); font: 9px var(--mono); letter-spacing: 1px; text-transform: uppercase; }
|
||||||
|
.auto-id-change { padding: 2px 8px; font-size: 11px; margin-left: auto; }
|
||||||
|
.auto-id-row[hidden], .auto-id-change[hidden], .auto-id-badge[hidden] { display: none; }
|
||||||
|
.auto-id-field input { font-family: var(--mono, monospace); font-size: 12px; }
|
||||||
|
|
||||||
|
/* Docker vhosts (post-0.36.6): section headers aligned left, hint follows the title */
|
||||||
|
.docker-service-editor>summary, .docker-health-editor>summary, .docker-acl-editor>summary,
|
||||||
|
.docker-protocol-editor>summary, .docker-timeout-editor>summary, .docker-passive-health-editor>summary {
|
||||||
|
justify-content: flex-start; text-align: left;
|
||||||
|
}
|
||||||
|
.docker-service-editor>summary>span, .docker-health-editor>summary>span, .docker-acl-editor>summary>span,
|
||||||
|
.docker-protocol-editor>summary>span, .docker-timeout-editor>summary>span, .docker-passive-health-editor>summary>span { text-align: left; }
|
||||||
|
.docker-service-editor>summary .docker-config-hint, .docker-health-editor>summary>small, .docker-acl-editor>summary>small,
|
||||||
|
.docker-protocol-editor>summary>small, .docker-timeout-editor>summary>small, .docker-passive-health-editor>summary>small {
|
||||||
|
flex: 1 1 auto; min-width: 0; text-align: left;
|
||||||
|
}
|
||||||
|
@media (min-width:721px) {
|
||||||
|
.docker-service-editor>summary>span, .docker-health-editor>summary>span, .docker-acl-editor>summary>span,
|
||||||
|
.docker-protocol-editor>summary>span, .docker-timeout-editor>summary>span, .docker-passive-health-editor>summary>span { flex: 0 0 190px; }
|
||||||
|
}
|
||||||
@@ -5,10 +5,6 @@
|
|||||||
"bandwidth",
|
"bandwidth",
|
||||||
"errors",
|
"errors",
|
||||||
"memory",
|
"memory",
|
||||||
"extra_backends_healthy",
|
|
||||||
"extra_error_share",
|
|
||||||
"extra_rejected_rate",
|
|
||||||
"extra_cert_expiry",
|
|
||||||
"websocket",
|
"websocket",
|
||||||
"backend_traffic",
|
"backend_traffic",
|
||||||
"extra_response_latency",
|
"extra_response_latency",
|
||||||
@@ -69,8 +65,10 @@
|
|||||||
"cache_diagnostics",
|
"cache_diagnostics",
|
||||||
"extra_cold_queue_memory",
|
"extra_cold_queue_memory",
|
||||||
"process_diagnostics",
|
"process_diagnostics",
|
||||||
|
"process_simple",
|
||||||
"network_diagnostics",
|
"network_diagnostics",
|
||||||
"backend_table",
|
"backend_table",
|
||||||
|
"backend_tiny",
|
||||||
"www_status",
|
"www_status",
|
||||||
"www_kind",
|
"www_kind",
|
||||||
"www_capacity",
|
"www_capacity",
|
||||||
@@ -82,7 +80,11 @@
|
|||||||
"www_failures",
|
"www_failures",
|
||||||
"www_traffic",
|
"www_traffic",
|
||||||
"www_top_services",
|
"www_top_services",
|
||||||
"www_status_trend"
|
"www_status_trend",
|
||||||
|
"extra_backends_healthy",
|
||||||
|
"extra_error_share",
|
||||||
|
"extra_rejected_rate",
|
||||||
|
"extra_cert_expiry"
|
||||||
],
|
],
|
||||||
"dashboard_hidden_widgets": [
|
"dashboard_hidden_widgets": [
|
||||||
"cache_outcomes",
|
"cache_outcomes",
|
||||||
@@ -136,7 +138,13 @@
|
|||||||
"www_status_trend",
|
"www_status_trend",
|
||||||
"extra_cold_queue_memory",
|
"extra_cold_queue_memory",
|
||||||
"wide_cache_activity",
|
"wide_cache_activity",
|
||||||
"backend_rotations"
|
"backend_rotations",
|
||||||
|
"extra_backends_healthy",
|
||||||
|
"extra_error_share",
|
||||||
|
"extra_rejected_rate",
|
||||||
|
"extra_cert_expiry",
|
||||||
|
"backend_tiny",
|
||||||
|
"process_simple"
|
||||||
],
|
],
|
||||||
"dashboard_density": "compact",
|
"dashboard_density": "compact",
|
||||||
"overview_samples": 600
|
"overview_samples": 600
|
||||||
|
|||||||
@@ -705,8 +705,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "process_diagnostics",
|
"id": "process_diagnostics",
|
||||||
"label_key": "processDiagnostics",
|
"label_key": "processOverviewTitle",
|
||||||
"description_key": "overviewPanelProcessDescription",
|
"description_key": "processOverviewDescription",
|
||||||
"element_selector": "[data-dashboard-widget=\"process_diagnostics\"]",
|
"element_selector": "[data-dashboard-widget=\"process_diagnostics\"]",
|
||||||
"data_source": "system_status",
|
"data_source": "system_status",
|
||||||
"no_data": "neutral_dash",
|
"no_data": "neutral_dash",
|
||||||
@@ -714,6 +714,17 @@
|
|||||||
"default_hidden": false,
|
"default_hidden": false,
|
||||||
"display_type": "panel"
|
"display_type": "panel"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "process_simple",
|
||||||
|
"label_key": "processClassicTitle",
|
||||||
|
"description_key": "overviewPanelProcessDescription",
|
||||||
|
"element_selector": "[data-dashboard-widget=\"process_simple\"]",
|
||||||
|
"data_source": "system_status",
|
||||||
|
"no_data": "neutral_dash",
|
||||||
|
"category": "panels",
|
||||||
|
"default_hidden": true,
|
||||||
|
"display_type": "panel"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "network_diagnostics",
|
"id": "network_diagnostics",
|
||||||
"label_key": "networkDiagnostics",
|
"label_key": "networkDiagnostics",
|
||||||
@@ -727,8 +738,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "backend_table",
|
"id": "backend_table",
|
||||||
"label_key": "vhHealthTitle",
|
"label_key": "backendOverviewTitle",
|
||||||
"description_key": "overviewPanelBackendsDescription",
|
"description_key": "backendOverviewDescription",
|
||||||
"element_selector": "[data-dashboard-widget=\"backend_table\"]",
|
"element_selector": "[data-dashboard-widget=\"backend_table\"]",
|
||||||
"data_source": "backend_status",
|
"data_source": "backend_status",
|
||||||
"no_data": "neutral_dash",
|
"no_data": "neutral_dash",
|
||||||
@@ -736,6 +747,17 @@
|
|||||||
"default_hidden": false,
|
"default_hidden": false,
|
||||||
"display_type": "table"
|
"display_type": "table"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "backend_tiny",
|
||||||
|
"label_key": "backendTinyTitle",
|
||||||
|
"description_key": "backendTinyDescription",
|
||||||
|
"element_selector": "[data-dashboard-widget=\"backend_tiny\"]",
|
||||||
|
"data_source": "backend_status",
|
||||||
|
"no_data": "neutral_dash",
|
||||||
|
"category": "panels",
|
||||||
|
"default_hidden": true,
|
||||||
|
"display_type": "table"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "www_status",
|
"id": "www_status",
|
||||||
"label_key": "webMetricStatus",
|
"label_key": "webMetricStatus",
|
||||||
@@ -876,7 +898,7 @@
|
|||||||
"data_source": "backend_status",
|
"data_source": "backend_status",
|
||||||
"no_data": "neutral_dash",
|
"no_data": "neutral_dash",
|
||||||
"category": "backends",
|
"category": "backends",
|
||||||
"default_hidden": false,
|
"default_hidden": true,
|
||||||
"display_type": "value_chart"
|
"display_type": "value_chart"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -887,7 +909,7 @@
|
|||||||
"data_source": "status_metrics",
|
"data_source": "status_metrics",
|
||||||
"no_data": "neutral_dash",
|
"no_data": "neutral_dash",
|
||||||
"category": "performance",
|
"category": "performance",
|
||||||
"default_hidden": false,
|
"default_hidden": true,
|
||||||
"display_type": "value_chart"
|
"display_type": "value_chart"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -898,7 +920,7 @@
|
|||||||
"data_source": "status_metrics",
|
"data_source": "status_metrics",
|
||||||
"no_data": "neutral_dash",
|
"no_data": "neutral_dash",
|
||||||
"category": "performance",
|
"category": "performance",
|
||||||
"default_hidden": false,
|
"default_hidden": true,
|
||||||
"display_type": "value_chart"
|
"display_type": "value_chart"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -909,7 +931,7 @@
|
|||||||
"data_source": "system_status",
|
"data_source": "system_status",
|
||||||
"no_data": "neutral_dash",
|
"no_data": "neutral_dash",
|
||||||
"category": "overview",
|
"category": "overview",
|
||||||
"default_hidden": false,
|
"default_hidden": true,
|
||||||
"display_type": "value_chart"
|
"display_type": "value_chart"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+19
-8
@@ -2,7 +2,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"];
|
const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"];
|
||||||
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false };
|
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false };
|
||||||
const expandedMetrics = new Set(), metricHistory = new Map();
|
const collapsedMetrics = new Set(), metricHistory = new Map();
|
||||||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.docker_vhost_sort) ? preferences.docker_vhost_sort : "service", descending: preferences?.docker_vhost_descending === true }; }
|
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.docker_vhost_sort) ? preferences.docker_vhost_sort : "service", descending: preferences?.docker_vhost_descending === true }; }
|
||||||
function syncSortDirection() { const control = $("docker-vhost-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
|
function syncSortDirection() { const control = $("docker-vhost-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
|
||||||
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); }
|
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); }
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled")));
|
const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled")));
|
||||||
const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim());
|
const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim());
|
||||||
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions);
|
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions);
|
||||||
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_-]{1,64}"; if (agent) id.readOnly = true;
|
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_\\-]{1,64}"; if (agent) id.readOnly = true;
|
||||||
const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true;
|
const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true;
|
||||||
const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876";
|
const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876";
|
||||||
const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password";
|
const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password";
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
const section = el("details", undefined, "docker-service-editor docker-service-metrics");
|
const section = el("details", undefined, "docker-service-editor docker-service-metrics");
|
||||||
section.dataset.viewKey = viewKey(agent.id, serviceKey);
|
section.dataset.viewKey = viewKey(agent.id, serviceKey);
|
||||||
section.dataset.agentId = agent.id; section.dataset.serviceKey = serviceKey;
|
section.dataset.agentId = agent.id; section.dataset.serviceKey = serviceKey;
|
||||||
section.open = expandedMetrics.has(section.dataset.viewKey);
|
section.open = !collapsedMetrics.has(section.dataset.viewKey);
|
||||||
const summary = el("summary"); summary.append(el("span", t("dockerTrafficStatistics"))); section.append(summary);
|
const summary = el("summary"); summary.append(el("span", t("dockerTrafficStatistics"))); section.append(summary);
|
||||||
const grid = el("div", undefined, "vhost-live-grid"), fields = {};
|
const grid = el("div", undefined, "vhost-live-grid"), fields = {};
|
||||||
for (const key of ["status", "vhRequests", "vhActive", "vhErrors", "vhLatency", "vhTraffic"]) {
|
for (const key of ["status", "vhRequests", "vhActive", "vhErrors", "vhLatency", "vhTraffic"]) {
|
||||||
@@ -237,8 +237,8 @@
|
|||||||
svg.append(line); chart.append(svg); section.append(chart);
|
svg.append(line); chart.append(svg); section.append(chart);
|
||||||
section.dockerMetricView = { fields, chartValue, line };
|
section.dockerMetricView = { fields, chartValue, line };
|
||||||
section.addEventListener("toggle", () => {
|
section.addEventListener("toggle", () => {
|
||||||
if (section.open) expandedMetrics.add(section.dataset.viewKey);
|
if (section.open) collapsedMetrics.delete(section.dataset.viewKey);
|
||||||
else expandedMetrics.delete(section.dataset.viewKey);
|
else collapsedMetrics.add(section.dataset.viewKey);
|
||||||
syncMetricsToggle();
|
syncMetricsToggle();
|
||||||
});
|
});
|
||||||
return section;
|
return section;
|
||||||
@@ -516,6 +516,11 @@
|
|||||||
}
|
}
|
||||||
card.hidden = rows.length ? visible === 0 : (!!query && !(card.dataset.searchText || "").includes(query));
|
card.hidden = rows.length ? visible === 0 : (!!query && !(card.dataset.searchText || "").includes(query));
|
||||||
}
|
}
|
||||||
|
const hideDisabled = $("docker-hide-disabled");
|
||||||
|
if (hideDisabled) {
|
||||||
|
const pending = [...document.querySelectorAll("#docker-discovery-list .docker-service-row")].filter(row => row.dockerSort?.published === 1 && !hiddenVhosts.has(row.dataset.viewKey)).length;
|
||||||
|
hideDisabled.disabled = pending === 0; hideDisabled.textContent = `${t("dockerHideDisabled")}${pending ? ` (${pending})` : ""}`;
|
||||||
|
}
|
||||||
const toggle = $("docker-show-hidden"); if (toggle) { const key = showHidden ? "dockerHideHidden" : "dockerShowHidden"; toggle.dataset.i18n = key; toggle.textContent = t(key); }
|
const toggle = $("docker-show-hidden"); if (toggle) { const key = showHidden ? "dockerHideHidden" : "dockerShowHidden"; toggle.dataset.i18n = key; toggle.textContent = t(key); }
|
||||||
syncMetricsToggle();
|
syncMetricsToggle();
|
||||||
}
|
}
|
||||||
@@ -560,8 +565,8 @@
|
|||||||
const expand = !sections.every(section => section.open);
|
const expand = !sections.every(section => section.open);
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
section.open = expand;
|
section.open = expand;
|
||||||
if (expand) expandedMetrics.add(section.dataset.viewKey);
|
if (expand) collapsedMetrics.delete(section.dataset.viewKey);
|
||||||
else expandedMetrics.delete(section.dataset.viewKey);
|
else collapsedMetrics.add(section.dataset.viewKey);
|
||||||
}
|
}
|
||||||
syncMetricsToggle();
|
syncMetricsToggle();
|
||||||
});
|
});
|
||||||
@@ -570,6 +575,12 @@
|
|||||||
$("docker-search")?.addEventListener("input", applyViewFilters);
|
$("docker-search")?.addEventListener("input", applyViewFilters);
|
||||||
$("docker-vhost-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); applyViewFilters(); });
|
$("docker-vhost-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); applyViewFilters(); });
|
||||||
$("docker-vhost-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); applyViewFilters(); });
|
$("docker-vhost-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); applyViewFilters(); });
|
||||||
|
$("docker-hide-disabled")?.addEventListener("click", () => {
|
||||||
|
for (const row of document.querySelectorAll("#docker-discovery-list .docker-service-row")) if (row.dockerSort?.published === 1) hiddenVhosts.add(row.dataset.viewKey);
|
||||||
|
saveHidden();
|
||||||
|
document.querySelectorAll("#docker-discovery-list .docker-view-toggle").forEach(button => { const row = button.closest(".docker-service-row"); if (row) button.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); });
|
||||||
|
applyViewFilters();
|
||||||
|
});
|
||||||
$("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); });
|
$("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); });
|
||||||
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
|
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
|
||||||
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
|
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
|
||||||
@@ -591,5 +602,5 @@
|
|||||||
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
|
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
|
||||||
run: async context => refresh(false, context)
|
run: async context => refresh(false, context)
|
||||||
});
|
});
|
||||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, renderStatus: renderDockerMetrics, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); expandedMetrics.clear(); metricHistory.clear(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, renderStatus: renderDockerMetrics, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); collapsedMetrics.clear(); metricHistory.clear(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||||
})();
|
})();
|
||||||
@@ -98,5 +98,11 @@
|
|||||||
run: async () => render()
|
run: async () => render()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", async event => {
|
||||||
|
const button = event.target.closest?.("[data-editor-cancel]"); if (!button) return;
|
||||||
|
const api = editors.get(button.dataset.editorCancel); if (!api?.isOpen?.()) return;
|
||||||
|
if (api.isDirty?.() && !(await appConfirm(t("draftDiscardConfirm"), { danger: true, confirmLabel: t("discardAction") }))) return;
|
||||||
|
api.close?.();
|
||||||
|
});
|
||||||
window.rustProxyDrafts = { register, dock: dockEditor, prepare, reset, refresh: render, captureControls, restoreControls, takeChildren, restoreChildren };
|
window.rustProxyDrafts = { register, dock: dockEditor, prepare, reset, refresh: render, captureControls, restoreControls, takeChildren, restoreChildren };
|
||||||
})();
|
})();
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
const form = $("notfound-form"), dialog = $("notfound-dialog"), list = $("notfound-list");
|
const form = $("notfound-form"), dialog = $("notfound-dialog"), list = $("notfound-list");
|
||||||
if (!form || !dialog || !list) return;
|
if (!form || !dialog || !list) return;
|
||||||
const sortColumns = ["id", "name", "hostname", "status", "mode", "listeners", "certificate"];
|
const sortColumns = ["id", "name", "hostname", "status", "mode", "listeners", "certificate"];
|
||||||
let editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [], listPrefs = { sort: "id", descending: false };
|
let notFoundAutoCertificate = null, editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [], listPrefs = { sort: "id", descending: false };
|
||||||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.notfound_list_sort) ? preferences.notfound_list_sort : "id", descending: preferences?.notfound_list_descending === true }; }
|
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.notfound_list_sort) ? preferences.notfound_list_sort : "id", descending: preferences?.notfound_list_descending === true }; }
|
||||||
function syncSortDirection() { const control = $("notfound-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
|
function syncSortDirection() { const control = $("notfound-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
|
||||||
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); render(); }
|
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); render(); }
|
||||||
@@ -44,9 +44,11 @@
|
|||||||
editing = item.id || null; revision = envelope?.revision ?? null; dirty = false;
|
editing = item.id || null; revision = envelope?.revision ?? null; dirty = false;
|
||||||
$("notfound-editing").textContent = editing || t("notfoundNewLabel");
|
$("notfound-editing").textContent = editing || t("notfoundNewLabel");
|
||||||
$("notfound-id").value = item.id || ""; $("notfound-id").disabled = Boolean(editing); $("notfound-name").value = item.name || "";
|
$("notfound-id").value = item.id || ""; $("notfound-id").disabled = Boolean(editing); $("notfound-name").value = item.name || "";
|
||||||
|
window.rustProxyAutoId.attach($("notfound-id"), { elements: [$("notfound-name")], taken: () => (envelope?.config.not_found_hosts || []).map(value => value.id) });
|
||||||
$("notfound-enabled").checked = item.enabled !== false; syncEnabledControl(); $("notfound-http2").checked = item.http2_enabled !== false; $("notfound-http3").checked = item.http3_enabled !== false;
|
$("notfound-enabled").checked = item.enabled !== false; syncEnabledControl(); $("notfound-http2").checked = item.http2_enabled !== false; $("notfound-http3").checked = item.http3_enabled !== false;
|
||||||
const hostHolder = $("notfound-hosts-editor"); hostHolder.replaceChildren(); readHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "notfoundHosts", helpKey: "vhHostsHelp", requiredKey: "notfoundHostsRequired", onChange: changed });
|
const hostHolder = $("notfound-hosts-editor"); hostHolder.replaceChildren(); readHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "notfoundHosts", helpKey: "vhHostsHelp", requiredKey: "notfoundHostsRequired", onChange: () => { changed(); notFoundAutoCertificate?.refresh(); } });
|
||||||
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
|
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
|
||||||
|
notFoundAutoCertificate = window.rustProxyAutoCertificate.attach($("notfound-certificate"), { auto: !editing, hosts: () => { try { return readHosts(); } catch { return []; } }, certificates: () => envelope?.config.tls?.certificates || [] }); notFoundAutoCertificate.refresh();
|
||||||
$("notfound-mode").value = item.page?.kind || "welcome"; populateStatuses(item.page?.status ?? 404); $("notfound-html").value = item.page?.html || ""; syncMode(); dialog.showModal();
|
$("notfound-mode").value = item.page?.kind || "welcome"; populateStatuses(item.page?.status ?? 404); $("notfound-html").value = item.page?.html || ""; syncMode(); dialog.showModal();
|
||||||
}
|
}
|
||||||
function suspendDraft() {
|
function suspendDraft() {
|
||||||
@@ -79,9 +81,15 @@
|
|||||||
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
|
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
|
||||||
list.replaceChildren(...entries.map(item => {
|
list.replaceChildren(...entries.map(item => {
|
||||||
const card = el("article", undefined, "vhost-card redirect-card"), head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
|
const card = el("article", undefined, "vhost-card redirect-card"), head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
|
||||||
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
|
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title); head.append(identity);
|
||||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not-found-hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
|
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("copyEntry"), () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(item); copy.id = ""; copy.name = ""; copy.frontend = { ...(copy.frontend || {}), hosts: [] }; copy.certificate_id = null; load(copy); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not-found-hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
|
||||||
const detail = el("div", undefined, "vhost-detail-strip"); for (const [key, text] of [["notfoundStatus", String(item.page?.status ?? 404)], ["notfoundMode", t(`notfoundMode${(item.page?.kind || "welcome").replace(/^./, c => c.toUpperCase())}`)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(key), "small-label"), el("b", text)); detail.append(node); } card.append(detail); return card;
|
const flow = el("div", undefined, "redirect-flow"), from = el("div", undefined, "redirect-flow-from"), to = el("div", undefined, "redirect-flow-to");
|
||||||
|
for (const host of item.frontend?.hosts || []) from.append(el("span", host, "redirect-host-chip"));
|
||||||
|
to.append(el("span", `${item.page?.status ?? 404} ${STATUS_NAMES[item.page?.status ?? 404] || ""}`.trim(), "redirect-target"), el("span", t(`notfoundMode${(item.page?.kind || "welcome").replace(/^./, c => c.toUpperCase())}`), "redirect-target-more"));
|
||||||
|
flow.append(from, el("span", "→", "redirect-flow-arrow"), to); card.append(flow);
|
||||||
|
const meta = el("div", undefined, "redirect-meta");
|
||||||
|
for (const [k, v] of [["redirectListeners", (item.frontend?.listener_ids || []).join(", ") || "-"], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("span", undefined, "redirect-meta-item"); node.append(el("span", t(k)), el("b", v)); meta.append(node); }
|
||||||
|
card.append(meta); return card;
|
||||||
}));
|
}));
|
||||||
if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine"));
|
if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine"));
|
||||||
}
|
}
|
||||||
|
|||||||
+364
-100
@@ -58,9 +58,12 @@
|
|||||||
data-i18n="transform">Transform</b></button>
|
data-i18n="transform">Transform</b></button>
|
||||||
<button class="nav" data-panel="settings"><span>13</span><b data-i18n="settings">Settings</b></button>
|
<button class="nav" data-panel="settings"><span>13</span><b data-i18n="settings">Settings</b></button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-bottom"><span class="small-label" data-i18n="runtime">RUNTIME</span>
|
<div class="sidebar-bottom" id="app-health-slot" hidden>
|
||||||
<p>Rust / Tokio / Hyper<br />Axum / Tower / SQLite</p>
|
<button class="local-tag app-health-button is-checking" id="app-health-open" type="button"
|
||||||
<div class="local-tag"><i></i><span data-i18n="controlPlane">CONTROL PLANE</span></div>
|
aria-haspopup="dialog" aria-controls="app-health-dialog">
|
||||||
|
<i aria-hidden="true"></i><span id="app-health-label"
|
||||||
|
data-i18n="appHealthSidebarChecking">CHECKING</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
@@ -97,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
|
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
|
||||||
autocomplete="username" id="username" maxlength="64" minlength="3"
|
autocomplete="username" id="username" maxlength="64" minlength="3"
|
||||||
pattern="[A-Za-z0-9_.-]{3,64}" required="" spellcheck="false" />
|
pattern="[A-Za-z0-9_.\-]{3,64}" required="" spellcheck="false" />
|
||||||
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
|
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
|
||||||
id="password" maxlength="128" required="" type="password" />
|
id="password" maxlength="128" required="" type="password" />
|
||||||
<div hidden="" id="confirm-field"><label data-i18n="confirmPassword" for="password-confirm">Confirm
|
<div hidden="" id="confirm-field"><label data-i18n="confirmPassword" for="password-confirm">Confirm
|
||||||
@@ -121,17 +124,13 @@
|
|||||||
aria-label="Overview chart samples" data-i18n-aria="overviewSamplesTitle">
|
aria-label="Overview chart samples" data-i18n-aria="overviewSamplesTitle">
|
||||||
<label class="function-live-chart-samples" for="overview-live-samples"><span
|
<label class="function-live-chart-samples" for="overview-live-samples"><span
|
||||||
data-i18n="liveChartSamples">Samples</span>
|
data-i18n="liveChartSamples">Samples</span>
|
||||||
<select id="overview-live-samples" aria-describedby="overview-samples-state">
|
<select id="overview-live-samples">
|
||||||
<option value="60">60</option>
|
<option value="60">60</option>
|
||||||
<option value="120">120</option>
|
<option value="120">120</option>
|
||||||
<option value="300">300</option>
|
<option value="300">300</option>
|
||||||
<option value="600">600</option>
|
<option value="600">600</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button class="quiet" id="overview-samples-save" type="button"
|
|
||||||
data-i18n="overviewSamplesRemember">Remember choice</button>
|
|
||||||
<small class="fine" id="overview-samples-state" role="status" aria-live="polite"
|
|
||||||
data-i18n="overviewSamplesSaved">Saved default</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-intro-title-row">
|
<div class="page-intro-title-row">
|
||||||
@@ -145,6 +144,9 @@
|
|||||||
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
|
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
|
||||||
<button class="primary" id="overview-inline-save" type="button"
|
<button class="primary" id="overview-inline-save" type="button"
|
||||||
data-i18n="overviewLayoutInlineSave">Save and lock</button>
|
data-i18n="overviewLayoutInlineSave">Save and lock</button>
|
||||||
|
<button class="quiet" id="overview-inline-compact" type="button"
|
||||||
|
data-i18n="overviewLayoutInlineCompact"
|
||||||
|
title="Move blocks into empty grid space once">Fill gaps</button>
|
||||||
<button class="quiet" id="overview-inline-cancel" type="button"
|
<button class="quiet" id="overview-inline-cancel" type="button"
|
||||||
data-i18n="overviewLayoutInlineCancel">Cancel changes</button>
|
data-i18n="overviewLayoutInlineCancel">Cancel changes</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,9 +261,87 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card" data-dashboard-widget="process_diagnostics">
|
<article class="card pr-card" data-dashboard-widget="process_diagnostics">
|
||||||
<div class="mini-heading">
|
<div class="mini-heading">
|
||||||
<h3 data-i18n="processDiagnostics">Process</h3>
|
<h3 data-i18n="processOverviewTitle">Process</h3>
|
||||||
|
<div class="mini-heading-actions">
|
||||||
|
<details class="metric-info">
|
||||||
|
<summary data-i18n-aria="memoryAccountingInfo"
|
||||||
|
aria-label="Memory accounting information">i</summary>
|
||||||
|
<div class="metric-info-popover">
|
||||||
|
<p class="fine" data-i18n="memoryAccountingHint">RSS is the real process
|
||||||
|
footprint. HOT/transient cache values are RustProxy-accounted
|
||||||
|
allocations; transform memory is a conservative active reservation.
|
||||||
|
Non-cache RSS is RSS minus accounted cache and includes transforms,
|
||||||
|
connections, TLS, logging, Tokio/runtime state, allocator overhead and
|
||||||
|
other buffers, so these rows are not all additive.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<span class="meta" id="po-sample-age">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl class="pr-chips">
|
||||||
|
<div class="pr-chip"><dt data-i18n="platform">Platform</dt><dd id="po-platform">-</dd></div>
|
||||||
|
<div class="pr-chip"><dt data-i18n="processThreads">OS threads</dt><dd id="po-threads">-</dd></div>
|
||||||
|
<div class="pr-chip"><dt data-i18n="processStartTime">Process start</dt><dd id="po-start">-</dd></div>
|
||||||
|
<div class="pr-chip"><dt data-i18n="uptime">Uptime</dt><dd id="po-uptime">-</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="pr-cols">
|
||||||
|
<section>
|
||||||
|
<h4 class="pr-title" data-i18n="processLoadTitle">Load</h4>
|
||||||
|
<dl class="pr-list">
|
||||||
|
<div class="pr-row"><dt data-i18n="processCpu">CPU (100% = 1 core)</dt><dd id="po-cpu">-</dd><i class="pr-meter"><b></b></i></div>
|
||||||
|
<div class="pr-row"><dt data-i18n="processRssTotal">RSS total</dt><dd id="po-rss-total">-</dd><i class="pr-meter"><b></b></i></div>
|
||||||
|
<div class="pr-row"><dt data-i18n="fileDescriptors">File descriptors / limit</dt><dd id="po-fds">-</dd><i class="pr-meter"><b></b></i></div>
|
||||||
|
<div class="pr-row"><dt data-i18n="containerMemory">cgroup RAM / limit</dt><dd id="po-cgroup">-</dd><i class="pr-meter"><b></b></i></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4 class="pr-title" data-i18n="processMemoryTitle">Memory</h4>
|
||||||
|
<div class="pr-split" id="po-rss-split" aria-hidden="true"><i class="a"></i><i class="f"></i></div>
|
||||||
|
<dl class="pr-list pr-plain">
|
||||||
|
<div class="pr-row"><dt data-i18n="processRssAnon">Anonymous RSS</dt><dd id="po-rss-anon">-</dd></div>
|
||||||
|
<div class="pr-row"><dt data-i18n="processRssFile">File-backed / shared RSS</dt><dd id="po-rss-file">-</dd></div>
|
||||||
|
<div class="pr-row"><dt data-i18n="memoryNonCacheRss">Non-cache process RSS</dt><dd id="po-non-cache">-</dd></div>
|
||||||
|
<div class="pr-row"><dt data-i18n="processVirtualMemory">Virtual memory / limit</dt><dd id="po-virtual">-</dd></div>
|
||||||
|
<div class="pr-row"><dt data-i18n="processDataSwap">Data address space / swap</dt><dd id="po-data-swap">-</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="mini-live-chart-stack pr-charts">
|
||||||
|
<div class="mini-live-chart">
|
||||||
|
<div class="mini-live-chart-head"><span data-i18n="processCpuHistory">Process CPU history</span><b id="po-cpu-chart-value">-</b></div>
|
||||||
|
<svg preserveAspectRatio="none" aria-label="Process CPU history" data-i18n-aria="processCpuHistory" role="img" viewbox="0 0 300 60">
|
||||||
|
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||||
|
<polyline class="diagnostic-line" id="po-cpu-sparkline" points="0,55 300,55"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="mini-live-chart">
|
||||||
|
<div class="mini-live-chart-head"><span data-i18n="processRssHistory">Process RSS history</span><b id="po-rss-chart-value">-</b></div>
|
||||||
|
<svg preserveAspectRatio="none" aria-label="Process RSS history" data-i18n-aria="processRssHistory" role="img" viewbox="0 0 300 60">
|
||||||
|
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||||
|
<polyline class="diagnostic-line process-rss-line" id="po-rss-sparkline" points="0,55 300,55"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="mini-live-chart">
|
||||||
|
<div class="mini-live-chart-head"><span data-i18n="processFdHistory">File descriptors history</span><b id="po-fd-chart-value">-</b></div>
|
||||||
|
<svg preserveAspectRatio="none" aria-label="File descriptors history" data-i18n-aria="processFdHistory" role="img" viewbox="0 0 300 60">
|
||||||
|
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||||
|
<polyline class="diagnostic-line pr-line-blue" id="po-fd-sparkline" points="0,55 300,55"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="mini-live-chart">
|
||||||
|
<div class="mini-live-chart-head"><span data-i18n="processThreadHistory">OS threads history</span><b id="po-thread-chart-value">-</b></div>
|
||||||
|
<svg preserveAspectRatio="none" aria-label="OS threads history" data-i18n-aria="processThreadHistory" role="img" viewbox="0 0 300 60">
|
||||||
|
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||||
|
<polyline class="diagnostic-line pr-line-muted" id="po-thread-sparkline" points="0,55 300,55"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="card" data-dashboard-widget="process_simple">
|
||||||
|
<div class="mini-heading">
|
||||||
|
<h3 data-i18n="processClassicTitle">Process simple</h3>
|
||||||
<div class="mini-heading-actions">
|
<div class="mini-heading-actions">
|
||||||
<details class="metric-info">
|
<details class="metric-info">
|
||||||
<summary data-i18n-aria="memoryAccountingInfo"
|
<summary data-i18n-aria="memoryAccountingInfo"
|
||||||
@@ -277,8 +357,7 @@
|
|||||||
</details>
|
</details>
|
||||||
<span class="meta" id="system-sample-age">-</span>
|
<span class="meta" id="system-sample-age">-</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</d <dl class="mini-metrics">
|
||||||
<dl class="mini-metrics">
|
|
||||||
<div>
|
<div>
|
||||||
<dt data-i18n="platform">Platform</dt>
|
<dt data-i18n="platform">Platform</dt>
|
||||||
<dd id="process-platform">-</dd>
|
<dd id="process-platform">-</dd>
|
||||||
@@ -431,10 +510,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card pool-card" id="backend-diagnostics-card"
|
<article class="card pool-card backend-overview-card" id="backend-overview-card"
|
||||||
data-dashboard-widget="backend_table">
|
data-dashboard-widget="backend_table">
|
||||||
<div class="card-heading backend-card-heading">
|
<div class="card-heading backend-card-heading">
|
||||||
<h2 data-i18n="vhHealthTitle">Backends</h2>
|
<div class="bo-heading">
|
||||||
|
<h2 data-i18n="backendOverviewTitle">Backends</h2>
|
||||||
|
<span class="bo-summary" id="bo-summary" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<div class="backend-table-tools">
|
||||||
|
<label class="sr-only" data-i18n="backendSearch" for="bo-search">Search
|
||||||
|
backends</label>
|
||||||
|
<input autocomplete="off" data-i18n-aria="backendSearch"
|
||||||
|
data-i18n-placeholder="backendSearchPlaceholder" id="bo-search"
|
||||||
|
type="search" />
|
||||||
|
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
|
||||||
|
by</span><select data-i18n-aria="backendSortBy" id="bo-sort"></select></label>
|
||||||
|
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
|
||||||
|
id="bo-sort-direction" type="button">↑</button>
|
||||||
|
<details class="backend-column-picker">
|
||||||
|
<summary class="quiet" data-i18n="backendColumns">Columns</summary>
|
||||||
|
<div class="backend-column-options" id="bo-column-options"></div>
|
||||||
|
</details>
|
||||||
|
<button class="quiet" data-i18n="vhManage" id="bo-open" type="button">Manage
|
||||||
|
vhosts</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bo-filters" id="bo-filters" role="group"></div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="bo-table">
|
||||||
|
<thead>
|
||||||
|
<tr id="bo-head"></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="bo-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="fine" data-i18n="vhHealthEmpty" id="bo-empty">No backend diagnostics yet.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card pool-card" id="backend-diagnostics-card"
|
||||||
|
data-dashboard-widget="backend_tiny">
|
||||||
|
<div class="card-heading backend-card-heading">
|
||||||
|
<h2 data-i18n="backendTinyTitle">Tiny backends</h2>
|
||||||
<div class="backend-table-tools">
|
<div class="backend-table-tools">
|
||||||
<label class="sr-only" data-i18n="backendSearch" for="pool-search">Search
|
<label class="sr-only" data-i18n="backendSearch" for="pool-search">Search
|
||||||
backends</label>
|
backends</label>
|
||||||
@@ -626,7 +741,8 @@
|
|||||||
data-i18n="dockerExpandAllMetrics" id="docker-metrics-toggle-all" type="button"
|
data-i18n="dockerExpandAllMetrics" id="docker-metrics-toggle-all" type="button"
|
||||||
aria-expanded="false" disabled>Expand all statistics</button><button class="quiet"
|
aria-expanded="false" disabled>Expand all statistics</button><button class="quiet"
|
||||||
data-i18n="dockerAgentsSettings" id="docker-settings-open" type="button">Docker agents
|
data-i18n="dockerAgentsSettings" id="docker-settings-open" type="button">Docker agents
|
||||||
settings</button><button class="quiet" data-i18n="dockerShowHidden"
|
settings</button><button class="quiet" data-i18n="dockerHideDisabled"
|
||||||
|
id="docker-hide-disabled" type="button" disabled>Hide disabled</button><button class="quiet" data-i18n="dockerShowHidden"
|
||||||
id="docker-show-hidden" type="button">Show hidden</button></div>
|
id="docker-show-hidden" type="button">Show hidden</button></div>
|
||||||
</article>
|
</article>
|
||||||
<div class="docker-agent-observations" id="docker-discovery-list"></div>
|
<div class="docker-agent-observations" id="docker-discovery-list"></div>
|
||||||
@@ -770,10 +886,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<label><span data-i18n="aclProfileId">Profile ID</span><input id="acl-profile-id"
|
|
||||||
maxlength="64" pattern="[A-Za-z0-9_-]{1,64}" required /></label>
|
|
||||||
<label><span data-i18n="aclProfileName">Display name</span><input id="acl-profile-name"
|
<label><span data-i18n="aclProfileName">Display name</span><input id="acl-profile-name"
|
||||||
maxlength="96" required /></label>
|
maxlength="96" required /></label>
|
||||||
|
<label class="auto-id-field"><span data-i18n="aclProfileId">Profile ID</span><input id="acl-profile-id"
|
||||||
|
maxlength="64" pattern="[A-Za-z0-9_\-]{1,64}" required /></label>
|
||||||
</div>
|
</div>
|
||||||
<section class="acl-policy-builder" aria-labelledby="acl-policy-mode-label">
|
<section class="acl-policy-builder" aria-labelledby="acl-policy-mode-label">
|
||||||
<div class="acl-policy-step-head">
|
<div class="acl-policy-step-head">
|
||||||
@@ -982,7 +1098,7 @@
|
|||||||
<div class="vh-grid">
|
<div class="vh-grid">
|
||||||
<label><span data-i18n="certificateId">Certificate ID</span><input
|
<label><span data-i18n="certificateId">Certificate ID</span><input
|
||||||
autocomplete="off" id="acme-name" maxlength="64"
|
autocomplete="off" id="acme-name" maxlength="64"
|
||||||
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
|
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||||
<label><span data-i18n="acmeEmail">ACME contact email</span><input
|
<label><span data-i18n="acmeEmail">ACME contact email</span><input
|
||||||
autocomplete="off" id="acme-email" maxlength="254" required=""
|
autocomplete="off" id="acme-email" maxlength="254" required=""
|
||||||
type="email" /></label>
|
type="email" /></label>
|
||||||
@@ -1063,7 +1179,7 @@
|
|||||||
<h3 data-i18n="certImport"></h3>
|
<h3 data-i18n="certImport"></h3>
|
||||||
<p class="fine" data-i18n="certLibraryHint"></p>
|
<p class="fine" data-i18n="certLibraryHint"></p>
|
||||||
<label data-i18n="certificateId" for="cert-import-name"></label><input
|
<label data-i18n="certificateId" for="cert-import-name"></label><input
|
||||||
id="cert-import-name" maxlength="64" pattern="[A-Za-z0-9_-]{1,64}"
|
id="cert-import-name" maxlength="64" pattern="[A-Za-z0-9_\-]{1,64}"
|
||||||
required="" />
|
required="" />
|
||||||
<label data-i18n="certCheckDomains" for="cert-import-domains"></label><input
|
<label data-i18n="certCheckDomains" for="cert-import-domains"></label><input
|
||||||
id="cert-import-domains" placeholder="app.example.com" required="" />
|
id="cert-import-domains" placeholder="app.example.com" required="" />
|
||||||
@@ -1081,7 +1197,7 @@
|
|||||||
<form class="edit-form" id="certificate-form">
|
<form class="edit-form" id="certificate-form">
|
||||||
<h3 data-i18n="addCertificate"></h3>
|
<h3 data-i18n="addCertificate"></h3>
|
||||||
<label data-i18n="certificateId" for="certificate-id"></label><input
|
<label data-i18n="certificateId" for="certificate-id"></label><input
|
||||||
id="certificate-id" maxlength="64" pattern="[A-Za-z0-9_.-]+"
|
id="certificate-id" maxlength="64" pattern="[A-Za-z0-9_.\-]+"
|
||||||
placeholder="app" required="" />
|
placeholder="app" required="" />
|
||||||
<label data-i18n="certLegacyNames" for="certificate-hosts"></label><input
|
<label data-i18n="certLegacyNames" for="certificate-hosts"></label><input
|
||||||
id="certificate-hosts" placeholder="app.localhost" />
|
id="certificate-hosts" placeholder="app.localhost" />
|
||||||
@@ -1094,10 +1210,10 @@
|
|||||||
<p class="fine" id="certificate-file-source-help"
|
<p class="fine" id="certificate-file-source-help"
|
||||||
data-i18n="certificateSourceManagedHelp"></p>
|
data-i18n="certificateSourceManagedHelp"></p>
|
||||||
<label data-i18n="certificateFile" for="certificate-file"></label><input
|
<label data-i18n="certificateFile" for="certificate-file"></label><input
|
||||||
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
|
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.\-]+"
|
||||||
placeholder="app.pem" required="" />
|
placeholder="app.pem" required="" />
|
||||||
<label data-i18n="privateKeyFile" for="certificate-key"></label><input
|
<label data-i18n="privateKeyFile" for="certificate-key"></label><input
|
||||||
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.-]+"
|
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.\-]+"
|
||||||
placeholder="app.key" required="" />
|
placeholder="app.key" required="" />
|
||||||
<div hidden id="certificate-external-acl-example"></div>
|
<div hidden id="certificate-external-acl-example"></div>
|
||||||
<div class="form-actions"><button type="button" class="quiet"
|
<div class="form-actions"><button type="button" class="quiet"
|
||||||
@@ -1396,8 +1512,8 @@
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-heading">
|
<div class="card-heading">
|
||||||
<h2 data-i18n="cacheObjectList">Stored objects</h2>
|
<h2 data-i18n="cacheObjectList">Stored objects</h2>
|
||||||
<div class="entity-actions"><button class="quiet" data-i18n="cachePrevious" id="cache-prev"
|
<div class="entity-actions cache-pager"><button class="quiet" data-i18n="cachePrevious" id="cache-prev"
|
||||||
type="button">Previous</button><span class="fine" id="cache-page"></span><button
|
type="button">Previous</button><span class="fine" id="cache-page" aria-live="polite"></span><button
|
||||||
class="quiet" data-i18n="cacheNext" id="cache-next" type="button">Next</button>
|
class="quiet" data-i18n="cacheNext" id="cache-next" type="button">Next</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1517,7 +1633,7 @@
|
|||||||
<h2 data-i18n="transformByType">Transformations by type</h2>
|
<h2 data-i18n="transformByType">Transformations by type</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table class="cache-table">
|
<table class="cache-table transform-type-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-i18n="transformType">Type</th>
|
<th data-i18n="transformType">Type</th>
|
||||||
@@ -1544,7 +1660,7 @@
|
|||||||
<p class="fine" data-i18n="transformScopeHint">Counters are in-memory runtime diagnostics. A
|
<p class="fine" data-i18n="transformScopeHint">Counters are in-memory runtime diagnostics. A
|
||||||
transformed cache HIT does not execute the transformer again.</p>
|
transformed cache HIT does not execute the transformer again.</p>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table class="cache-table">
|
<table class="cache-table transform-vhost-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-i18n="vhVhost">Vhost</th>
|
<th data-i18n="vhVhost">Vhost</th>
|
||||||
@@ -1791,6 +1907,17 @@
|
|||||||
</select><small class="fine" data-i18n="buttonStyleHelp">Normal uses the
|
</select><small class="fine" data-i18n="buttonStyleHelp">Normal uses the
|
||||||
refreshed controls; Compact restores the smaller source-style buttons
|
refreshed controls; Compact restores the smaller source-style buttons
|
||||||
across all actions and dialog tabs.</small></label>
|
across all actions and dialog tabs.</small></label>
|
||||||
|
<label class="check appearance-interface-icons-setting"><input
|
||||||
|
id="ui-interface-icons" type="checkbox" /><span><b
|
||||||
|
data-i18n="interfaceIcons">Show SVG interface icons</b><small
|
||||||
|
data-i18n="interfaceIconsHelp">Adds semantic SVG icons to
|
||||||
|
navigation,
|
||||||
|
actions, tabs and dialogs.</small></span></label>
|
||||||
|
<label class="check appearance-logo-animation-setting"><input
|
||||||
|
id="ui-logo-animation" type="checkbox" /><span><b
|
||||||
|
data-i18n="logoAnimation">Animate logo</b><small
|
||||||
|
data-i18n="logoAnimationHelp">Plays short, subtle animations of
|
||||||
|
the sidebar logo from time to time and on hover.</small></span></label>
|
||||||
<label><span data-i18n="cornerStyle">Corner rounding</span><select
|
<label><span data-i18n="cornerStyle">Corner rounding</span><select
|
||||||
id="ui-corner-style">
|
id="ui-corner-style">
|
||||||
<option data-i18n="cornerStyleDefault" value="default">Original
|
<option data-i18n="cornerStyleDefault" value="default">Original
|
||||||
@@ -1808,7 +1935,7 @@
|
|||||||
<section class="appearance-section">
|
<section class="appearance-section">
|
||||||
<div class="appearance-section-heading">
|
<div class="appearance-section-heading">
|
||||||
<div><span class="small-label" data-i18n="appearanceChartsSection">CHARTS</span>
|
<div><span class="small-label" data-i18n="appearanceChartsSection">CHARTS</span>
|
||||||
<h3 data-i18n="chartStyle">Chart style</h3>
|
<h3 data-i18n="appearanceChartsTitle">Charts</h3>
|
||||||
</div><button class="quiet appearance-section-reset"
|
</div><button class="quiet appearance-section-reset"
|
||||||
data-appearance-reset="charts" data-i18n="resetSection" type="button">Reset
|
data-appearance-reset="charts" data-i18n="resetSection" type="button">Reset
|
||||||
section</button>
|
section</button>
|
||||||
@@ -1827,6 +1954,14 @@
|
|||||||
</option>
|
</option>
|
||||||
<option data-i18n="chartStyleWarm" value="warm">Warm</option>
|
<option data-i18n="chartStyleWarm" value="warm">Warm</option>
|
||||||
<option data-i18n="chartStyleCool" value="cool">Cool</option>
|
<option data-i18n="chartStyleCool" value="cool">Cool</option>
|
||||||
|
<option data-i18n="chartStyleSunset" value="sunset">Sunset</option>
|
||||||
|
<option data-i18n="chartStyleAurora" value="aurora">Aurora</option>
|
||||||
|
<option data-i18n="chartStyleOcean" value="ocean">Ocean</option>
|
||||||
|
<option data-i18n="chartStyleForest" value="forest">Forest</option>
|
||||||
|
<option data-i18n="chartStylePastel" value="pastel">Pastel</option>
|
||||||
|
<option data-i18n="chartStyleTerminal" value="terminal">Terminal</option>
|
||||||
|
<option data-i18n="chartStyleBlueprint" value="blueprint">Blueprint</option>
|
||||||
|
<option data-i18n="chartStyleColorblind" value="colorblind">Colorblind safe</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
<label class="check appearance-chart-axes-setting"><input id="ui-chart-axes"
|
<label class="check appearance-chart-axes-setting"><input id="ui-chart-axes"
|
||||||
type="checkbox" /><span><b data-i18n="chartAxes">Show X/Y scales on
|
type="checkbox" /><span><b data-i18n="chartAxes">Show X/Y scales on
|
||||||
@@ -1851,7 +1986,7 @@
|
|||||||
<div class="appearance-section-heading">
|
<div class="appearance-section-heading">
|
||||||
<div><span class="small-label" data-i18n="appearanceDataSection">TABLES /
|
<div><span class="small-label" data-i18n="appearanceDataSection">TABLES /
|
||||||
LOGS</span>
|
LOGS</span>
|
||||||
<h3 data-i18n="tableDensity">Tables and logs density</h3>
|
<h3 data-i18n="appearanceTablesTitle">Tables and logs</h3>
|
||||||
</div><button class="quiet appearance-section-reset"
|
</div><button class="quiet appearance-section-reset"
|
||||||
data-appearance-reset="tables" data-i18n="resetSection" type="button">Reset
|
data-appearance-reset="tables" data-i18n="resetSection" type="button">Reset
|
||||||
section</button>
|
section</button>
|
||||||
@@ -2101,79 +2236,184 @@
|
|||||||
<div data-policy-settings-page="block-allow">
|
<div data-policy-settings-page="block-allow">
|
||||||
<form class="card edit-form global-policy-form" id="global-security-form">
|
<form class="card edit-form global-policy-form" id="global-security-form">
|
||||||
<div class="card-heading">
|
<div class="card-heading">
|
||||||
<div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP LISTENERS</span>
|
<div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP
|
||||||
|
LISTENERS</span>
|
||||||
<h2 data-i18n="globalBlockAllow">Global block / allow</h2>
|
<h2 data-i18n="globalBlockAllow">Global block / allow</h2>
|
||||||
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
|
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="fine" data-i18n="globalPolicyHelp">These rules apply before routing to the backend on all public HTTP vhosts, redirects and 404 hosts. Per-vhost restrictions remain active; a local allow cannot bypass a global block. Administration and TCP/UDP Streams are excluded.</p>
|
<p class="fine" data-i18n="globalPolicyHelp">These rules apply before routing to the
|
||||||
|
backend on all public HTTP vhosts, redirects and 404 hosts. Per-vhost
|
||||||
|
restrictions remain active; a local allow cannot bypass a global block.
|
||||||
|
Administration and TCP/UDP Streams are excluded.</p>
|
||||||
<section class="global-policy-section">
|
<section class="global-policy-section">
|
||||||
<div class="global-policy-heading"><h3 data-i18n="globalCountryTitle">Country access</h3><p class="fine" data-i18n="globalCountryHelp">Uses the verified client IP and GeoIP database. Unknown countries are denied in allow mode, but allowed in block mode.</p></div>
|
<div class="global-policy-heading">
|
||||||
|
<h3 data-i18n="globalCountryTitle">Country access</h3>
|
||||||
|
<p class="fine" data-i18n="globalCountryHelp">Uses the verified client IP
|
||||||
|
and GeoIP database. Unknown countries are denied in allow mode, but
|
||||||
|
allowed in block mode.</p>
|
||||||
|
</div>
|
||||||
<div class="global-policy-fields">
|
<div class="global-policy-fields">
|
||||||
<label><span data-i18n="globalCountryMode">Country policy</span><select id="global-country-mode">
|
<label><span data-i18n="globalCountryMode">Country policy</span><select
|
||||||
|
id="global-country-mode">
|
||||||
<option value="off" data-i18n="vhOff">Off</option>
|
<option value="off" data-i18n="vhOff">Off</option>
|
||||||
<option value="block" data-i18n="vhCountryBlock">Block listed</option>
|
<option value="block" data-i18n="vhCountryBlock">Block listed
|
||||||
<option value="allow" data-i18n="vhCountryAllow">Allow listed only</option>
|
</option>
|
||||||
|
<option value="allow" data-i18n="vhCountryAllow">Allow listed only
|
||||||
|
</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
<label><span data-i18n="globalCountryList">ISO country codes (one per line)</span><textarea id="global-country-list" rows="3" maxlength="1024" spellcheck="false" placeholder="RU CN"></textarea></label>
|
<label><span data-i18n="globalCountryList">ISO country codes (one per
|
||||||
|
line)</span><textarea id="global-country-list" rows="3"
|
||||||
|
maxlength="1024" spellcheck="false"
|
||||||
|
placeholder="RU CN"></textarea></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="global-policy-section">
|
<section class="global-policy-section">
|
||||||
<div class="global-policy-heading"><h3 data-i18n="globalUaTitle">User-Agent blocklist</h3><p class="fine" data-i18n="uaBlockPatternHelp">Case-insensitive substring matching; * is a wildcard. A missing User-Agent is not blocked.</p></div>
|
<div class="global-policy-heading">
|
||||||
<label class="check"><input id="global-ua-block-enabled" type="checkbox"/><span data-i18n="globalUaEnabled">Block matching User-Agents</span></label>
|
<h3 data-i18n="globalUaTitle">User-Agent blocklist</h3>
|
||||||
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="4" maxlength="32768" spellcheck="false" placeholder="BadBot curl/* *scanner*"></textarea></label>
|
<p class="fine" data-i18n="uaBlockPatternHelp">Case-insensitive substring
|
||||||
|
matching; * is a wildcard. A missing User-Agent is not blocked.</p>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span
|
||||||
|
data-i18n="globalUaEnabled">Block matching User-Agents</span></label>
|
||||||
|
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per
|
||||||
|
line</span><textarea id="global-ua-block-patterns" rows="4"
|
||||||
|
maxlength="32768" spellcheck="false"
|
||||||
|
placeholder="BadBot curl/* *scanner*"></textarea></label>
|
||||||
</section>
|
</section>
|
||||||
<section class="global-policy-section">
|
<section class="global-policy-section">
|
||||||
<div class="global-policy-heading"><h3 data-i18n="globalIpTitle">IP / CIDR access</h3><p class="fine" data-i18n="globalIpHelp">Each line: allow or deny followed by IPv4/IPv6 address, CIDR or allow-only DNS name. Default allow with no rules leaves access unrestricted.</p></div>
|
<div class="global-policy-heading">
|
||||||
<label class="check"><input id="global-ip-enabled" type="checkbox"/><span data-i18n="globalIpEnabled">Enable global IP rules</span></label>
|
<h3 data-i18n="globalIpTitle">IP / CIDR access</h3>
|
||||||
|
<p class="fine" data-i18n="globalIpHelp">Each line: allow or deny followed
|
||||||
|
by IPv4/IPv6 address, CIDR or allow-only DNS name. Default allow with no
|
||||||
|
rules leaves access unrestricted.</p>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input id="global-ip-enabled" type="checkbox" /><span
|
||||||
|
data-i18n="globalIpEnabled">Enable global IP rules</span></label>
|
||||||
<div class="global-policy-fields">
|
<div class="global-policy-fields">
|
||||||
<label><span data-i18n="aclDefault">Default action</span><select id="global-ip-default"><option value="allow" data-i18n="allow">Allow</option><option value="deny" data-i18n="deny">Deny</option></select></label>
|
<label><span data-i18n="aclDefault">Default action</span><select
|
||||||
<label><span data-i18n="globalIpRules">Rules (one per line)</span><textarea id="global-ip-rules" rows="3" maxlength="32768" placeholder="deny 192.0.2.0/24 allow 10.0.0.0/8" spellcheck="false"></textarea></label>
|
id="global-ip-default">
|
||||||
|
<option value="allow" data-i18n="allow">Allow</option>
|
||||||
|
<option value="deny" data-i18n="deny">Deny</option>
|
||||||
|
</select></label>
|
||||||
|
<label><span data-i18n="globalIpRules">Rules (one per line)</span><textarea
|
||||||
|
id="global-ip-rules" rows="3" maxlength="32768"
|
||||||
|
placeholder="deny 192.0.2.0/24 allow 10.0.0.0/8"
|
||||||
|
spellcheck="false"></textarea></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="global-policy-section">
|
<section class="global-policy-section">
|
||||||
<div class="global-policy-heading"><h3 data-i18n="globalMethodTitle">HTTP methods</h3><p class="fine" data-i18n="globalMethodHelp">Block selected methods or allow only those listed. Applies before redirects and cache invalidation; denied requests receive 405.</p></div>
|
<div class="global-policy-heading">
|
||||||
|
<h3 data-i18n="globalMethodTitle">HTTP methods</h3>
|
||||||
|
<p class="fine" data-i18n="globalMethodHelp">Block selected methods or allow
|
||||||
|
only those listed. Applies before redirects and cache invalidation;
|
||||||
|
denied requests receive 405.</p>
|
||||||
|
</div>
|
||||||
<div class="global-policy-fields">
|
<div class="global-policy-fields">
|
||||||
<label><span data-i18n="globalMethodMode">Method policy</span><select id="global-method-mode"><option value="off" data-i18n="vhOff">Off</option><option value="block" data-i18n="locationMethodBlock">Block listed</option><option value="allow" data-i18n="locationMethodAllow">Allow listed only</option></select></label>
|
<label><span data-i18n="globalMethodMode">Method policy</span><select
|
||||||
<label><span data-i18n="globalMethodList">Methods (comma, space or newline separated)</span><textarea id="global-method-list" rows="2" maxlength="1024" spellcheck="false" placeholder="TRACE, TRACK"></textarea></label>
|
id="global-method-mode">
|
||||||
|
<option value="off" data-i18n="vhOff">Off</option>
|
||||||
|
<option value="block" data-i18n="locationMethodBlock">Block listed
|
||||||
|
</option>
|
||||||
|
<option value="allow" data-i18n="locationMethodAllow">Allow listed
|
||||||
|
only</option>
|
||||||
|
</select></label>
|
||||||
|
<label><span data-i18n="globalMethodList">Methods (comma, space or newline
|
||||||
|
separated)</span><textarea id="global-method-list" rows="2"
|
||||||
|
maxlength="1024" spellcheck="false"
|
||||||
|
placeholder="TRACE, TRACK"></textarea></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="global-policy-section">
|
<section class="global-policy-section">
|
||||||
<div class="global-policy-heading"><h3 data-i18n="globalContentTypeTitle">Request Content-Type</h3><p class="fine" data-i18n="globalContentTypeHelp">Reject declared MIME types (exact or type/*) with 403. This is a header rule, not body inspection.</p></div>
|
<div class="global-policy-heading">
|
||||||
<label class="check"><input id="global-content-type-enabled" type="checkbox"/><span data-i18n="globalContentTypeEnabled">Enable Content-Type denylist</span></label>
|
<h3 data-i18n="globalContentTypeTitle">Request Content-Type</h3>
|
||||||
<label><span data-i18n="globalContentTypeList">Blocked MIME types, one per line</span><textarea id="global-content-type-list" rows="3" maxlength="16384" spellcheck="false" placeholder="application/x-msdownload application/x-sh"></textarea></label>
|
<p class="fine" data-i18n="globalContentTypeHelp">Reject declared MIME types
|
||||||
|
(exact or type/*) with 403. This is a header rule, not body inspection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input id="global-content-type-enabled"
|
||||||
|
type="checkbox" /><span data-i18n="globalContentTypeEnabled">Enable
|
||||||
|
Content-Type denylist</span></label>
|
||||||
|
<label><span data-i18n="globalContentTypeList">Blocked MIME types, one per
|
||||||
|
line</span><textarea id="global-content-type-list" rows="3"
|
||||||
|
maxlength="16384" spellcheck="false"
|
||||||
|
placeholder="application/x-msdownload application/x-sh"></textarea></label>
|
||||||
</section>
|
</section>
|
||||||
<section class="global-policy-section">
|
<section class="global-policy-section">
|
||||||
<div class="global-policy-heading"><h3 data-i18n="globalRateTitle">Shared rate limit</h3><p class="fine" data-i18n="globalRateHelp">Optional per-client-IP limit shared by all public vhosts. Runs in addition to per-vhost limits; use conservative values to avoid blocking legitimate shared clients.</p></div>
|
<div class="global-policy-heading">
|
||||||
<label class="check"><input id="global-rate-enabled" type="checkbox"/><span data-i18n="globalRateEnabled">Enable global request rate limit</span></label>
|
<h3 data-i18n="globalRateTitle">Shared rate limit</h3>
|
||||||
|
<p class="fine" data-i18n="globalRateHelp">Optional per-client-IP limit
|
||||||
|
shared by all public vhosts. Runs in addition to per-vhost limits; use
|
||||||
|
conservative values to avoid blocking legitimate shared clients.</p>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input id="global-rate-enabled" type="checkbox" /><span
|
||||||
|
data-i18n="globalRateEnabled">Enable global request rate
|
||||||
|
limit</span></label>
|
||||||
<div class="global-rate-fields">
|
<div class="global-rate-fields">
|
||||||
<label><span data-i18n="globalRateRequests">Requests per window</span><input id="global-rate-requests" type="number" min="1" max="1000000" required value="120"/></label>
|
<label><span data-i18n="globalRateRequests">Requests per window</span><input
|
||||||
<label><span data-i18n="globalRateWindow">Window (seconds)</span><input id="global-rate-window" type="number" min="1" max="3600" required value="60"/></label>
|
id="global-rate-requests" type="number" min="1" max="1000000"
|
||||||
<label><span data-i18n="globalRateBurst">Burst allowance</span><input id="global-rate-burst" type="number" min="0" max="1000000" required value="30"/></label>
|
required value="120" /></label>
|
||||||
|
<label><span data-i18n="globalRateWindow">Window (seconds)</span><input
|
||||||
|
id="global-rate-window" type="number" min="1" max="3600" required
|
||||||
|
value="60" /></label>
|
||||||
|
<label><span data-i18n="globalRateBurst">Burst allowance</span><input
|
||||||
|
id="global-rate-burst" type="number" min="0" max="1000000" required
|
||||||
|
value="30" /></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="form-actions"><button class="primary" data-i18n="globalPolicySave" type="submit">Apply global block / allow rules</button></div>
|
<div class="form-actions"><button class="primary" data-i18n="globalPolicySave"
|
||||||
|
type="submit">Apply global block / allow rules</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div data-policy-settings-page="security-headers" hidden="">
|
<div data-policy-settings-page="security-headers" hidden="">
|
||||||
<form class="card edit-form global-policy-form" id="global-security-headers-form">
|
<form class="card edit-form global-policy-form" id="global-security-headers-form">
|
||||||
<div class="card-heading"><div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP LISTENERS</span><h2 data-i18n="globalSecurityHeaders">Security headers</h2></div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span></div>
|
<div class="card-heading">
|
||||||
<p class="fine" data-i18n="globalHeadersSecurityHelp">Global response-header baseline for public HTTP vhosts, redirects and 404 hosts. Explicit per-vhost values override matching global headers. HSTS is sent only over HTTPS; test CSP carefully before rollout.</p>
|
<div><span class="small-label" data-i18n="globalPolicyScope">PUBLIC HTTP
|
||||||
<section class="global-policy-section"><h3 data-i18n="vhSecurityHstsSection">HTTP Strict Transport Security (HSTS)</h3>
|
LISTENERS</span>
|
||||||
<label class="check"><input id="global-hsts-enabled" type="checkbox"/><span data-i18n="vhHsts">Enable HSTS</span></label>
|
<h2 data-i18n="globalSecurityHeaders">Security headers</h2>
|
||||||
|
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
|
||||||
|
</div>
|
||||||
|
<p class="fine" data-i18n="globalHeadersSecurityHelp">Global response-header
|
||||||
|
baseline for public HTTP vhosts, redirects and 404 hosts. Explicit per-vhost
|
||||||
|
values override matching global headers. HSTS is sent only over HTTPS; test CSP
|
||||||
|
carefully before rollout.</p>
|
||||||
|
<section class="global-policy-section">
|
||||||
|
<h3 data-i18n="vhSecurityHstsSection">HTTP Strict Transport Security (HSTS)</h3>
|
||||||
|
<label class="check"><input id="global-hsts-enabled" type="checkbox" /><span
|
||||||
|
data-i18n="vhHsts">Enable HSTS</span></label>
|
||||||
<div class="global-rate-fields">
|
<div class="global-rate-fields">
|
||||||
<label><span data-i18n="vhHstsAge">Max age (seconds)</span><input id="global-hsts-age" type="number" min="0" max="63072000" required value="31536000"/></label>
|
<label><span data-i18n="vhHstsAge">Max age (seconds)</span><input
|
||||||
<label class="check"><input id="global-hsts-sub" type="checkbox"/><span data-i18n="vhHstsSub">Include subdomains</span></label>
|
id="global-hsts-age" type="number" min="0" max="63072000" required
|
||||||
<label class="check"><input id="global-hsts-preload" type="checkbox"/><span data-i18n="vhHstsPreload">Preload</span></label>
|
value="31536000" /></label>
|
||||||
|
<label class="check"><input id="global-hsts-sub" type="checkbox" /><span
|
||||||
|
data-i18n="vhHstsSub">Include subdomains</span></label>
|
||||||
|
<label class="check"><input id="global-hsts-preload" type="checkbox" /><span
|
||||||
|
data-i18n="vhHstsPreload">Preload</span></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="global-policy-section"><h3 data-i18n="vhSecurityHeadersSection">Response security headers</h3>
|
<section class="global-policy-section">
|
||||||
<label class="check"><input id="global-nosniff" type="checkbox"/><span data-i18n="vhNosniff">X-Content-Type-Options: nosniff</span></label>
|
<h3 data-i18n="vhSecurityHeadersSection">Response security headers</h3>
|
||||||
<label><span data-i18n="vhFrame">X-Frame-Options</span><select id="global-frame"><option value="preserve" data-i18n="vhPreserve">Preserve origin value</option><option value="deny" data-i18n="vhDeny">DENY</option><option value="sameorigin" data-i18n="vhSameorigin">SAMEORIGIN</option></select></label>
|
<label class="check"><input id="global-nosniff" type="checkbox" /><span
|
||||||
<label><span data-i18n="vhReferrer">Referrer-Policy</span><input id="global-referrer" maxlength="8192" spellcheck="false" placeholder="strict-origin-when-cross-origin"/></label>
|
data-i18n="vhNosniff">X-Content-Type-Options: nosniff</span></label>
|
||||||
<label><span data-i18n="vhCsp">Content-Security-Policy</span><textarea id="global-csp" rows="3" maxlength="8192" spellcheck="false" placeholder="default-src 'self'"></textarea></label>
|
<label><span data-i18n="vhFrame">X-Frame-Options</span><select
|
||||||
<label><span data-i18n="vhPermissions">Permissions-Policy</span><input id="global-permissions" maxlength="8192" spellcheck="false" placeholder="camera=(), microphone=()"/></label>
|
id="global-frame">
|
||||||
|
<option value="preserve" data-i18n="vhPreserve">Preserve origin value
|
||||||
|
</option>
|
||||||
|
<option value="deny" data-i18n="vhDeny">DENY</option>
|
||||||
|
<option value="sameorigin" data-i18n="vhSameorigin">SAMEORIGIN</option>
|
||||||
|
</select></label>
|
||||||
|
<label><span data-i18n="vhReferrer">Referrer-Policy</span><input
|
||||||
|
id="global-referrer" maxlength="8192" spellcheck="false"
|
||||||
|
placeholder="strict-origin-when-cross-origin" /></label>
|
||||||
|
<label><span data-i18n="vhCsp">Content-Security-Policy</span><textarea
|
||||||
|
id="global-csp" rows="3" maxlength="8192" spellcheck="false"
|
||||||
|
placeholder="default-src 'self'"></textarea></label>
|
||||||
|
<label><span data-i18n="vhPermissions">Permissions-Policy</span><input
|
||||||
|
id="global-permissions" maxlength="8192" spellcheck="false"
|
||||||
|
placeholder="camera=(), microphone=()" /></label>
|
||||||
</section>
|
</section>
|
||||||
<div class="form-actions"><button class="primary" data-i18n="globalSecurityHeadersSave" type="submit">Apply security headers</button></div>
|
<div class="form-actions"><button class="primary"
|
||||||
|
data-i18n="globalSecurityHeadersSave" type="submit">Apply security
|
||||||
|
headers</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div data-policy-settings-page="headers" hidden="">
|
<div data-policy-settings-page="headers" hidden="">
|
||||||
@@ -2266,6 +2506,7 @@
|
|||||||
data-i18n="cacheTabDiagnostics" type="button">Static & diagnostics</button>
|
data-i18n="cacheTabDiagnostics" type="button">Static & diagnostics</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div data-cache-settings-page="general">
|
<div data-cache-settings-page="general">
|
||||||
|
<h3 class="subheading cache-section-title" data-i18n="cacheSectionBehavior">Behavior</h3>
|
||||||
<label class="check"><input id="cache-enabled" type="checkbox" /><span
|
<label class="check"><input id="cache-enabled" type="checkbox" /><span
|
||||||
data-i18n="enableCache">Enable shared cache</span></label>
|
data-i18n="enableCache">Enable shared cache</span></label>
|
||||||
<p class="fine" data-i18n="cacheSafety">Only complete public-safe GET responses.
|
<p class="fine" data-i18n="cacheSafety">Only complete public-safe GET responses.
|
||||||
@@ -2311,6 +2552,16 @@
|
|||||||
<input id="cache-varnish-grace" type="number" min="0" max="604800" step="1"
|
<input id="cache-varnish-grace" type="number" min="0" max="604800" step="1"
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
|
<label data-i18n="cacheDefaultTtl" for="cache-default-ttl">Fallback TTL for public
|
||||||
|
responses (0 = off)</label><input id="cache-default-ttl" max="604800" min="0"
|
||||||
|
required="" type="number" />
|
||||||
|
<label class="check"><input id="cache-query" type="checkbox" /><span
|
||||||
|
data-i18n="bypassQuery">Bypass URLs with a query string</span></label>
|
||||||
|
<label data-i18n="cachePrefixes" for="cache-prefixes">Bypass path prefixes, one per
|
||||||
|
line</label><textarea id="cache-prefixes" placeholder="/api/private" rows="3"
|
||||||
|
spellcheck="false"></textarea>
|
||||||
|
|
||||||
|
<h3 class="subheading cache-section-title" data-i18n="cacheSectionMemory">Memory and limits</h3>
|
||||||
<div class="card soft-card cache-memory-sizing">
|
<div class="card soft-card cache-memory-sizing">
|
||||||
<div class="card-heading">
|
<div class="card-heading">
|
||||||
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
|
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
|
||||||
@@ -2365,14 +2616,7 @@
|
|||||||
<p class="fine" data-i18n="cacheAutoTrimHelp">When HOT memory is above the target,
|
<p class="fine" data-i18n="cacheAutoTrimHelp">When HOT memory is above the target,
|
||||||
only entries idle for the configured age are removed in bounded passes. Active
|
only entries idle for the configured age are removed in bounded passes. Active
|
||||||
objects are not purged.</p>
|
objects are not purged.</p>
|
||||||
<label data-i18n="cacheDefaultTtl" for="cache-default-ttl">Fallback TTL for public
|
|
||||||
responses (0 = off)</label><input id="cache-default-ttl" max="604800" min="0"
|
|
||||||
required="" type="number" />
|
|
||||||
<label class="check"><input id="cache-query" type="checkbox" /><span
|
|
||||||
data-i18n="bypassQuery">Bypass URLs with a query string</span></label>
|
|
||||||
<label data-i18n="cachePrefixes" for="cache-prefixes">Bypass path prefixes, one per
|
|
||||||
line</label><textarea id="cache-prefixes" placeholder="/api/private" rows="3"
|
|
||||||
spellcheck="false"></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div data-cache-settings-page="resilience" hidden>
|
<div data-cache-settings-page="resilience" hidden>
|
||||||
<h3 class="subheading" data-i18n="cacheCollapseTitle">Request collapsing</h3>
|
<h3 class="subheading" data-i18n="cacheCollapseTitle">Request collapsing</h3>
|
||||||
@@ -2500,9 +2744,11 @@
|
|||||||
<label data-i18n="cacheStaticExtensions" for="cache-static-extensions">Global static
|
<label data-i18n="cacheStaticExtensions" for="cache-static-extensions">Global static
|
||||||
extensions</label><textarea id="cache-static-extensions" rows="5"
|
extensions</label><textarea id="cache-static-extensions" rows="5"
|
||||||
spellcheck="false"></textarea>
|
spellcheck="false"></textarea>
|
||||||
<p class="fine cache-static-guidance" data-i18n="cacheStaticHelp">Cookie bypass is limited to these file
|
<p class="fine cache-static-guidance" data-i18n="cacheStaticHelp">Cookie bypass is
|
||||||
|
limited to these file
|
||||||
extensions. Response privacy rules still apply.</p>
|
extensions. Response privacy rules still apply.</p>
|
||||||
<h3 class="subheading cache-header-settings-heading" data-i18n="cacheHeadersSection">Cache response headers</h3>
|
<h3 class="subheading cache-header-settings-heading"
|
||||||
|
data-i18n="cacheHeadersSection">Cache response headers</h3>
|
||||||
<div class="switch-grid"><label class="check"><input id="cache-status-header"
|
<div class="switch-grid"><label class="check"><input id="cache-status-header"
|
||||||
type="checkbox" /><span>X-Cache: HIT / MISS /
|
type="checkbox" /><span>X-Cache: HIT / MISS /
|
||||||
BYPASS</span></label><label class="check"><input id="cache-hits-header"
|
BYPASS</span></label><label class="check"><input id="cache-hits-header"
|
||||||
@@ -2608,7 +2854,8 @@
|
|||||||
concurrent transformations</label><input id="transform-max-inflight" min="1"
|
concurrent transformations</label><input id="transform-max-inflight" min="1"
|
||||||
max="1024" required="" type="number" /></div>
|
max="1024" required="" type="number" /></div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="subheading transform-operations-title" data-i18n="transformOperationsTitle">Content transformations</h3>
|
<h3 class="subheading transform-operations-title" data-i18n="transformOperationsTitle">
|
||||||
|
Content transformations</h3>
|
||||||
<div class="switch-grid transform-operations-grid">
|
<div class="switch-grid transform-operations-grid">
|
||||||
<label class="check"><input id="transform-javascript" type="checkbox" /><span
|
<label class="check"><input id="transform-javascript" type="checkbox" /><span
|
||||||
data-i18n="transformJavascript">JavaScript minification</span></label>
|
data-i18n="transformJavascript">JavaScript minification</span></label>
|
||||||
@@ -3143,8 +3390,8 @@
|
|||||||
<p class="fine" data-i18n="tlsPqcHelp">TLS 1.3 only: prioritize hybrid
|
<p class="fine" data-i18n="tlsPqcHelp">TLS 1.3 only: prioritize hybrid
|
||||||
X25519MLKEM768 with classic key exchanges as fallback. This is a key exchange
|
X25519MLKEM768 with classic key exchanges as fallback. This is a key exchange
|
||||||
group, not a cipher suite. Disabled by default.</p>
|
group, not a cipher suite. Disabled by default.</p>
|
||||||
<a href="/docs#doc=docs%2FTRANSPORT.md" target="_blank"
|
<a href="/docs#doc=docs%2FTRANSPORT.md" target="_blank" rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer" data-i18n="tlsPqcDocs">Learn about PQC, compatibility
|
data-i18n="tlsPqcDocs">Learn about PQC, compatibility
|
||||||
and testing (documentation)</a>
|
and testing (documentation)</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="fine" id="tls-policy-key-exchange"></p>
|
<p class="fine" id="tls-policy-key-exchange"></p>
|
||||||
@@ -3298,8 +3545,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<footer class="page-footer"><span>RustProxy <b>__RUSTPROXY_VERSION__</b></span><span
|
<footer class="page-footer"><span>RustProxy <b>__RUSTPROXY_VERSION__</b></span><span
|
||||||
id="footer-status">API - /
|
id="footer-status">API -</span></footer>
|
||||||
dropped: 0</span></footer>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -3667,10 +3913,10 @@
|
|||||||
service ID/name and PHP-FPM endpoint are not replaced.</p>
|
service ID/name and PHP-FPM endpoint are not replaced.</p>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<label><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64"
|
|
||||||
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
|
|
||||||
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96"
|
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96"
|
||||||
required="" /></label>
|
required="" /></label>
|
||||||
|
<label class="auto-id-field"><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64"
|
||||||
|
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||||
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
|
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
|
||||||
id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
|
id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
|
||||||
<div class="vh-wide" hidden id="web-service-permissions"></div>
|
<div class="vh-wide" hidden id="web-service-permissions"></div>
|
||||||
@@ -3987,7 +4233,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button>
|
<button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button>
|
||||||
<button class="quiet" data-i18n="vhReset" id="web-service-reset" type="button">Discard changes</button>
|
<button class="quiet" data-i18n="vhReset" id="web-service-reset" type="button">Discard changes</button><button class="quiet" data-editor-cancel="web-server" data-i18n="vhCancel" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -4134,7 +4380,7 @@ log_errors = On</code></pre>
|
|||||||
<div id="vhost-fields"></div>
|
<div id="vhost-fields"></div>
|
||||||
<div class="form-actions"><button class="primary" data-i18n="apply" id="vhost-save" type="submit">Validate,
|
<div class="form-actions"><button class="primary" data-i18n="apply" id="vhost-save" type="submit">Validate,
|
||||||
save and apply</button><button class="quiet" data-i18n="vhReset" id="vhost-reset"
|
save and apply</button><button class="quiet" data-i18n="vhReset" id="vhost-reset"
|
||||||
type="button">Discard changes</button></div>
|
type="button">Discard changes</button><button class="quiet" data-editor-cancel="vhost" data-i18n="vhCancel" type="button">Cancel</button></div>
|
||||||
<p class="fine" data-i18n="vhSafetyHint">Private IP binding is not authentication. Keep firewall/ACL rules.
|
<p class="fine" data-i18n="vhSafetyHint">Private IP binding is not authentication. Keep firewall/ACL rules.
|
||||||
HSTS preload is opt-in; affinity cookies are not login sessions.</p>
|
HSTS preload is opt-in; affinity cookies are not login sessions.</p>
|
||||||
</form>
|
</form>
|
||||||
@@ -4157,10 +4403,10 @@ log_errors = On</code></pre>
|
|||||||
<fieldset class="vh-group editor-identity-section">
|
<fieldset class="vh-group editor-identity-section">
|
||||||
<legend data-i18n="hostIdentitySection">Host and domains</legend>
|
<legend data-i18n="hostIdentitySection">Host and domains</legend>
|
||||||
<div class="vh-grid">
|
<div class="vh-grid">
|
||||||
<label><span data-i18n="redirectId">Redirect ID</span><input id="redirect-id" maxlength="64"
|
|
||||||
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
|
|
||||||
<label><span data-i18n="redirectName">Display name</span><input id="redirect-name"
|
<label><span data-i18n="redirectName">Display name</span><input id="redirect-name"
|
||||||
maxlength="96" /></label>
|
maxlength="96" /></label>
|
||||||
|
<label class="auto-id-field"><span data-i18n="redirectId">Redirect ID</span><input id="redirect-id" maxlength="64"
|
||||||
|
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||||
<label class="check"><input id="redirect-enabled" type="checkbox" /><span
|
<label class="check"><input id="redirect-enabled" type="checkbox" /><span
|
||||||
data-i18n="enabled">Enabled</span></label>
|
data-i18n="enabled">Enabled</span></label>
|
||||||
<div class="vh-wide" id="redirect-hosts-editor"></div>
|
<div class="vh-wide" id="redirect-hosts-editor"></div>
|
||||||
@@ -4199,7 +4445,7 @@ log_errors = On</code></pre>
|
|||||||
</section>
|
</section>
|
||||||
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and
|
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and
|
||||||
apply</button><button class="quiet" data-i18n="vhReset" id="redirect-reset" type="button">Discard
|
apply</button><button class="quiet" data-i18n="vhReset" id="redirect-reset" type="button">Discard
|
||||||
changes</button></div>
|
changes</button><button class="quiet" data-editor-cancel="redirect" data-i18n="vhCancel" type="button">Cancel</button></div>
|
||||||
<p class="fine" data-i18n="redirectSafetyHint">Redirect hosts do not contact a backend. Global host ACLs
|
<p class="fine" data-i18n="redirectSafetyHint">Redirect hosts do not contact a backend. Global host ACLs
|
||||||
still apply. Avoid self-referential rules that can create browser redirect loops.</p>
|
still apply. Avoid self-referential rules that can create browser redirect loops.</p>
|
||||||
</form>
|
</form>
|
||||||
@@ -4215,10 +4461,10 @@ log_errors = On</code></pre>
|
|||||||
<fieldset class="vh-group editor-identity-section">
|
<fieldset class="vh-group editor-identity-section">
|
||||||
<legend data-i18n="hostIdentitySection">Host and domains</legend>
|
<legend data-i18n="hostIdentitySection">Host and domains</legend>
|
||||||
<div class="vh-grid">
|
<div class="vh-grid">
|
||||||
<label><span data-i18n="notfoundId">Host ID</span><input id="notfound-id" maxlength="64"
|
|
||||||
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
|
|
||||||
<label><span data-i18n="notfoundName">Display name</span><input id="notfound-name"
|
<label><span data-i18n="notfoundName">Display name</span><input id="notfound-name"
|
||||||
maxlength="96" /></label>
|
maxlength="96" /></label>
|
||||||
|
<label class="auto-id-field"><span data-i18n="notfoundId">Host ID</span><input id="notfound-id" maxlength="64"
|
||||||
|
pattern="[A-Za-z0-9_\-]{1,64}" required="" /></label>
|
||||||
<label class="check"><input id="notfound-enabled" type="checkbox" /><span
|
<label class="check"><input id="notfound-enabled" type="checkbox" /><span
|
||||||
data-i18n="enabled">Enabled</span></label>
|
data-i18n="enabled">Enabled</span></label>
|
||||||
<div class="vh-wide" id="notfound-hosts-editor"></div>
|
<div class="vh-wide" id="notfound-hosts-editor"></div>
|
||||||
@@ -4270,7 +4516,7 @@ log_errors = On</code></pre>
|
|||||||
</section>
|
</section>
|
||||||
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and
|
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and
|
||||||
apply</button><button class="quiet" data-i18n="vhReset" id="notfound-reset" type="button">Discard
|
apply</button><button class="quiet" data-i18n="vhReset" id="notfound-reset" type="button">Discard
|
||||||
changes</button></div>
|
changes</button><button class="quiet" data-editor-cancel="notfound" data-i18n="vhCancel" type="button">Cancel</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
@@ -4286,10 +4532,27 @@ log_errors = On</code></pre>
|
|||||||
<div id="stream-fields"></div>
|
<div id="stream-fields"></div>
|
||||||
<div class="form-actions"><button class="primary" data-i18n="apply" id="stream-save" type="submit">Validate,
|
<div class="form-actions"><button class="primary" data-i18n="apply" id="stream-save" type="submit">Validate,
|
||||||
save and apply</button><button class="quiet" data-i18n="vhReset" id="stream-reset"
|
save and apply</button><button class="quiet" data-i18n="vhReset" id="stream-reset"
|
||||||
type="button">Discard changes</button></div>
|
type="button">Discard changes</button><button class="quiet" data-editor-cancel="stream" data-i18n="vhCancel" type="button">Cancel</button></div>
|
||||||
<p class="fine" data-i18n="streamRestartHelp">Changing a stream listener requires a RustProxy restart.</p>
|
<p class="fine" data-i18n="streamRestartHelp">Changing a stream listener requires a RustProxy restart.</p>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog aria-labelledby="app-health-title" class="vhost-dialog app-health-dialog" id="app-health-dialog">
|
||||||
|
<div class="window-heading">
|
||||||
|
<div><span class="small-label" data-i18n="appHealthSelfCheck">SELF-CHECK</span>
|
||||||
|
<h2 data-i18n="appHealthTitle" id="app-health-title">Application status</h2>
|
||||||
|
</div>
|
||||||
|
<button class="outline" data-i18n="close" id="app-health-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<article class="card app-health-card">
|
||||||
|
<div class="app-health-summary" id="app-health-summary-row">
|
||||||
|
<i aria-hidden="true"></i>
|
||||||
|
<div><strong id="app-health-summary" data-i18n="appHealthChecking">Checking application status</strong>
|
||||||
|
<p class="fine" id="app-health-updated"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-health-checks" id="app-health-checks"></div>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
<dialog aria-labelledby="about-title" class="vhost-dialog about-dialog" id="about-dialog">
|
<dialog aria-labelledby="about-title" class="vhost-dialog about-dialog" id="about-dialog">
|
||||||
<div class="window-heading">
|
<div class="window-heading">
|
||||||
<h2 data-i18n="aboutTitle" id="about-title">About RustProxy</h2>
|
<h2 data-i18n="aboutTitle" id="about-title">About RustProxy</h2>
|
||||||
@@ -4335,15 +4598,16 @@ log_errors = On</code></pre>
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<div class="about-links">
|
<div class="about-links">
|
||||||
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
|
<a class="outline about-repository" data-ui-icon-name="none" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
|
||||||
target="_blank"><span data-i18n="aboutPackagesRepository">Website and packages</span><span>rustproxy.linuxiarz.pl</span></a>
|
target="_blank"><span data-i18n="aboutPackagesRepository">Website and
|
||||||
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy"
|
packages</span><span>rustproxy.linuxiarz.pl</span></a>
|
||||||
|
<a class="outline about-repository" data-ui-icon-name="none" href="https://git.linuxiarz.pl/gru/RustProxy"
|
||||||
rel="noopener noreferrer" target="_blank"><span data-i18n="aboutRepository">Source
|
rel="noopener noreferrer" target="_blank"><span data-i18n="aboutRepository">Source
|
||||||
code</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
|
code</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
|
||||||
<a class="outline about-repository" href="/docs#doc=docs%2FREADME.md" rel="noopener"
|
<a class="outline about-repository" data-ui-icon-name="none" href="/docs#doc=docs%2FREADME.md" rel="noopener"
|
||||||
target="_blank"><span data-i18n="aboutDocumentation">Documentation and API
|
target="_blank"><span data-i18n="aboutDocumentation">Documentation and API
|
||||||
reference</span><span>/docs</span></a>
|
reference</span><span>/docs</span></a>
|
||||||
<a class="outline about-repository" href="/docs#doc=LICENSE" rel="noopener" target="_blank"><span
|
<a class="outline about-repository" data-ui-icon-name="none" href="/docs#doc=LICENSE" rel="noopener" target="_blank"><span
|
||||||
data-i18n="aboutLicenseText">Full license text</span><span>LICENSE</span></a>
|
data-i18n="aboutLicenseText">Full license text</span><span>LICENSE</span></a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
function addRow(item = { id: `listener-${$("named-listener-rows").children.length + 1}`, name: "", scope: "internal", enabled: true, binding: { address: "127.0.0.1:8181", tls: false, http2: false, http2_settings: null, h2c: false, http3: null } }) {
|
function addRow(item = { id: `listener-${$("named-listener-rows").children.length + 1}`, name: "", scope: "internal", enabled: true, binding: { address: "127.0.0.1:8181", tls: false, http2: false, http2_settings: null, h2c: false, http3: null } }) {
|
||||||
if ($("named-listener-rows").children.length >= 64) { toast(t("listenerLimit"), true); return; }
|
if ($("named-listener-rows").children.length >= 64) { toast(t("listenerLimit"), true); return; }
|
||||||
const row = el("fieldset", undefined, "named-listener-row"); row.append(label("legend", "listenerEndpoint")); const grid = el("div", undefined, "vh-grid"); row.append(grid); const b = item.binding || {};
|
const row = el("fieldset", undefined, "named-listener-row"); row.append(label("legend", "listenerEndpoint")); const grid = el("div", undefined, "vh-grid"); row.append(grid); const b = item.binding || {};
|
||||||
const id = field(grid, "listener-id", "listenerId", item.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
|
const id = field(grid, "listener-id", "listenerId", item.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_\\-]{1,64}" });
|
||||||
const name = field(grid, "listener-name", "listenerName", item.name, "text", { maxLength: 96 });
|
const name = field(grid, "listener-name", "listenerName", item.name, "text", { maxLength: 96 });
|
||||||
const scope = field(grid, "listener-scope", "listenerScope", item.scope || "internal", "select", { values: [["internal", "listenerInternal"], ["public", "listenerPublic"]] });
|
const scope = field(grid, "listener-scope", "listenerScope", item.scope || "internal", "select", { values: [["internal", "listenerInternal"], ["public", "listenerPublic"]] });
|
||||||
const currentAddresses = [b.address, ...(item.additional_addresses || [])].filter(Boolean);
|
const currentAddresses = [b.address, ...(item.additional_addresses || [])].filter(Boolean);
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
window.rustProxySettings?.selectHttpTab?.("http2");
|
window.rustProxySettings?.selectHttpTab?.("http2");
|
||||||
document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" });
|
document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" });
|
||||||
});
|
});
|
||||||
h2Settings.classList.add("listener-h2-settings-link");
|
h2Settings.classList.add("listener-h2-settings-link"); h2Settings.dataset.uiIconName = "settings";
|
||||||
const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null);
|
const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null);
|
||||||
const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox");
|
const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox");
|
||||||
const h3Settings = button(protocolActions, "h3Configure", () => {
|
const h3Settings = button(protocolActions, "h3Configure", () => {
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
window.rustProxySettings?.selectHttpTab?.("http3");
|
window.rustProxySettings?.selectHttpTab?.("http3");
|
||||||
document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" });
|
document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" });
|
||||||
});
|
});
|
||||||
h3Settings.classList.add("listener-h3-settings-link");
|
h3Settings.classList.add("listener-h3-settings-link"); h3Settings.dataset.uiIconName = "settings";
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
h2.disabled = !tls.checked; h2c.disabled = tls.checked; h3.disabled = !tls.checked;
|
h2.disabled = !tls.checked; h2c.disabled = tls.checked; h3.disabled = !tls.checked;
|
||||||
h2Settings.hidden = !((tls.checked && h2.checked) || (!tls.checked && h2c.checked));
|
h2Settings.hidden = !((tls.checked && h2.checked) || (!tls.checked && h2c.checked));
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
type="password" />
|
type="password" />
|
||||||
</div>
|
</div>
|
||||||
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
|
<label data-i18n="username" for="username">Username</label><input autocapitalize="none"
|
||||||
autocomplete="username" id="username" maxlength="64" minlength="3" pattern="[A-Za-z0-9_.-]{3,64}"
|
autocomplete="username" id="username" maxlength="64" minlength="3" pattern="[A-Za-z0-9_.\-]{3,64}"
|
||||||
required="" spellcheck="false" />
|
required="" spellcheck="false" />
|
||||||
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
|
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
|
||||||
id="password" maxlength="128" required="" type="password" />
|
id="password" maxlength="128" required="" type="password" />
|
||||||
|
|||||||
@@ -85,6 +85,11 @@
|
|||||||
];
|
];
|
||||||
const defaultColumns = () => new Set(columns.map(column => column.id));
|
const defaultColumns = () => new Set(columns.map(column => column.id));
|
||||||
let visibleColumns = defaultColumns();
|
let visibleColumns = defaultColumns();
|
||||||
|
function active() { return dialog.open && !paused && !expanded; }
|
||||||
|
function syncLivePolling(fetchNow = false) {
|
||||||
|
window.rustProxyRefresh?.sync?.();
|
||||||
|
if (fetchNow && active()) void window.rustProxyRefresh?.manual?.("logs-fallback");
|
||||||
|
}
|
||||||
function source() { return paused || expanded || lastLogs || { entries: [] }; }
|
function source() { return paused || expanded || lastLogs || { entries: [] }; }
|
||||||
function mergeExpanded(data) {
|
function mergeExpanded(data) {
|
||||||
if (!expanded || paused || !data?.entries) return;
|
if (!expanded || paused || !data?.entries) return;
|
||||||
@@ -189,7 +194,7 @@
|
|||||||
if (!preferencesLoaded) void loadPreferences();
|
if (!preferencesLoaded) void loadPreferences();
|
||||||
if (vhost) { const opt = el("option", vhost); opt.value = vhost; $("log-vhost").append(opt); $("log-vhost").value = vhost; }
|
if (vhost) { const opt = el("option", vhost); opt.value = vhost; $("log-vhost").append(opt); $("log-vhost").value = vhost; }
|
||||||
if (!dialog.open) { if (docked) dialog.show(); else dialog.showModal(); }
|
if (!dialog.open) { if (docked) dialog.show(); else dialog.showModal(); }
|
||||||
render();
|
render(); syncLivePolling(true);
|
||||||
}
|
}
|
||||||
syncColumnPicker(); syncHeaders();
|
syncColumnPicker(); syncHeaders();
|
||||||
$("logs-open").addEventListener("click", () => { $("logs-menu").open = false; open(); }); $("logs-close").addEventListener("click", () => dialog.close());
|
$("logs-open").addEventListener("click", () => { $("logs-menu").open = false; open(); }); $("logs-close").addEventListener("click", () => dialog.close());
|
||||||
@@ -200,14 +205,14 @@
|
|||||||
expanded = { ...snapshot, capacity: 4096, window: 4096 }; render();
|
expanded = { ...snapshot, capacity: 4096, window: 4096 }; render();
|
||||||
toast(t("logBufferExpanded").replace("{count}", String(snapshot.entries?.length || 0)));
|
toast(t("logBufferExpanded").replace("{count}", String(snapshot.entries?.length || 0)));
|
||||||
} catch (error) { toast(error.message, true); }
|
} catch (error) { toast(error.message, true); }
|
||||||
finally { button.disabled = Boolean(expanded); }
|
finally { button.disabled = Boolean(expanded); syncLivePolling(); }
|
||||||
});
|
});
|
||||||
$("logs-pause").addEventListener("click", () => { paused = paused ? null : structuredClone(source()); render(); });
|
$("logs-pause").addEventListener("click", () => { paused = paused ? null : structuredClone(source()); render(); syncLivePolling(!paused); });
|
||||||
$("logs-pin").addEventListener("click", () => { pinning = true; dialog.close(); docked = !docked; dialog.classList.toggle("docked", docked); if (docked) dialog.show(); else dialog.showModal(); pinning = false; render(); });
|
$("logs-pin").addEventListener("click", () => { pinning = true; dialog.close(); docked = !docked; dialog.classList.toggle("docked", docked); if (docked) dialog.show(); else dialog.showModal(); pinning = false; render(); syncLivePolling(true); });
|
||||||
dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); });
|
dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); syncLivePolling(); });
|
||||||
for (const id of filters) $(id).addEventListener(id === "log-filter" ? "input" : "change", render);
|
for (const id of filters) $(id).addEventListener(id === "log-filter" ? "input" : "change", render);
|
||||||
$("export-logs").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify({ entries: filtered(), capacity: source().capacity, filtered: true }, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = "RustProxy-access-log.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
|
$("export-logs").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify({ entries: filtered(), capacity: source().capacity, filtered: true }, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = "RustProxy-access-log.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
|
||||||
window.rustProxyLogs = { render, open, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); } };
|
window.rustProxyLogs = { render, open, active, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); syncLivePolling(); } };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
@@ -361,15 +366,20 @@
|
|||||||
rules.placeholder = custom ? "allow 10.0.0.0/8\ndeny 10.21.0.0/16" : "10.0.0.0/8\n203.0.113.5";
|
rules.placeholder = custom ? "allow 10.0.0.0/8\ndeny 10.21.0.0/16" : "10.0.0.0/8\n203.0.113.5";
|
||||||
syncRuleRows(convertRules); renderPreview();
|
syncRuleRows(convertRules); renderPreview();
|
||||||
}
|
}
|
||||||
|
function attachAclProfileId() { window.rustProxyAutoId.attach($("acl-profile-id"), { elements: [$("acl-profile-name")], taken: () => (envelope?.config.acl_profiles || []).map(value => value.id) }); }
|
||||||
function clear() {
|
function clear() {
|
||||||
editing = null; dirty = false; form.reset(); setMode("allow_exceptions"); aclDefault.value = "deny"; countryMode.value = "off"; countryEnabled.checked = false; countriesInput.value = ""; $("acl-profile-id").readOnly = false; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy();
|
editing = null; dirty = false; form.reset(); setMode("allow_exceptions"); aclDefault.value = "deny"; countryMode.value = "off"; countryEnabled.checked = false; countriesInput.value = ""; $("acl-profile-id").readOnly = false; attachAclProfileId(); $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy();
|
||||||
}
|
}
|
||||||
function edit(profile) {
|
function edit(profile) {
|
||||||
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id;
|
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id; attachAclProfileId();
|
||||||
const cMode = profile.country_access?.mode || "off"; aclDefault.value = profile.acl?.default || (cMode === "allow" ? "deny" : "allow"); countryMode.value = cMode;
|
const cMode = profile.country_access?.mode || "off"; aclDefault.value = profile.acl?.default || (cMode === "allow" ? "deny" : "allow"); countryMode.value = cMode;
|
||||||
countryEnabled.checked = cMode !== "off"; countriesInput.value = (profile.country_access?.countries || []).join("\n");
|
countryEnabled.checked = cMode !== "off"; countriesInput.value = (profile.country_access?.countries || []).join("\n");
|
||||||
setMode(inferMode(profile)); renderRuleRows(profile.acl?.rules || []); $("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncPolicy();
|
setMode(inferMode(profile)); renderRuleRows(profile.acl?.rules || []); $("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncPolicy();
|
||||||
}
|
}
|
||||||
|
function copyProfile(profile) {
|
||||||
|
edit(profile); editing = null; $("acl-profile-id").readOnly = false; $("acl-profile-id").value = ""; $("acl-profile-name").value = ""; attachAclProfileId();
|
||||||
|
$("acl-profile-editing").textContent = t("aclProfileNew"); dirty = true; $("acl-profile-name").focus();
|
||||||
|
}
|
||||||
function friendlySummary(profile) {
|
function friendlySummary(profile) {
|
||||||
const mode = inferMode(profile), countries = profile.country_access?.countries || [], rulesList = profile.acl?.rules || [];
|
const mode = inferMode(profile), countries = profile.country_access?.countries || [], rulesList = profile.acl?.rules || [];
|
||||||
if (mode === "allow_exceptions" || mode === "block_exceptions") {
|
if (mode === "allow_exceptions" || mode === "block_exceptions") {
|
||||||
@@ -416,7 +426,7 @@
|
|||||||
attached.append(el("summary", `${t("aclProfileAttached")} (${users.length})`), el("p", users.join(", "), "entity-details"));
|
attached.append(el("summary", `${t("aclProfileAttached")} (${users.length})`), el("p", users.join(", "), "entity-details"));
|
||||||
box.append(attached);
|
box.append(attached);
|
||||||
}
|
}
|
||||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => {
|
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("copyEntry"), () => copyProfile(profile)), action(t("remove"), async () => {
|
||||||
if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; }
|
if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; }
|
||||||
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear();
|
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear();
|
||||||
})); box.append(actions); return box;
|
})); box.append(actions); return box;
|
||||||
|
|||||||
@@ -334,7 +334,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="overview-chart-heading" data-i18n="systemLoad">System load</h3>
|
<h3 class="overview-chart-heading" data-i18n="systemLoad">System load</h3>
|
||||||
<h2><span id="system-cpu">-</span> <small>CPU</small></h2>
|
<h2><span id="system-cpu">-</span> <small>CPU</small></h2>
|
||||||
</div><span class="meta" id="system-source">-</span>
|
</div><span class="meta system-uptime-meta"><span data-i18n="systemUptime">System uptime</span>: <b
|
||||||
|
id="system-uptime">-</b></span>
|
||||||
</div><svg preserveAspectRatio="none" aria-label="System CPU history" data-i18n-aria="systemChart" id="system-chart"
|
</div><svg preserveAspectRatio="none" aria-label="System CPU history" data-i18n-aria="systemChart" id="system-chart"
|
||||||
role="img" viewbox="0 0 300 100">
|
role="img" viewbox="0 0 300 100">
|
||||||
<path class="chart-grid" d="M0 25H300 M0 50H300 M0 75H300"></path>
|
<path class="chart-grid" d="M0 25H300 M0 50H300 M0 75H300"></path>
|
||||||
@@ -348,14 +349,15 @@
|
|||||||
<div class="memory-line"><span data-i18n="systemSwap">System swap</span><b id="system-swap">-</b></div>
|
<div class="memory-line"><span data-i18n="systemSwap">System swap</span><b id="system-swap">-</b></div>
|
||||||
<div class="chart-footer"><span data-i18n="loadAverage">Load 1 / 5 / 15 min</span><span id="system-load">-</span>
|
<div class="chart-footer"><span data-i18n="loadAverage">Load 1 / 5 / 15 min</span><span id="system-load">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-footer"><span data-i18n="systemUptime">System uptime</span><span id="system-uptime">-</span></div>
|
|
||||||
</article>
|
</article>
|
||||||
<article class="card connection-card" data-dashboard-widget="connection_chart">
|
<article class="card connection-card" data-dashboard-widget="connection_chart">
|
||||||
<div class="card-heading">
|
<div class="card-heading">
|
||||||
<div>
|
<div class="connection-heading-main">
|
||||||
<h3 class="overview-chart-heading" data-i18n="connectionActivity">Connection activity</h3>
|
<h3 class="overview-chart-heading" data-i18n="connectionActivity">Connection activity</h3>
|
||||||
<h2><span id="connection-current">0</span> <small data-i18n="connections">Open
|
<h2><span class="connection-current-main"><span id="connection-current">0</span> <small
|
||||||
connections</small></h2>
|
data-i18n="connections">Open connections</small></span><small
|
||||||
|
class="meta connection-backends-meta"><span data-i18n="backends">Backends</span>: <b
|
||||||
|
id="connection-backends">0</b></small></h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg preserveAspectRatio="none" aria-label="Connection activity history" data-i18n-aria="connectionActivity"
|
<svg preserveAspectRatio="none" aria-label="Connection activity history" data-i18n-aria="connectionActivity"
|
||||||
@@ -368,6 +370,4 @@
|
|||||||
connections</span> <b id="connection-open-value">0</b></span><span
|
connections</span> <b id="connection-open-value">0</b></span><span
|
||||||
class="connection-exchange-key"><i></i><span data-i18n="activeExchanges">Active
|
class="connection-exchange-key"><i></i><span data-i18n="activeExchanges">Active
|
||||||
exchanges</span> <b id="connection-exchange-value">0</b></span></div>
|
exchanges</span> <b id="connection-exchange-value">0</b></span></div>
|
||||||
<div class="chart-footer"><span><span data-i18n="backends">Backends</span>: <b id="connection-backends">0</b></span>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
@@ -34,39 +34,42 @@
|
|||||||
<article class="stat stat-cache-outcomes" data-dashboard-widget="cache_outcomes">
|
<article class="stat stat-cache-outcomes" data-dashboard-widget="cache_outcomes">
|
||||||
<div class="cache-mix-header">
|
<div class="cache-mix-header">
|
||||||
<span data-i18n="cacheMixTitle">Cache outcomes</span>
|
<span data-i18n="cacheMixTitle">Cache outcomes</span>
|
||||||
<small class="cache-mix-caption" id="cache-mix-caption" aria-label="HTTP requests since service start" title="HTTP requests since service start">-</small>
|
<small class="cache-mix-caption" id="cache-mix-caption" aria-label="HTTP requests since service start"
|
||||||
|
title="HTTP requests since service start">-</small>
|
||||||
</div>
|
</div>
|
||||||
<strong id="cache-mix-headline">-</strong>
|
<strong id="cache-mix-headline">-</strong>
|
||||||
<div class="cache-mix-bar" id="cache-mix-bar" role="img" aria-label="Cumulative HTTP cache outcomes since service start">
|
<div class="cache-mix-bar" id="cache-mix-bar" role="img"
|
||||||
<span class="cache-mix-hit" id="cache-mix-hit" style="width:0%"></span>
|
aria-label="Cumulative HTTP cache outcomes since service start">
|
||||||
<span class="cache-mix-miss" id="cache-mix-miss" style="width:0%"></span>
|
<span class="cache-mix-hit" id="cache-mix-hit"></span>
|
||||||
<span class="cache-mix-bypass" id="cache-mix-bypass" style="width:0%"></span>
|
<span class="cache-mix-miss" id="cache-mix-miss"></span>
|
||||||
<span class="cache-mix-other" id="cache-mix-other" style="width:0%"></span>
|
<span class="cache-mix-bypass" id="cache-mix-bypass"></span>
|
||||||
|
<span class="cache-mix-other" id="cache-mix-other"></span>
|
||||||
</div>
|
</div>
|
||||||
<small class="cache-mix-legend">
|
<small class="cache-mix-legend">
|
||||||
<span class="cache-mix-hit-key"><i></i>HIT <b><span id="cache-mix-hit-count">-</span><span class="cache-mix-percent" id="cache-mix-hit-percent">-</span></b></span>
|
<span class="cache-mix-hit-key" id="cache-mix-hit-key"><i></i>HIT <b id="cache-mix-hit-count">-</b></span>
|
||||||
<span class="cache-mix-miss-key"><i></i>MISS <b><span id="cache-mix-miss-count">-</span><span class="cache-mix-percent" id="cache-mix-miss-percent">-</span></b></span>
|
<span class="cache-mix-miss-key" id="cache-mix-miss-key"><i></i>MISS <b id="cache-mix-miss-count">-</b></span>
|
||||||
<span class="cache-mix-bypass-key"><i></i>BYPASS <b><span id="cache-mix-bypass-count">-</span><span class="cache-mix-percent" id="cache-mix-bypass-percent">-</span></b></span>
|
<span class="cache-mix-bypass-key" id="cache-mix-bypass-key"><i></i>BYPASS <b id="cache-mix-bypass-count">-</b></span>
|
||||||
<span class="cache-mix-other-key"><i></i><span data-i18n="cacheMixOther">OTHER</span> <b><span id="cache-mix-other-count">-</span><span class="cache-mix-percent"
|
<span class="cache-mix-other-key" id="cache-mix-other-key"><i></i><span data-i18n="cacheMixOther">OTHER</span> <b id="cache-mix-other-count">-</b></span>
|
||||||
id="cache-mix-other-percent">-</span></b></span>
|
|
||||||
</small>
|
</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat" data-dashboard-widget="memory"><span data-i18n="processMemory">Process
|
<article class="stat" data-dashboard-widget="memory"><span data-i18n="processMemory">Process
|
||||||
memory</span><strong id="metric-rss">-</strong><small id="process-summary">-</small>
|
memory</span><strong id="metric-rss">-</strong><small id="process-summary">-</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-with-action" data-dashboard-widget="websocket"><span data-i18n="wsActive">WebSocket
|
<article class="stat stat-with-action" data-dashboard-widget="websocket"><span data-i18n="wsActive">WebSocket
|
||||||
tunnels</span><button class="quiet stat-corner-action" data-i18n="tunnelsOpen" id="tunnels-open"
|
tunnels</span><button class="pill stat-corner-action stat-corner-badge" data-i18n="tunnelsOpen"
|
||||||
type="button">Active tunnels</button><strong id="metric-ws">0</strong><small
|
data-ui-icon-name="none" id="tunnels-open" type="button">Active tunnels</button><div class="stat-value-row"><strong
|
||||||
aria-label="Active WebSocket tunnels by frontend protocol"><span data-i18n="wsH1">H1
|
id="metric-ws">0</strong><small class="stat-side-value" title="Established"><span
|
||||||
Upgrade</span> <span id="metric-ws-h1">0</span> · <span data-i18n="wsH2Rfc8441">H2
|
data-i18n="wsEstablished">Established</span> <b id="metric-ws-total">0</b></small></div><small
|
||||||
RFC 8441</span> <span id="metric-ws-h2">0</span> · <span data-i18n="wsH3Rfc9220">H3
|
aria-label="Active WebSocket tunnels by frontend protocol"><span data-i18n="wsH1" title="H1 Upgrade">H1</span>
|
||||||
RFC 9220</span> <span id="metric-ws-h3">0</span><br><span data-i18n="wsEstablished">Established</span>:
|
<span id="metric-ws-h1">0</span> · <span data-i18n="wsH2Rfc8441" title="H2 RFC 8441">H2</span>
|
||||||
<span id="metric-ws-total">0</span></small></article>
|
<span id="metric-ws-h2">0</span> · <span data-i18n="wsH3Rfc9220" title="H3 RFC 9220">H3</span>
|
||||||
|
<span id="metric-ws-h3">0</span></small></article>
|
||||||
<article class="stat" data-dashboard-widget="http2"><span data-i18n="h2Requests">HTTP/2
|
<article class="stat" data-dashboard-widget="http2"><span data-i18n="h2Requests">HTTP/2
|
||||||
requests</span><strong id="metric-h2">0</strong><small><span data-i18n="h2ActiveConnections">Active H2
|
requests</span><strong id="metric-h2">0</strong><small><span data-i18n="h2ActiveConnections">H2
|
||||||
connections</span>: <span id="metric-h2-active">0</span></small></article>
|
connections</span>: <span id="metric-h2-active">0</span></small></article>
|
||||||
<article class="stat" data-dashboard-widget="http3"><span data-i18n="h3Requests">HTTP/3
|
<article class="stat" data-dashboard-widget="http3"><span data-i18n="h3Requests">HTTP/3
|
||||||
requests</span><strong id="metric-h3">0</strong></article>
|
requests</span><strong id="metric-h3">0</strong><small><span data-i18n="h3ActiveConnections">H3
|
||||||
|
connections</span>: <span id="metric-h3-active">0</span></small></article>
|
||||||
<article class="stat" data-dashboard-widget="tls"><span data-i18n="tlsFailures">TLS handshake
|
<article class="stat" data-dashboard-widget="tls"><span data-i18n="tlsFailures">TLS handshake
|
||||||
errors</span><strong id="metric-tls-errors">0</strong></article>
|
errors</span><strong id="metric-tls-errors">0</strong></article>
|
||||||
<article class="stat stat-chart" data-dashboard-widget="http_protocol"><span data-i18n="httpProtocolMix">HTTP protocol
|
<article class="stat stat-chart" data-dashboard-widget="http_protocol"><span data-i18n="httpProtocolMix">HTTP protocol
|
||||||
@@ -142,13 +145,14 @@
|
|||||||
</polyline>
|
</polyline>
|
||||||
</svg><small id="backend-dashboard-errors-detail">-</small>
|
</svg><small id="backend-dashboard-errors-detail">-</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-chart" data-dashboard-widget="backend_latency" hidden>
|
<article class="stat stat-chart backend-latency-stat" data-dashboard-widget="backend_latency" hidden>
|
||||||
<span data-i18n="backendWidgetLatency">Health probe RTT</span><strong id="backend-dashboard-latency">-</strong>
|
<span data-i18n="backendWidgetLatency">Health probe RTT</span><small class="backend-health-origins"
|
||||||
|
id="backend-dashboard-latency-detail">Health Origins -</small><strong id="backend-dashboard-latency">-</strong>
|
||||||
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetLatency" role="img" viewBox="0 0 300 60">
|
<svg preserveAspectRatio="none" data-i18n-aria="backendWidgetLatency" role="img" viewBox="0 0 300 60">
|
||||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
<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 class="diagnostic-line proto-h2-line" id="backend-dashboard-latency-line" points="0,55 300,55">
|
||||||
</polyline>
|
</polyline>
|
||||||
</svg><small id="backend-dashboard-latency-detail">-</small>
|
</svg>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-chart" data-dashboard-widget="backend_traffic" hidden>
|
<article class="stat stat-chart" data-dashboard-widget="backend_traffic" hidden>
|
||||||
<span data-i18n="backendWidgetTraffic">Backend traffic</span><strong id="backend-dashboard-traffic">-</strong>
|
<span data-i18n="backendWidgetTraffic">Backend traffic</span><strong id="backend-dashboard-traffic">-</strong>
|
||||||
@@ -366,14 +370,16 @@
|
|||||||
</svg><small id="overview-extra-extra_backend_unhealthy-detail">-</small>
|
</svg><small id="overview-extra-extra_backend_unhealthy-detail">-</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-meter" data-dashboard-widget="extra_backends_healthy" hidden>
|
<article class="stat stat-meter" data-dashboard-widget="extra_backends_healthy" hidden>
|
||||||
<span data-i18n="overviewExtraBackendsHealthy">overviewExtraBackendsHealthy</span><strong id="overview-extra-extra_backends_healthy">-</strong>
|
<span data-i18n="overviewExtraBackendsHealthy">overviewExtraBackendsHealthy</span><strong
|
||||||
|
id="overview-extra-extra_backends_healthy">-</strong>
|
||||||
<div class="overview-meter" id="overview-extra-extra_backends_healthy-meter" role="progressbar"
|
<div class="overview-meter" id="overview-extra-extra_backends_healthy-meter" role="progressbar"
|
||||||
data-i18n-aria="overviewExtraBackendsHealthy" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
|
data-i18n-aria="overviewExtraBackendsHealthy" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
|
||||||
<span class="overview-meter-fill" id="overview-extra-extra_backends_healthy-meter-fill"></span>
|
<span class="overview-meter-fill" id="overview-extra-extra_backends_healthy-meter-fill"></span>
|
||||||
</div><small id="overview-extra-extra_backends_healthy-detail">-</small>
|
</div><small id="overview-extra-extra_backends_healthy-detail">-</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-chart" data-dashboard-widget="extra_error_share" hidden>
|
<article class="stat stat-chart" data-dashboard-widget="extra_error_share" hidden>
|
||||||
<span data-i18n="overviewExtraErrorShare">overviewExtraErrorShare</span><strong id="overview-extra-extra_error_share">-</strong>
|
<span data-i18n="overviewExtraErrorShare">overviewExtraErrorShare</span><strong
|
||||||
|
id="overview-extra-extra_error_share">-</strong>
|
||||||
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraErrorShare" role="img" viewBox="0 0 300 60">
|
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraErrorShare" role="img" viewBox="0 0 300 60">
|
||||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||||
<polyline class="diagnostic-line" id="overview-extra-extra_error_share-line" points="0,55 300,55">
|
<polyline class="diagnostic-line" id="overview-extra-extra_error_share-line" points="0,55 300,55">
|
||||||
@@ -381,7 +387,8 @@
|
|||||||
</svg><small id="overview-extra-extra_error_share-detail">-</small>
|
</svg><small id="overview-extra-extra_error_share-detail">-</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-chart" data-dashboard-widget="extra_rejected_rate" hidden>
|
<article class="stat stat-chart" data-dashboard-widget="extra_rejected_rate" hidden>
|
||||||
<span data-i18n="overviewExtraRejectedRate">overviewExtraRejectedRate</span><strong id="overview-extra-extra_rejected_rate">-</strong>
|
<span data-i18n="overviewExtraRejectedRate">overviewExtraRejectedRate</span><strong
|
||||||
|
id="overview-extra-extra_rejected_rate">-</strong>
|
||||||
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraRejectedRate" role="img" viewBox="0 0 300 60">
|
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraRejectedRate" role="img" viewBox="0 0 300 60">
|
||||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||||
<polyline class="diagnostic-line" id="overview-extra-extra_rejected_rate-line" points="0,55 300,55">
|
<polyline class="diagnostic-line" id="overview-extra-extra_rejected_rate-line" points="0,55 300,55">
|
||||||
@@ -389,7 +396,8 @@
|
|||||||
</svg><small id="overview-extra-extra_rejected_rate-detail">-</small>
|
</svg><small id="overview-extra-extra_rejected_rate-detail">-</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-meter" data-dashboard-widget="extra_cert_expiry" hidden>
|
<article class="stat stat-meter" data-dashboard-widget="extra_cert_expiry" hidden>
|
||||||
<span data-i18n="overviewExtraCertExpiry">overviewExtraCertExpiry</span><strong id="overview-extra-extra_cert_expiry">-</strong>
|
<span data-i18n="overviewExtraCertExpiry">overviewExtraCertExpiry</span><strong
|
||||||
|
id="overview-extra-extra_cert_expiry">-</strong>
|
||||||
<div class="overview-meter" id="overview-extra-extra_cert_expiry-meter" role="progressbar"
|
<div class="overview-meter" id="overview-extra-extra_cert_expiry-meter" role="progressbar"
|
||||||
data-i18n-aria="overviewExtraCertExpiry" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
|
data-i18n-aria="overviewExtraCertExpiry" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
|
||||||
<span class="overview-meter-fill" id="overview-extra-extra_cert_expiry-meter-fill"></span>
|
<span class="overview-meter-fill" id="overview-extra-extra_cert_expiry-meter-fill"></span>
|
||||||
@@ -401,7 +409,7 @@
|
|||||||
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="HTTP requests / s">
|
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="HTTP requests / s">
|
||||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
<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>
|
<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>
|
</svg><small id="overview-extra-extra_total_rps-detail" hidden></small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-chart" data-dashboard-widget="extra_active_exchanges" hidden>
|
<article class="stat stat-chart" data-dashboard-widget="extra_active_exchanges" hidden>
|
||||||
<span data-i18n="overviewExtraActiveExchanges">Active HTTP exchanges</span><strong
|
<span data-i18n="overviewExtraActiveExchanges">Active HTTP exchanges</span><strong
|
||||||
@@ -410,21 +418,21 @@
|
|||||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
<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 class="diagnostic-line" id="overview-extra-extra_active_exchanges-line" points="0,55 300,55">
|
||||||
</polyline>
|
</polyline>
|
||||||
</svg><small id="overview-extra-extra_active_exchanges-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
</svg><small id="overview-extra-extra_active_exchanges-detail" hidden></small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-meter" data-dashboard-widget="extra_h1_share" hidden>
|
<article class="stat stat-meter" data-dashboard-widget="extra_h1_share" hidden>
|
||||||
<span data-i18n="overviewExtraH1Share">HTTP/1.1 share</span><strong id="overview-extra-extra_h1_share">-</strong>
|
<span data-i18n="overviewExtraH1Share">HTTP/1.1 share</span><strong id="overview-extra-extra_h1_share">-</strong>
|
||||||
<div class="overview-meter" id="overview-extra-extra_h1_share-meter" role="progressbar"
|
<div class="overview-meter" id="overview-extra-extra_h1_share-meter" role="progressbar"
|
||||||
data-i18n-aria="overviewExtraH1Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
|
data-i18n-aria="overviewExtraH1Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
|
||||||
<span class="overview-meter-fill" id="overview-extra-extra_h1_share-meter-fill"></span>
|
<span class="overview-meter-fill" id="overview-extra-extra_h1_share-meter-fill"></span>
|
||||||
</div><small id="overview-extra-extra_h1_share-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
</div><small id="overview-extra-extra_h1_share-detail" hidden></small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-meter" data-dashboard-widget="extra_h3_share" hidden>
|
<article class="stat stat-meter" data-dashboard-widget="extra_h3_share" hidden>
|
||||||
<span data-i18n="overviewExtraH3Share">HTTP/3 share</span><strong id="overview-extra-extra_h3_share">-</strong>
|
<span data-i18n="overviewExtraH3Share">HTTP/3 share</span><strong id="overview-extra-extra_h3_share">-</strong>
|
||||||
<div class="overview-meter" id="overview-extra-extra_h3_share-meter" role="progressbar"
|
<div class="overview-meter" id="overview-extra-extra_h3_share-meter" role="progressbar"
|
||||||
data-i18n-aria="overviewExtraH3Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
|
data-i18n-aria="overviewExtraH3Share" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
|
||||||
<span class="overview-meter-fill" id="overview-extra-extra_h3_share-meter-fill"></span>
|
<span class="overview-meter-fill" id="overview-extra-extra_h3_share-meter-fill"></span>
|
||||||
</div><small id="overview-extra-extra_h3_share-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
</div><small id="overview-extra-extra_h3_share-detail" hidden></small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-chart" data-dashboard-widget="extra_cache_miss_rate" hidden>
|
<article class="stat stat-chart" data-dashboard-widget="extra_cache_miss_rate" hidden>
|
||||||
<span data-i18n="overviewExtraCacheMissRate">Cache misses / s</span><strong
|
<span data-i18n="overviewExtraCacheMissRate">Cache misses / s</span><strong
|
||||||
@@ -433,7 +441,7 @@
|
|||||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
<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 class="diagnostic-line" id="overview-extra-extra_cache_miss_rate-line" points="0,55 300,55">
|
||||||
</polyline>
|
</polyline>
|
||||||
</svg><small id="overview-extra-extra_cache_miss_rate-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
</svg><small id="overview-extra-extra_cache_miss_rate-detail" hidden></small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat stat-chart" data-dashboard-widget="extra_backend_active" hidden>
|
<article class="stat stat-chart" data-dashboard-widget="extra_backend_active" hidden>
|
||||||
<span data-i18n="overviewExtraBackendActive">In-flight backend requests</span><strong
|
<span data-i18n="overviewExtraBackendActive">In-flight backend requests</span><strong
|
||||||
@@ -441,7 +449,7 @@
|
|||||||
<svg preserveAspectRatio="none" viewBox="0 0 300 60" role="img" aria-label="In-flight backend requests">
|
<svg preserveAspectRatio="none" 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>
|
<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>
|
<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>
|
</svg><small id="overview-extra-extra_backend_active-detail" hidden></small>
|
||||||
</article>
|
</article>
|
||||||
<!-- RP_METRICS_SPLIT -->
|
<!-- RP_METRICS_SPLIT -->
|
||||||
<article class="stat stat-donut" data-dashboard-widget="www_status" hidden>
|
<article class="stat stat-donut" data-dashboard-widget="www_status" hidden>
|
||||||
@@ -517,5 +525,4 @@
|
|||||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||||
<polyline class="diagnostic-line proto-h2-line" id="www_php_requests-line" points="0,55 300,55"></polyline>
|
<polyline class="diagnostic-line proto-h2-line" id="www_php_requests-line" points="0,55 300,55"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
<small data-i18n="webMetricPerSecond">Requests per second</small>
|
|
||||||
</article>
|
</article>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules");
|
const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules");
|
||||||
if (!form || !dialog || !list || !rules) return;
|
if (!form || !dialog || !list || !rules) return;
|
||||||
const sortColumns = ["id", "name", "hostname", "status", "target", "rules", "listeners", "certificate"];
|
const sortColumns = ["id", "name", "hostname", "status", "target", "rules", "listeners", "certificate"];
|
||||||
let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [], listPrefs = { sort: "id", descending: false };
|
let redirectAutoCertificate = null, editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [], listPrefs = { sort: "id", descending: false };
|
||||||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.redirect_list_sort) ? preferences.redirect_list_sort : "id", descending: preferences?.redirect_list_descending === true }; }
|
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.redirect_list_sort) ? preferences.redirect_list_sort : "id", descending: preferences?.redirect_list_descending === true }; }
|
||||||
function syncSortDirection() { const control = $("redirect-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
|
function syncSortDirection() { const control = $("redirect-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
|
||||||
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); render(); }
|
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); render(); }
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
const input = (labelKey, name, type = "text") => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("input"); node.type = type; node.dataset.field = name; label.append(node); grid.append(label); return node; };
|
const input = (labelKey, name, type = "text") => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("input"); node.type = type; node.dataset.field = name; label.append(node); grid.append(label); return node; };
|
||||||
const select = (labelKey, name, values) => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("select"); node.dataset.field = name; for (const [v, k] of values) node.append(option(v, k)); label.append(node); grid.append(label); return node; };
|
const select = (labelKey, name, values) => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("select"); node.dataset.field = name; for (const [v, k] of values) node.append(option(v, k)); label.append(node); grid.append(label); return node; };
|
||||||
const check = (labelKey, name) => { const label = el("label", undefined, "check"); const node = el("input"); node.type = "checkbox"; node.dataset.field = name; label.append(node, el("span", t(labelKey))); grid.append(label); return node; };
|
const check = (labelKey, name) => { const label = el("label", undefined, "check"); const node = el("input"); node.type = "checkbox"; node.dataset.field = name; label.append(node, el("span", t(labelKey))); grid.append(label); return node; };
|
||||||
const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_-]{1,64}"; id.value = data.id;
|
const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_\\-]{1,64}"; id.value = data.id;
|
||||||
const enabled = check("enabled", "enabled"); enabled.checked = data.enabled !== false;
|
const enabled = check("enabled", "enabled"); enabled.checked = data.enabled !== false;
|
||||||
const sourceScheme = select("redirectSourceScheme", "source_scheme", [["any", "redirectSchemeAny"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); sourceScheme.value = data.source_scheme;
|
const sourceScheme = select("redirectSourceScheme", "source_scheme", [["any", "redirectSchemeAny"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); sourceScheme.value = data.source_scheme;
|
||||||
const matchType = select("redirectMatchType", "match_type", [["prefix", "redirectMatchPrefix"], ["exact", "redirectMatchExact"]]); matchType.value = data.match_type;
|
const matchType = select("redirectMatchType", "match_type", [["prefix", "redirectMatchPrefix"], ["exact", "redirectMatchExact"]]); matchType.value = data.match_type;
|
||||||
@@ -70,9 +70,11 @@
|
|||||||
$("redirect-editing").textContent = editing ? editing : t("redirectNewLabel");
|
$("redirect-editing").textContent = editing ? editing : t("redirectNewLabel");
|
||||||
$("redirect-id").value = item.id || ""; $("redirect-id").disabled = Boolean(editing);
|
$("redirect-id").value = item.id || ""; $("redirect-id").disabled = Boolean(editing);
|
||||||
$("redirect-name").value = item.name || "";
|
$("redirect-name").value = item.name || "";
|
||||||
const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: changed });
|
window.rustProxyAutoId.attach($("redirect-id"), { elements: [$("redirect-name")], taken: () => (envelope?.config.redirects || []).map(value => value.id) });
|
||||||
|
const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: () => { changed(); redirectAutoCertificate?.refresh(); } });
|
||||||
$("redirect-enabled").checked = item.enabled !== false; syncEnabledControl(); $("redirect-http2").checked = item.http2_enabled !== false; $("redirect-http3").checked = item.http3_enabled !== false;
|
$("redirect-enabled").checked = item.enabled !== false; syncEnabledControl(); $("redirect-http2").checked = item.http2_enabled !== false; $("redirect-http3").checked = item.http3_enabled !== false;
|
||||||
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
|
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
|
||||||
|
redirectAutoCertificate = window.rustProxyAutoCertificate.attach($("redirect-certificate"), { auto: !editing, hosts: () => { try { return readRedirectHosts(); } catch { return []; } }, certificates: () => envelope?.config.tls?.certificates || [] }); redirectAutoCertificate.refresh();
|
||||||
rules.replaceChildren(); for (const [i, rule] of (item.rules?.length ? item.rules : defaults().rules).entries()) addRule(rule, i);
|
rules.replaceChildren(); for (const [i, rule] of (item.rules?.length ? item.rules : defaults().rules).entries()) addRule(rule, i);
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
}
|
}
|
||||||
@@ -118,11 +120,18 @@
|
|||||||
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
|
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
|
||||||
list.replaceChildren(...entries.map(item => {
|
list.replaceChildren(...entries.map(item => {
|
||||||
const card = el("article", undefined, "vhost-card redirect-card"); const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
|
const card = el("article", undefined, "vhost-card redirect-card"); const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
|
||||||
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
|
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title); head.append(identity);
|
||||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
|
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("copyEntry"), () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(item); copy.id = ""; copy.name = ""; copy.frontend = { ...(copy.frontend || {}), hosts: [] }; copy.certificate_id = null; load(copy); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
|
||||||
const targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))];
|
const targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))];
|
||||||
const targetSummary = targets.slice(0, 2).join(" · ") + (targets.length > 2 ? ` · +${targets.length - 2}` : "");
|
const flow = el("div", undefined, "redirect-flow"), from = el("div", undefined, "redirect-flow-from"), to = el("div", undefined, "redirect-flow-to");
|
||||||
const detail = el("div", undefined, "vhost-detail-strip"); for (const [k, v] of [["redirectTarget", targetSummary || "-"], ["redirectRules", String((item.rules || []).length)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(k), "small-label"), el("b", v)); detail.append(node); } card.append(detail); return card;
|
for (const host of item.frontend?.hosts || []) from.append(el("span", host, "redirect-host-chip"));
|
||||||
|
if (!targets.length) to.append(el("span", "-", "redirect-target"));
|
||||||
|
for (const target of targets.slice(0, 3)) to.append(el("span", target, "redirect-target"));
|
||||||
|
if (targets.length > 3) to.append(el("span", `+${targets.length - 3}`, "redirect-target-more"));
|
||||||
|
flow.append(from, el("span", "→", "redirect-flow-arrow"), to); card.append(flow);
|
||||||
|
const meta = el("div", undefined, "redirect-meta");
|
||||||
|
for (const [k, v] of [["redirectRules", String((item.rules || []).length)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ") || "-"], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("span", undefined, "redirect-meta-item"); node.append(el("span", t(k)), el("b", v)); meta.append(node); }
|
||||||
|
card.append(meta); return card;
|
||||||
}));
|
}));
|
||||||
if (!entries.length) list.append(el("p", t(query ? "redirectSearchEmpty" : "redirectEmpty"), "fine"));
|
if (!entries.length) list.append(el("p", t(query ? "redirectSearchEmpty" : "redirectEmpty"), "fine"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
window.rustProxyRefresh?.setSettingsTab(name);
|
window.rustProxyRefresh?.setSettingsTab(name);
|
||||||
if (authenticated && name === "security") void window.rustProxyApiTokens?.refresh?.();
|
if (authenticated && name === "security") void window.rustProxyApiTokens?.refresh?.();
|
||||||
if (authenticated && name === "prometheus") void window.rustProxyPrometheusToken?.refresh?.();
|
if (authenticated && name === "prometheus") void window.rustProxyPrometheusToken?.refresh?.();
|
||||||
|
if (authenticated && name === "certificate-api") void window.rustProxyCertificateApi?.load?.();
|
||||||
if (name !== "prometheus") window.rustProxyPrometheusToken?.hideSecret?.();
|
if (name !== "prometheus") window.rustProxyPrometheusToken?.hideSecret?.();
|
||||||
if (name !== "security") window.rustProxyApiTokens?.hideSecret?.();
|
if (name !== "security") window.rustProxyApiTokens?.hideSecret?.();
|
||||||
}
|
}
|
||||||
@@ -251,7 +252,8 @@
|
|||||||
$("tls-policy-key-exchange").textContent = kxGroups.length ? `${t("tlsPqcKeyExchangeGroups")}: ${kxGroups.join(", ")}` : "";
|
$("tls-policy-key-exchange").textContent = kxGroups.length ? `${t("tlsPqcKeyExchangeGroups")}: ${kxGroups.join(", ")}` : "";
|
||||||
$("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : "";
|
$("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : "";
|
||||||
$("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : "";
|
$("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : "";
|
||||||
window.rustProxyCertificateApi?.render(); window.rustProxyCertificateApi?.load();
|
window.rustProxyCertificateApi?.render();
|
||||||
|
if (authenticated && active === "certificate-api") void window.rustProxyCertificateApi?.load?.();
|
||||||
}
|
}
|
||||||
$("geoip-format").addEventListener("change", syncGeoipFormat); $("geoip-enabled").addEventListener("change", syncGeoipFormat);
|
$("geoip-format").addEventListener("change", syncGeoipFormat); $("geoip-enabled").addEventListener("change", syncGeoipFormat);
|
||||||
bindSettingsForm("geoip-form", next => {
|
bindSettingsForm("geoip-form", next => {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
return grid;
|
return grid;
|
||||||
};
|
};
|
||||||
const connectionFields = backendSection("streamBackendConnectionSection");
|
const connectionFields = backendSection("streamBackendConnectionSection");
|
||||||
field(connectionFields, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
|
field(connectionFields, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_\\-]{1,64}" });
|
||||||
field(connectionFields, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
|
field(connectionFields, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
|
||||||
field(connectionFields, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
|
field(connectionFields, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
|
||||||
field(connectionFields, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
|
field(connectionFields, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
|
||||||
@@ -127,8 +127,9 @@
|
|||||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
||||||
$("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel");
|
$("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel");
|
||||||
let g = group("streamEditor", "details");
|
let g = group("streamEditor", "details");
|
||||||
field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
|
const streamName = field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
|
||||||
field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
|
const streamIdInput = field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_\\-]{1,64}", readOnly: Boolean(editing) });
|
||||||
|
window.rustProxyAutoId.attach(streamIdInput, { elements: [streamName], taken: () => (envelope?.config.streams || []).map(item => item.id) });
|
||||||
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
|
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
|
||||||
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||||
window.rustProxyEnableControl(enabledControl);
|
window.rustProxyEnableControl(enabledControl);
|
||||||
@@ -213,7 +214,7 @@
|
|||||||
if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill"));
|
if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill"));
|
||||||
identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity);
|
identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity);
|
||||||
const actions = el("div", undefined, "entity-actions");
|
const actions = el("div", undefined, "entity-actions");
|
||||||
actions.append(button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), button("remove", async () => {
|
actions.append(button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), button("copyEntry", () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(stream); copy.id = ""; copy.name = ""; copy.listen = ""; load(copy); }), button("remove", async () => {
|
||||||
if (busy || !(await appConfirm(t("streamDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.streams = (next.streams || []).filter(item => item.id !== stream.id); if (await saveSection("streams", next.streams || [])) { if (editing === stream.id) load(); render(); }
|
if (busy || !(await appConfirm(t("streamDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.streams = (next.streams || []).filter(item => item.id !== stream.id); if (await saveSection("streams", next.streams || [])) { if (editing === stream.id) load(); render(); }
|
||||||
}));
|
}));
|
||||||
head.append(actions); box.append(head);
|
head.append(actions); box.append(head);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
(() => {
|
(() => {
|
||||||
let loading = false, lastData = null, activeTab = "overview";
|
let loading = false, lastData = null, activeTab = "overview", staticData = null, staticRevision = null;
|
||||||
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
|
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
|
||||||
const scope = () => $("traffic-scope")?.value || "*";
|
const scope = () => $("traffic-scope")?.value || "*";
|
||||||
const number = value => fmt(Number(value) || 0);
|
const number = value => fmt(Number(value) || 0);
|
||||||
@@ -116,7 +116,17 @@
|
|||||||
if (!authenticated || currentPanel !== "traffic" || loading) return;
|
if (!authenticated || currentPanel !== "traffic" || loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`, { signal });
|
const base = `/api/traffic?vhost=${encodeURIComponent(scope())}`;
|
||||||
|
let data = await api(staticData ? `${base}&view=live` : base, { signal });
|
||||||
|
if (staticData && data.revision !== staticRevision) {
|
||||||
|
data = await api(base, { signal });
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.vhosts)) {
|
||||||
|
staticData = { vhosts: data.vhosts };
|
||||||
|
staticRevision = data.revision;
|
||||||
|
} else if (staticData) {
|
||||||
|
data = { ...staticData, ...data };
|
||||||
|
}
|
||||||
if (isCurrent()) render(data);
|
if (isCurrent()) render(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError" && isCurrent()) $("traffic-panel-state").textContent = error.message;
|
if (error?.name !== "AbortError" && isCurrent()) $("traffic-panel-state").textContent = error.message;
|
||||||
@@ -125,7 +135,7 @@
|
|||||||
function refresh() { return rustProxyRefresh.manual("traffic"); }
|
function refresh() { return rustProxyRefresh.manual("traffic"); }
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
lastData = null; staticData = null; staticRevision = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
||||||
document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren());
|
document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren());
|
||||||
for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) {
|
for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) {
|
||||||
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
|
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
(() => {
|
(() => {
|
||||||
let loading = false, generation = 0, lastData = null;
|
let loading = false, generation = 0, lastData = null, staticData = null, staticRevision = null;
|
||||||
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
|
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
|
||||||
const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] };
|
const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] };
|
||||||
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
|
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
function renderReasons(counters) {
|
function renderReasons(counters) {
|
||||||
const entries = Object.entries(counters?.reasons || {}).sort((a, b) => b[1] - a[1]);
|
const entries = Object.entries(counters?.reasons || {}).sort((a, b) => b[1] - a[1]);
|
||||||
const target = $("transform-reason-list"); target.replaceChildren(...entries.map(([key, count]) => {
|
const target = $("transform-reason-list"); target.replaceChildren(...entries.map(([key, count]) => {
|
||||||
const tag = el("span", `${key}: ${number(count)}`, "cache-reason"); tag.title = key; return tag;
|
const tag = el("span", undefined, "cache-reason-line"); tag.append(el("span", key, "cache-reason-name"), el("b", number(count), "cache-reason-count")); tag.title = key; return tag;
|
||||||
}));
|
}));
|
||||||
$("transform-reasons-empty").hidden = entries.length > 0;
|
$("transform-reasons-empty").hidden = entries.length > 0;
|
||||||
$("tp-skip-top").textContent = entries.length ? entries.slice(0, 2).map(([key, count]) => `${key}: ${number(count)}`).join(" · ") : t("transformNoSkipsShort");
|
$("tp-skip-top").textContent = entries.length ? entries.slice(0, 2).map(([key, count]) => `${key}: ${number(count)}`).join(" · ") : t("transformNoSkipsShort");
|
||||||
@@ -113,16 +113,12 @@
|
|||||||
const rows = (data?.metrics?.scopes || []).filter(row => wanted === "*" || row.vhost === wanted);
|
const rows = (data?.metrics?.scopes || []).filter(row => wanted === "*" || row.vhost === wanted);
|
||||||
$("transform-vhost-rows").replaceChildren(...rows.map(row => {
|
$("transform-vhost-rows").replaceChildren(...rows.map(row => {
|
||||||
const tr = el("tr");
|
const tr = el("tr");
|
||||||
for (const value of [
|
tr.append(el("td", row.vhost, "transform-vhost-name"));
|
||||||
row.vhost,
|
for (const value of [number(row.applied), number(row.cache_hits), number(row.skipped)]) tr.append(el("td", value));
|
||||||
number(row.applied),
|
tr.append(el("td", number(row.errors), Number(row.errors) > 0 ? "transform-errors-nonzero" : "transform-zero"), el("td", fmtBytes(row.saved_bytes || 0)));
|
||||||
number(row.cache_hits),
|
const original = Number(row.original_bytes) || 0, share = original > 0 ? Math.max(0, Math.min(100, (Number(row.saved_bytes) || 0) / original * 100)) : 0;
|
||||||
number(row.skipped),
|
const reduction = el("td", undefined, "cache-ratio-cell"), meter = el("span", undefined, "cache-ratio-meter"), fill = el("i"); fill.style.width = `${share}%`; meter.append(fill); reduction.append(el("span", percent(row), "cache-ratio-value"), meter);
|
||||||
number(row.errors),
|
tr.append(reduction, el("td", averageMs(row)));
|
||||||
fmtBytes(row.saved_bytes || 0),
|
|
||||||
percent(row),
|
|
||||||
averageMs(row)
|
|
||||||
]) tr.append(el("td", value));
|
|
||||||
return tr;
|
return tr;
|
||||||
}));
|
}));
|
||||||
$("transform-vhosts-empty").hidden = rows.length > 0;
|
$("transform-vhosts-empty").hidden = rows.length > 0;
|
||||||
@@ -155,7 +151,14 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
const epoch = authEpoch, token = generation;
|
const epoch = authEpoch, token = generation;
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/transform", { signal });
|
let data = await api(staticData ? "/api/transform?view=live" : "/api/transform", { signal });
|
||||||
|
if (staticData && data.revision !== staticRevision) data = await api("/api/transform", { signal });
|
||||||
|
if (data.settings && Array.isArray(data.vhosts)) {
|
||||||
|
staticData = { settings: data.settings, vhosts: data.vhosts };
|
||||||
|
staticRevision = data.revision;
|
||||||
|
} else if (staticData) {
|
||||||
|
data = { ...staticData, ...data };
|
||||||
|
}
|
||||||
if (isCurrent() && token === generation) render(data);
|
if (isCurrent() && token === generation) render(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||||
@@ -179,7 +182,7 @@
|
|||||||
window.rustProxyTransformPanel = {
|
window.rustProxyTransformPanel = {
|
||||||
refresh,
|
refresh,
|
||||||
reset() {
|
reset() {
|
||||||
generation++; lastData = null; resetLiveChart();
|
generation++; lastData = null; staticData = null; staticRevision = null; resetLiveChart();
|
||||||
$("transform-type-rows")?.replaceChildren();
|
$("transform-type-rows")?.replaceChildren();
|
||||||
$("transform-vhost-rows")?.replaceChildren();
|
$("transform-vhost-rows")?.replaceChildren();
|
||||||
$("transform-reason-list")?.replaceChildren();
|
$("transform-reason-list")?.replaceChildren();
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
|
|||||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
|
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
|
||||||
|
|
||||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
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 });
|
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 });
|
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"], ["h2", "vhProtocolH2Strict"]] });
|
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"], ["h2", "vhProtocolH2Strict"]] });
|
||||||
@@ -379,7 +379,7 @@ function locationsEditor(parent, values) {
|
|||||||
const row = el("details", undefined, "vh-location"); row.open = true;
|
const row = el("details", undefined, "vh-location"); row.open = true;
|
||||||
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
||||||
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
||||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_\\-]{1,64}", maxLength: 64 });
|
||||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||||
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
||||||
@@ -391,7 +391,7 @@ function locationsEditor(parent, values) {
|
|||||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||||
|
|
||||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_\\-]{0,64}" });
|
||||||
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||||
note(handlingBody, "locationErrorFallbackHelp");
|
note(handlingBody, "locationErrorFallbackHelp");
|
||||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
|||||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||||
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||||
let g = group("vhEditor"); const basicFields = subsection(g, "vhGeneralDetailsSection");
|
let g = group("vhEditor"); const basicFields = subsection(g, "vhGeneralDetailsSection");
|
||||||
field(basicFields, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(basicFields, "vhIdRenameHelp");
|
const vhostIdInput = field(basicFields, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_\\-]{1,64}" }); if (editing) note(basicFields, "vhIdRenameHelp");
|
||||||
let refreshCanonical = () => { };
|
let refreshCanonical = () => { }, vhostAutoId = null, vhostAutoCertificate = null;
|
||||||
|
vhostAutoId = window.rustProxyAutoId.attach(vhostIdInput, { text: () => lines(read("hosts")?.value || "")[0] || "", taken: () => (envelope?.config.vhosts || []).map(item => item.id), resettable: !editing });
|
||||||
const enabledControl = field(basicFields, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
const enabledControl = field(basicFields, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||||
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
||||||
const enabledLabel = enabledControl.closest("label");
|
const enabledLabel = enabledControl.closest("label");
|
||||||
@@ -88,7 +89,8 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
|||||||
const websocketLegacy = field(websocketFields, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
const websocketLegacy = field(websocketFields, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||||
note(websocketFields, "vhWebsocketLegacySupportHelp");
|
note(websocketFields, "vhWebsocketLegacySupportHelp");
|
||||||
g = group("vhFrontends");
|
g = group("vhFrontends");
|
||||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
|
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => { refreshCanonical(hosts); vhostAutoId?.refresh(); vhostAutoCertificate?.refresh(hosts); });
|
||||||
|
if (!editing) g.lastElementChild?.after(vhostIdInput.closest("label"));
|
||||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||||
note(g, "listenerDefaultPortsHint");
|
note(g, "listenerDefaultPortsHint");
|
||||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||||
@@ -269,7 +271,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
|||||||
passiveSlow.addEventListener("change", syncPassive);
|
passiveSlow.addEventListener("change", syncPassive);
|
||||||
syncPassive();
|
syncPassive();
|
||||||
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
||||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
|
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_\\-]*" });
|
||||||
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
||||||
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
|
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
|
||||||
g = group("vhTrafficPolicy");
|
g = group("vhTrafficPolicy");
|
||||||
@@ -340,7 +342,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
|||||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||||
g = group("vhLocations");
|
g = group("vhLocations");
|
||||||
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_\\-]{0,64}" });
|
||||||
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||||
note(g, "vhErrorFallbackHelp");
|
note(g, "vhErrorFallbackHelp");
|
||||||
locationsReader = locationsEditor(g, value.routes || []);
|
locationsReader = locationsEditor(g, value.routes || []);
|
||||||
@@ -348,6 +350,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
|||||||
const certificateRow = el("div", undefined, "vh-wide vh-certificate-select-row"); g.append(certificateRow);
|
const certificateRow = el("div", undefined, "vh-wide vh-certificate-select-row"); g.append(certificateRow);
|
||||||
const selector = field(certificateRow, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
const selector = field(certificateRow, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
||||||
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
||||||
|
vhostAutoCertificate = window.rustProxyAutoCertificate.attach(selector, { auto: !editing, anchor: certificateRow, hosts: () => { try { return readHosts(); } catch { return []; } }, certificates: () => [...(envelope?.config.tls?.certificates || []), ...stagedCertificates] }); vhostAutoCertificate.refresh();
|
||||||
certificateRow.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); }));
|
certificateRow.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); }));
|
||||||
note(g, "certSelectHint");
|
note(g, "certSelectHint");
|
||||||
const mismatch = field(g, "allow-certificate-mismatch", "vhCertificateMismatchForce", value.allow_certificate_mismatch === true, "checkbox");
|
const mismatch = field(g, "allow-certificate-mismatch", "vhCertificateMismatchForce", value.allow_certificate_mismatch === true, "checkbox");
|
||||||
|
|||||||
+229
-14
@@ -202,7 +202,7 @@ async function savePoolTablePrefs() {
|
|||||||
if (!authenticated) return;
|
if (!authenticated) return;
|
||||||
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
|
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
|
||||||
}
|
}
|
||||||
syncPoolColumns(); syncPoolSortDirection();
|
syncPoolColumns(); syncPoolSortDirection(); applyBackendOverviewView();
|
||||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||||
finally { poolPreferenceSaveRunning = false; }
|
finally { poolPreferenceSaveRunning = false; }
|
||||||
}
|
}
|
||||||
@@ -212,11 +212,15 @@ function syncPoolColumns() {
|
|||||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
|
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
|
||||||
}
|
}
|
||||||
function syncPoolSortDirection() {
|
function syncPoolSortDirection() {
|
||||||
const control = $("pool-sort-direction"); if (!control) return;
|
for (const id of ["pool-sort-direction", "bo-sort-direction"]) {
|
||||||
|
const control = $(id); if (!control) continue;
|
||||||
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
|
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
|
||||||
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function applyPoolTableView() {
|
function applyPoolTableView() {
|
||||||
|
for (const id of ["pool-sort", "bo-sort"]) { const control = $(id); if (control && control.value !== poolTablePrefs.sort) control.value = poolTablePrefs.sort; }
|
||||||
|
applyBackendOverviewView();
|
||||||
const body = $("pool-rows"); if (!body) return;
|
const body = $("pool-rows"); if (!body) return;
|
||||||
const query = ($("pool-search")?.value || "").trim().toLowerCase();
|
const query = ($("pool-search")?.value || "").trim().toLowerCase();
|
||||||
const key = $("pool-sort")?.value || poolTablePrefs.sort;
|
const key = $("pool-sort")?.value || poolTablePrefs.sort;
|
||||||
@@ -234,18 +238,200 @@ function applyPoolTableView() {
|
|||||||
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
|
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
|
||||||
}
|
}
|
||||||
function initPoolTableTools() {
|
function initPoolTableTools() {
|
||||||
const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search");
|
buildBackendOverviewTools();
|
||||||
if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); }
|
for (const id of ["pool-sort", "bo-sort"]) {
|
||||||
direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
|
const sort = $(id); if (!sort) continue;
|
||||||
search?.addEventListener("input", applyPoolTableView);
|
sort.value = poolTablePrefs.sort;
|
||||||
|
sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||||
|
}
|
||||||
|
for (const id of ["pool-sort-direction", "bo-sort-direction"]) $(id)?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||||
|
$("pool-search")?.addEventListener("input", applyPoolTableView);
|
||||||
|
$("bo-search")?.addEventListener("input", applyBackendOverviewView);
|
||||||
|
$("bo-open")?.addEventListener("click", () => setPanel("vhosts"));
|
||||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
|
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
|
||||||
const visible = new Set(poolTablePrefs.columns);
|
const visible = new Set(poolTablePrefs.columns);
|
||||||
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
|
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
|
||||||
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
|
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
|
||||||
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns();
|
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns(); applyBackendOverviewView();
|
||||||
}));
|
}));
|
||||||
syncPoolColumns(); syncPoolSortDirection();
|
syncPoolColumns(); syncPoolSortDirection();
|
||||||
}
|
}
|
||||||
|
// Backends overview (dashboard card "backend_table"): a richer view of the same backend data. It shares the sort,
|
||||||
|
// direction and column preferences with the compact "Tiny backends" table and follows table density and corner style.
|
||||||
|
const BO_SORT_LABELS = { vhost: "vhVhost", backend: "backend", status: "status", active: "vhActive", requests: "vhRequests", errors: "vhErrors", latency: "vhLatency", traffic: "vhTraffic", generation: "vhClientGeneration", generationRequests: "vhGenerationRequests", generationAge: "vhGenerationAge", rotations: "vhClientRotations", lastFailure: "vhLastFailure", lastRecovery: "vhLastRecovery" };
|
||||||
|
const BO_DETAIL_COLUMNS = ["generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||||
|
const BO_SAMPLES = 30;
|
||||||
|
const boOpen = new Set(), boSeries = new Map();
|
||||||
|
let boSnapshot = null;
|
||||||
|
let boItems = [], boFilter = "all", boHasProxy = true;
|
||||||
|
function buildBackendOverviewTools() {
|
||||||
|
const sort = $("bo-sort"), options = $("bo-column-options");
|
||||||
|
if (sort && !sort.options.length) for (const key of poolColumns) { const option = el("option", t(BO_SORT_LABELS[key])); option.value = key; option.dataset.i18n = BO_SORT_LABELS[key]; sort.append(option); }
|
||||||
|
if (options && !options.children.length) for (const key of poolColumns) {
|
||||||
|
const label = el("label", undefined, "check"), input = el("input"), text = el("span", t(BO_SORT_LABELS[key]));
|
||||||
|
input.type = "checkbox"; input.dataset.poolColumnToggle = key; text.dataset.i18n = BO_SORT_LABELS[key]; label.append(input, text); options.append(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function boSeriesOf(item) { return item.series || { latency: [], inRate: [], outRate: [], requests: [] }; }
|
||||||
|
function boAgo(ms) {
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - Number(ms)) / 1000));
|
||||||
|
const value = seconds < 60 ? `${seconds} s` : seconds < 3600 ? `${Math.floor(seconds / 60)} min` : seconds < 86400 ? `${Math.floor(seconds / 3600)} h` : `${Math.floor(seconds / 86400)} d`;
|
||||||
|
return t("backendAgo").replace("{value}", value);
|
||||||
|
}
|
||||||
|
const BO_SVG = "http://www.w3.org/2000/svg";
|
||||||
|
function boSvg(width, kind) {
|
||||||
|
const svg = document.createElementNS(BO_SVG, "svg");
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${width} 20`); svg.setAttribute("class", `bo-spark bo-spark-${kind}`); svg.setAttribute("aria-hidden", "true");
|
||||||
|
boBaseline(svg, width, kind === "latency" ? 10 : 19); // idle or not-yet-sampled rows still show where the chart is
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
function boBaseline(svg, width, y) {
|
||||||
|
const line = document.createElementNS(BO_SVG, "line");
|
||||||
|
line.setAttribute("class", "bo-base"); line.setAttribute("x1", "0"); line.setAttribute("x2", String(width)); line.setAttribute("y1", String(y)); line.setAttribute("y2", String(y));
|
||||||
|
svg.append(line);
|
||||||
|
}
|
||||||
|
function boPoints(values, width, min, max) {
|
||||||
|
const flat = max - min < max * 0.05, step = width / Math.max(1, values.length - 1);
|
||||||
|
return values.map((value, index) => `${(index * step).toFixed(1)},${(flat ? 10 : 17 - ((value - min) / (max - min)) * 14).toFixed(1)}`).join(" ");
|
||||||
|
}
|
||||||
|
function boSparkline(history, current) {
|
||||||
|
const svg = boSvg(120, "latency"), samples = history.filter(value => value > 0);
|
||||||
|
if (!(current > 0) || samples.length < 3) return svg;
|
||||||
|
const line = document.createElementNS(BO_SVG, "polyline");
|
||||||
|
line.setAttribute("points", boPoints(samples, 120, Math.min(...samples), Math.max(...samples)));
|
||||||
|
svg.append(line); return svg;
|
||||||
|
}
|
||||||
|
function boBars(values) {
|
||||||
|
const svg = boSvg(84, "bars"), max = Math.max(0, ...values);
|
||||||
|
if (values.length < 3 || max <= 0) return svg;
|
||||||
|
const width = 84 / values.length;
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
const bar = document.createElementNS(BO_SVG, "rect"), height = value > 0 ? Math.max(1.5, value / max * 18) : 0;
|
||||||
|
bar.setAttribute("x", (index * width + width * 0.15).toFixed(1)); bar.setAttribute("width", (width * 0.7).toFixed(1));
|
||||||
|
bar.setAttribute("y", (20 - height).toFixed(1)); bar.setAttribute("height", height.toFixed(1)); svg.append(bar);
|
||||||
|
});
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
function boTrafficChart(inSamples, outSamples) {
|
||||||
|
const svg = boSvg(96, "traffic"), max = Math.max(0, ...inSamples, ...outSamples);
|
||||||
|
if (inSamples.length < 3 || max <= 0) return svg;
|
||||||
|
for (const [values, kind] of [[inSamples, "in"], [outSamples, "out"]]) {
|
||||||
|
const line = document.createElementNS(BO_SVG, "polyline"), step = 96 / (values.length - 1);
|
||||||
|
line.setAttribute("class", `bo-line-${kind}`);
|
||||||
|
line.setAttribute("points", values.map((value, index) => `${(index * step).toFixed(1)},${(18 - (value / max) * 16).toFixed(1)}`).join(" "));
|
||||||
|
svg.append(line);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
function renderBackendOverview(items, hasProxy, snapshot = null) {
|
||||||
|
boItems = items; boHasProxy = hasProxy !== false;
|
||||||
|
const seen = new Set(), sample = snapshot === null || snapshot !== boSnapshot;
|
||||||
|
boSnapshot = snapshot;
|
||||||
|
const push = (list, value) => { list.push(value); if (list.length > BO_SAMPLES) list.shift(); };
|
||||||
|
for (const item of items) {
|
||||||
|
seen.add(item.key);
|
||||||
|
const series = boSeries.get(item.key) || { latency: [], inRate: [], outRate: [], requests: [], lastRequests: null };
|
||||||
|
if (sample) {
|
||||||
|
push(series.latency, item.latency); push(series.inRate, item.trafficIn || 0); push(series.outRate, item.trafficOut || 0);
|
||||||
|
push(series.requests, series.lastRequests === null ? 0 : Math.max(0, item.requests - series.lastRequests)); series.lastRequests = item.requests;
|
||||||
|
}
|
||||||
|
boSeries.set(item.key, series);
|
||||||
|
item.series = series;
|
||||||
|
}
|
||||||
|
for (const key of boSeries.keys()) if (!seen.has(key)) boSeries.delete(key);
|
||||||
|
for (const key of boOpen) if (!seen.has(key)) boOpen.delete(key);
|
||||||
|
$("backend-overview-card")?.classList.toggle("is-no-proxy", !boHasProxy);
|
||||||
|
applyBackendOverviewView();
|
||||||
|
}
|
||||||
|
function applyBackendOverviewView() {
|
||||||
|
const body = $("bo-rows"); if (!body) return;
|
||||||
|
const visibleColumns = new Set(poolTablePrefs.columns);
|
||||||
|
const query = ($("bo-search")?.value || "").trim().toLowerCase();
|
||||||
|
const focusKey = document.activeElement?.dataset?.boKey, hadFocus = Boolean(focusKey) && body.contains(document.activeElement);
|
||||||
|
const counts = { all: boItems.length, up: 0, down: 0, other: 0 };
|
||||||
|
for (const item of boItems) counts[item.group]++;
|
||||||
|
const summary = $("bo-summary"); if (summary) summary.textContent = boItems.length ? t("backendSummary").replace("{up}", counts.up).replace("{total}", boItems.length) : "";
|
||||||
|
const filters = $("bo-filters");
|
||||||
|
if (filters) {
|
||||||
|
filters.setAttribute("aria-label", t("backendFilterLabel"));
|
||||||
|
filters.replaceChildren(...[["all", "backendFilterAll"], ["up", "vhUp"], ["down", "vhDown"], ["other", "backendFilterOther"]].map(([key, label]) => {
|
||||||
|
const chip = el("button", undefined, `bo-chip is-${key}`), count = el("b", String(counts[key]), counts[key] ? "" : "is-zero");
|
||||||
|
chip.type = "button"; chip.dataset.uiIconName = "none"; chip.setAttribute("aria-pressed", String(boFilter === key));
|
||||||
|
chip.append(el("span", t(label)), count); chip.addEventListener("click", () => { boFilter = key; applyBackendOverviewView(); }); return chip;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const key = poolTablePrefs.sort;
|
||||||
|
const list = boItems.filter(item => (boFilter === "all" || item.group === boFilter) && (!query || item.search.includes(query)));
|
||||||
|
list.sort((left, right) => {
|
||||||
|
const a = left.sort[key] ?? "", b = right.sort[key] ?? "";
|
||||||
|
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||||
|
return poolTablePrefs.descending ? -result : result;
|
||||||
|
});
|
||||||
|
const showIdentity = visibleColumns.has("vhost") || visibleColumns.has("backend"), showEvent = visibleColumns.has("lastFailure") || visibleColumns.has("lastRecovery");
|
||||||
|
const detailColumns = BO_DETAIL_COLUMNS.filter(column => visibleColumns.has(column)), expandable = detailColumns.length > 0;
|
||||||
|
const columns = [
|
||||||
|
expandable && ["toggle", ""], showIdentity && ["identity", `${visibleColumns.has("vhost") ? t("vhVhost") : ""}${visibleColumns.has("vhost") && visibleColumns.has("backend") ? " / " : ""}${visibleColumns.has("backend") ? t("backend") : ""}`],
|
||||||
|
visibleColumns.has("status") && ["status", t("status")], visibleColumns.has("active") && ["active", t("vhActive"), true], visibleColumns.has("requests") && ["requests", t("vhRequests"), true],
|
||||||
|
visibleColumns.has("errors") && ["errors", t("vhErrors")], visibleColumns.has("latency") && ["latency", t("vhLatency")], visibleColumns.has("traffic") && ["traffic", t("vhTraffic"), true], showEvent && ["event", t("backendLastEvent")],
|
||||||
|
].filter(Boolean);
|
||||||
|
const head = $("bo-head"); if (head) head.replaceChildren(...columns.map(([id, label, numeric]) => { const th = el("th", label, numeric ? "is-num" : ""); th.dataset.boColumn = id; if (id === "toggle") th.setAttribute("aria-label", t("backendDetailsToggle")); return th; }));
|
||||||
|
const rows = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const row = el("tr", undefined, `bo-row is-${item.tone}${item.disabled ? " is-disabled" : ""}`), open = expandable && boOpen.has(item.key);
|
||||||
|
for (const [id] of columns) {
|
||||||
|
const cell = el("td"); cell.dataset.boColumn = id;
|
||||||
|
if (id === "toggle") {
|
||||||
|
const button = el("button", undefined, "bo-expand"); button.type = "button"; button.dataset.uiIconName = "none"; button.dataset.boKey = item.key;
|
||||||
|
button.setAttribute("aria-expanded", String(open)); button.setAttribute("aria-label", t("backendDetailsToggle")); button.title = t("backendDetailsToggle");
|
||||||
|
button.addEventListener("click", () => { if (boOpen.has(item.key)) boOpen.delete(item.key); else boOpen.add(item.key); applyBackendOverviewView(); });
|
||||||
|
cell.append(button);
|
||||||
|
} else if (id === "identity") {
|
||||||
|
cell.className = "bo-identity"; const name = el("div", visibleColumns.has("vhost") ? item.vhost : item.backend, "bo-name"); name.title = item.vhost; cell.append(name);
|
||||||
|
if (visibleColumns.has("vhost") && visibleColumns.has("backend")) { const sub = el("div", item.backend, "bo-sub"); sub.title = item.backend; cell.append(sub); }
|
||||||
|
} else if (id === "status") {
|
||||||
|
const badge = el("span", item.label, `bo-badge is-${item.tone}`); badge.title = item.title; badge.prepend(el("i")); cell.append(badge);
|
||||||
|
} else if (id === "active") { cell.className = "is-num"; cell.textContent = item.activeText; }
|
||||||
|
else if (id === "requests") {
|
||||||
|
cell.className = "bo-requests"; const wrap = el("div", undefined, "bo-chartcell");
|
||||||
|
wrap.append(boBars(boSeriesOf(item).requests), el("span", String(item.requests))); wrap.title = t("backendRequestsTrend"); cell.append(wrap);
|
||||||
|
}
|
||||||
|
else if (id === "errors") {
|
||||||
|
const pct = item.requests ? item.errors / item.requests * 100 : 0, top = el("div", undefined, "bo-meter-top"), track = el("div", undefined, "bo-meter"), fill = el("i");
|
||||||
|
top.append(el("span", String(item.errors), item.errors ? "" : "is-zero"), el("small", item.errors ? (pct < 0.1 ? "<0.1%" : `${pct.toFixed(pct < 10 ? 1 : 0)}%`) : "0%"));
|
||||||
|
fill.style.width = `${item.errors ? Math.min(100, Math.max(4, pct)) : 0}%`; if (pct >= 5) fill.className = "is-high"; track.append(fill); cell.className = "bo-errors"; cell.append(top, track);
|
||||||
|
} else if (id === "latency") {
|
||||||
|
cell.className = "bo-latency"; cell.append(el("span", item.latency > 0 ? `${item.latency.toFixed(1)} ms` : "-", item.latency > 0 ? "" : "is-zero"), boSparkline(boSeriesOf(item).latency, item.latency));
|
||||||
|
} else if (id === "traffic") {
|
||||||
|
cell.className = "bo-traffic"; const wrap = el("div", undefined, "bo-chartcell"), text = el("div", undefined, "bo-traffic-text"); wrap.title = `${rateBytes(item.traffic)}/s`;
|
||||||
|
const line = (kind, label, value) => { const row = el("div", undefined, `bo-${kind}`); row.append(el("span", label), el("b", `${rateBytes(value)}/s`, value > 0 ? "" : "is-zero")); return row; };
|
||||||
|
text.append(line("in", "IN", item.trafficIn || 0), line("out", "OUT", item.trafficOut || 0));
|
||||||
|
wrap.append(boTrafficChart(boSeriesOf(item).inRate, boSeriesOf(item).outRate), text); cell.append(wrap);
|
||||||
|
}
|
||||||
|
else if (id === "event") {
|
||||||
|
cell.className = "bo-event";
|
||||||
|
if (item.lastFailureMs && item.lastFailureMs >= item.lastRecoveryMs) { const line = el("span", item.lastFailureClass || t("backendEventFailed"), "is-failed"); line.title = item.lastFailureDetail || ""; cell.append(line, el("small", boAgo(item.lastFailureMs))); }
|
||||||
|
else if (item.lastRecoveryMs) cell.append(el("span", t("backendEventRecovered"), "is-recovered"), el("small", boAgo(item.lastRecoveryMs)));
|
||||||
|
else cell.append(el("span", t("backendEventNone"), "is-zero"));
|
||||||
|
}
|
||||||
|
row.append(cell);
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
if (open) {
|
||||||
|
const detail = el("tr", undefined, "bo-detail-row"), cell = el("td"), grid = el("div", undefined, "bo-detail-grid"); cell.colSpan = Math.max(1, columns.length);
|
||||||
|
const values = { generation: item.generation, generationRequests: item.generationRequests, generationAge: item.generationAgeText, rotations: item.rotations, lastFailure: item.lastFailureText, lastRecovery: item.lastRecoveryText };
|
||||||
|
for (const column of detailColumns) { const entry = el("div", undefined, "bo-detail-item"), value = el("b", String(values[column] ?? "-")); if (column === "lastFailure" && item.lastFailureMs) { value.className = "is-failed"; value.title = item.lastFailureDetail || ""; } else if (column === "lastRecovery" && item.lastRecoveryMs) value.className = "is-recovered"; entry.append(el("span", t(BO_SORT_LABELS[column])), value); grid.append(entry); }
|
||||||
|
cell.append(grid); detail.append(cell); rows.push(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scroller = body.closest(".table-scroll"), scrollTop = scroller?.scrollTop ?? 0, scrollLeft = scroller?.scrollLeft ?? 0;
|
||||||
|
body.replaceChildren(...rows);
|
||||||
|
if (scroller) { scroller.scrollTop = scrollTop; scroller.scrollLeft = scrollLeft; }
|
||||||
|
// restore keyboard focus without scrolling: the table is rebuilt on every refresh and a plain focus() jumped back to the focused row
|
||||||
|
if (hadFocus) [...body.querySelectorAll(".bo-expand")].find(button => button.dataset.boKey === focusKey)?.focus({ preventScroll: true });
|
||||||
|
const empty = $("bo-empty");
|
||||||
|
if (empty) { empty.textContent = boItems.length ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = list.length > 0 || !boHasProxy; }
|
||||||
|
const toggleAll = document.querySelectorAll("#bo-column-options [data-pool-column-toggle]"); toggleAll.forEach(input => { input.checked = visibleColumns.has(input.dataset.poolColumnToggle); });
|
||||||
|
}
|
||||||
async function toggleVhostEnabled(vhost) {
|
async function toggleVhostEnabled(vhost) {
|
||||||
if (!envelope || busy) return;
|
if (!envelope || busy) return;
|
||||||
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
|
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
|
||||||
@@ -286,7 +472,8 @@ function render() {
|
|||||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||||
);
|
);
|
||||||
const diagnosticsCard = $("backend-diagnostics-card");
|
const diagnosticsCard = $("backend-diagnostics-card");
|
||||||
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
|
diagnosticsCard?.classList.toggle("is-no-proxy", !configuredHasProxySource);
|
||||||
|
$("backend-overview-card")?.classList.toggle("is-no-proxy", !configuredHasProxySource);
|
||||||
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
||||||
const visibleVhosts = (config.vhosts || []).filter(v => {
|
const visibleVhosts = (config.vhosts || []).filter(v => {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
@@ -327,7 +514,7 @@ function render() {
|
|||||||
openButton.disabled = !browseHost || v.enabled === false;
|
openButton.disabled = !browseHost || v.enabled === false;
|
||||||
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
|
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
|
||||||
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
|
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
|
||||||
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
|
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("copyEntry", () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(v); copy.id = ""; copy.frontend = { ...(copy.frontend || {}), hosts: [] }; copy.certificate_id = null; copy.allow_certificate_mismatch = false; copy.www_redirect_target = null; load(copy); }), button("remove", async () => {
|
||||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||||
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
||||||
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
||||||
@@ -435,16 +622,26 @@ function render() {
|
|||||||
}
|
}
|
||||||
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||||
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
||||||
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
|
const bytesIn = Math.max(0, Number(member.bytes_in) || 0), bytesOut = Math.max(0, Number(member.bytes_out) || 0), total = bytesIn + bytesOut;
|
||||||
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
||||||
const previous = backendTrafficHistory.get(key);
|
const previous = backendTrafficHistory.get(key);
|
||||||
if (previous?.snapshot === snapshot) return previous.rate;
|
if (previous?.snapshot === snapshot) return previous.rate;
|
||||||
const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds"));
|
const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds"));
|
||||||
const observed = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0;
|
const sampled = continuous && total >= previous.total, seconds = sampled ? (now - previous.at) / 1000 : 0;
|
||||||
|
const observed = sampled ? (total - previous.total) / seconds : 0;
|
||||||
|
let inRate = sampled && bytesIn >= previous.bytesIn ? (bytesIn - previous.bytesIn) / seconds : 0;
|
||||||
|
let outRate = sampled && bytesOut >= previous.bytesOut ? (bytesOut - previous.bytesOut) / seconds : 0;
|
||||||
const rate = reported > 0 ? reported : observed;
|
const rate = reported > 0 ? reported : observed;
|
||||||
backendTrafficHistory.set(key, { ...rustProxyRateSampling.stamp(now), total, snapshot, rate });
|
// keep the in/out split consistent with the total shown elsewhere (the server-reported rate wins when present)
|
||||||
|
if (rate > 0 && inRate + outRate === 0) { inRate = rate * (total > 0 ? bytesIn / total : 0.5); outRate = rate - inRate; }
|
||||||
|
else if (reported > 0 && inRate + outRate > 0) { const scale = reported / (inRate + outRate); inRate *= scale; outRate *= scale; }
|
||||||
|
backendTrafficHistory.set(key, { ...rustProxyRateSampling.stamp(now), total, bytesIn, bytesOut, snapshot, rate, inRate, outRate });
|
||||||
return rate;
|
return rate;
|
||||||
}
|
}
|
||||||
|
function backendTrafficSplit(poolId, member) {
|
||||||
|
const history = backendTrafficHistory.get(`${poolId}\u0000${member.id}`);
|
||||||
|
return { inRate: history?.inRate || 0, outRate: history?.outRate || 0 };
|
||||||
|
}
|
||||||
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
||||||
@@ -459,7 +656,7 @@ function renderStatus(status) {
|
|||||||
renderUpstreamProtocolBadges(status);
|
renderUpstreamProtocolBadges(status);
|
||||||
refreshVhostBackendPopup(status);
|
refreshVhostBackendPopup(status);
|
||||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
const rows = [], summaries = new Map(), aggregate = new Map(), overviewItems = [];
|
||||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||||
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
||||||
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
||||||
@@ -481,6 +678,14 @@ function renderStatus(status) {
|
|||||||
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
|
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
|
||||||
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
|
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
|
||||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||||
|
const tone = vhostDisabled ? "muted" : member.health_mode === "off" && member.status === "up" ? "muted" : member.status === "up" ? "up" : member.status === "down" ? "down" : member.status === "checking" || member.status === "draining" ? "warn" : "muted";
|
||||||
|
overviewItems.push({
|
||||||
|
key: `${pool.id}\u0000${member.id}`, vhost: poolLabel, backend: `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, disabled: vhostDisabled, tone, group: tone === "up" ? "up" : tone === "down" ? "down" : "other",
|
||||||
|
label, title: vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`, activeText: activeLimit, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, ...(({ inRate, outRate }) => ({ trafficIn: inRate, trafficOut: outRate }))(backendTrafficSplit(pool.id, member)),
|
||||||
|
generation: member.upstream_client_generation ?? "-", generationRequests: member.upstream_client_generation_requests ?? "-", generationAgeText, rotations: member.upstream_client_rotations_total ?? "-",
|
||||||
|
lastFailureMs: Number(member.last_failure_at_ms) || 0, lastFailureClass: member.last_failure_class || "", lastFailureDetail: member.last_failure_detail || "", lastFailureText: lastFailure, lastRecoveryMs: Number(member.last_recovered_ms) || 0, lastRecoveryText: lastRecovery,
|
||||||
|
sort: sortValues, search: row.poolSearchValue,
|
||||||
|
});
|
||||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||||
}
|
}
|
||||||
@@ -524,8 +729,9 @@ function renderStatus(status) {
|
|||||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||||
);
|
);
|
||||||
$("pool-rows").replaceChildren(...rows);
|
$("pool-rows").replaceChildren(...rows);
|
||||||
|
renderBackendOverview(overviewItems, hasConfiguredProxySource, status);
|
||||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
backendDiagnosticsCard?.classList.toggle("is-no-proxy", !hasConfiguredProxySource);
|
||||||
applyPoolTableView();
|
applyPoolTableView();
|
||||||
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
||||||
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
||||||
@@ -590,6 +796,15 @@ form.addEventListener("submit", async event => {
|
|||||||
wwwRedirect: item?.www_redirect === true,
|
wwwRedirect: item?.www_redirect === true,
|
||||||
wwwTarget: item?.www_redirect_target || null
|
wwwTarget: item?.www_redirect_target || null
|
||||||
});
|
});
|
||||||
|
if (value.certificate_id && !value.allow_certificate_mismatch) {
|
||||||
|
const certificate = [...(envelope?.config.tls?.certificates || []), ...stagedCertificates].find(item => item.id === value.certificate_id);
|
||||||
|
const inventory = certificateInventory.get(value.certificate_id)?.covered_hosts || [], coverage = inventory.length ? inventory : (certificate?.covered_hosts || []);
|
||||||
|
const uncovered = coverage.length ? (value.frontend?.hosts || []).filter(host => !coverage.some(pattern => window.rustProxyAutoCertificate.covers(pattern, host))) : [];
|
||||||
|
if (uncovered.length) {
|
||||||
|
if (!(await appConfirm(t("vhCertificateUncoveredPrompt").replace("{certificate}", value.certificate_id).replace("{hosts}", uncovered.join(", ")), { danger: true }))) { activateTab("ssl"); return; }
|
||||||
|
value.allow_certificate_mismatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (value.allow_certificate_mismatch && (!oldVhost?.allow_certificate_mismatch || certificateScope(value) !== certificateScope(oldVhost)) && !(await appConfirm(t("vhCertificateMismatchConfirm"), { danger: true }))) return;
|
if (value.allow_certificate_mismatch && (!oldVhost?.allow_certificate_mismatch || certificateScope(value) !== certificateScope(oldVhost)) && !(await appConfirm(t("vhCertificateMismatchConfirm"), { danger: true }))) return;
|
||||||
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
|
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
|
||||||
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
|
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
|
||||||
|
|||||||
@@ -330,6 +330,7 @@
|
|||||||
$("web-service-id").value = item.id || "";
|
$("web-service-id").value = item.id || "";
|
||||||
$("web-service-id").readOnly = !!editing;
|
$("web-service-id").readOnly = !!editing;
|
||||||
$("web-service-name").value = item.name || "";
|
$("web-service-name").value = item.name || "";
|
||||||
|
window.rustProxyAutoId.attach($("web-service-id"), { elements: [$("web-service-name")], taken: () => (envelope?.config.web_services || []).map(value => value.id) });
|
||||||
$("web-service-root").value = item.document_root || "";
|
$("web-service-root").value = item.document_root || "";
|
||||||
populateSettings(item);
|
populateSettings(item);
|
||||||
$("web-service-preset").value = "custom";
|
$("web-service-preset").value = "custom";
|
||||||
|
|||||||
+1
-1
@@ -174,7 +174,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
pub async fn save_admin_preferences(&self, username: &str, document: &str) -> Result<bool> {
|
pub async fn save_admin_preferences(&self, username: &str, document: &str) -> Result<bool> {
|
||||||
ensure!(
|
ensure!(
|
||||||
(2..=4096).contains(&document.len()),
|
(2..=crate::db::ADMIN_PREFERENCES_MAX_BYTES).contains(&document.len()),
|
||||||
"administrator preferences document is too large"
|
"administrator preferences document is too large"
|
||||||
);
|
);
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
|
|||||||
Vendored
+39
-2
@@ -6,7 +6,6 @@ mod tests {
|
|||||||
h.insert("cache-control", value.parse().unwrap());
|
h.insert("cache-control", value.parse().unwrap());
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
// Legacy policy; varnish-compatible behavior is tested separately.
|
|
||||||
fn settings() -> Settings {
|
fn settings() -> Settings {
|
||||||
Settings {
|
Settings {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -1559,7 +1558,10 @@ mod tests {
|
|||||||
let second = cache
|
let second = cache
|
||||||
.ticket(1, "http", "example.test", "app", "/second", &headers)
|
.ticket(1, "http", "example.test", "app", "/second", &headers)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fill(&cache, first.clone(), "first", &s).await;
|
// Stored bodies are trimmed to their real size, so the first object must
|
||||||
|
// itself be large for the next fill reservation to need its space.
|
||||||
|
let large: &'static str = Box::leak("f".repeat(60 * 1024).into_boxed_str());
|
||||||
|
fill(&cache, first.clone(), large, &s).await;
|
||||||
assert!(cache.get(&first).is_some());
|
assert!(cache.get(&first).is_some());
|
||||||
let before = cache.view();
|
let before = cache.view();
|
||||||
assert!(before.hot_allocated_bytes <= s.max_bytes);
|
assert!(before.hot_allocated_bytes <= s.max_bytes);
|
||||||
@@ -1821,4 +1823,39 @@ mod tests {
|
|||||||
assert_eq!(cache.view().entries, 0);
|
assert_eq!(cache.view().entries, 0);
|
||||||
assert_eq!(cache.view().fills_active, 0);
|
assert_eq!(cache.view().fills_active, 0);
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stored_entries_do_not_pin_origin_buffers_or_spare_capacity() {
|
||||||
|
let cache = Cache::new(1);
|
||||||
|
let s = settings();
|
||||||
|
let t = ticket(&cache, &HeaderMap::new());
|
||||||
|
// Header values parsed by hyper are slices of a larger read buffer.
|
||||||
|
let read_buffer = Bytes::from(vec![b'x'; 64 * 1024]);
|
||||||
|
let mut h = headers("max-age=60");
|
||||||
|
h.insert(
|
||||||
|
"x-origin",
|
||||||
|
HeaderValue::from_maybe_shared(read_buffer.slice(0..16)).unwrap(),
|
||||||
|
);
|
||||||
|
h.insert(
|
||||||
|
::http::header::ETAG,
|
||||||
|
HeaderValue::from_maybe_shared(read_buffer.slice(16..24)).unwrap(),
|
||||||
|
);
|
||||||
|
let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap();
|
||||||
|
let mut response = Response::new(Body::from("small body"));
|
||||||
|
*response.headers_mut() = h;
|
||||||
|
cache
|
||||||
|
.wrap(response, t.clone(), p, &s)
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(cache.get(&t).is_some());
|
||||||
|
assert!(
|
||||||
|
read_buffer.try_into_mut().is_ok(),
|
||||||
|
"cached headers must not keep the origin read buffer alive"
|
||||||
|
);
|
||||||
|
let store = cache.store.lock().unwrap();
|
||||||
|
let entry = store.entries.values().next().expect("stored object");
|
||||||
|
assert_eq!(entry.body.len(), 10);
|
||||||
|
assert!(entry.body_allocated <= 10 + 10 / 8 + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Vendored
+11
-2
@@ -126,10 +126,19 @@ impl Cache {
|
|||||||
.uncacheable_until
|
.uncacheable_until
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
|
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY && !memo.contains_key(&ticket.key) {
|
||||||
memo.retain(|_, until| *until > now);
|
memo.retain(|_, until| *until > now);
|
||||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
|
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
|
||||||
return;
|
// Keep hit-for-miss working for new keys under sustained churn:
|
||||||
|
// drop the marker closest to expiry instead of silently
|
||||||
|
// re-enabling request collapsing for this (uncacheable) key.
|
||||||
|
if let Some(oldest) = memo
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, until)| **until)
|
||||||
|
.map(|(key, _)| *key)
|
||||||
|
{
|
||||||
|
memo.remove(&oldest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
memo.insert(ticket.key, now + UNCACHEABLE_MEMO_TTL);
|
memo.insert(ticket.key, now + UNCACHEABLE_MEMO_TTL);
|
||||||
|
|||||||
Vendored
+40
-2
@@ -1,3 +1,16 @@
|
|||||||
|
fn detached_header_value(value: &HeaderValue) -> HeaderValue {
|
||||||
|
let mut detached = HeaderValue::from_bytes(value.as_bytes())
|
||||||
|
.expect("existing HTTP header value must remain valid when copied");
|
||||||
|
detached.set_sensitive(value.is_sensitive());
|
||||||
|
detached
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detach_header_map(headers: &mut HeaderMap) {
|
||||||
|
for value in headers.values_mut() {
|
||||||
|
*value = detached_header_value(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ByteGaugeGuard<'a> {
|
struct ByteGaugeGuard<'a> {
|
||||||
counter: &'a AtomicU64,
|
counter: &'a AtomicU64,
|
||||||
bytes: u64,
|
bytes: u64,
|
||||||
@@ -102,6 +115,9 @@ impl Candidate {
|
|||||||
let (body, body_allocation) = if let Some(body) = self.shared.take() {
|
let (body, body_allocation) = if let Some(body) = self.shared.take() {
|
||||||
(body, self.shared_allocated)
|
(body, self.shared_allocated)
|
||||||
} else {
|
} else {
|
||||||
|
if self.data.capacity() > self.data.len().saturating_add(self.data.len() / 8) {
|
||||||
|
self.data.shrink_to_fit();
|
||||||
|
}
|
||||||
let allocated = self.data.capacity().max(self.data.len());
|
let allocated = self.data.capacity().max(self.data.len());
|
||||||
(Bytes::from(std::mem::take(&mut self.data)), allocated)
|
(Bytes::from(std::mem::take(&mut self.data)), allocated)
|
||||||
};
|
};
|
||||||
@@ -111,6 +127,12 @@ impl Candidate {
|
|||||||
let domain = self.ticket.domain.clone();
|
let domain = self.ticket.domain.clone();
|
||||||
let path = self.ticket.path.clone();
|
let path = self.ticket.path.clone();
|
||||||
let target = self.ticket.target.clone();
|
let target = self.ticket.target.clone();
|
||||||
|
detach_header_map(&mut self.headers);
|
||||||
|
self.origin_etag = self.origin_etag.as_ref().map(detached_header_value);
|
||||||
|
self.origin_last_modified = self
|
||||||
|
.origin_last_modified
|
||||||
|
.as_ref()
|
||||||
|
.map(detached_header_value);
|
||||||
let tags = cache_tags(&self.headers);
|
let tags = cache_tags(&self.headers);
|
||||||
let cost = hot_entry_cost(
|
let cost = hot_entry_cost(
|
||||||
body_allocation,
|
body_allocation,
|
||||||
@@ -226,7 +248,21 @@ impl HttpBody for FillBody {
|
|||||||
let needed = candidate.data.len().saturating_add(data.len());
|
let needed = candidate.data.len().saturating_add(data.len());
|
||||||
if needed <= candidate.settings.max_object_bytes {
|
if needed <= candidate.settings.max_object_bytes {
|
||||||
let before = candidate.data.capacity();
|
let before = candidate.data.capacity();
|
||||||
let required_growth = needed.saturating_sub(before);
|
let target_capacity = if needed <= before {
|
||||||
|
before
|
||||||
|
} else if let Some(length) =
|
||||||
|
candidate.plan.expected_length.filter(|length| {
|
||||||
|
*length >= needed
|
||||||
|
&& *length <= candidate.settings.max_object_bytes
|
||||||
|
})
|
||||||
|
{
|
||||||
|
length
|
||||||
|
} else {
|
||||||
|
needed
|
||||||
|
.max(before.saturating_mul(2))
|
||||||
|
.min(candidate.settings.max_object_bytes)
|
||||||
|
};
|
||||||
|
let required_growth = target_capacity.saturating_sub(before);
|
||||||
if required_growth > 0
|
if required_growth > 0
|
||||||
&& !candidate
|
&& !candidate
|
||||||
.fill_gauge
|
.fill_gauge
|
||||||
@@ -246,7 +282,9 @@ impl HttpBody for FillBody {
|
|||||||
} else if required_growth > 0
|
} else if required_growth > 0
|
||||||
&& candidate
|
&& candidate
|
||||||
.data
|
.data
|
||||||
.try_reserve_exact(needed.saturating_sub(candidate.data.len()))
|
.try_reserve_exact(
|
||||||
|
target_capacity.saturating_sub(candidate.data.len()),
|
||||||
|
)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
candidate.cache.record_reason(
|
candidate.cache.record_reason(
|
||||||
|
|||||||
Vendored
+1
@@ -106,6 +106,7 @@ impl Cache {
|
|||||||
cache.demote_idle();
|
cache.demote_idle();
|
||||||
cache.sweep_cold().await;
|
cache.sweep_cold().await;
|
||||||
cache.gc_banned(256);
|
cache.gc_banned(256);
|
||||||
|
flush_pending_allocator_release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+18
-3
@@ -43,7 +43,18 @@ impl Cache {
|
|||||||
ticket.freshness_headers(),
|
ticket.freshness_headers(),
|
||||||
);
|
);
|
||||||
if state.servable() {
|
if state.servable() {
|
||||||
let sequence = store.next_sequence();
|
// Approximate LRU: an entry already among the most recently used
|
||||||
|
// eighth of the store is not re-linked on every hit. This keeps the
|
||||||
|
// global HOT lock short for popular objects without changing which
|
||||||
|
// cold entries are evicted first.
|
||||||
|
let promote = store
|
||||||
|
.entries
|
||||||
|
.get(&storage_key)
|
||||||
|
.is_some_and(|entry| {
|
||||||
|
store.sequence.saturating_sub(entry.sequence)
|
||||||
|
> (store.entries.len() as u64) / 8
|
||||||
|
});
|
||||||
|
let sequence = if promote { store.next_sequence() } else { 0 };
|
||||||
let (
|
let (
|
||||||
old_sequence,
|
old_sequence,
|
||||||
headers,
|
headers,
|
||||||
@@ -59,7 +70,9 @@ impl Cache {
|
|||||||
let old_sequence = entry.sequence;
|
let old_sequence = entry.sequence;
|
||||||
entry.hits = entry.hits.saturating_add(1);
|
entry.hits = entry.hits.saturating_add(1);
|
||||||
entry.last_access = Instant::now();
|
entry.last_access = Instant::now();
|
||||||
|
if promote {
|
||||||
entry.sequence = sequence;
|
entry.sequence = sequence;
|
||||||
|
}
|
||||||
(
|
(
|
||||||
old_sequence,
|
old_sequence,
|
||||||
Arc::clone(&entry.headers),
|
Arc::clone(&entry.headers),
|
||||||
@@ -72,8 +85,10 @@ impl Cache {
|
|||||||
entry.body.len(),
|
entry.body.len(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
if promote {
|
||||||
store.lru.remove(&old_sequence);
|
store.lru.remove(&old_sequence);
|
||||||
store.lru.insert(sequence, storage_key);
|
store.lru.insert(sequence, storage_key);
|
||||||
|
}
|
||||||
self.counters.hits.fetch_add(1, Ordering::Relaxed);
|
self.counters.hits.fetch_add(1, Ordering::Relaxed);
|
||||||
drop(store);
|
drop(store);
|
||||||
|
|
||||||
@@ -88,7 +103,7 @@ impl Cache {
|
|||||||
response.extensions_mut().insert(crate::compression::Skip);
|
response.extensions_mut().insert(crate::compression::Skip);
|
||||||
}
|
}
|
||||||
response.headers_mut().insert(::http::header::AGE,
|
response.headers_mut().insert(::http::header::AGE,
|
||||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
HeaderValue::from(age),
|
||||||
);
|
);
|
||||||
self.domain_stats
|
self.domain_stats
|
||||||
.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
|
.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
|
||||||
@@ -285,7 +300,7 @@ impl Cache {
|
|||||||
response.extensions_mut().insert(crate::compression::Skip);
|
response.extensions_mut().insert(crate::compression::Skip);
|
||||||
}
|
}
|
||||||
response.headers_mut().insert(::http::header::AGE,
|
response.headers_mut().insert(::http::header::AGE,
|
||||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
HeaderValue::from(age),
|
||||||
);
|
);
|
||||||
self.domain_stats
|
self.domain_stats
|
||||||
.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
|
.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
|
||||||
|
|||||||
Vendored
+1
-1
@@ -99,7 +99,7 @@ impl Cache {
|
|||||||
self.counters.hits.fetch_add(1, Ordering::Relaxed);
|
self.counters.hits.fetch_add(1, Ordering::Relaxed);
|
||||||
let mut response = cached.response(headers);
|
let mut response = cached.response(headers);
|
||||||
response.headers_mut().insert(::http::header::AGE,
|
response.headers_mut().insert(::http::header::AGE,
|
||||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
HeaderValue::from(age),
|
||||||
);
|
);
|
||||||
Some(Hit {
|
Some(Hit {
|
||||||
response,
|
response,
|
||||||
|
|||||||
Vendored
+5
-5
@@ -277,8 +277,8 @@ struct ColdEntry {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ColdStore {
|
struct ColdStore {
|
||||||
entries: HashMap<Key, ColdEntry>,
|
entries: KeyMap<ColdEntry>,
|
||||||
variants: HashMap<Key, Vec<Key>>,
|
variants: KeyMap<Vec<Key>>,
|
||||||
lru: BTreeMap<u64, Key>,
|
lru: BTreeMap<u64, Key>,
|
||||||
bytes: u64,
|
bytes: u64,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
@@ -304,8 +304,8 @@ enum ColdCommand {
|
|||||||
}
|
}
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Store {
|
struct Store {
|
||||||
entries: HashMap<Key, Entry>,
|
entries: KeyMap<Entry>,
|
||||||
variants: HashMap<Key, Vec<Key>>,
|
variants: KeyMap<Vec<Key>>,
|
||||||
lru: BTreeMap<u64, Key>,
|
lru: BTreeMap<u64, Key>,
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
logical_bytes: usize,
|
logical_bytes: usize,
|
||||||
@@ -314,7 +314,7 @@ struct Store {
|
|||||||
revision: u64,
|
revision: u64,
|
||||||
control_revision: u64,
|
control_revision: u64,
|
||||||
scope_generations: HashMap<String, u64>,
|
scope_generations: HashMap<String, u64>,
|
||||||
key_generations: HashMap<Key, u64>,
|
key_generations: KeyMap<u64>,
|
||||||
}
|
}
|
||||||
impl Store {
|
impl Store {
|
||||||
fn index_variant(&mut self, base_key: Key, key: Key) {
|
fn index_variant(&mut self, base_key: Key, key: Key) {
|
||||||
|
|||||||
Vendored
+31
-24
@@ -268,13 +268,12 @@ impl Settings {
|
|||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(value) = &self.via_header {
|
if let Some(value) = self
|
||||||
headers.append(
|
.via_header
|
||||||
http::header::VIA,
|
.as_deref()
|
||||||
value
|
.and_then(crate::settings::cached_header_value)
|
||||||
.parse::<HeaderValue>()
|
{
|
||||||
.expect("validated cache Via header"),
|
headers.append(http::header::VIA, value);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if self.expose_only_hits && outcome != "HIT" {
|
if self.expose_only_hits && outcome != "HIT" {
|
||||||
return;
|
return;
|
||||||
@@ -300,7 +299,7 @@ impl Settings {
|
|||||||
headers,
|
headers,
|
||||||
"x-cache-hits",
|
"x-cache-hits",
|
||||||
"x-upstream-cache-hits",
|
"x-upstream-cache-hits",
|
||||||
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
|
HeaderValue::from(hits),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if self.expose_cache_status {
|
if self.expose_cache_status {
|
||||||
@@ -311,30 +310,34 @@ impl Settings {
|
|||||||
)
|
)
|
||||||
.then_some(("stale", Some(StatusCode::NOT_MODIFIED)))
|
.then_some(("stale", Some(StatusCode::NOT_MODIFIED)))
|
||||||
});
|
});
|
||||||
let mut value = if let Some((why, status)) = forwarded {
|
use std::fmt::Write as _;
|
||||||
let mut value = format!("RustProxy; fwd={why}");
|
let mut value = String::with_capacity(96);
|
||||||
|
value.push_str("RustProxy; ");
|
||||||
|
if let Some((why, status)) = forwarded {
|
||||||
|
let _ = write!(value, "fwd={why}");
|
||||||
if let Some(status) = status {
|
if let Some(status) = status {
|
||||||
value.push_str(&format!("; fwd-status={}", status.as_u16()));
|
let _ = write!(value, "; fwd-status={}", status.as_u16());
|
||||||
}
|
}
|
||||||
value
|
|
||||||
} else {
|
} else {
|
||||||
match outcome {
|
match outcome {
|
||||||
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
|
"HIT" => {
|
||||||
"MISS" => "RustProxy; fwd=miss".to_owned(),
|
let _ = write!(value, "hit; ttl={ttl}");
|
||||||
"BYPASS" if reason == Some("request_method") => {
|
}
|
||||||
"RustProxy; fwd=method".to_owned()
|
"MISS" => value.push_str("fwd=miss"),
|
||||||
|
"BYPASS" if reason == Some("request_method") => value.push_str("fwd=method"),
|
||||||
|
_ => value.push_str("fwd=bypass"),
|
||||||
}
|
}
|
||||||
_ => "RustProxy; fwd=bypass".to_owned(),
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
if let Some(reason) = reason {
|
if let Some(reason) = reason {
|
||||||
value.push_str(&format!("; detail=\"{reason}\""));
|
let _ = write!(value, "; detail=\"{reason}\"");
|
||||||
}
|
}
|
||||||
if transformed {
|
if transformed {
|
||||||
value.push_str("; transformed");
|
value.push_str("; transformed");
|
||||||
}
|
}
|
||||||
headers.append(::http::header::CACHE_STATUS,
|
headers.append(
|
||||||
HeaderValue::from_str(&value).expect("generated Cache-Status member"),
|
::http::header::CACHE_STATUS,
|
||||||
|
HeaderValue::from_maybe_shared(bytes::Bytes::from(value))
|
||||||
|
.expect("generated Cache-Status member"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,7 +348,7 @@ impl Settings {
|
|||||||
headers,
|
headers,
|
||||||
"x-cache-resident-age",
|
"x-cache-resident-age",
|
||||||
"x-upstream-cache-resident-age",
|
"x-upstream-cache-resident-age",
|
||||||
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
|
HeaderValue::from(resident_age),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,9 +360,13 @@ fn insert_cache_diagnostic(
|
|||||||
upstream_name: &'static str,
|
upstream_name: &'static str,
|
||||||
value: HeaderValue,
|
value: HeaderValue,
|
||||||
) {
|
) {
|
||||||
let upstream_values: Vec<HeaderValue> = headers.get_all(name).iter().cloned().collect();
|
let name = http::HeaderName::from_static(name);
|
||||||
|
if headers.contains_key(&name) {
|
||||||
|
let upstream_values: Vec<HeaderValue> = headers.get_all(&name).iter().cloned().collect();
|
||||||
|
let upstream_name = http::HeaderName::from_static(upstream_name);
|
||||||
for upstream_value in upstream_values {
|
for upstream_value in upstream_values {
|
||||||
headers.append(upstream_name, upstream_value);
|
headers.append(&upstream_name, upstream_value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers.insert(name, value);
|
headers.insert(name, value);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+69
@@ -28,6 +28,36 @@ const COLD_QUEUE: usize = 16;
|
|||||||
const COLD_FORMAT_VERSION: u8 = 3;
|
const COLD_FORMAT_VERSION: u8 = 3;
|
||||||
type Key = [u8; 32];
|
type Key = [u8; 32];
|
||||||
|
|
||||||
|
/// Cache keys are SHA-256 digests, so their bytes are already uniformly
|
||||||
|
/// distributed and not attacker-steerable. Hashing them again with SipHash
|
||||||
|
/// costs measurable time on every HOT lookup; this hasher folds the digest
|
||||||
|
/// cheaply instead (any other input is still mixed, Fx-style).
|
||||||
|
#[derive(Default, Clone, Copy)]
|
||||||
|
struct DigestKeyHasher(u64);
|
||||||
|
impl std::hash::Hasher for DigestKeyHasher {
|
||||||
|
fn finish(&self) -> u64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn write(&mut self, bytes: &[u8]) {
|
||||||
|
if bytes.len() == 32 {
|
||||||
|
let mut word = [0u8; 8];
|
||||||
|
word.copy_from_slice(&bytes[..8]);
|
||||||
|
self.0 ^= u64::from_le_bytes(word);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for chunk in bytes.chunks(8) {
|
||||||
|
let mut word = [0u8; 8];
|
||||||
|
word[..chunk.len()].copy_from_slice(chunk);
|
||||||
|
self.0 = (self.0.rotate_left(5) ^ u64::from_le_bytes(word))
|
||||||
|
.wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn write_usize(&mut self, _length: usize) {
|
||||||
|
// The only usize written for `[u8; 32]` is its constant length prefix.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type KeyMap<V> = HashMap<Key, V, std::hash::BuildHasherDefault<DigestKeyHasher>>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||||
enum CleanupPriority {
|
enum CleanupPriority {
|
||||||
Automatic(u64, SystemTime, SystemTime),
|
Automatic(u64, SystemTime, SystemTime),
|
||||||
@@ -41,7 +71,46 @@ struct CleanupCandidate {
|
|||||||
key: Key,
|
key: Key,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Minimum spacing between two `malloc_trim` calls. Trimming walks every glibc
|
||||||
|
/// arena under its lock and can take milliseconds with a large heap; doing it
|
||||||
|
/// after each eviction made a full HOT cache with constant turnover several
|
||||||
|
/// times slower. Skipped requests are remembered and flushed by the cache
|
||||||
|
/// maintenance tick, so freed memory still returns to the OS within seconds.
|
||||||
|
const ALLOCATOR_RELEASE_INTERVAL_MS: u64 = 250;
|
||||||
|
static ALLOCATOR_RELEASE_LAST_MS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static ALLOCATOR_RELEASE_PENDING: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
fn allocator_clock_ms() -> u64 {
|
||||||
|
static EPOCH: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||||
|
// +1 so the very first request is never mistaken for "just trimmed".
|
||||||
|
(EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64)
|
||||||
|
.saturating_add(ALLOCATOR_RELEASE_INTERVAL_MS + 1)
|
||||||
|
}
|
||||||
|
|
||||||
fn release_allocator_memory() {
|
fn release_allocator_memory() {
|
||||||
|
let now = allocator_clock_ms();
|
||||||
|
let last = ALLOCATOR_RELEASE_LAST_MS.load(Ordering::Relaxed);
|
||||||
|
if now.saturating_sub(last) < ALLOCATOR_RELEASE_INTERVAL_MS
|
||||||
|
|| ALLOCATOR_RELEASE_LAST_MS
|
||||||
|
.compare_exchange(last, now, Ordering::AcqRel, Ordering::Relaxed)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
ALLOCATOR_RELEASE_PENDING.store(true, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ALLOCATOR_RELEASE_PENDING.store(false, Ordering::Relaxed);
|
||||||
|
cache_trim_allocator();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from the periodic cache maintenance task.
|
||||||
|
fn flush_pending_allocator_release() {
|
||||||
|
if ALLOCATOR_RELEASE_PENDING.load(Ordering::Relaxed) {
|
||||||
|
release_allocator_memory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_trim_allocator() {
|
||||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::malloc_trim(0);
|
libc::malloc_trim(0);
|
||||||
|
|||||||
+23
-10
@@ -150,28 +150,35 @@ pub fn compile_reusing(
|
|||||||
type LocalHostBuild = (
|
type LocalHostBuild = (
|
||||||
HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||||
HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||||
HashMap<String, Vec<crate::vhost::Listener>>,
|
HashMap<String, Vec<(String, crate::vhost::Listener)>>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||||
let mut redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>> = HashMap::new();
|
let mut redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>> = HashMap::new();
|
||||||
let mut not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>> =
|
let mut not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
let mut claimed_hosts: HashMap<String, Vec<crate::vhost::Listener>> = HashMap::new();
|
let mut claimed_hosts: HashMap<String, Vec<(String, crate::vhost::Listener)>> =
|
||||||
|
HashMap::new();
|
||||||
for redirect in &config.redirects {
|
for redirect in &config.redirects {
|
||||||
let bindings = redirect.frontend.resolved(&config.listeners)?;
|
let bindings = redirect.frontend.resolved(&config.listeners)?;
|
||||||
for name in &redirect.frontend.hosts {
|
for name in &redirect.frontend.hosts {
|
||||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||||
for a in claimed.iter() {
|
for (owner, a) in claimed.iter() {
|
||||||
for b in &bindings {
|
for b in &bindings {
|
||||||
ensure!(
|
ensure!(
|
||||||
!crate::vhost::socket_overlap(a.address, b.address),
|
!crate::vhost::socket_overlap(a.address, b.address),
|
||||||
"overlapping frontend bindings for hostname {}",
|
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||||
name
|
name,
|
||||||
|
owner,
|
||||||
|
a.address
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
claimed.extend(bindings.iter().copied());
|
claimed.extend(
|
||||||
|
bindings
|
||||||
|
.iter()
|
||||||
|
.map(|binding| (format!("redirect {}", redirect.id), *binding)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if redirect.enabled {
|
if redirect.enabled {
|
||||||
let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?);
|
let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?);
|
||||||
@@ -187,16 +194,22 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
|||||||
let bindings = host.frontend.resolved(&config.listeners)?;
|
let bindings = host.frontend.resolved(&config.listeners)?;
|
||||||
for name in &host.frontend.hosts {
|
for name in &host.frontend.hosts {
|
||||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||||
for a in claimed.iter() {
|
for (owner, a) in claimed.iter() {
|
||||||
for b in &bindings {
|
for b in &bindings {
|
||||||
ensure!(
|
ensure!(
|
||||||
!crate::vhost::socket_overlap(a.address, b.address),
|
!crate::vhost::socket_overlap(a.address, b.address),
|
||||||
"overlapping frontend bindings for hostname {}",
|
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||||
name
|
name,
|
||||||
|
owner,
|
||||||
|
a.address
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
claimed.extend(bindings.iter().copied());
|
claimed.extend(
|
||||||
|
bindings
|
||||||
|
.iter()
|
||||||
|
.map(|binding| (format!("404 host {}", host.id), *binding)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if host.enabled {
|
if host.enabled {
|
||||||
let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?);
|
let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?);
|
||||||
|
|||||||
+21
-8
@@ -65,7 +65,8 @@ pub fn compile_with_active(
|
|||||||
php_profiles
|
php_profiles
|
||||||
.insert(profile.id.clone(), profile.php.clone())
|
.insert(profile.id.clone(), profile.php.clone())
|
||||||
.is_none(),
|
.is_none(),
|
||||||
"duplicate PHP-FPM profile id"
|
"duplicate PHP-FPM profile id {:?}: every profile needs a unique ID",
|
||||||
|
profile.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ensure!(
|
ensure!(
|
||||||
@@ -120,7 +121,8 @@ pub fn compile_with_active(
|
|||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
web_services.insert(service.id.clone(), compiled).is_none(),
|
web_services.insert(service.id.clone(), compiled).is_none(),
|
||||||
"duplicate Web Service id"
|
"duplicate Web Service id {:?}: every Web Service needs a unique ID",
|
||||||
|
service.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
||||||
@@ -145,7 +147,11 @@ pub fn compile_with_active(
|
|||||||
validate_source(&route.source)?;
|
validate_source(&route.source)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id");
|
ensure!(
|
||||||
|
vhost_ids.insert(host.id.clone()),
|
||||||
|
"duplicate vhost id {:?}: every vhost needs a unique ID",
|
||||||
|
host.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ensure!(
|
ensure!(
|
||||||
config.redirects.len() <= 64,
|
config.redirects.len() <= 64,
|
||||||
@@ -158,7 +164,8 @@ pub fn compile_with_active(
|
|||||||
.with_context(|| format!("redirect {}", redirect.id))?;
|
.with_context(|| format!("redirect {}", redirect.id))?;
|
||||||
ensure!(
|
ensure!(
|
||||||
redirect_ids.insert(redirect.id.clone()),
|
redirect_ids.insert(redirect.id.clone()),
|
||||||
"duplicate redirect id"
|
"duplicate redirect id {:?}: every redirect needs a unique ID",
|
||||||
|
redirect.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ensure!(
|
ensure!(
|
||||||
@@ -171,7 +178,8 @@ pub fn compile_with_active(
|
|||||||
.with_context(|| format!("404 host {}", host.id))?;
|
.with_context(|| format!("404 host {}", host.id))?;
|
||||||
ensure!(
|
ensure!(
|
||||||
not_found_ids.insert(host.id.clone()),
|
not_found_ids.insert(host.id.clone()),
|
||||||
"duplicate 404 host id"
|
"duplicate 404 host id {:?}: every 404 host needs a unique ID",
|
||||||
|
host.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let effective_tls = crate::tls::for_all_frontends(
|
let effective_tls = crate::tls::for_all_frontends(
|
||||||
@@ -279,7 +287,11 @@ pub fn compile_with_active(
|
|||||||
let mut acl_profiles: HashMap<String, (Option<CompiledAcl>, crate::rate_limit::CountryAccess)> =
|
let mut acl_profiles: HashMap<String, (Option<CompiledAcl>, crate::rate_limit::CountryAccess)> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
for profile in &mut config.acl_profiles {
|
for profile in &mut config.acl_profiles {
|
||||||
ensure!(valid_id(&profile.id), "invalid ACL profile id");
|
ensure!(
|
||||||
|
valid_id(&profile.id),
|
||||||
|
"invalid ACL profile id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||||
|
profile.id
|
||||||
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
!profile.name.trim().is_empty()
|
!profile.name.trim().is_empty()
|
||||||
&& profile.name.len() <= 96
|
&& profile.name.len() <= 96
|
||||||
@@ -293,7 +305,8 @@ pub fn compile_with_active(
|
|||||||
acl_profiles
|
acl_profiles
|
||||||
.insert(profile.id.clone(), (acl, profile.country_access.clone()))
|
.insert(profile.id.clone(), (acl, profile.country_access.clone()))
|
||||||
.is_none(),
|
.is_none(),
|
||||||
"duplicate ACL profile id"
|
"duplicate ACL profile id {:?}: every ACL profile needs a unique ID",
|
||||||
|
profile.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
crate::acl::dynamic::configured_hostnames(&config)?;
|
crate::acl::dynamic::configured_hostnames(&config)?;
|
||||||
@@ -380,7 +393,7 @@ fn normalize_prefix(prefix: &mut String) -> Result<()> {
|
|||||||
&& !prefix.contains('?')
|
&& !prefix.contains('?')
|
||||||
&& !prefix.contains('#')
|
&& !prefix.contains('#')
|
||||||
&& !prefix.bytes().any(|b| b <= 32 || b == 127),
|
&& !prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||||
"invalid path prefix"
|
"invalid path prefix: it must start with '/', be at most 512 bytes and contain no '?', '#', spaces or control characters"
|
||||||
);
|
);
|
||||||
if prefix.len() > 1 {
|
if prefix.len() > 1 {
|
||||||
*prefix = prefix.trim_end_matches('/').to_owned();
|
*prefix = prefix.trim_end_matches('/').to_owned();
|
||||||
|
|||||||
+25
-10
@@ -79,16 +79,22 @@ fn compile_routing(
|
|||||||
);
|
);
|
||||||
for name in &routing_hosts {
|
for name in &routing_hosts {
|
||||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||||
for a in claimed.iter() {
|
for (owner, a) in claimed.iter() {
|
||||||
for b in &host_bindings {
|
for b in &host_bindings {
|
||||||
ensure!(
|
ensure!(
|
||||||
!crate::vhost::socket_overlap(a.address, b.address),
|
!crate::vhost::socket_overlap(a.address, b.address),
|
||||||
"overlapping frontend bindings for hostname {}",
|
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||||
name
|
name,
|
||||||
|
owner,
|
||||||
|
a.address
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
claimed.extend(host_bindings.iter().copied());
|
claimed.extend(
|
||||||
|
host_bindings
|
||||||
|
.iter()
|
||||||
|
.map(|binding| (format!("vhost {}", host.id), *binding)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let needs_proxy_pool = host.source.is_reverse_proxy()
|
let needs_proxy_pool = host.source.is_reverse_proxy()
|
||||||
|| host.routes.iter().any(|route| {
|
|| host.routes.iter().any(|route| {
|
||||||
@@ -323,7 +329,8 @@ fn compile_routing(
|
|||||||
for route in &mut host.routes {
|
for route in &mut host.routes {
|
||||||
ensure!(
|
ensure!(
|
||||||
valid_id(&route.id) && path_ids.insert(route.id.clone()),
|
valid_id(&route.id) && path_ids.insert(route.id.clone()),
|
||||||
"invalid/duplicate vhost path id"
|
"location ID {:?} is invalid or used twice in this vhost (use 1-64 letters, digits, '-' or '_', unique per vhost)",
|
||||||
|
route.id
|
||||||
);
|
);
|
||||||
let path_regex = match route.match_type {
|
let path_regex = match route.match_type {
|
||||||
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => {
|
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => {
|
||||||
@@ -331,7 +338,8 @@ fn compile_routing(
|
|||||||
!route.path_prefix.is_empty()
|
!route.path_prefix.is_empty()
|
||||||
&& route.path_prefix.len() <= 1024
|
&& route.path_prefix.len() <= 1024
|
||||||
&& !route.path_prefix.chars().any(char::is_control),
|
&& !route.path_prefix.chars().any(char::is_control),
|
||||||
"invalid vhost location regex"
|
"location {}: the regular expression must be 1-1024 characters without control characters",
|
||||||
|
route.id
|
||||||
);
|
);
|
||||||
let mut builder = regex::RegexBuilder::new(&route.path_prefix);
|
let mut builder = regex::RegexBuilder::new(&route.path_prefix);
|
||||||
builder
|
builder
|
||||||
@@ -340,17 +348,23 @@ fn compile_routing(
|
|||||||
)
|
)
|
||||||
.size_limit(2 * 1024 * 1024)
|
.size_limit(2 * 1024 * 1024)
|
||||||
.dfa_size_limit(2 * 1024 * 1024);
|
.dfa_size_limit(2 * 1024 * 1024);
|
||||||
Some(builder.build().context("invalid vhost location regex")?)
|
Some(builder.build().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"location {}: invalid or too complex regular expression",
|
||||||
|
route.id
|
||||||
|
)
|
||||||
|
})?)
|
||||||
}
|
}
|
||||||
crate::vhost::PathMatch::Exact => {
|
crate::vhost::PathMatch::Exact => {
|
||||||
ensure!(
|
ensure!(
|
||||||
!route.path_prefix.contains('?')
|
!route.path_prefix.contains('?')
|
||||||
&& !route.path_prefix.contains('#')
|
&& !route.path_prefix.contains('#')
|
||||||
&& !route.path_prefix.bytes().any(|b| b <= 32 || b == 127),
|
&& !route.path_prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||||
"invalid exact location path"
|
"location {}: an exact path must not contain '?', '#', spaces or control characters",
|
||||||
|
route.id
|
||||||
);
|
);
|
||||||
route.path_prefix = crate::acl::routing_path(&route.path_prefix)
|
route.path_prefix = crate::acl::routing_path(&route.path_prefix)
|
||||||
.context("invalid exact location path")?;
|
.with_context(|| format!("location {}: invalid exact path", route.id))?;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => {
|
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => {
|
||||||
@@ -361,7 +375,8 @@ fn compile_routing(
|
|||||||
if !route.internal {
|
if !route.internal {
|
||||||
ensure!(
|
ensure!(
|
||||||
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
||||||
"duplicate vhost location matcher"
|
"location {}: another location already uses the same path and match type",
|
||||||
|
route.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
validate_route_rewrite(route, path_regex.as_ref())?;
|
validate_route_rewrite(route, path_regex.as_ref())?;
|
||||||
|
|||||||
@@ -139,6 +139,73 @@ impl Snapshot {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
/// Specificity of the vhost frontend matching `host` (an exact name beats any wildcard,
|
||||||
|
/// a longer wildcard suffix beats a shorter one).
|
||||||
|
pub fn frontend_rank(&self, host: &str, local: SocketAddr, tls: bool) -> Option<usize> {
|
||||||
|
let mut rank = None;
|
||||||
|
Self::find_host_pattern(host, |pattern| {
|
||||||
|
let found = self.frontends.get(pattern).is_some_and(|frontends| {
|
||||||
|
frontends
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.bindings.iter().any(|l| l.matches(local, tls)))
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
rank = Some(host_pattern_rank(pattern, host));
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rank
|
||||||
|
}
|
||||||
|
/// Specificity of the redirect host matching `host` and `path` (see `frontend_rank`).
|
||||||
|
pub fn redirect_rank(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
path: &str,
|
||||||
|
local: SocketAddr,
|
||||||
|
tls: bool,
|
||||||
|
) -> Option<usize> {
|
||||||
|
let mut rank = None;
|
||||||
|
Self::find_host_pattern(host, |pattern| {
|
||||||
|
let found = self.redirects.get(pattern).is_some_and(|redirects| {
|
||||||
|
redirects.iter().any(|redirect| {
|
||||||
|
redirect
|
||||||
|
.bindings
|
||||||
|
.iter()
|
||||||
|
.any(|binding| binding.matches(local, tls))
|
||||||
|
&& redirect.rules.iter().any(|rule| rule.matches(path, tls))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
rank = Some(host_pattern_rank(pattern, host));
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rank
|
||||||
|
}
|
||||||
|
/// Specificity of the 404 host matching `host` (see `frontend_rank`).
|
||||||
|
pub fn not_found_rank(&self, host: &str, local: SocketAddr, tls: bool) -> Option<usize> {
|
||||||
|
let mut rank = None;
|
||||||
|
Self::find_host_pattern(host, |pattern| {
|
||||||
|
let found = self.not_found_hosts.get(pattern).is_some_and(|items| {
|
||||||
|
items.iter().any(|item| {
|
||||||
|
item.bindings
|
||||||
|
.iter()
|
||||||
|
.any(|binding| binding.matches(local, tls))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if found {
|
||||||
|
rank = Some(host_pattern_rank(pattern, host));
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rank
|
||||||
|
}
|
||||||
pub fn redirect_on(
|
pub fn redirect_on(
|
||||||
&self,
|
&self,
|
||||||
host: &str,
|
host: &str,
|
||||||
@@ -204,6 +271,13 @@ impl Snapshot {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn host_pattern_rank(pattern: &str, host: &str) -> usize {
|
||||||
|
if pattern == host {
|
||||||
|
usize::MAX
|
||||||
|
} else {
|
||||||
|
pattern.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
fn select_route<'a, F>(
|
fn select_route<'a, F>(
|
||||||
routes: &'a [Arc<CompiledRoute>],
|
routes: &'a [Arc<CompiledRoute>],
|
||||||
path: &str,
|
path: &str,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ use sqlx::{
|
|||||||
};
|
};
|
||||||
use std::{path::Path, time::Duration};
|
use std::{path::Path, time::Duration};
|
||||||
|
|
||||||
pub const SCHEMA_VERSION: i64 = 11;
|
pub const SCHEMA_VERSION: i64 = 12;
|
||||||
|
pub const ADMIN_PREFERENCES_MAX_BYTES: usize = 64 * 1024;
|
||||||
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
|
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
|
||||||
pub const AUDIT_CAPACITY: i64 = 10_000;
|
pub const AUDIT_CAPACITY: i64 = 10_000;
|
||||||
|
|
||||||
@@ -129,6 +130,13 @@ impl Store {
|
|||||||
sqlx::raw_sql(include_str!("../migrations/0011_tokens.sql"))
|
sqlx::raw_sql(include_str!("../migrations/0011_tokens.sql"))
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
if version < 12 {
|
||||||
|
sqlx::raw_sql(include_str!(
|
||||||
|
"../migrations/0012_admin_preferences_size.sql"
|
||||||
|
))
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
|
sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
|
||||||
.bind(serde_json::to_string(initial)?)
|
.bind(serde_json::to_string(initial)?)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -139,7 +147,7 @@ impl Store {
|
|||||||
.bind(crate::auth::now())
|
.bind(crate::auth::now())
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("PRAGMA user_version = 11")
|
sqlx::query("PRAGMA user_version = 12")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|||||||
+10
-2
@@ -37,7 +37,11 @@ fn default_page() -> crate::pages::Page {
|
|||||||
|
|
||||||
impl Host {
|
impl Host {
|
||||||
pub fn validate(&mut self) -> Result<()> {
|
pub fn validate(&mut self) -> Result<()> {
|
||||||
ensure!(crate::config::valid_id(&self.id), "invalid 404 host id");
|
ensure!(
|
||||||
|
crate::config::valid_id(&self.id),
|
||||||
|
"invalid 404 host id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||||
|
self.id
|
||||||
|
);
|
||||||
if self.name.trim().is_empty() {
|
if self.name.trim().is_empty() {
|
||||||
self.name = self.id.clone();
|
self.name = self.id.clone();
|
||||||
}
|
}
|
||||||
@@ -55,7 +59,11 @@ impl Host {
|
|||||||
let mut hosts = std::collections::HashSet::new();
|
let mut hosts = std::collections::HashSet::new();
|
||||||
for host in &mut self.frontend.hosts {
|
for host in &mut self.frontend.hosts {
|
||||||
*host = crate::config::normalize_host_pattern(host)?;
|
*host = crate::config::normalize_host_pattern(host)?;
|
||||||
ensure!(hosts.insert(host.clone()), "duplicate 404 hostname");
|
ensure!(
|
||||||
|
hosts.insert(host.clone()),
|
||||||
|
"hostname {} is listed twice in this 404 host",
|
||||||
|
host
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ensure!(
|
ensure!(
|
||||||
!self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16,
|
!self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16,
|
||||||
|
|||||||
@@ -139,8 +139,21 @@ async fn forward_request(
|
|||||||
}
|
}
|
||||||
let websocket_legacy_support =
|
let websocket_legacy_support =
|
||||||
handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
|
handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
|
||||||
let redirect = snapshot.redirect_on(&host, &path, local.0, tls);
|
// The most specific host name wins: an exact vhost such as autodiscover.example.com must not be
|
||||||
let local_host = snapshot.not_found_host_on(&host, local.0, tls);
|
// shadowed by a wildcard redirect or 404 host (*.example.com) that happens to be evaluated first.
|
||||||
|
let frontend_rank = frontend.and_then(|_| snapshot.frontend_rank(&host, local.0, tls));
|
||||||
|
let redirect = snapshot
|
||||||
|
.redirect_on(&host, &path, local.0, tls)
|
||||||
|
.filter(|_| match (frontend_rank, snapshot.redirect_rank(&host, &path, local.0, tls)) {
|
||||||
|
(Some(vhost), Some(redirect)) => redirect >= vhost,
|
||||||
|
_ => true,
|
||||||
|
});
|
||||||
|
let local_host = snapshot
|
||||||
|
.not_found_host_on(&host, local.0, tls)
|
||||||
|
.filter(|_| match (frontend_rank, snapshot.not_found_rank(&host, local.0, tls)) {
|
||||||
|
(Some(vhost), Some(local_host)) => local_host >= vhost,
|
||||||
|
_ => true,
|
||||||
|
});
|
||||||
if request.version() == Version::HTTP_2
|
if request.version() == Version::HTTP_2
|
||||||
&& (frontend.is_some_and(|frontend| !frontend.http2_enabled)
|
&& (frontend.is_some_and(|frontend| !frontend.http2_enabled)
|
||||||
|| redirect.is_some_and(|(redirect, _)| !redirect.http2_enabled)
|
|| redirect.is_some_and(|(redirect, _)| !redirect.http2_enabled)
|
||||||
|
|||||||
+12
-4
@@ -127,7 +127,11 @@ pub struct Host {
|
|||||||
}
|
}
|
||||||
impl Host {
|
impl Host {
|
||||||
pub fn validate(&mut self) -> Result<()> {
|
pub fn validate(&mut self) -> Result<()> {
|
||||||
ensure!(crate::config::valid_id(&self.id), "invalid redirect id");
|
ensure!(
|
||||||
|
crate::config::valid_id(&self.id),
|
||||||
|
"invalid redirect id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||||
|
self.id
|
||||||
|
);
|
||||||
if self.name.is_empty() {
|
if self.name.is_empty() {
|
||||||
self.name = self.id.clone();
|
self.name = self.id.clone();
|
||||||
}
|
}
|
||||||
@@ -140,16 +144,20 @@ impl Host {
|
|||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
!self.frontend.hosts.is_empty(),
|
!self.frontend.hosts.is_empty(),
|
||||||
"redirect requires at least one hostname"
|
"redirect requires at least one hostname (for example old.example.com)"
|
||||||
);
|
);
|
||||||
let mut hosts = std::collections::HashSet::new();
|
let mut hosts = std::collections::HashSet::new();
|
||||||
for host in &mut self.frontend.hosts {
|
for host in &mut self.frontend.hosts {
|
||||||
*host = crate::config::normalize_host_pattern(host)?;
|
*host = crate::config::normalize_host_pattern(host)?;
|
||||||
ensure!(hosts.insert(host.clone()), "duplicate redirect hostname");
|
ensure!(
|
||||||
|
hosts.insert(host.clone()),
|
||||||
|
"hostname {} is listed twice in this redirect",
|
||||||
|
host
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ensure!(
|
ensure!(
|
||||||
!self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16,
|
!self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16,
|
||||||
"redirect requires 1..16 named listeners"
|
"select between 1 and 16 listeners for this redirect"
|
||||||
);
|
);
|
||||||
if self.rules.is_empty() {
|
if self.rules.is_empty() {
|
||||||
self.rules.push(Rule::default());
|
self.rules.push(Rule::default());
|
||||||
|
|||||||
+22
-1
@@ -454,13 +454,34 @@ impl ServerHeader {
|
|||||||
headers.remove(::http::header::SERVER);
|
headers.remove(::http::header::SERVER);
|
||||||
}
|
}
|
||||||
ServerMode::Custom => {
|
ServerMode::Custom => {
|
||||||
if let Ok(value) = self.value.parse() {
|
if let Some(value) = cached_header_value(&self.value) {
|
||||||
headers.insert(::http::header::SERVER, value);
|
headers.insert(::http::header::SERVER, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cached_header_value(value: &str) -> Option<HeaderValue> {
|
||||||
|
const CAPACITY: usize = 16;
|
||||||
|
thread_local! {
|
||||||
|
static CACHE: std::cell::RefCell<Vec<(Box<str>, HeaderValue)>> =
|
||||||
|
const { std::cell::RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
CACHE.with(|cache| {
|
||||||
|
let mut cache = cache.borrow_mut();
|
||||||
|
if let Some((_, header)) = cache.iter().find(|(key, _)| key.as_ref() == value) {
|
||||||
|
return Some(header.clone());
|
||||||
|
}
|
||||||
|
let header =
|
||||||
|
HeaderValue::from_maybe_shared(bytes::Bytes::copy_from_slice(value.as_bytes())).ok()?;
|
||||||
|
if cache.len() >= CAPACITY {
|
||||||
|
cache.remove(0);
|
||||||
|
}
|
||||||
|
cache.push((value.into(), header.clone()));
|
||||||
|
Some(header)
|
||||||
|
})
|
||||||
|
}
|
||||||
include!("settings/public_origin.rs");
|
include!("settings/public_origin.rs");
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
include!("settings/tests.rs");
|
include!("settings/tests.rs");
|
||||||
@@ -367,7 +367,7 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<A
|
|||||||
let mut checker = ResolvesServerCertUsingSni::new();
|
let mut checker = ResolvesServerCertUsingSni::new();
|
||||||
checker
|
checker
|
||||||
.add(&validation_name, key.clone())
|
.add(&validation_name, key.clone())
|
||||||
.with_context(|| format!("certificate does not cover {host}; explicit Vhost consent is required to override"))?;
|
.with_context(|| format!("certificate {} does not cover {host}. Select a certificate that covers it, or enable \"Force an incompatible certificate\" in the vhost SSL tab (not available for redirects and 404 hosts)", cert.id))?;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(certificate = %cert.id, hostname = %host,
|
tracing::warn!(certificate = %cert.id, hostname = %host,
|
||||||
"Explicitly serving a TLS certificate without hostname verification; clients may reject it");
|
"Explicitly serving a TLS certificate without hostname verification; clients may reject it");
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ pub async fn apply(
|
|||||||
parts.headers.remove(::http::header::ACCEPT_RANGES);
|
parts.headers.remove(::http::header::ACCEPT_RANGES);
|
||||||
parts.headers.insert(::http::header::ETAG, transformed_etag(&transformed));
|
parts.headers.insert(::http::header::ETAG, transformed_etag(&transformed));
|
||||||
parts.headers.insert(::http::header::CONTENT_LENGTH,
|
parts.headers.insert(::http::header::CONTENT_LENGTH,
|
||||||
HeaderValue::from_str(&transformed.len().to_string()).expect("integer header"),
|
HeaderValue::from(transformed.len()),
|
||||||
);
|
);
|
||||||
drop_vary_accept_encoding(&mut parts.headers);
|
drop_vary_accept_encoding(&mut parts.headers);
|
||||||
parts.extensions.insert(validators);
|
parts.extensions.insert(validators);
|
||||||
|
|||||||
+14
-5
@@ -46,7 +46,11 @@ impl PathRoute {
|
|||||||
}
|
}
|
||||||
impl Vhost {
|
impl Vhost {
|
||||||
pub fn validate(&mut self) -> Result<()> {
|
pub fn validate(&mut self) -> Result<()> {
|
||||||
ensure!(crate::config::valid_id(&self.id), "invalid vhost id");
|
ensure!(
|
||||||
|
crate::config::valid_id(&self.id),
|
||||||
|
"invalid vhost id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||||
|
self.id
|
||||||
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
!self.websocket_legacy_support || self.websockets,
|
!self.websocket_legacy_support || self.websockets,
|
||||||
"legacy WebSocket compatibility requires WebSocket support to be enabled"
|
"legacy WebSocket compatibility requires WebSocket support to be enabled"
|
||||||
@@ -69,12 +73,16 @@ impl Vhost {
|
|||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
!self.frontend.hosts.is_empty(),
|
!self.frontend.hosts.is_empty(),
|
||||||
"vhost requires at least one hostname"
|
"vhost requires at least one hostname (for example app.example.com) in the Frontend section"
|
||||||
);
|
);
|
||||||
let mut hosts = std::collections::HashSet::new();
|
let mut hosts = std::collections::HashSet::new();
|
||||||
for host in &mut self.frontend.hosts {
|
for host in &mut self.frontend.hosts {
|
||||||
*host = crate::config::normalize_host_pattern(host)?;
|
*host = crate::config::normalize_host_pattern(host)?;
|
||||||
ensure!(hosts.insert(host.clone()), "duplicate vhost hostname");
|
ensure!(
|
||||||
|
hosts.insert(host.clone()),
|
||||||
|
"hostname {} is listed twice in this vhost",
|
||||||
|
host
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(target) = &mut self.www_redirect_target {
|
if let Some(target) = &mut self.www_redirect_target {
|
||||||
*target = crate::config::normalize_host_pattern(target)?;
|
*target = crate::config::normalize_host_pattern(target)?;
|
||||||
@@ -101,7 +109,7 @@ impl Vhost {
|
|||||||
self.content_type_policy.validate()?;
|
self.content_type_policy.validate()?;
|
||||||
ensure!(
|
ensure!(
|
||||||
!self.frontend.listener_ids.is_empty(),
|
!self.frontend.listener_ids.is_empty(),
|
||||||
"vhost requires at least one named listener"
|
"select at least one listener for this vhost in the Frontend section"
|
||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
self.frontend.listener_ids.len() <= 16,
|
self.frontend.listener_ids.len() <= 16,
|
||||||
@@ -150,7 +158,8 @@ impl Vhost {
|
|||||||
for route in &mut self.routes {
|
for route in &mut self.routes {
|
||||||
ensure!(
|
ensure!(
|
||||||
crate::config::valid_id(&route.id) && route_ids.insert(route.id.clone()),
|
crate::config::valid_id(&route.id) && route_ids.insert(route.id.clone()),
|
||||||
"invalid/duplicate vhost path id"
|
"location ID {:?} is invalid or used twice in this vhost (use 1-64 letters, digits, '-' or '_', unique per vhost)",
|
||||||
|
route.id
|
||||||
);
|
);
|
||||||
route.source.validate()?;
|
route.source.validate()?;
|
||||||
ensure!(
|
ensure!(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Browser smoke test for the optional semantic SVG interface icons.
|
||||||
|
|
||||||
|
Uses a locally installed Chromium when available and otherwise exits successfully
|
||||||
|
with a skip message. It exercises the real index.html plus the embedded icon
|
||||||
|
module without requiring a running RustProxy backend.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
WEB = ROOT / "src/admin/web"
|
||||||
|
chromium = next((shutil.which(name) for name in ("chromium", "chromium-browser", "google-chrome") if shutil.which(name)), None)
|
||||||
|
if not chromium:
|
||||||
|
print("Panel SVG icon browser smoke: SKIP (Chromium not installed)")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
page = (WEB / "index.html").read_text(encoding="utf-8")
|
||||||
|
page = re.sub(r'<script\s+defer=""\s+src="/assets/app___RUSTPROXY_ASSET_ID__\.js"></script>', "", page, count=1)
|
||||||
|
icons = (WEB / "app/icons.js").read_text(encoding="utf-8")
|
||||||
|
script = r'''
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const translations = { edit: "Edit", remove: "Remove", copyValue: "Copy value", previous: "Previous", next: "Next" };
|
||||||
|
function t(key) { return translations[key] || key; }
|
||||||
|
''' + icons + r'''
|
||||||
|
(() => {
|
||||||
|
const fail = message => { document.getElementById("browser-icon-test").textContent = `FAIL: ${message}`; throw new Error(message); };
|
||||||
|
const result = document.getElementById("browser-icon-test");
|
||||||
|
const eligible = [...document.querySelectorAll("button, a.primary, a.outline, a.quiet")].filter(node => window.rustProxyIcons.iconFor(node) !== null);
|
||||||
|
if (document.querySelectorAll("svg.ui-icon").length !== 0) fail("icons must be absent before enabling the preference");
|
||||||
|
window.rustProxyIcons.setEnabled(true);
|
||||||
|
const decorated = eligible.filter(node => node.querySelector(":scope > svg.ui-icon"));
|
||||||
|
if (decorated.length !== eligible.length) fail(`expected ${eligible.length} decorated controls, got ${decorated.length}`);
|
||||||
|
if ([...document.querySelectorAll("svg.ui-icon")].some(icon => icon.getAttribute("aria-hidden") !== "true")) fail("SVG icons must be aria-hidden");
|
||||||
|
const expectedPanels = { overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box", redirects: "route", notfound: "fileX", streams: "transfer", "acl-profiles": "shield", tls: "certificate", traffic: "activity", cache: "database", transform: "wand", settings: "settings" };
|
||||||
|
for (const [panel, icon] of Object.entries(expectedPanels)) {
|
||||||
|
const button = document.querySelector(`[data-panel="${panel}"]`);
|
||||||
|
if (button?.querySelector(":scope > svg.ui-icon")?.dataset.uiIcon !== icon) fail(`panel ${panel} must use ${icon}`);
|
||||||
|
}
|
||||||
|
const dynamic = document.createElement("button"); dynamic.type = "button"; dynamic.className = "quiet"; dynamic.textContent = "Edit"; document.body.append(dynamic);
|
||||||
|
window.rustProxyIcons.refresh(dynamic);
|
||||||
|
if (dynamic.querySelector("svg.ui-icon")?.dataset.uiIcon !== "edit") fail("translated dynamic Edit action must use edit icon");
|
||||||
|
dynamic.textContent = "Remove item"; dynamic.setAttribute("aria-label", "Remove item"); window.rustProxyIcons.refresh(dynamic);
|
||||||
|
if (dynamic.querySelector("svg.ui-icon")?.dataset.uiIcon !== "trash") fail("dynamic Remove action must use trash icon");
|
||||||
|
const visibility = document.createElement("button"); visibility.dataset.dashboardWidgetAction = "toggle"; visibility.className = "dashboard-widget-toggle is-hidden"; document.body.append(visibility);
|
||||||
|
window.rustProxyIcons.refresh(visibility);
|
||||||
|
if (visibility.querySelector("svg.ui-icon")?.dataset.uiIcon !== "eyeOff") fail("hidden widget action must use eyeOff");
|
||||||
|
visibility.classList.remove("is-hidden"); window.rustProxyIcons.refresh(visibility);
|
||||||
|
if (visibility.querySelector("svg.ui-icon")?.dataset.uiIcon !== "eye") fail("visible widget action must use eye");
|
||||||
|
window.rustProxyIcons.setEnabled(false);
|
||||||
|
if (document.querySelectorAll("svg.ui-icon").length !== 0) fail("disabling preference must remove all injected icons");
|
||||||
|
result.textContent = `OK ${eligible.length} controls`;
|
||||||
|
})();
|
||||||
|
'''
|
||||||
|
page = page.replace("</body>", '<pre id="browser-icon-test">PENDING</pre><script>' + script + "</script></body>", 1)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="rustproxy-icons-") as tmp:
|
||||||
|
target = Path(tmp) / "icons-test.html"
|
||||||
|
target.write_text(page, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[chromium, "--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--virtual-time-budget=1000", "--dump-dom", target.as_uri()],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=8,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("Panel SVG icon browser smoke: SKIP (Chromium cannot start in this environment)")
|
||||||
|
raise SystemExit(0)
|
||||||
|
match = re.search(r'<pre id="browser-icon-test">([^<]+)</pre>', proc.stdout)
|
||||||
|
status = html.unescape(match.group(1)) if match else "missing result marker"
|
||||||
|
if proc.returncode != 0 or not status.startswith("OK "):
|
||||||
|
raise SystemExit(f"Panel SVG icon browser smoke failed: {status}\n{proc.stderr[-4000:]}")
|
||||||
|
print(f"Panel SVG icon browser smoke: {status}")
|
||||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user