Compare commits
4
Commits
master
..
90d209554e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d209554e | ||
|
|
851f172581 | ||
|
|
e80e2f4862 | ||
|
|
d40c30d0e8 |
No files matched your search
@@ -10,4 +10,3 @@ problems.txt
|
||||
todo/
|
||||
scripts/routeros-oci-build/.env
|
||||
dist
|
||||
/*.*
|
||||
@@ -1,6 +1,6 @@
|
||||
# RustProxy source and documentation maintenance
|
||||
|
||||
Current source version: **0.36.7**. The Cargo package and binary are named `rust-proxy`.
|
||||
Current source version: **0.35.1**. The Cargo package and binary are named `rust-proxy`.
|
||||
|
||||
Before editing, read [README.md](README.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
|
||||
|
||||
|
||||
+1
-116
@@ -2,125 +2,10 @@
|
||||
|
||||
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.7 — Docker vhosts and cache panel polish
|
||||
|
||||
- **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 "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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Fixed:** network interface traffic below 1 KiB/s is shown as a whole number of bytes instead of a long decimal.
|
||||
- **Upgrade notes:** no configuration changes are required. Existing Overview layouts receive the new tiles at the end; hide or move them in the layout editor.
|
||||
|
||||
## 0.35.1 — performance, licensing and documentation
|
||||
|
||||
- **Faster Web Server:** static files are read directly by the request instead of being handed to a background thread pool. Small and large files are served up to 1.8× faster with lower latency and CPU use.
|
||||
- **Faster reverse proxy and cache:** lighter request processing, header handling and traffic statistics collection. Proxied requests are about 3–7% faster and cache hits about 11% faster on the same hardware.
|
||||
- **Faster reverse proxy and cache:** lighter request processing, header handling and traffic statistics collection. Proxied requests are about 12–19% faster and cache hits about 20% faster on the same hardware.
|
||||
- **Vhost charts:** the per-Vhost request-rate chart no longer shows a false spike right after the page opens or when two refreshes arrive at the same moment.
|
||||
- **Performance guide:** new documentation page with the benchmark method, a comparison with a similarly configured Nginx and production tuning advice.
|
||||
- **About window:** redesigned with the project description, copyright, a GNU AGPL v3.0-or-later license summary and links to the project website, source code, built-in documentation and the full license text.
|
||||
|
||||
Generated
+1
-1
@@ -1943,7 +1943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.36.7"
|
||||
version = "0.35.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+2
-6
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.36.7"
|
||||
version = "0.35.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.99.1"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
@@ -17,7 +17,7 @@ ipnet = "2.11"
|
||||
rpassword = "7.4"
|
||||
zeroize = "1.8"
|
||||
anyhow = "1.0"
|
||||
mimalloc = { version = "0.1", default-features = false, optional = true }
|
||||
mimalloc = { version = "0.1", default-features = false }
|
||||
libc = "0.2"
|
||||
arc-swap = "1.7"
|
||||
axum = { version = "0.8.9", default-features = false, features = ["http1", "http2", "json", "query", "tokio", "ws"] }
|
||||
@@ -72,10 +72,6 @@ systemstat = "=0.2.7"
|
||||
# Swagger assets are prepared by build.rs and embedded behind the admin guard.
|
||||
# No adapter build script, implicit version fallback or Cargo-config dependency.
|
||||
|
||||
[features]
|
||||
default = []
|
||||
mimalloc = ["dep:mimalloc"]
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = "1.0"
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Default managed installation: HTTP on `:80`, HTTPS on `:443` with a self-signed
|
||||
|
||||
## Performance
|
||||
|
||||
RustProxy streams requests and responses asynchronously on Tokio and Hyper. On one CPU core it serves roughly 15,000 proxied and 26,000 static requests per second with full statistics and logging enabled, and it is faster than a comparably configured Nginx for large proxied responses. Benchmark method, the full comparison and tuning advice are in [Performance](docs/PERFORMANCE.md).
|
||||
RustProxy streams requests and responses asynchronously on Tokio and Hyper. On one CPU core it serves roughly 14,000 proxied and 24,000 static requests per second with full statistics and logging enabled, and it is faster than a comparably configured Nginx for large proxied responses. Benchmark method, the full comparison and tuning advice are in [Performance](docs/PERFORMANCE.md).
|
||||
|
||||
## Building
|
||||
|
||||
@@ -66,8 +66,6 @@ RustProxy streams requests and responses asynchronously on Tokio and Hyper. On o
|
||||
|
||||
Rust stable (see `rust-version` in `Cargo.toml`), a C compiler and Python 3.10+ are required. The build embeds the panel, the documentation and Swagger UI, so the binary has no runtime web dependencies.
|
||||
|
||||
Optional memory allocator: `cargo build --release --features mimalloc` handles heavy HOT cache turnover faster but uses about 25% more memory; see [Performance](docs/PERFORMANCE.md#memory-allocator).
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2026 Mateusz Gruszczyński.
|
||||
|
||||
+103
-119
@@ -1,13 +1,13 @@
|
||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
e76e688874828e3743d03693b1d2872b9633b4db09909673786caf30f7cfe489 .gitignore
|
||||
357a4c6e4ac6aca6baf93f43013fa8a1701e68289b7d0d47d1728d03734846eb AGENTS.md
|
||||
c43200ec7df77449be7074265cce582e9af7c10af70dca843e9a13285fa0b317 CHANGELOG.md
|
||||
881fafd324cb38c3c016cd853e65b9870819d2ac6ce932a6dd6b6755701444d4 Cargo.lock
|
||||
90664b6534166fb8bfb8d2b61885e3397d5df78f584e5a334ff81c522833c555 Cargo.toml
|
||||
ba8bc979322a494cfce59398085259d059142d6a789bbe73ed7992d143123f06 .gitignore
|
||||
2ffb0549e6c8f12093f2d5c5b222695a391d555208ca69415049dedd6e1d59fe AGENTS.md
|
||||
195f115f819549f66f2063b52f0c97ec3b7e4f7b7c003636eda6ae18b90f0cdf CHANGELOG.md
|
||||
8e434690bf511ffcda205e58719abdc7c47ed0d2a14660a8d62f6984231bc3a0 Cargo.lock
|
||||
59b870fa0f6ff5a26e6068dfa437c4b707d8af7840c5a3d6880423d564f227e4 Cargo.toml
|
||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||
0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 LICENSE
|
||||
82d1dee2fc68158f0ea73259151461e64c8b18d807cd1fecfb1ed99dc30cf5f4 README.md
|
||||
cabbed1bd8ae8433b2d179c72339b1464d307b5dbb59aadac9bbb326298a92a9 README.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
c0df6b46bf795d49d00fcd9e2a9247aed0d3a99b6cae1dc5ab6614e8b3d30009 build.rs
|
||||
95ececaba550a4bcfd6754073877ba0633373b1e1451521b9ac905a51e8ca6eb deploy/README.md
|
||||
@@ -28,29 +28,29 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
|
||||
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
|
||||
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
|
||||
60d108fd168064e4d6980ed7499f3219017f9878c743697e77f2efc592e4d629 docs/ACCESS.md
|
||||
5a8821f28a1f529263db462a0ed1f7c082ad91b5936491806913e5cb5b274fd9 docs/API.md
|
||||
a1b6b8675d3b8321160406bc716f1355356e1e3dd056d4785be1d06a99313f31 docs/API.md
|
||||
728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md
|
||||
4fb55a549535f37467f14536cbf0db7be027ca8a019c2919a384af4f23655300 docs/CACHE.md
|
||||
5e7b6668be6bfec77755ae45b9d369aa14135fce7632de392c6b29a7b584245a docs/CERTIFICATES.md
|
||||
32599d67a11fd0ebbc612e82b9f602789f4ed4103cda482c6870d8741100c8b4 docs/CERTIFICATES.md
|
||||
02bb383b5c3bce22616c3cc3db30a9469806dc16decb509973e84c60aa30fc5b docs/CONFIGURATION.md
|
||||
fec29ce846d6705eb45819d85d382f28e28a146b265feab2e4c7cce2a35e5883 docs/CUSTOM_LOCATIONS.md
|
||||
9c54b8da03adb1bc1a3db96a72fb99924dca25eed5ac662989aba18ca226d066 docs/DASHBOARD.md
|
||||
47f600d9f70b0ce3b16c34a4aead62ce21b95c29f76a7c229631f782e791021a docs/DASHBOARD.md
|
||||
11a08bf2110bbeabc924eb049a30177a216af5607ccdaf8be9178e751f78a2c4 docs/DOCKER_DISCOVERY.md
|
||||
3e7e63d1d8f5d5019d5e4fdbabfe44fb39c2b75e7141d9c94846d946f34849f2 docs/FEATURES.md
|
||||
2c5f97b12be53db32c7e2888d23c0ef1d82d1742df44f7e60ea818dc801a719a docs/INSTALL.md
|
||||
6e37859bf0dced99d4a7db93790cb38fcd90191932827946d5223eaa4c784fce docs/LOGS.md
|
||||
d3592e93f9b24712c2dd27e35e28f657fbfb1c2f627e83eb804094760871f602 docs/FEATURES.md
|
||||
3939c14e83ef6856a421f358a3f7fce3d50ea375bc1b9f0419f27a3eed658f6f docs/INSTALL.md
|
||||
694cb7b15c5bbdc94cecd15dc516c05dcb3467849322cdd125b872d36dee4699 docs/LOGS.md
|
||||
0e9ae9509a78d00951d2c7c3f13eaf518d1f3448a29b048f5933e1f5f8440275 docs/MIGRATION.md
|
||||
0972248b7062b37e61edcc34dab5fe73ee9bb50195a0aa475c72efa0dd311aa9 docs/MONITORING.md
|
||||
e2e8fc307885bd02c177aea5740b2ac767fc7bdcfe7034dd75b8646f3014b39f docs/PERFORMANCE.md
|
||||
1222db8b21bd4c14522953d5bf117fb38d6b84c455ac06b50a65990d131c283a docs/MONITORING.md
|
||||
cd1ba85b82eb02bb09e22eed5605cbf9831dca1dabe65377dced25d50a5e35d5 docs/PERFORMANCE.md
|
||||
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
|
||||
954b1602c865e157c867f1c81dca615f2beaa33c34de0b0fa8a6069db2aae7d0 docs/README.md
|
||||
9095d0df62a4548459ca739c97fcca37d8902cee1ae04b7f9da57aaea7749a9a docs/README.md
|
||||
490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md
|
||||
70e8c8f3352629f5600537058b1997739d9e465747cf3051da8a2cc779c3b728 docs/SCREENSHOTS.md
|
||||
3a48184b36d225757fc5e2d22c629f89bfe5d4007ff087f0be0ce02a5fb04c28 docs/SECURITY.md
|
||||
bbef60ff1771da9f48af812987766e1e479408873ae2f079b13f1d5c62ecc6ff docs/STREAMS.md
|
||||
8cfcda59060106151f13e93ca25c1280b4390433b3fbe2921a6eb3c13bf9766d docs/TRANSPORT.md
|
||||
be6e8af62a7bf958b94ec15e9e3263263e511d1d33abae4e45d114833fa52b07 docs/TROUBLESHOOTING.md
|
||||
b4abb86942f665230904fad570c1487bc173aa84633801e2999f2bfa197de4a7 docs/VHOSTS.md
|
||||
6de1f152989250d7e53fff652cbdaa98aeb42310f6c4daa9650fce647ee17266 docs/VHOSTS.md
|
||||
e67928a9d044e71b681c3e52f9dd8b151d6b72f965d16d5da8b0eadde98b70e9 docs/WEB_SERVER.md
|
||||
3769f97c74b1a4a2f14d82a6f9c30fa3ee48892f423bf82dbd2ec1bd9280e45a docs/images/about.png
|
||||
a85a7b9d8cac74fb1b231471d313e6587ccfc2e8abd2885a79067704176c724b docs/images/api.png
|
||||
@@ -81,20 +81,18 @@ af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhost
|
||||
1a041c505669625a7f0f8011484cf5b6d2ec69a7de859801312e4fcf4c8d3555 install-debian13.sh
|
||||
909e9ab94617ace74a425f15084bf85f3086cc52ba5b28f2b441ff800abc6ef9 install.sh
|
||||
42f9f1d18c021fc7734b5bd020844bf678725a0e539e2fcbe05ec676f6239eae integrations/README.md
|
||||
ba753f69557bd9fa990e56beabebccf39f93b5f6fb7e66a2e241c8c461dfd763 integrations/opnsense-acme/README.md
|
||||
3a1f8c326618a4613bb35f9e83428f6d1cf668b9e37b4731e5d38c3081fc94d8 integrations/opnsense-acme/actions_rustproxy_cert.conf
|
||||
2d4c859aae5ba357a6d30024c8487ae8f59f01b192711f13f9c2f8fee0f48b50 integrations/opnsense-acme/README.md
|
||||
de6550ab8f48d556d752c252212c38ba65491fd224f972c10fbe552f91ed7520 integrations/opnsense-acme/opnsense-rustproxy-cert-deploy.sh
|
||||
73784ac652708b04ca6c91aeed4524f51f50d63715bbeefe7f5590b353e78943 integrations/opnsense-acme/rustproxy-acme.conf.example
|
||||
45be151cd85fc2ef2f1fdc3003ace63381af262e73af8a685ab48cb697cc0de1 integrations/routeros-oci-build/.env.routeros.example
|
||||
11aa102de1089ecc375f20c4bd5420fcfd3b2ef23fe0c8bdf04953fcfaf40ef0 integrations/routeros-oci-build/Dockerfile.routeros
|
||||
7c7a3e51f43b43e1c962a4b0bb591f99c1f06977bf77f23719f7f867dfe7eb8e integrations/routeros-oci-build/Dockerfile.routeros.dockerignore
|
||||
5025cf1086254f3e664cd50be2737b437286cbf311c79d87578c3d1d6c4a4f64 integrations/routeros-oci-build/README.md
|
||||
6dbaf119accd963ce58656a9a1d38cd66a997a34c8eea9b0c7d4e4f4a6cc700f integrations/routeros-oci-build/README.md
|
||||
3f092a9ab80221eddd67be4d1a776acbc6e04c1eb6011b588280c28e83b2582f integrations/routeros-oci-build/build-routeros-oci.sh
|
||||
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
||||
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
|
||||
4d4c80c3ac03a1f1adda548378cad5e4237921c7f8a5946ae101c6389d8377ac langs/en_US.json
|
||||
b0ebd18ce391a7fe6d34b20b5af9be896517fa2bf51be175a95943e23bb32846 langs/pl_pl.json
|
||||
8ed80f14633970899d56f4ea72684a8d4b1f2843c1c78bfacc4cbb5f7ce9ef4b make_zip.py
|
||||
c6334d6f120922208d3b04aa7c1d838d6c6d7a48dddc89748c7f438fc76cf937 langs/en_US.json
|
||||
0bc6dfc7c19a186c3cae9d01b7c9cab879e3f8d621f61d0b0cf71860088ef05a langs/pl_pl.json
|
||||
9664543e4d03422788a65e42f968c1f026c2235cb5be6fc3e0ad318997077538 make_zip.py
|
||||
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
|
||||
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
|
||||
5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql
|
||||
@@ -103,9 +101,8 @@ faa7e50922bf180c363f7627fbcfe8079831e36282220d17481cf69bc64b57f1 migrations/000
|
||||
f093aef9639a959a2522946995a02d2e67c464e794c3ef665261a9a5b348c968 migrations/0007_secret_store.sql
|
||||
90f6dce865496e018c047133d3bc1721fc2a19b2f4ced22e2a3625d9e9ff18ac migrations/0008_config_history.sql
|
||||
fc7585fdcbe99fe3496f0e62f4c4e2161f4a0173222ba4950331aec6c44e50f8 migrations/0009_certificate_api_grants.sql
|
||||
d58a464870895cbefcaa2b4adfd6fcd0d5182d19f2528eef6509e190046cc9fe migrations/0010_dashboard_presets.sql
|
||||
a8b1e5ec3236a0c2681dfa1746c526eeef77e8bcb5ea8634d954ac398b4642bb migrations/0010_dashboard_presets.sql
|
||||
e56ac3718ea7d73fd9afb1c3b44ca95c9bea424c32166e37ce913bee0e67a277 migrations/0011_tokens.sql
|
||||
273ce62a518657853428ca44dfe4dd2a56abbbbba684e905a2d0147d08359888 migrations/0012_admin_preferences_size.sql
|
||||
478220552ffeea3de00e2866d427d9f87f4cfc4ae5232e5c3a774e62425b1e0c nagios/README.md
|
||||
91233910aa4dab07ae917fef93d018a7a0beaab697f9a0bb889ac613e595cca4 nagios/check_rustproxy.py
|
||||
a42e01b341725c9da0928edf40c8c3b9c814ef90592f208c52b1f2c56a2ad383 nagios/check_rustproxy_api
|
||||
@@ -118,15 +115,15 @@ fd1c34599f725ad5f7c3c0cc89e08b11917baef02c83af054ac8528b2e01dd8e scripts/README
|
||||
292c65ad2a7f0ca13da4a00a836df17c3192728dc02034b75a6a70eb6b5eb25c scripts/audit_css_usage.py
|
||||
5fc5c7f274aa5a6c07970f7b2e548acabae3dc2a24d598308fd768a8d9de2510 scripts/build/__init__.py
|
||||
c3066f40f152a2655f88314fd9cf784eddbfa6c3238a494da13d972ef9ad930c scripts/build/panel_css.py
|
||||
2bf1851e3cf7ac576e38fe4e02532898df2ec11ff8e802266c1db203f29f6a91 scripts/build/swagger_assets.py
|
||||
5e98d6c30047512825f5dc1c42709b82f0f6342fc0db8346a67ae85ffeb40c90 scripts/build/swagger_assets.py
|
||||
e4d15aa6aa864d04b9b30aca33826b833632af02ca643f8b23e219a5ea1298dd scripts/deploy/deploy.py
|
||||
f0edbdca3af49cfa8f9c118d3eb0977c99569264dc5d74a85c4610234ea1fe88 scripts/deploy/deploy_core.py
|
||||
f26297284e0ad9fb4e4317deac24b25de819c10d04fed551134ddb6debc41837 scripts/deploy/deploy_runtime.py
|
||||
618603239b000bbe4af36a6a6ee268758af632c761fc53fc6b7f44a4a818bdf1 scripts/deploy/deploy_core.py
|
||||
992c9a806464fa10902de5be102f1609469986331fc0ae223a5b5f8507153574 scripts/deploy/deploy_runtime.py
|
||||
a59587ce8ad41b1bbb431356b58398f9c8ff7cb96a613a91752e907a70e10c2e scripts/deploy/upgrade.py
|
||||
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
|
||||
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
|
||||
4c905a387f210c30fbf12a7738676fd39819bc96bc4a272da1db5f18a2d3013e scripts/migration/migrate_npm_core.py
|
||||
960fdf97ec899107a567405e9b9c2b70ee16951d5ea26c37d885a7920f7b3aa1 scripts/preflight.py
|
||||
2ae780e30c1212ffe134f1785b2767a4a6591d5f74baef6c2f47cc8df35afbdb scripts/preflight.py
|
||||
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
|
||||
d759315babd9971b354d6f702d4c861eb1cd5c41b1e2e0f1a94ad4d2c2db49a3 scripts/runtime/acme_provider.py
|
||||
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
||||
@@ -163,67 +160,65 @@ b9c2952e3fcfca540d0ad64f2d3acc5c1f5240e707f4f23ef4d452f46fcb520f src/admin/docu
|
||||
059218501907355b22bc84e7e0c41ec72565af283f5e97170ce7707ea5874009 src/admin/file_logs.rs
|
||||
d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file_logs_io.rs
|
||||
77b8c05645969834cc8a58e249a7b60a57d6c47ad1c5cd92ffa5e4a9b5a8e73c src/admin/file_logs_query.rs
|
||||
743bb7436f77e04015328a0e7e7d425e43ef8aa8d61467bde157e462c1dcdc2e src/admin/logging_api.rs
|
||||
444d7715406ffd2305a72decf7fdf633aa4c2752cd881c8fa3b95ed971d79820 src/admin/logging_api.rs
|
||||
243f4a9c3fa99781e19c3952354d2632c0ce65e1dafac729567cbe3c177f94cb src/admin/mod.rs
|
||||
96101a80516c9cd668feb90a2cdd766363b4335b37888a926bb322e0cf3a160a src/admin/openapi.json
|
||||
f7a356109786635dd58cba590c71c796dbf36b47f25845190c75ee5c6a9fcb86 src/admin/operations.rs
|
||||
f2525e5e7aca40b04f565a6865170a56546caade45918e96968443a0d5fcdad2 src/admin/openapi.json
|
||||
1f4ec45d146e00ef664fae37517f8fcb5455d47daec496c02b2fc15ddec49131 src/admin/operations.rs
|
||||
257d93a29d0bda96462bcf695aba8d0ec0f9412c379e066ec46ae8ee034e3a53 src/admin/panel_assets.rs
|
||||
f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs
|
||||
229efd2691e51755f402ee04bcd9b806e3d3176c39526b9f7c2381ce779a7ba5 src/admin/preferences.rs
|
||||
b3c3c2d33c150eb42ce210c6074e5eff6a8af9391118012e413bc9323d3ae50c src/admin/preferences.rs
|
||||
35a7f99c7448c90b8637f4b3a7becca138f1f378fe5dc3f87122151525bb20f9 src/admin/preferences_columns.rs
|
||||
a238feee9afce314b8f6f7257e5488a53c4d680f33450a4e3e460fccbe54de88 src/admin/preferences_tests.rs
|
||||
b31595d9522962ae16dff83dd8bc063d31fb027cbf357e9953285d2e622daa26 src/admin/preferences_tests.rs
|
||||
62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs
|
||||
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
|
||||
6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs
|
||||
fb4ae8f958e136f46c50a3db8347ea10123c2a3a775dbea7e261aa0b3c7e7994 src/admin/router.rs
|
||||
c8454f3092145c5fefdc8d93951ae46f192a6eb8139dbe288284b3a1c803b192 src/admin/router.rs
|
||||
e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/session.rs
|
||||
7aeb784a3ef58795c8fe26467bb4e1a00e61420911f798a584ab09c12f73a754 src/admin/status.rs
|
||||
2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs
|
||||
8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs
|
||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||
d8693d5341ff5be2dcda5a12bf55f173773cb9eb642862d9f4b03c4760ebf8fc src/admin/tests.rs
|
||||
3bcd09729f3718d9ea88afae10c9e37b127d742babedd46e20ffd05eb31885e3 src/admin/tests.rs
|
||||
c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js
|
||||
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
|
||||
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
|
||||
faa9735c2a02746b2954bc4955eb609f94afebb685cd5521d2629640cea2777a src/admin/web/app/backend-dashboard.js
|
||||
5a9842a3f3ac5f6c769413496ea5ea1d019446d051cdde252784b1a1cc46996f src/admin/web/app/backend-dashboard.js
|
||||
aed3aea4eef0011c6b51d39736782e7d1ec52662e422f40e0c7b9ddfc19d0378 src/admin/web/app/certificate-inspection.js
|
||||
f6b0533ffd6d0e6471519120c440619a7fb4b255854ba3dd23d81cb35cd21afb src/admin/web/app/chart-scales.js
|
||||
1a4d40a86067493e75b47f2d735d50fa4d161344ce145d4e502b0225144ed45a src/admin/web/app/chart-scales.js
|
||||
e5b39b5592ba81292571033f26934ac0cd42bcdca7395e91094973cecb3eeaf8 src/admin/web/app/config-history.js
|
||||
d5453830975947c453542f4aa175e575a39604902213300fa2db0f77e02df1af src/admin/web/app/core.js
|
||||
d695ec90fb5c98c27d7ba1d2bebf54f9840985fc530efcec3fcaf48d74b36c6b src/admin/web/app/dashboard-inline-layout.js
|
||||
f0bc2a8b7c8faf60938c71f49764ddd4376685e19454c176a61307f885c5369d src/admin/web/app/core.js
|
||||
622d34e1616fee9a480d208192fc5a84d62d84ef4233d6a7d95e2bfb8cef415b src/admin/web/app/dashboard-inline-layout.js
|
||||
e4238322a985d3e8b7f2de94b7af31e1c718775590b2c7b3428b878cd43337b7 src/admin/web/app/dashboard-layout-editor.js
|
||||
a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/app/dashboard-presets.js
|
||||
0be1024d9ff657044e04ed71f275cff3b432406d5fdcdeac726daaee54444abd src/admin/web/app/icons.js
|
||||
e5a92fa1cb7e049082f0764b245560c41a9a34bcd8a9536fd872d9169fa5465d src/admin/web/app/logo-animation.js
|
||||
32aa11f041eafdf16e66adef6f5847a67413785224da2c19ecbf825996622aff src/admin/web/app/overview-extra-metrics.js
|
||||
25d89adb3b6e995d7c51d9b315e167949d5d8d4403162bf1fbe8e98dfb392dee src/admin/web/app/overview-extra-metrics.js
|
||||
5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js
|
||||
4d1d4760cdff44771c9d880a7c0da81d7233ef6e3a4868111a522703831a7a54 src/admin/web/app/overview-samples.js
|
||||
b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.js
|
||||
db9012546cae8c58fc2bcbee71c9a95956a13a6db9e6d126c6a3770d45a4e4d2 src/admin/web/app/overview-wide-metrics.js
|
||||
bfd9fafb3112197e8f875dbf87a9a66c81aef0fe83687601a84a66f5e13f69bc src/admin/web/app/path-permissions.js
|
||||
e540119edc555cefbe5147d97e06527adf49c984ba7a4dd51dc67603d3a46041 src/admin/web/app/preferences.js
|
||||
46c7cd36397375eb7e8c230c8aa97b1dbea0a63800beb977efe1ec67a14b237e src/admin/web/app/preferences.js
|
||||
9e2b12d34ab1fb29716645720577ad49b16c03b46c482a03695e8f645d170e79 src/admin/web/app/rate-sampling.js
|
||||
3b8e566d81a5b94ef21c73e7805ae128454f085554de9358040de266bcbdb0e0 src/admin/web/app/refresh.js
|
||||
e5f8a0124d57df71638df86b2262d5c210fe7947736712dda7f97e6ca36048f6 src/admin/web/app/runtime.js
|
||||
43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js
|
||||
7888eaea26c5cecc097ad276b73ae6c63def1db896053def6852ddf9d6739906 src/admin/web/app/runtime.js
|
||||
31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js
|
||||
c7a18238ac426de4d1cf84a395ee4dc66410d60a57a3822695dc10adc668d956 src/admin/web/app/settings.js
|
||||
b7ead49224a4ec67181d3f24f9d9634bd6d76756f826a4d5d993300637d1d8f3 src/admin/web/app/stream-dashboard.js
|
||||
7de070cc8cdde45d7e0932259299e341e25b32c9ffbd87e4fcce934d0f090de4 src/admin/web/app/web-overview.js
|
||||
f87af546830f01411dcf93749b4f75ca0f0bb3745a3390eff73ee14fc768cef0 src/admin/web/cache-panel.js
|
||||
9f323507649910561518b63cf6632e81b3ecc4b09501ae53b635705f34ea6587 src/admin/web/certificate-api.js
|
||||
4810137aa4217a86770617d442cf5dc6bec0d9f19e20bb2155e932a9a111cedf src/admin/web/css/00-tokens.css
|
||||
466a24e234e1ec15f17e7d1994e838ca1ee0f52f03a4ca8fb6d9903bf2afde55 src/admin/web/css/10-base.css
|
||||
8a1996d37f138790c12a704deedde9de74f733e7dcbb6be816fd68c281e8e0a9 src/admin/web/css/20-shell.css
|
||||
d4b20ada2d91ddbc1613c642194df19e601948b34c4483fd04930c3e6db02394 src/admin/web/css/30-overview.css
|
||||
ca93e41d1c6001d82f4891cacdd712e896a0355d6db9f76cf62dd2f2225a8674 src/admin/web/css/40-editors.css
|
||||
87df062d7478176641364c07de396ca988a37b1a8bcb892b53cc578fe8471850 src/admin/web/css/50-settings.css
|
||||
9775d86312c03b9c722ce713f2dc4a99985157d2f6b98277f9c0dabdcf53de2c src/admin/web/css/60-monitoring.css
|
||||
483664859787a2c54b51e73891cb760cfa15ff5e2e4132c96989eaa8109cf3a4 src/admin/web/css/70-responsive.css
|
||||
596205feca534302699120b84848cb0f3e9ef2ad7ba24eac18d5751532a40194 src/admin/web/cache-panel.js
|
||||
e9788a9d1e0bce8775f612a5c842afea8a0dcf01301acdef4b69ff6a52d2ef45 src/admin/web/certificate-api.js
|
||||
a09f060b47771c56e9b9e57f1b896a32a2f87f1fb33401ac76932a91d247c67e src/admin/web/css/00-tokens.css
|
||||
77b8b748f31ef383c64ba5ca1740e9b6073f0de1e77c2c8c62d0f38bb52dd296 src/admin/web/css/10-base.css
|
||||
d3f19407ae97b3f6cca015877d369f4e3dc86d8ea7de08e430c85468fa033f5e src/admin/web/css/20-shell.css
|
||||
7282b376c4df9e423eb84130b28d74ebb49c3341eed8aa284c29d8da413fd2d0 src/admin/web/css/30-overview.css
|
||||
dd82b5e3ead91c20bb08cf8d71bbba31ea8e3d108c417913c63409f3e34da7e1 src/admin/web/css/40-editors.css
|
||||
6af9fcb439823ec63abe2c1dd86c9ecba17c233262fd8ab80d321d64ae2758ab src/admin/web/css/50-settings.css
|
||||
f8b515c63080c71ee647cfd8c36628271e8357b31cd84f00cf591e9b752b44fe src/admin/web/css/60-monitoring.css
|
||||
c7c387e21e7bb148689e16f5fda6cd47c870043260dec059bbe66e09ac86fdd9 src/admin/web/css/70-responsive.css
|
||||
8009afdb04d5170687816730cc930a22fead1fe0248835ca7e8de81d6907395d src/admin/web/css/README.md
|
||||
8241bd9489dfed082863948829a44a49bf0a8e9cd63c55809655109e393e0918 src/admin/web/css/manifest.json
|
||||
ee51ab0912e0ade6a3e1c2ea6f533bb2f65a6d3e6ec9087e25b9f0df009c19b2 src/admin/web/dashboard-defaults.json
|
||||
20d65b22900d0de2f71dbafee2fe9a237e0a76515b386022b94c75d50bdfd926 src/admin/web/dashboard-widgets.json
|
||||
560a8e047182fbcee08aa4f8d999400a8a5ddca0b6a4640efd46f6b5c34fde91 src/admin/web/dashboard-defaults.json
|
||||
14213011cfb6ac334c8711b76bbdde97588a7b7ba4eb2592a1e387f877d6cbe0 src/admin/web/dashboard-widgets.json
|
||||
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
|
||||
bd170341634d7fe98172e0714b3cb8ee299a31abdbc9ab1309f5e61816dbcf0d src/admin/web/docker.js
|
||||
448a4e00e78ca368f9307cc84f87e893e5225dc87fdeadfbb227fd4b1faf1702 src/admin/web/docker.js
|
||||
fec062a5959450b1711948adf3189ceb37edca6fbead2e5bfc03b7ce397c70b1 src/admin/web/docs.css
|
||||
aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html
|
||||
fd8506a91f769b57b62d9d8e389fb67764599354bf702958a913be79f779707b src/admin/web/docs.js
|
||||
@@ -243,34 +238,34 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
|
||||
003d382cc8dd69ad7237679632c04a9389366bb71dd7dca8ee64ee06614aa9a5 src/admin/web/error_auth_provider.html
|
||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
|
||||
882e99059bb2ff110c452eadc0b5779a16af1e34cb1b2536c80cf2056f8c3e8a src/admin/web/host404.js
|
||||
463cdd85c53dfed869335fe4d8559554b7e555a0b44f648df94a97b1e17f6e56 src/admin/web/index.html
|
||||
7979c490b7fcfcd8f5e52e99fcf7757d78509337a625341c98f62c6da50da374 src/admin/web/integrations.js
|
||||
3f0525ca1d4e088ccd4988b8a117b26a8cf8c12213b24c37081d41b68d2b0ff2 src/admin/web/host404.js
|
||||
64c9c8f4ce6821dbf84cd1aa0912d1b5f47421f592bebf2fdc2bff341e293dde src/admin/web/index.html
|
||||
7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js
|
||||
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
||||
ac9f667273918e18406df06cb2613c923f50a88897693a03a18f500e22dc6ec2 src/admin/web/login.html
|
||||
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
|
||||
c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js
|
||||
44b44790341067304e15974afcbbc7cc99686e88c2e43f62979d285eca6f726e src/admin/web/management.js
|
||||
1f3d10e389c9c20e6eb019509fa6e82b50c6b43bbf988c1210a4d42bb73985d0 src/admin/web/partials/overview-charts.html
|
||||
c8d6ab08b1fde881b1ca40222b282be039e2c4fba1b79f59454221cf893a9bde src/admin/web/partials/overview-metrics.html
|
||||
734aeac4855f0913a84a82d7b5c17b37de4504b50a8701edeb775d6926929bce src/admin/web/management.js
|
||||
4481a592fcce9a2aa1db5e4a25619e19fd459c39039b86c0549dfc427eec1b45 src/admin/web/partials/overview-charts.html
|
||||
a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/partials/overview-metrics.html
|
||||
3928afce2ed6d5f2d3e29475dd29e4c3bd3c6df180ad5119beaff0c7a58e3b3b src/admin/web/prometheus-token.js
|
||||
6f33915d33d3a8bbfaeec424651a63eda3d23b0f296ae028a59ea46463ba9fdf src/admin/web/redirects.js
|
||||
76a01f590d49649dcb1f554c221290919b2a8438e6001737f3c096fa6daec74d src/admin/web/settings.js
|
||||
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
|
||||
2e891f34cad2a2950a47e529e15ed9dd41beedec71280b8035b19cfb91a5d317 src/admin/web/settings.js
|
||||
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
||||
dc3f01d1bba6fe29223983d869c509ae99bd6a40e362c6ec4605078876a05a2f src/admin/web/streams.js
|
||||
ae0259cf32f53d69a22a640b1c34562018d4ffb3864494012de3a31c0bb4ac1e src/admin/web/traffic-panel.js
|
||||
675ea169d4cc55fda5e291af536338b882bede1eaa6c9d5cc7dfde0779c7c4d1 src/admin/web/transform-panel.js
|
||||
87917b0388090f2b27ec6460be56fea96dc09b42735319e0a91330748848e288 src/admin/web/vhosts/core.js
|
||||
141565fbd8d2dceaa9b6f488046b96c897c94d475450db22ce854ce534336318 src/admin/web/vhosts/editor.js
|
||||
3776f72d7051af86ac5fbdd4581040dd88a424c0c6cde789301b032b159094d8 src/admin/web/vhosts/health.js
|
||||
1ea78a6e5b5d813febf2359ecec7fde42dc1278531053e91428f4d72b8a1d62b src/admin/web/streams.js
|
||||
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
||||
c7e0ca45c19498d40ef6f4122de23f0e73094e905d39f798942f70d15b9cf86e src/admin/web/transform-panel.js
|
||||
1cb5c0197b64791aedf992677609049f510ffd1944642528b0294d3dc113a2c5 src/admin/web/vhosts/core.js
|
||||
aaefda612ab04860f7c5a16f42a400fdd229277606237fb38eace1aa2fcd436a src/admin/web/vhosts/editor.js
|
||||
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
|
||||
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
|
||||
8c3da49973c62333559b0e3588c02beb0b425c025eac5cfa82a1bb968f70d9dd src/admin/web/vhosts/rates.js
|
||||
b3b9c94d779f249f09e74894659437649d40f31879d4f3077d5df941d5443454 src/admin/web/vhosts/render.js
|
||||
903df941cfd04bd2e34178b5b72911f5c0a6be9aa593ce544c685aacbd757da2 src/admin/web/vhosts/render.js
|
||||
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
|
||||
9e8e99e3cdfc32097d0c63dabb19d1e439b3be2264baa67678b46715b3a5bb4e src/admin/web/web-server.js
|
||||
dafce3a59a6007e59d7a4018fce96a14cd8e8cedb3ce77f02ee229a280c122d8 src/admin/web/web-server.js
|
||||
2d40c2cb32d7365df0c9cd6c8cb6f0483c7a799786c648f4836a0d9740c6c5ef src/auth/files/mod.rs
|
||||
b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs
|
||||
50ef648351271d78310e00caa6d246e0009171ed036a3e4e1144737df8c064c2 src/auth/mod.rs
|
||||
cc4ddd57a264d12bffab131bf04b5cd22df75d38b61e21ce669f5a8a97b109ac src/auth/store.rs
|
||||
25b9f78a7eee277902d375266bd0f023b79794bcdf646214fc161c5e6dab72d1 src/auth/store.rs
|
||||
745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs
|
||||
a4c14df927c22a7fa46023f25cfe48d3aa3c245bec97fce834999d9bcc8d75e1 src/auth_provider/choice.rs
|
||||
514e99e0d3da21abd633d53872234bc4d7d0178583ab291e53cf4b1802fd5337 src/auth_provider/errors.rs
|
||||
@@ -287,44 +282,44 @@ d4a388f0897e50ad26e1d8ea527eccdf656557abb4b88fe714cc2b1969d8715e src/authentik/
|
||||
859cec0bcd604f205d7d8e14d1c810e33d97fd7cf00ddcc30877bcea7c5fc2b5 src/backup/tests.rs
|
||||
5293e1b06f82654929805b53b9092a82b7503a54b34da3e882a999c5f14ee579 src/backup/validation.rs
|
||||
eec564a7ef886d18ab75289025a47d62bb702f07d63f92d2f83b7afeecebbb15 src/balance.rs
|
||||
9158a2bbb303c53bd3d2c779d9eb6e4470d028c4f28f8e329788e9c8c830f843 src/balance/health.rs
|
||||
458733ad4ec56d58112de6ffa3539ca7e9fa8311048a4e120da96b5f899d5dae src/balance/lease_runtime.rs
|
||||
bd693ea1d15d8da2abc08c6b5b50d42b693d9d4e9344205e9ad7ae779c19387e src/balance/model.rs
|
||||
ced69832cb32e7dbd0203d74f5a9c60c2cdc682729f4c5e2c01e7c082e7adc24 src/balance/pool_core.rs
|
||||
5006ece2fbb377c3944f67d9a7df9bed18861f010a7025d59259db5ae27a00b7 src/balance/health.rs
|
||||
344631248c71164187497a10d177d8eaa3e2f4a08111520d2536ec89ae55e1e0 src/balance/lease_runtime.rs
|
||||
ebe2b95f8fc5904850a88207f0690449ced49b28dc14ce5608b706659e338fca src/balance/model.rs
|
||||
a283df0f06bde3eafde24aa0d456b084f81864b4761b7162671f3b4e73620ccf src/balance/pool_core.rs
|
||||
477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs
|
||||
d789bf40cf9433e2fd16409d112210fe93154588b1ac9bddc9c7f78e1db14426 src/balance/pool_view.rs
|
||||
a361c341932ffd186c0b21356c91ab1fe8043f5020d40798266d15ae896a114e src/balance/tests.rs
|
||||
3e75a3cba5f1cd1af996ab71b6ad50f6aa05ac9cd97ee3cf88fce7c6b7271fbe src/balance/tests.rs
|
||||
ae06d238b1451328c751a511476ef39c012f3a397393f939ca02f96ecd0119a6 src/basic_auth/mod.rs
|
||||
9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs
|
||||
90f26bcd36720076f874cd0a21230316b7303f68e23839e987cf51e13c23352b src/cache/cache_regression_tests.rs
|
||||
e35648537d5ac0bfb604997200d1be25652ee4e8c49bf8b720b68df087bd0249 src/cache/cache_regression_tests.rs
|
||||
7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs
|
||||
b75ff512a1c6344483e00d2c67578ceea6b5472c279da0246f3622daa68d7e16 src/cache/cold/cleanup.rs
|
||||
5fde9e9f77277d557372e4614264512804192bb51f3d2eae785988c17b2e7253 src/cache/cold/cold_tier_tests.rs
|
||||
3c3897980c31a69d96f9295689d1ae19f2190da758c69ee7a0541f4171288bb8 src/cache/cold/demotion.rs
|
||||
17b2a157b03f43f49ed05f1f3391aef025a453b748056c67229cb3ceaa53bb7a src/cache/cold/startup.rs
|
||||
83095bc93132f306c537b31ba929669fedac99af3c4f133d0533b5ed7ac4e6ed src/cache/cold/worker.rs
|
||||
c6c54984cf3460e6227d40d8d6917d7ca5d809ac364c80effe354c64133c765d src/cache/collapse.rs
|
||||
237b535362af6fca4fc2a51decd1125243ce3068a840f95f9564fdfda8a187e0 src/cache/collapse.rs
|
||||
346fdb40d85627b583a01fa17c65e45c822dc7216198cdeadbb3d07904286fa3 src/cache/dashboard.rs
|
||||
be3d80cd340edb47755190a668e2f2a2776940665f95ae7110893b706eb94faa src/cache/dashboard_tests.rs
|
||||
847c660e6cb7d752f9dac2807c5045a000172c25b0e437cbade06ad7b101dcba src/cache/fill.rs
|
||||
5b0a3dec613fa22a38237618ddaeeca9df5c79acb499bfd78a31d9e7ce104d61 src/cache/fill.rs
|
||||
675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs
|
||||
c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs
|
||||
bbd9f372648d89b5e14d5b58029582d37f66a89752caf2c14eb1cbf8f24bb7a0 src/cache/invalidation/http_invalidation_tests.rs
|
||||
6223eaea3f061b111330cc26b28d85b9b239bef30328025f2ef337291e22a445 src/cache/invalidation/invalidate.rs
|
||||
c1ee3d13b373672c53b5c9ebfe5c4b66b89f2f2913c328defd5cf6502f96a654 src/cache/lifecycle.rs
|
||||
1f3c731b57e98b76654446068167f3f5d1aad88259633ea871efdf0d790a6ebc src/cache/lifecycle.rs
|
||||
bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs
|
||||
c2e258da25fbf6df7da3de5a80a04d74e85c03ab22c175d7c9a6340b8ea1f1b5 src/cache/lookup/hot.rs
|
||||
9b9a9e82e162699437798ee016e6dd87673ba8157935ac22b7bf6f5df551f841 src/cache/lookup/hot.rs
|
||||
4148106b9baf70e41eeba24233b1edbe8dd6f927c11f2c7647e89fbbfb60555e src/cache/lookup/hot_hit_034_tests.rs
|
||||
e9c6ab0c2be9f96573f826e6a1b9e4c2b8b526f779e72e95a6463ae07e45ab55 src/cache/lookup/insert.rs
|
||||
7f02dc8eeb6123cb22307b76985b606afda2d87b7f845894685cfe4a9aee8b2a src/cache/lookup/tiered.rs
|
||||
18b9614fdeb7d9916c9f7b394bdc7f78737e5a7c0249763a47d8dd88bd4d52b8 src/cache/lookup/tiered.rs
|
||||
639d5be6258699ec7e9f260ec33bd2c366203b6655bf0e98f30517c2e12ceeeb src/cache/mod.rs
|
||||
0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs
|
||||
b96e30ef2b12ad7647915457f087a4d11b6141847bc5e971a75c2739c88c3d8c src/cache/model/cache.rs
|
||||
44b1da391c0ff51ffba6ae43ddfbadc171c240ec74c5bde630d3c5993b6ac15b src/cache/model/cache.rs
|
||||
0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs
|
||||
f52123b588205ae96c7cded93569e99a1b4db4b361e1f7a91fc703afa6b6f6e5 src/cache/model/entry.rs
|
||||
d4409e5137bf29bdbfc11f4ec3444e3ae408d6057304b29f50d885affb441d9b src/cache/model/entry.rs
|
||||
ff1c3a1a3bc0d26218dcc943ef951c2a212c180bbf6e4bf1c542ffbf12a9f150 src/cache/model/http_invalidation.rs
|
||||
ee9508498d420a726abee1d12ca21fd5117c1908ff53c2ce29f01de4380f26ac src/cache/model/maintenance.rs
|
||||
723c6f0580db70b1ef40269b28a4aadc89f024b25be00afe73a971950663d252 src/cache/model/settings.rs
|
||||
f3859df02872b6a99b7aee38c34d7b4c48966e4e32df827844507e44d68bd6bb src/cache/model/settings.rs
|
||||
14f3f8ae5252a2dc13fcf9556523ffb945b77ee92a5fb4939cd27e113fc61660 src/cache/model/views.rs
|
||||
e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs
|
||||
70c255e15f2b8c030988a2263b0e07b5c16e7e26770c72becbcec6da4550fcd9 src/cache/policy/request.rs
|
||||
@@ -339,16 +334,16 @@ a1132b980833b1b4ce2bba9aa681ff255a0e2d61faaa65a316860da503ea1e27 src/cache/v022
|
||||
1a2eb6684d9ea4a7e90970d936c75f7c8bbec46a4181fdf273be9bdac8eec847 src/compression/mod.rs
|
||||
8de82d9188c9c337690c6ec4afdad9fff20651c358e34602a93d8bb8c3d2489a src/compression/tests.rs
|
||||
e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs
|
||||
2c4312dd7410dc231ed204d7d201d716eedb41045fd95df7cbb51d5561540d99 src/config/backend.rs
|
||||
6ad644fc4ae821d2c30329ace36652a7ac12d674e71e7dbdcdda3554c983f6f9 src/config/compile.rs
|
||||
9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs
|
||||
367b46f2b57143db17044378b1f5a55bab1eae3ad6ba40f388896995d1b2a13d src/config/compile.rs
|
||||
fbea01fea1213d98aea812c1b78d5db289e09200b98ae514d742235cd3fa70e1 src/config/internal_routing.rs
|
||||
cc75698be2828000e88f2c482bd26f2fa4050e3432bec1b5a581dca17fef4a37 src/config/model.rs
|
||||
0ebb02ae4f7403e347fed433d93ce9a4a46b1532cb9c4b2b4dc17885f871ed6f src/config/model.rs
|
||||
75839458ddbc7480ec4492a95eeb67b617ba7d62f7b67e7a767a3453b74dbcbc src/config/route_policy_tests.rs
|
||||
e442c6561723e8dbf73d25912227cebf7aa84ac1523e8bda8e6b4bdad1b674df src/config/routing.rs
|
||||
5bc35ebca158aa399a1d2273c21130e1cfaf5c89cb401689ef863d7deca6b801 src/config/snapshot.rs
|
||||
365483b6aca6ce5880bec5235db869b1bbc2e2551945c99303cd005903fe6207 src/config/routing.rs
|
||||
dd9a6ad8f58e0cdd1ae74f3f972fee0ac14383cd8b8f518403b9a36c169f35bd src/config/snapshot.rs
|
||||
477cddaa1e1c0b9b605a2f550971a04cf30287519cba70cfbb2e1860e3a93ff2 src/config/tests.rs
|
||||
f35de0ee1516ece6bbfd2b2a7db7d1e0f5048b0ec47cf2a3765dffb799abfd93 src/config/transport_tests.rs
|
||||
a09f361d70510d6226f4dd6785e649c3dc01ccafc70a4c8b0dcd098e224129b7 src/db.rs
|
||||
e0f46d885ed81ce306a110560cdd2c217dadde054986d89fcec835ebf6939d65 src/db.rs
|
||||
c290d0cb02dfa5d2c60ecabac1cfb20c8884e3d14cca1944906c6d8e677f1784 src/db/api_tokens.rs
|
||||
892ec0e5985700cca399c861197f6c9d2f1c823d8d95b88abac9d27ae15a069a src/db/certificate_api.rs
|
||||
5491dd7bb9b3fceac97c4dea63aba6d5c7abcb0fbd06e36ae986e00c79bc07d0 src/db/dashboard_presets.rs
|
||||
@@ -388,7 +383,7 @@ e9795eaa3e743c09f5ab0e18e6d359bc97a3aee55510c74923532d64df90c873 src/logging/fo
|
||||
66c4596e644d9dd539d468575e2de6459bac2624fb1e1064cdae61a21c2e0240 src/logging/model.rs
|
||||
bf898b318feb9bd3dd2c335b6b1bad172da63fd5cefbff3e267421ce37c076fb src/logging/runtime.rs
|
||||
3d6ea5508bc9609c6ce99a3a1d3fec2fafb95a2a8de579a80262b73c6eed3a63 src/logging/tests.rs
|
||||
cdcb3806669207c17279da959bbfcfacf18cc192f24e862c0afa0c389a41631a src/main.rs
|
||||
e1c61fdfc3f429ad3fa2116921381cbc5d2f850fa119a16c8f691d6e3e184724 src/main.rs
|
||||
87e69b46c548b861e9d90d9d2e42dfc2630891f4583800b1942f9065d25e29cd src/main/admin_runtime.rs
|
||||
224da3127b5f846a8932fb7f4b6e23d0dd590dc0170506267381d66042c64666 src/main/bootstrap.rs
|
||||
5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs
|
||||
@@ -400,7 +395,7 @@ a719c51aebcba0f16f8810c37618201954e79d6806da085ee6c5e4697a05b521 src/metrics.rs
|
||||
31154052757c2bb975b8ce8cad67f3fad7b5132b615a73d5b0d7bf1b5311722b src/metrics/rate_sampling.rs
|
||||
f27f1f8bed769883d1a7ae6a48f89f7712394dfab65d05702b0f944f0a45bd8e src/metrics/vhost/mod.rs
|
||||
ee401f943bd8b2706c33419ed69102269c08dcb4ceba6f4be2ac0861115874a7 src/metrics/vhost/tests.rs
|
||||
f2a632f9d3d7f9985ff796545263ebe03fbf5e9a5c2e88288e89a700d3af81d3 src/not_found/mod.rs
|
||||
9c96c3e4edb20dd0c36b5c373f1925dd60368e75853002206523072d418d5417 src/not_found/mod.rs
|
||||
fa2fc6c6987a8356a9da92cade10db62426d8863e9cef3c0d4324c45780c5b33 src/not_found/tests.rs
|
||||
c19998732c2d65b464f43d17a58644db5bcf7b4a6dd4c31a65a091c57692a5bb src/oidc/mod.rs
|
||||
c4dbe0f8ded237d45b6a3977bdc7544296e4408996815cae9e28af028419d0b7 src/oidc/tests.rs
|
||||
@@ -408,11 +403,11 @@ c4dbe0f8ded237d45b6a3977bdc7544296e4408996815cae9e28af028419d0b7 src/oidc/tests
|
||||
eb95c68c8639ce3ae658eea3334795cdb5e129fb2ba10955f7dffe06d8909b57 src/pages/mod.rs
|
||||
86008e469c84a7242b7d4947e2116694ababa2cb26120f246d00923a7c019720 src/pages/tests.rs
|
||||
5650a3f70162abd97021e0a51a92cac2c4dd39004426ef99dff36cbb0741aeac src/prometheus.rs
|
||||
6c97eb9c593a7802422100e8130921e63b6b9b1b0387b6860af9f49581cd50b3 src/prometheus/core.rs
|
||||
192a780d5874afc158d62a7dbe1687d734635afe9342fff526b8a2f6db4e5633 src/prometheus/core.rs
|
||||
2321ee667569803dfe6c92c0e98adba0ac8fcdc6a3eacd5857233f1acae93daf src/prometheus/docker_misc.rs
|
||||
cc909edf740a091f9057912f555dd888b644fa1cb49647fe0e2e1f5dba8ff5af src/prometheus/json.rs
|
||||
f9aab3297fcf647295849f77cbf99197f4dd27efd6817e54c03451e04972430c src/prometheus/model.rs
|
||||
f8a9541b568ade946dfc0b3353548a773a0126f705b537815ce0523439b07ef4 src/prometheus/tests.rs
|
||||
37badc9ae3c63a918a4b8c956beeefdacd78caafa94dd214903d37b62989adf4 src/prometheus/tests.rs
|
||||
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
|
||||
cdbfa0e7f49200dc70ea6df8f0594399ee3e0c5fc3d279cc336e2d83d0f8a535 src/prometheus/vhosts_backends.rs
|
||||
f1b0254f53b1a4336bd8170ad441ba072b49a114e497b985cb20699b4e375044 src/prometheus/web_php.rs
|
||||
@@ -433,7 +428,7 @@ d0d64fd4dd89efd4a9fe089ff4551815cbeaf5b883ddcff803f1b13b5e918dc2 src/proxy/forw
|
||||
ccc54869ffbcf1edae9ddb96f6dc2191739f8804ee0318b7a6844def6c4693e5 src/proxy/forward/success_prepare.rs
|
||||
c965dba1d2639b338f3f3cfc17f760786a30abab708dfb7920ea3bc94d4d734b src/proxy/forward/upstream.rs
|
||||
5d72200f3b5308215b7a3d7b1f910932eaa4712f9e2954afdfbe2f252e2a2d3c src/proxy/forward/web_source.rs
|
||||
602879a0f8f475fe63b5e27238f4b371277a6780aa970c6b140c8d7fd3074e37 src/proxy/forward_entry.rs
|
||||
0b03eed32b00d709e5887c179e49f7bdf0926ac19d2055af511333262039ed19 src/proxy/forward_entry.rs
|
||||
9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs
|
||||
30057732c35c369740a176f7da285e35a4157571a04328f6c6f1494d4ec8550b src/proxy/headers/authority_tests.rs
|
||||
e87437ccf80e50e0159d204dfdd1f8dc0739b6e375be192fce0af749810258cf src/proxy/headers/mod.rs
|
||||
@@ -456,7 +451,7 @@ a44421714aab37f7ceda19e3ec2512c645d2a3ce5dcb3da5bc7a0d87a442d6a8 src/proxy/webs
|
||||
999cbf85cb6e917db529e30ba4a5eed1cc9e95a054da7b3039c730bb80061449 src/proxy/websocket/validation.rs
|
||||
80d0f744f7ad9f0d1edeb0d5c23b13cb40431f6b6f4d10d534ed34d4198e3dfd src/rate_limit/mod.rs
|
||||
c85c9f05dac65cde6878bef3ef7258eb9de79ca14c26ca2492e0e13839c40fc3 src/rate_limit/tests.rs
|
||||
db254128c8013adaad7280f40a0edd8b196083afef299cd9e193e6db1d698b82 src/redirect/mod.rs
|
||||
e53c40b85792d4804a432e3c97dc13af8e29304bb0d250ee9ab9018bde522eeb src/redirect/mod.rs
|
||||
811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs
|
||||
244d72b3dab36ec505c96d26a967420714fd3807268820f680fb3dcea0e88c0f src/runtime.rs
|
||||
3c71d8eef9ccb49ad93cf6014c5c27756ce0eaeddfca116048f24c483fda68e2 src/runtime/certificates.rs
|
||||
@@ -478,7 +473,7 @@ b0c4a46b40b09794fd8a037afca380ee3b95941bf75a0e1102a4e520a4bd8be3 src/secret_sto
|
||||
5f6a59c6085c1250a93a70020661d1e8174d3b600b494d16f8eaf7941ee3bb99 src/server/serve/mod.rs
|
||||
048a05a46108f50bef7da50b0c03b752d76e586b411de8ca3f55313bbdb08aee src/server/serve/tests.rs
|
||||
669ea1041c103cb952f7a516363c8fe7baf51622f721aabdd57015e0e0279530 src/server/tls_runtime.rs
|
||||
38abd50db3c5742ee6514654bcfa0b854fdf6e2a29e034ac2e5d36c4b95b00ae src/settings.rs
|
||||
c03472e8d937bb6f851593f4e0d347dec6b66e85e577c87a09eee0acd990bca1 src/settings.rs
|
||||
b2e2924047019b1ada59c58a4060476151c99be12c0b4092f2cfd201c0756255 src/settings/public_origin.rs
|
||||
c599d1cdca84ac18da7e7cdf6e2d1556d4df770481a0607edb96f6068cc051e2 src/settings/tests.rs
|
||||
2ad7888b263e1dd9f0bd588dc35590c9eb232f8fbacd10ea7554cb55b4dbd25b src/stream.rs
|
||||
@@ -498,12 +493,12 @@ df98becda799b6bf4ff5e2fec7d0e0a486e6b7eca6f9f5ad49ffdafd49a4f498 src/tls.rs
|
||||
93e0576ac9077e645a72d164bad34cd75fe6032dd0516284d08bddd2fbda4b7c src/tls/certificates.rs
|
||||
68cc35b3cae43b0321cf667a613310a4b9ec5d8994461d6f3543b1852771bf08 src/tls/external_paths.rs
|
||||
937b95bb8621ab98d7cad3d223e4dee305350fbd0149b7527396d3bdcc28a68b src/tls/external_paths_tests.rs
|
||||
1ff95e95d38eedeefcf8a931569d628763d5a328aa5573c73c928f06795037ba src/tls/server_config.rs
|
||||
f02bb2775e662b2d75810f631ad3da4e0b56a64ae72b36c0edeae37e979b0fb5 src/tls/server_config.rs
|
||||
94b70c52fdc7bd6e32fba9eed9124d030c692246f6990d61e01b891765275add src/tls/tests.rs
|
||||
ce51a3280331d67c8406d2c5bc156ac0b492afa58e1f328bcda76db0720a6a9d src/tls/types.rs
|
||||
a897c48e3528565fef8d4e32f0c3c078adfb68655c80e1af011f44a8bcfac6f6 src/traffic/mod.rs
|
||||
bd62b4ecdcf831d183c7720766a11703c8612d614e3abf38d2bff28df34710fd src/traffic/tests.rs
|
||||
bfbbaa3b949ae946fa929bfbae3fd9a25f2ce792abbd0ed370ccf5683250bc4d src/transform/apply.rs
|
||||
e373c7d2630dd209c7a7b7c00c20f80030d83c9e388968656d7323dbd2a11b46 src/transform/apply.rs
|
||||
ae30a984fa39d5c45a676effbc880c923078ba8120adff1bff10e108350b31b5 src/transform/compile.rs
|
||||
ffc0740462e5566a96912ab3089f18fc28f1c093ca65eec860fc6ea801fd907d src/transform/css.rs
|
||||
2b9d5c11d4c02b4a3ad3b9bb1a18c63693f245b854084f5eba16595df47b47f8 src/transform/html/mod.rs
|
||||
@@ -531,7 +526,7 @@ d61c427c1e9cf457f1232b822d73e846e9f329cfbe61215e7ca061d53762155d src/vhost.rs
|
||||
a8c5ec5095178a0656a001b0d7100e240d1f32b052ea60df37f8681269638ab3 src/vhost/model.rs
|
||||
fc8af7342a3b147921e9040116af5dfe680b3df910db2416ada18198bbeea7df src/vhost/redirect.rs
|
||||
2651d7e3e3fadf3de74a75eafe2bbad8d02939551ccc0504c1a092a0b33b0db5 src/vhost/tests.rs
|
||||
c32ff8fae0c291bf072404aa118cb81016752b2929c89c7ec845d97006e677ce src/vhost/validation.rs
|
||||
190b2bbdc7ef5181ae4dbe1659f6f0c7ead5d09936b109d0c5be7ea2e7edd681 src/vhost/validation.rs
|
||||
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
|
||||
9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs
|
||||
5ed79fa5b9c53f95ca200d89a9ba927dda816b61fbdf3b852bd3c9670e3d2d4f src/web_server/config/model.rs
|
||||
@@ -558,18 +553,7 @@ e5f5fbe702f66bfcbcbc1d1f3cd3d89bdbb929bb86aed94868a2a06a4b9e59e3 src/web_server
|
||||
b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs
|
||||
714418c86ab241020a561fcf662a8fe324a6a70cc2fd3a313c56d7939c14b9e8 src/web_server/tests.rs
|
||||
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
|
||||
102b83171a6429b6375d8001d6c5294702385cd58ee55727cb657b1f3566e877 tests/panel_icons_browser.py
|
||||
52e61c389a60d147184ee76925cac76e454f8f2e3cb91039bb06e17787ab900f tests/panel_icons_contract.js
|
||||
01af3008be27fabb486c141c67099af104f2725cc56f4c656aea9741a3c150cd tests/panel_interface_contract.py
|
||||
f9ac7a0117056cce2bcf1a20404db9977a9b1194c3d7df0eaef48b7e7f82b9f0 tests/panel_refresh_contract.js
|
||||
e24a0f84f1bfc46964df865ac387d651f7b384d781cbc8f3a9a5b0b60f3d69ca tests/vhost_auth_selection.js
|
||||
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 third_party/swagger-ui-5.33.1/LICENSE
|
||||
0d20d1adef18aee3f40dd258172155521ce702ac445cb5f7b7d60ed32dad2fb2 third_party/swagger-ui-5.33.1/NOTICE
|
||||
2c9e148b9acd740850aa33ae52878f1ecf19cdc67f6041e2c0a7aab1e95782f2 third_party/swagger-ui-5.33.1/README.md
|
||||
f51b881c441fe8cab388c744911671ff925694b650e5ae7a5072cfdceb9ddaa4 third_party/swagger-ui-5.33.1/SHA256SUMS
|
||||
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 third_party/swagger-ui-5.33.1/swagger-ui-bundle.js
|
||||
63818894e4b04cd0e3180d9cb20761e227a939121e7484f8e1d528227c756f89 third_party/swagger-ui-5.33.1/swagger-ui-bundle.js.LICENSE.txt
|
||||
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 third_party/swagger-ui-5.33.1/swagger-ui.css
|
||||
eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
|
||||
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
|
||||
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
||||
|
||||
Executable → Regular
File mode changed.
+3
-6
@@ -20,24 +20,21 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot
|
||||
| `POST /api/config/geoip/update` | Run a GeoIP download/check/reload immediately using already-saved GeoIP settings; does not change revision. |
|
||||
| `GET /api/config/history` · `POST /api/config/history/restore` | Review/restore retained configuration snapshots. |
|
||||
| `GET /api/status` · `GET /api/backends` | Operational status and backend observations. |
|
||||
| `GET /api/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. `GET` supports the lightweight `view=live` polling view. |
|
||||
| `GET /api/transform` | Transform observations and resource usage. Supports the lightweight `view=live` polling view. |
|
||||
| `GET /api/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. |
|
||||
| `GET /api/logs` · `GET /api/live-logs` | Recent and streaming request logs. |
|
||||
| `GET /api/file-logs` · `GET /api/file-logs/query` | Stored-log exploration. |
|
||||
| `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. Traffic supports the lightweight `view=live` polling view. |
|
||||
| `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. |
|
||||
| `GET /api/metrics/json` · `GET /api/metrics/summary` | Full JSON snapshot (includes all Prometheus samples and `summary`) or compact computed `summary` only, both pull-only. [Monitoring](MONITORING.md). |
|
||||
| `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. |
|
||||
| `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. |
|
||||
| `POST /api/certificates/inspect` | Read DNS SAN coverage from a managed/external public PEM path for the editor; admin browser session only. |
|
||||
| `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. |
|
||||
| `GET/PUT /api/preferences` | Personal UI settings, including theme/layout and optional `interface_icons` and `logo_animation` (both default `false`). |
|
||||
| `GET/PUT /api/preferences` | Personal UI settings. |
|
||||
| `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. |
|
||||
| `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.
|
||||
|
||||
For browser polling, `GET /api/cache?view=live`, `GET /api/transform?view=live` and `GET /api/traffic?view=live` omit static response members that do not need to be retransmitted on every interval. Their `revision` identifies the active configuration; when it changes, clients should fetch the corresponding endpoint without `view=live` and refresh their cached static fields. Requests without `view=live` remain the complete compatibility form. Cache live responses contain metrics/dashboard/bans, Transform live responses contain resources/metrics, and Traffic live responses retain current settings/GeoIP/analytics while omitting the static Vhost list. Unknown `view` values are rejected.
|
||||
|
||||
Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed file or an absolute external path and returns `{ "cert_file": "...", "covered_hosts": ["example.com"] }`. It applies the existing certificate-file trust and size checks and reads no private key. It makes no configuration or permission changes. This POST requires the signed-in administrator's normal Origin, request-marker and CSRF checks; general RO/RW API tokens and certificate-only credentials cannot call it.
|
||||
|
||||
## Access and authentication
|
||||
|
||||
+1
-12
@@ -61,17 +61,6 @@ When another service owns the certificate, add a manual certificate with the act
|
||||
/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
|
||||
|
||||
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.
|
||||
@@ -80,7 +69,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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -14,45 +14,10 @@ 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.
|
||||
|
||||
Optional quick-health tiles (since 0.35.2, hidden by default; enable them in the layout editor or **Settings → Appearance → Dashboard layout**):
|
||||
|
||||
| Tile | Shows |
|
||||
|---|---|
|
||||
| **Healthy backends** | Monitored backend members passing health checks as `up / total`, with the names of failing members. |
|
||||
| **Error share** | Percentage of HTTP requests that ended with an error during the last sampling interval. |
|
||||
| **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. |
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
|
||||
1. Open the relevant workspace and inspect existing state before editing.
|
||||
|
||||
+3
-6
@@ -11,7 +11,7 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se
|
||||
| Vhosts | Exact and wildcard hostnames, aliases, several listeners per Vhost, per-Vhost HTTP/2 and HTTP/3 switches |
|
||||
| Backends | HTTP and HTTPS origins, HTTP/1.1, automatic ALPN (H2/H1), strict HTTP/2 and H2C, optional upstream certificate verification with custom CA and server name |
|
||||
| Load balancing | Least connections (default), round robin, least traffic, adaptive and IP hash; member weights, backup and draining members, sticky cookie or IP affinity |
|
||||
| Health checks | Active TCP or HTTP probes with thresholds, optional separate probe port, expected response text and live health status per member |
|
||||
| Health checks | Active TCP or HTTP probes with thresholds and live health status per member |
|
||||
| Connection reuse | Bounded keep-alive pools with idle and age limits, pool recycling and protocol telemetry |
|
||||
| Custom locations | Prefix, exact and regex paths, path rewriting, per-location backends, Web Services, local responses, redirects and internal error fallbacks |
|
||||
| WebSockets | HTTP/1.1 Upgrade, RFC 8441 (HTTP/2) and RFC 9220 (HTTP/3) Extended CONNECT |
|
||||
@@ -45,7 +45,6 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se
|
||||
- Managed ACME with HTTP-01 and DNS-01 (Cloudflare, OVH, RFC 2136), automatic renewal and job history.
|
||||
- Certificate inventory with expiry, coverage check and bulk assignment to Vhosts.
|
||||
- Restricted certificate API keys for deploy/reload from external systems such as OPNsense.
|
||||
- Prometheus metrics for the expiry date and remaining days of every installed certificate, ready for alerting.
|
||||
|
||||

|
||||
|
||||
@@ -75,13 +74,12 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se
|
||||
|
||||
## Monitoring and logs
|
||||
|
||||
- Overview dashboard with requests per second, bandwidth, status codes, error share, cache hit ratio, protocol mix, system load, backend health, requests rejected by limits and nearest certificate expiry.
|
||||
- Customizable dashboard layouts (drag-and-drop with a live drop placeholder, keyboard moves, per-block width, explicit gap filling) and saved presets.
|
||||
- Overview dashboard with requests per second, bandwidth, status codes, cache hit ratio, protocol mix, system load and backend health.
|
||||
- Customizable dashboard layouts and saved presets.
|
||||
- Per-Vhost, per-backend, per-Web-Service and per-Stream statistics.
|
||||
- Live request log in the browser and in the terminal (`--livelogs`).
|
||||
- Stored JSON access logs with search syntax, per-Vhost log files and an error log.
|
||||
- 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.
|
||||
|
||||

|
||||
@@ -95,7 +93,6 @@ RustProxy is a single service that combines a reverse proxy, a static/PHP web se
|
||||
- Nginx Proxy Manager import with a dry-run plan.
|
||||
- Managed install/update scripts with rollback, DEB/RPM packages, Docker image and an OPNsense plugin.
|
||||
- Panel in English and Polish, light and dark themes, adjustable density and corner style.
|
||||
- Optional account-scoped semantic SVG icons for navigation, actions, tabs and dialogs; off by default.
|
||||
|
||||
## Administration and API
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ bash dev.sh test
|
||||
bash dev.sh release
|
||||
```
|
||||
|
||||
`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/`.
|
||||
`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/`.
|
||||
|
||||
For a first Linux/systemd installation:
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ RustProxy has several independent logging paths. Choose the one matching the que
|
||||
|
||||
## Live Logs
|
||||
|
||||
The administration panel does not fetch `/api/logs` while Live Logs is closed. Opening or docking the Live Logs window starts the bounded live snapshot refresh; closing it stops that polling. Pausing or expanding the history buffer also suspends periodic `/api/logs` reads until live viewing resumes. The main `/api/events` status stream is kept independent by using `logs=false` in the panel.
|
||||
|
||||
Use **Logs → Live Logs** in the authenticated panel, or locally:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -44,14 +44,6 @@ scrape_configs:
|
||||
- targets: ['127.0.0.1:9191']
|
||||
```
|
||||
|
||||
Certificate expiry is exported for every installed certificate, including manually uploaded ones: `rust_proxy_certificate_not_after_timestamp_seconds{certificate="…"}` and `rust_proxy_certificate_days_remaining{certificate="…"}` (negative after expiry). Values are read from the certificate files and refreshed at most every five minutes. Example alert:
|
||||
|
||||
```yaml
|
||||
- alert: RustProxyCertificateExpiresSoon
|
||||
expr: rust_proxy_certificate_days_remaining < 14
|
||||
for: 1h
|
||||
```
|
||||
|
||||
When using a bearer token, configure Prometheus `authorization` or `bearer_token_file` securely rather than embedding a clear token in an exported dashboard. Review `grafana/README.md` and `grafana/rustproxy-overview.json` for chart examples; `nagios/README.md` contains external check scripts.
|
||||
|
||||
## JSON metrics API for external dashboards
|
||||
|
||||
+8
-45
@@ -20,7 +20,7 @@ Results are only comparable when both servers do the same work. A stock Nginx in
|
||||
|
||||
- Machine: 2 vCPU virtual machine, Linux 6.18, loopback networking.
|
||||
- Each server is pinned to one CPU core (`worker_processes 1` for Nginx, `taskset` for RustProxy). The load generator and the origin share the second core.
|
||||
- Load: `wrk -t1 -c50 -d8s`, HTTP/1.1 keep-alive, two or three runs per scenario, average reported.
|
||||
- Load: `wrk -t1 -c50 -d8s`, HTTP/1.1 keep-alive, two runs per scenario, average reported.
|
||||
- Origin: Nginx serving static files with `Cache-Control: public, max-age=600`.
|
||||
- Nginx 1.24 and RustProxy release builds (`lto = "thin"`, `codegen-units = 1`).
|
||||
|
||||
@@ -28,59 +28,23 @@ Results are only comparable when both servers do the same work. A stock Nginx in
|
||||
|
||||
Requests per second on one CPU core (higher is better):
|
||||
|
||||
| Scenario | Nginx | RustProxy 0.35.0 | RustProxy 0.35.2 | 0.35.2 vs 0.35.0 |
|
||||
| Scenario | Nginx | RustProxy 0.35.0 | RustProxy 0.35.1 | 0.35.1 vs 0.35.0 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Reverse proxy, 1 KiB response | 61,400 | 14,700 | 15,100 | +3% |
|
||||
| Reverse proxy, 100 KiB response | 10,600 | 14,500 | 15,500 | +7% |
|
||||
| Cache hit, 1 KiB response | 53,300 | 22,500 | 25,000 | +11% |
|
||||
| Web Server, 1 KiB file | 85,000 | 15,200 | 26,000 | +71% |
|
||||
| Web Server, 100 KiB file | 25,900 | 9,200 | 16,900 | +84% |
|
||||
| Reverse proxy, 1 KiB response | 54,700 | 12,700 | 14,200 | +12% |
|
||||
| Reverse proxy, 100 KiB response | 9,600 | 11,400 | 13,600 | +19% |
|
||||
| Cache hit, 1 KiB response | 51,000 | 19,500 | 23,600 | +21% |
|
||||
| Web Server, 1 KiB file | 78,500 | 13,400 | 23,900 | +78% |
|
||||
| Web Server, 100 KiB file | 22,500 | 8,000 | 14,900 | +86% |
|
||||
|
||||
Reading the results:
|
||||
|
||||
- **Large proxied responses:** RustProxy streams the body directly between connections and is faster than Nginx in its default buffering mode, which spools larger upstream responses to temporary files.
|
||||
- **Small requests:** Nginx remains ahead, because its fixed per-request cost is lower. In RustProxy this cost is dominated by per-request accounting (live statistics, traffic analytics, request IDs and logs) and by the asynchronous runtime. For most deployments it is far below the capacity of the backends behind the proxy.
|
||||
- **Static files:** since 0.35.1 RustProxy reads files directly on the request task instead of a background thread pool, which nearly doubles static throughput.
|
||||
- **Why small requests and cache hits trail Nginx:** each proxied request crosses the loopback network twice (client and backend), and on a VM about half of the CPU time goes to the kernel for those writes and the wake-ups of the other side. Most of the remaining time is RustProxy's per-request work that Nginx does not do in the same way: request IDs, live per-Vhost statistics, traffic analytics and structured access-log entries, plus the asynchronous runtime. A cache hit saves the backend hop but still pays for this accounting, so its gap is similar. No single function dominates the profile; closing the gap further would require architectural changes rather than tuning.
|
||||
- **Static files:** 0.35.1 reads files directly on the request task instead of a background thread pool, which nearly doubles static throughput.
|
||||
- **CPU per request:** at the 1 KiB proxy scenario RustProxy uses about 70 µs of CPU time per request on the test machine, Nginx about 17 µs. A single core therefore sustains well over 10,000 proxied requests per second.
|
||||
|
||||
Throughput scales with additional CPU cores; RustProxy uses all available cores automatically.
|
||||
|
||||
## Memory allocator
|
||||
|
||||
Release builds use the operating system's allocator (glibc on most Linux distributions). An alternative allocator, [mimalloc](https://github.com/microsoft/mimalloc), can be compiled in as an optional feature:
|
||||
|
||||
```bash
|
||||
cargo build --release --features mimalloc
|
||||
```
|
||||
|
||||
It is off by default because of how it behaves with a large HOT cache. Measured on the test machine with a 300 MiB HOT cache under constant turnover (random objects of 1–260 KiB, continuous evictions):
|
||||
|
||||
| Build | Process memory (RSS) | Throughput under cache turnover |
|
||||
|---|---:|---:|
|
||||
| System allocator (default) | 614 MiB | 5,700 req/s |
|
||||
| mimalloc | 760 MiB (+24%) | 20,300 req/s (3.5×) |
|
||||
| mimalloc with `MIMALLOC_PURGE_DELAY=0` | 720 MiB (+17%) | 18,600 req/s |
|
||||
|
||||
In the standard scenarios from the results table (proxy, cache hits, static files) the two allocators performed within measurement noise. On a production server with a 600 MiB HOT cache the same difference showed as about 800 MiB versus 1.1–1.2 GiB of process memory.
|
||||
|
||||
Choose mimalloc only when all of these apply:
|
||||
|
||||
- the HOT cache holds many objects that are replaced constantly (high eviction rate on the **Cache** page);
|
||||
- throughput under that turnover matters more than memory;
|
||||
- the server has at least 30% RAM headroom above the configured HOT cache size.
|
||||
|
||||
Otherwise keep the default. The system allocator returns memory from evicted cache entries to the operating system 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
|
||||
|
||||
| Setting | Recommendation |
|
||||
@@ -92,7 +56,6 @@ The mimalloc rows above were measured with 0.35.2 and a different object mix; re
|
||||
| Logging | Keep stored log files on fast local storage and use retention limits on busy servers. |
|
||||
| Limits | `--max-connections` (default 512) and `--max-inflight` (default 256) cap concurrency. Raise them on busy servers with enough memory. |
|
||||
| `sendfile` | Optional for large cleartext static files on Linux. On the test machine it gave no measurable gain, so keep `off` unless your own measurements show otherwise. |
|
||||
| Memory allocator | Keep the default system allocator. See [Memory allocator](#memory-allocator) for when the optional `mimalloc` build is worth its extra memory. |
|
||||
| TLS | TLS 1.3 with session resumption keeps handshake cost low; HTTP/2 or HTTP/3 reduce connection setup for browsers. |
|
||||
|
||||
## Reproducing the test
|
||||
|
||||
+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.
|
||||
- [Migration](MIGRATION.md) — upgrades and Nginx Proxy Manager import.
|
||||
- [Docker discovery](DOCKER_DISCOVERY.md) — Docker agent and generated Vhosts.
|
||||
- [Administration API](API.md) — authentication, tokens, endpoints, lightweight polling views and configuration sections.
|
||||
- [Administration API](API.md) — authentication, tokens, endpoints and configuration sections.
|
||||
|
||||
## Integrations
|
||||
|
||||
|
||||
@@ -65,8 +65,6 @@ Connection reuse is bounded by configured idle-pool/recycle limits. The default
|
||||
Backend pools support scheduling/weighting, standby members, draining and optional session affinity. Check the **Backends** status view for member health, recently observed HTTP response code, upstream protocol and resource usage. A reported status from normal traffic is not necessarily the result of a health probe.
|
||||
|
||||
- Health monitoring may use TCP or HTTP checks with a path, Host, interval and thresholds. Avoid write endpoints; use a lightweight `/health` or equivalent.
|
||||
- **Health check port** (`health_check.port`) probes a different port than the one receiving traffic, like `check port` in HAProxy. Example: a backend serving traffic on `http://10.0.0.5:80` with a status endpoint on port `8079` uses `"health_check": {"mode": "http", "path": "/status", "port": 8079}`. The probe keeps the backend host, scheme, TLS settings and address restrictions; only the port changes. Leave it empty to probe the traffic port. The port can be set for the whole pool or per backend.
|
||||
- **Expected response text** (`health_check.expect_body`) makes an HTTP probe fail unless the first 64 KiB of the response body contain the given text, like `http-check expect string` in HAProxy. Use it for status pages that answer `200` even when the application is degraded, for example `"expect_body": "\"status\":\"ok\""`. Failed matches appear as `http_body` in backend health details.
|
||||
- Each member can override the pool health check or disable active/passive monitoring with `health_check_enabled=false`. Disabling checks treats the member as available, not proven healthy.
|
||||
- New monitored pools can temporarily have no eligible healthy member until rise checks succeed. A failed/incorrect check can produce `503` even if the process is reachable.
|
||||
- `backup` members act as fallbacks; `drain` prevents new normal selection while letting existing work complete within normal lifetimes. Explicitly pinned backends need careful policy review.
|
||||
|
||||
@@ -5,7 +5,7 @@ A simple mechanism for automatically sending a certificate generated by `os-acme
|
||||
## Files
|
||||
|
||||
- `opnsense-rustproxy-cert-deploy.sh` - sends the certificate and private key to RustProxy.
|
||||
- `rustproxy-acme.conf.example` - example connection settings, API token, and certificate file paths.
|
||||
- `rustproxy-acme.conf` - connection settings, API token, and certificate file paths.
|
||||
- `actions_rustproxy_cert.conf` - optional `configd` action that allows the script to be executed from Cron in OPNsense.
|
||||
|
||||
## 1. Installation
|
||||
@@ -50,15 +50,13 @@ KEY_FILE='/path/to/private.key'
|
||||
|
||||
## 3. RustProxy API
|
||||
|
||||
Create a certificate API key in **Settings -> Certificate API** and enter its values in the configuration:
|
||||
Create a certificate API key in RustProxy and enter its values in the configuration:
|
||||
|
||||
```text
|
||||
RUSTPROXY_KEY_ID
|
||||
RUSTPROXY_TOKEN
|
||||
```
|
||||
|
||||
The credential must explicitly allow `CERTIFICATE_ID` and **Also allow deploy** (`allow_deploy`) must be enabled. A reload-only credential will be rejected with HTTP 403.
|
||||
|
||||
The script sends the certificate to:
|
||||
|
||||
```text
|
||||
@@ -90,7 +88,7 @@ cp actions_rustproxy_cert.conf /usr/local/opnsense/service/conf/actions.d/
|
||||
service configd restart
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
[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
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
For example, with RustProxy version `0.36.7` and the defaults this publishes:
|
||||
For example, with RustProxy version `0.35.1` and the defaults this publishes:
|
||||
|
||||
```text
|
||||
zot.example.net/network/rustproxy:0.36.7
|
||||
zot.example.net/network/rustproxy:0.35.1
|
||||
zot.example.net/network/rustproxy:latest
|
||||
zot.example.net/network/rustproxy:routeros
|
||||
```
|
||||
|
||||
+21
-93
@@ -253,8 +253,7 @@
|
||||
"certificateSafety": "Certificates secure TLS connections, not routing. Assign a certificate to a vhost. Changes affect new handshakes.",
|
||||
"wsActive": "WebSocket tunnels",
|
||||
"h2Requests": "HTTP/2 requests",
|
||||
"h2ActiveConnections": "H2 connections",
|
||||
"h3ActiveConnections": "H3 connections",
|
||||
"h2ActiveConnections": "Active H2 connections",
|
||||
"tlsFailures": "TLS handshake errors",
|
||||
"tlsListener": "TLS listener",
|
||||
"notEnabled": "Disabled",
|
||||
@@ -458,9 +457,6 @@
|
||||
"vhHttp": "HTTP status + response time",
|
||||
"vhHealthPath": "HTTP check path",
|
||||
"vhHealthHost": "HTTP Host override (optional)",
|
||||
"vhHealthPort": "Health check port (optional)",
|
||||
"vhHealthPortPlaceholder": "Backend port",
|
||||
"vhHealthExpectBody": "Expected text in HTTP response (optional)",
|
||||
"vhInterval": "Interval (ms)",
|
||||
"vhTimeout": "Timeout (ms)",
|
||||
"vhSlow": "Slow threshold (ms; 0 = off)",
|
||||
@@ -468,7 +464,7 @@
|
||||
"vhRise": "Successes before recovery",
|
||||
"vhStatusMin": "Minimum HTTP status",
|
||||
"vhStatusMax": "Maximum HTTP status",
|
||||
"vhHealthNote": "HTTP probes use HTTP/1.1 and measure time to headers, not body duration. When Host override is blank, RustProxy automatically uses the first exact DNS hostname of the vhost; if none exists it falls back to the backend host. Use TCP for h2c-only services. New backends wait for successful probes. Active health is independent from passive health. Health check port probes a different port than traffic (for example a status port 8079 for a backend on port 80); host, TLS and address restrictions stay the same. Expected text marks the backend as down unless the first 64 KiB of the HTTP probe response contain it.",
|
||||
"vhHealthNote": "HTTP probes use HTTP/1.1 and measure time to headers, not body duration. When Host override is blank, RustProxy automatically uses the first exact DNS hostname of the vhost; if none exists it falls back to the backend host. Use TCP for h2c-only services. New backends wait for successful probes. Active health is independent from passive health.",
|
||||
"vhPassiveHealth": "Passive Backend Health",
|
||||
"vhPassiveEnabled": "Allow normal request failures to mark a backend unhealthy",
|
||||
"vhPassiveHelp": "Off by default. Errors are still logged and counted, but normal user traffic cannot change backend health until this is explicitly enabled.",
|
||||
@@ -638,13 +634,13 @@
|
||||
"certCheckDomains": "Domains to verify (comma-separated)",
|
||||
"certAcmeLater": "Issued certificates are imported into the library and assigned per vhost.",
|
||||
"certAdvanced": "Existing files / advanced",
|
||||
"certLegacyNames": "SNI names (optional; used when a vhost is set to Global SNI / default certificate)",
|
||||
"certLegacyNames": "Legacy SNI names (optional; prefer per-vhost assignment)",
|
||||
"certAssigned": "Assigned vhosts",
|
||||
"certUnassigned": "Not assigned",
|
||||
"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.",
|
||||
"certManage": "Manage certificate library",
|
||||
"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.",
|
||||
"certSelectHint": "Select an imported certificate. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.",
|
||||
"vhTinyauth": "Authentication",
|
||||
"vhTabAuth": "Authentication",
|
||||
"taUrl": "Tinyauth URL / verification endpoint",
|
||||
@@ -1678,7 +1674,6 @@
|
||||
"dockerSearchPlaceholder": "Search service, host, alias or agent...",
|
||||
"dockerShowHidden": "Show Hidden",
|
||||
"dockerHideHidden": "Hide Hidden",
|
||||
"dockerHideDisabled": "Hide disabled",
|
||||
"dockerHideVhost": "Hide from view",
|
||||
"dockerUnhideVhost": "Show in view",
|
||||
"dockerCertificate": "Frontend certificate",
|
||||
@@ -1727,16 +1722,7 @@
|
||||
"dockerAliasesShort": "aliases",
|
||||
"dockerAgentDetails": "Agent details",
|
||||
"dockerServices": "Services",
|
||||
"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.",
|
||||
"dockerOpenApp": "Open app",
|
||||
"dockerLiveAccessLogsHelp": "Live HTTP access logs for this Docker vhost, not the container stdout/stderr.",
|
||||
"networkInterfacesHistory": "Traffic by interface",
|
||||
"networkInterfaceRate": "RX / TX",
|
||||
@@ -1888,9 +1874,9 @@
|
||||
"tunnelKillReasonDefault": "admin panel",
|
||||
"listenerH2c": "h2c cleartext HTTP/2 (prior knowledge + HTTP/1.1 Upgrade)",
|
||||
"vhWebsocketRfc8441": "RFC 8441 Extended CONNECT over HTTP/2",
|
||||
"wsH1": "H1",
|
||||
"wsH2Rfc8441": "H2",
|
||||
"wsH3Rfc9220": "H3",
|
||||
"wsH1": "H1 Upgrade",
|
||||
"wsH2Rfc8441": "H2 RFC 8441",
|
||||
"wsH3Rfc9220": "H3 RFC 9220",
|
||||
"wsEstablished": "Established",
|
||||
"httpProtocolMix": "HTTP protocol mix",
|
||||
"wsProtocolMix": "WebSocket protocol mix",
|
||||
@@ -2371,14 +2357,6 @@
|
||||
"chartStyleMonochrome": "Monochrome",
|
||||
"chartStyleWarm": "Warm",
|
||||
"chartStyleCool": "Cool",
|
||||
"chartStyleSunset": "Sunset",
|
||||
"chartStyleAurora": "Aurora",
|
||||
"chartStyleOcean": "Ocean",
|
||||
"chartStyleForest": "Forest",
|
||||
"chartStylePastel": "Pastel",
|
||||
"chartStyleTerminal": "Terminal",
|
||||
"chartStyleBlueprint": "Blueprint",
|
||||
"chartStyleColorblind": "Colorblind safe",
|
||||
"settingsSearch": "Find settings",
|
||||
"settingsSearchPlaceholder": "Search settings, sections and options...",
|
||||
"settingsSearchResults": "Matching sections",
|
||||
@@ -2387,10 +2365,6 @@
|
||||
"appearanceInterfaceSection": "INTERFACE",
|
||||
"appearanceInterfaceTitle": "Interface",
|
||||
"appearanceChartsSection": "CHARTS",
|
||||
"cacheSectionBehavior": "Behavior",
|
||||
"cacheSectionMemory": "Memory and limits",
|
||||
"appearanceChartsTitle": "Charts",
|
||||
"appearanceTablesTitle": "Tables and logs",
|
||||
"appearanceDataSection": "TABLES / LOGS",
|
||||
"appearanceDashboardSection": "DASHBOARD",
|
||||
"resetSection": "Reset section",
|
||||
@@ -2425,10 +2399,6 @@
|
||||
"buttonStyleNormal": "Normal",
|
||||
"buttonStyleCompact": "Compact",
|
||||
"buttonStyleHelp": "Normal uses the refreshed controls; Compact restores the smaller source-style buttons across all actions and dialog tabs.",
|
||||
"interfaceIcons": "Show SVG interface icons",
|
||||
"interfaceIconsHelp": "Adds semantic SVG icons to navigation, actions, tabs and dialogs.",
|
||||
"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.",
|
||||
"setupCommandExample": "Standard service installation example:",
|
||||
"certificateRuntimeTitle": "ACME runtime",
|
||||
@@ -2668,7 +2638,7 @@
|
||||
"backendWidgetInFlight": "In flight",
|
||||
"backendWidgetErrorsRate": "Recent errors/s",
|
||||
"backendWidgetErrorPercent": "Recent failed requests",
|
||||
"backendWidgetProbeCount": "Health Origins",
|
||||
"backendWidgetProbeCount": "Probed healthy origins",
|
||||
"backendWidgetH2c": "H2C origins",
|
||||
"backendWidgetModeUnknown": "Unknown",
|
||||
"vhProtocolCompatibilityWarning": "Compatibility mode: saved HTTP/2 HTTPS origins retain the previous H2/H1 TLS fallback. Choose H2 only if HTTP/1.1 must be rejected.",
|
||||
@@ -2776,7 +2746,7 @@
|
||||
"overviewLayoutTypeFilter": "Filter blocks by type",
|
||||
"overviewLayoutUnlock": "Unlock layout",
|
||||
"overviewLayoutLock": "Lock and save",
|
||||
"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.",
|
||||
"overviewLayoutInlineHint": "Layout unlocked: reorder blocks within groups of similar size, hide with ×, then save or cancel. The grid aligns card heights automatically.",
|
||||
"overviewLayoutInlineDrag": "Drag to move",
|
||||
"overviewLayoutInlineSave": "Save and lock",
|
||||
"overviewLayoutInlineCancel": "Cancel changes",
|
||||
@@ -2799,22 +2769,6 @@
|
||||
"overviewExtraCacheMissRateDetail": "Rate of cache misses.",
|
||||
"overviewExtraBackendActive": "In-flight backend requests",
|
||||
"overviewExtraBackendActiveDetail": "Current HTTP upstream requests across backends.",
|
||||
"overviewExtraBackendsHealthy": "Healthy backends",
|
||||
"overviewExtraBackendsHealthyDescription": "Monitored backend members that currently pass health checks, with the names of failing members.",
|
||||
"overviewExtraBackendsNone": "No monitored backends",
|
||||
"overviewExtraBackendsDown": "Down",
|
||||
"overviewExtraBackendsAllUp": "All monitored backends are up",
|
||||
"overviewExtraErrorShare": "Error share",
|
||||
"overviewExtraErrorShareDescription": "Share of HTTP requests that ended with an error in the last sampling interval.",
|
||||
"overviewExtraErrorShareDetail": "Errors / requests",
|
||||
"overviewExtraRejectedRate": "Rejected by limits / s",
|
||||
"overviewExtraRejectedRateDescription": "Requests and connections rejected by concurrency limits. Sustained values suggest raising max-connections or max-inflight.",
|
||||
"overviewExtraRejectedDetail": "Total requests / connections",
|
||||
"overviewExtraCertExpiry": "Nearest certificate expiry",
|
||||
"overviewExtraCertExpiryDescription": "Days until the earliest installed certificate expires; the bar is full at 90 days or more.",
|
||||
"overviewExtraCertNone": "No certificates installed",
|
||||
"overviewExtraCertCount": "certificates",
|
||||
"overviewExtraDaysUnit": "days",
|
||||
"overviewWideProtocolRates": "HTTP/1.1 · HTTP/2 · HTTP/3 traffic",
|
||||
"overviewWideProtocolRatesDetail": "Requests per second by frontend protocol.",
|
||||
"overviewWideCpu": "System and process CPU",
|
||||
@@ -2867,35 +2821,11 @@
|
||||
"overviewTransformErrors": "Errors",
|
||||
"overviewMeterNoLimit": "No configured limit",
|
||||
"overviewCacheHitsMisses": "HIT {hits}, MISS {misses}",
|
||||
"overviewSamplesRemember": "Remember choice",
|
||||
"overviewSamplesSaved": "Saved default",
|
||||
"overviewSamplesTemporary": "For this visit only",
|
||||
"overviewSamplesStorageUnavailable": "Could not save in this browser",
|
||||
"overviewSamplesTitle": "Overview chart samples",
|
||||
"appHealthTitle": "Application status",
|
||||
"appHealthSelfCheck": "SELF-CHECK",
|
||||
"appHealthChecking": "Checking application status",
|
||||
"appHealthOk": "All systems operational",
|
||||
"appHealthWarning": "Attention required",
|
||||
"appHealthError": "Problem detected",
|
||||
"appHealthSidebarChecking": "CHECKING",
|
||||
"appHealthSidebarOk": "APPLICATION OK",
|
||||
"appHealthSidebarWarning": "APPLICATION WARNING",
|
||||
"appHealthSidebarError": "APPLICATION ERROR",
|
||||
"appHealthUpdated": "Updated {time}",
|
||||
"appHealthNoStatus": "No current status",
|
||||
"appHealthApi": "API",
|
||||
"appHealthApiOk": "Live application status is responding.",
|
||||
"appHealthApiError": "No current status from the application.",
|
||||
"appHealthListeners": "Listeners",
|
||||
"appHealthListenersOk": "Active listeners match the desired configuration.",
|
||||
"appHealthListenersRestart": "Listener changes are pending; restart RustProxy to apply them.",
|
||||
"appHealthBackends": "Backends",
|
||||
"appHealthBackendsNone": "No backend pools are configured.",
|
||||
"appHealthBackendsOk": "All active backend pools are ready.",
|
||||
"appHealthBackendsWarning": "{down} down, {checking} checking: {members}.",
|
||||
"appHealthBackendsError": "Unavailable pools: {pools}.",
|
||||
"appHealthBackendsAffected": "Affected backends: {members}.",
|
||||
"appHealthLogging": "Logging",
|
||||
"appHealthLoggingOk": "Logging pipeline reports no queue drops or write/send errors.",
|
||||
"appHealthLoggingWarning": "{dropped} queued entries dropped, {errors} write/send errors since start.",
|
||||
"webMetricStatus": "WWW HTTP status",
|
||||
"webMetricStatusHelp": "Completed Web Server status classes, all-time since reload.",
|
||||
"webMetricKind": "Static vs PHP",
|
||||
@@ -3015,13 +2945,11 @@
|
||||
"certificateInspectKept": "Certificate read; your SNI names were preserved.",
|
||||
"certificateInspectNoNames": "Certificate contains no DNS SAN names.",
|
||||
"certificateInspectFailed": "Cannot read certificate. Check the file and its permissions, then read again.",
|
||||
"pathPermissionsOptions": "Options and help",
|
||||
"overviewLayoutNarrow": "Decrease card width",
|
||||
"overviewLayoutWiden": "Increase card width",
|
||||
"overviewLayoutWidth": "Card width in 12-column grid",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
"pathPermissionsOptions": "Options and help"
|
||||
},
|
||||
"overviewLayoutNarrow": "Decrease card width",
|
||||
"overviewLayoutWiden": "Increase card width",
|
||||
"overviewLayoutWidth": "Card width in 12-column grid",
|
||||
"overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert.",
|
||||
"dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again."
|
||||
}
|
||||
+21
-93
@@ -253,8 +253,7 @@
|
||||
"certificateSafety": "Certyfikaty samopodpisane służą do testów. Certyfikat nie tworzy trasy. Zmiana dotyczy nowych połączeń TLS.",
|
||||
"wsActive": "Tunele WebSocket",
|
||||
"h2Requests": "Żądania HTTP/2",
|
||||
"h2ActiveConnections": "Połączenia H2",
|
||||
"h3ActiveConnections": "Połączenia H3",
|
||||
"h2ActiveConnections": "Aktywne połączenia H2",
|
||||
"tlsFailures": "Błędy handshake TLS",
|
||||
"tlsListener": "Listener TLS",
|
||||
"notEnabled": "Wyłączony",
|
||||
@@ -458,9 +457,6 @@
|
||||
"vhHttp": "Status HTTP + czas odpowiedzi",
|
||||
"vhHealthPath": "Ścieżka testu HTTP",
|
||||
"vhHealthHost": "Własny Host testu HTTP (opcjonalny)",
|
||||
"vhHealthPort": "Port testu zdrowia (opcjonalny)",
|
||||
"vhHealthPortPlaceholder": "Port backendu",
|
||||
"vhHealthExpectBody": "Oczekiwany tekst w odpowiedzi HTTP (opcjonalny)",
|
||||
"vhInterval": "Interwał (ms)",
|
||||
"vhTimeout": "Limit czasu (ms)",
|
||||
"vhSlow": "Próg wolnej odpowiedzi (ms; 0 = wył.)",
|
||||
@@ -468,7 +464,7 @@
|
||||
"vhRise": "Sukcesy przed przywróceniem",
|
||||
"vhStatusMin": "Minimalny status HTTP",
|
||||
"vhStatusMax": "Maksymalny status HTTP",
|
||||
"vhHealthNote": "Sondy HTTP używają HTTP/1.1 i mierzą czas do nagłówków, nie czas całego body. Gdy pole Host jest puste, RustProxy automatycznie używa pierwszej dokładnej domeny DNS vhosta; jeśli jej nie ma, używa hosta backendu. Dla usług tylko h2c użyj TCP. Nowe backendy czekają na udaną sondę. Active health jest niezależny od passive health. Port testu zdrowia pozwala sprawdzać inny port niż ruch (np. port statusu 8079 dla backendu na porcie 80); host, TLS i ograniczenia adresów pozostają te same. Oczekiwany tekst oznacza backend jako niedostępny, jeśli pierwsze 64 KiB odpowiedzi testu HTTP go nie zawiera.",
|
||||
"vhHealthNote": "Sondy HTTP używają HTTP/1.1 i mierzą czas do nagłówków, nie czas całego body. Gdy pole Host jest puste, RustProxy automatycznie używa pierwszej dokładnej domeny DNS vhosta; jeśli jej nie ma, używa hosta backendu. Dla usług tylko h2c użyj TCP. Nowe backendy czekają na udaną sondę. Active health jest niezależny od passive health.",
|
||||
"vhPassiveHealth": "Pasywne zdrowie backendu",
|
||||
"vhPassiveEnabled": "Pozwól, aby błędy zwykłych requestów oznaczały backend jako niedostępny",
|
||||
"vhPassiveHelp": "Domyślnie wyłączone. Błędy nadal są logowane i zliczane, ale zwykły ruch użytkowników nie zmienia stanu backendu bez świadomego włączenia.",
|
||||
@@ -638,13 +634,13 @@
|
||||
"certCheckDomains": "Domeny do sprawdzenia (po przecinku)",
|
||||
"certAcmeLater": "Wydane certyfikaty trafiaja do biblioteki i sa przypisywane do vhostow.",
|
||||
"certAdvanced": "Istniejące pliki / zaawansowane",
|
||||
"certLegacyNames": "Nazwy SNI (opcjonalne; używane, gdy vhost ma Globalne SNI / certyfikat domyślny)",
|
||||
"certLegacyNames": "Stare nazwy SNI (opcjonalne; zalecane przypisanie w vhoście)",
|
||||
"certAssigned": "Przypisane vhosty",
|
||||
"certUnassigned": "Nieprzypisany",
|
||||
"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.",
|
||||
"certManage": "Zarządzaj biblioteką certyfikatów",
|
||||
"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.",
|
||||
"certSelectHint": "Wybierz zaimportowany certyfikat. Import i rotacja są w zakładce Certyfikaty. Przypisania są sprawdzane względem nazw vhosta.",
|
||||
"vhTinyauth": "Uwierzytelnianie",
|
||||
"vhTabAuth": "Uwierzytelnianie",
|
||||
"taUrl": "URL Tinyauth / punkt weryfikacji",
|
||||
@@ -1678,7 +1674,6 @@
|
||||
"dockerSearchPlaceholder": "Szukaj usługi, hosta, aliasu lub agenta...",
|
||||
"dockerShowHidden": "Pokaż ukryte",
|
||||
"dockerHideHidden": "Ukryj ukryte",
|
||||
"dockerHideDisabled": "Ukryj wyłączone",
|
||||
"dockerHideVhost": "Ukryj z widoku",
|
||||
"dockerUnhideVhost": "Pokaż w widoku",
|
||||
"dockerCertificate": "Certyfikat frontendu",
|
||||
@@ -1727,16 +1722,7 @@
|
||||
"dockerAliasesShort": "aliasów",
|
||||
"dockerAgentDetails": "Szczegóły agenta",
|
||||
"dockerServices": "Usługi",
|
||||
"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.",
|
||||
"dockerOpenApp": "Otwórz aplikację",
|
||||
"dockerLiveAccessLogsHelp": "Logi żądań HTTP dla tego Docker vhosta, nie stdout/stderr kontenera.",
|
||||
"networkInterfacesHistory": "Ruch według interfejsów",
|
||||
"networkInterfaceRate": "RX / TX",
|
||||
@@ -1888,9 +1874,9 @@
|
||||
"tunnelKillReasonDefault": "panel administracyjny",
|
||||
"listenerH2c": "h2c HTTP/2 bez TLS (prior knowledge + Upgrade z HTTP/1.1)",
|
||||
"vhWebsocketRfc8441": "RFC 8441 Extended CONNECT przez HTTP/2",
|
||||
"wsH1": "H1",
|
||||
"wsH2Rfc8441": "H2",
|
||||
"wsH3Rfc9220": "H3",
|
||||
"wsH1": "H1 Upgrade",
|
||||
"wsH2Rfc8441": "H2 RFC 8441",
|
||||
"wsH3Rfc9220": "H3 RFC 9220",
|
||||
"wsEstablished": "Utworzone",
|
||||
"httpProtocolMix": "Miks protokołów HTTP",
|
||||
"wsProtocolMix": "Miks protokołów WebSocket",
|
||||
@@ -2371,14 +2357,6 @@
|
||||
"chartStyleMonochrome": "Monochromatyczny",
|
||||
"chartStyleWarm": "Ciepły",
|
||||
"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",
|
||||
"settingsSearchPlaceholder": "Szukaj ustawień, sekcji i opcji...",
|
||||
"settingsSearchResults": "Pasujące sekcje",
|
||||
@@ -2387,10 +2365,6 @@
|
||||
"appearanceInterfaceSection": "INTERFEJS",
|
||||
"appearanceInterfaceTitle": "Interfejs",
|
||||
"appearanceChartsSection": "WYKRESY",
|
||||
"cacheSectionBehavior": "Działanie",
|
||||
"cacheSectionMemory": "Pamięć i limity",
|
||||
"appearanceChartsTitle": "Wykresy",
|
||||
"appearanceTablesTitle": "Tabele i logi",
|
||||
"appearanceDataSection": "TABELE / LOGI",
|
||||
"appearanceDashboardSection": "DASHBOARD",
|
||||
"resetSection": "Resetuj sekcję",
|
||||
@@ -2425,10 +2399,6 @@
|
||||
"buttonStyleNormal": "Normalny",
|
||||
"buttonStyleCompact": "Kompaktowy",
|
||||
"buttonStyleHelp": "Normalny używa odświeżonych przycisków; Kompaktowy przywraca mniejsze przyciski ze źródłowego interfejsu we wszystkich akcjach i zakładkach modali.",
|
||||
"interfaceIcons": "Pokaż ikony SVG interfejsu",
|
||||
"interfaceIconsHelp": "Dodaje semantyczne ikony SVG do nawigacji, akcji, zakładek i okien dialogowych.",
|
||||
"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.",
|
||||
"setupCommandExample": "Przykład dla standardowej instalacji usługi:",
|
||||
"certificateRuntimeTitle": "Środowisko ACME",
|
||||
@@ -2668,7 +2638,7 @@
|
||||
"backendWidgetInFlight": "W toku",
|
||||
"backendWidgetErrorsRate": "Błędy/s",
|
||||
"backendWidgetErrorPercent": "Błędne żądania w ostatnim okresie",
|
||||
"backendWidgetProbeCount": "Zdrowe backendy",
|
||||
"backendWidgetProbeCount": "Zdrowe backendy z sondami",
|
||||
"backendWidgetH2c": "Backendy h2c",
|
||||
"backendWidgetModeUnknown": "Nieznane",
|
||||
"vhProtocolCompatibilityWarning": "Tryb zgodności: zapisane wcześniej backendy HTTPS HTTP/2 zachowują fallback TLS H2/H1. Wybierz „Tylko H2”, jeżeli HTTP/1.1 ma być odrzucony.",
|
||||
@@ -2776,7 +2746,7 @@
|
||||
"overviewLayoutTypeFilter": "Filtruj bloki według typu",
|
||||
"overviewLayoutUnlock": "Odblokuj układ",
|
||||
"overviewLayoutLock": "Zablokuj i zapisz",
|
||||
"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.",
|
||||
"overviewLayoutInlineHint": "Układ odblokowany: przesuwaj bloki w obrębie grup podobnych rozmiarów, ukrywaj przyciskiem ×, a potem zapisz lub anuluj. Siatka automatycznie wyrównuje wysokości.",
|
||||
"overviewLayoutInlineDrag": "Przeciągnij, aby przesunąć",
|
||||
"overviewLayoutInlineSave": "Zapisz i zablokuj",
|
||||
"overviewLayoutInlineCancel": "Anuluj zmiany",
|
||||
@@ -2799,22 +2769,6 @@
|
||||
"overviewExtraCacheMissRateDetail": "Tempo nietrafień w cache.",
|
||||
"overviewExtraBackendActive": "Żądania do backendów",
|
||||
"overviewExtraBackendActiveDetail": "Suma aktualnych żądań do backendów HTTP.",
|
||||
"overviewExtraBackendsHealthy": "Sprawne backendy",
|
||||
"overviewExtraBackendsHealthyDescription": "Monitorowane backendy, które obecnie przechodzą testy zdrowia, wraz z nazwami niedziałających.",
|
||||
"overviewExtraBackendsNone": "Brak monitorowanych backendów",
|
||||
"overviewExtraBackendsDown": "Niedostępne",
|
||||
"overviewExtraBackendsAllUp": "Wszystkie monitorowane backendy działają",
|
||||
"overviewExtraErrorShare": "Udział błędów",
|
||||
"overviewExtraErrorShareDescription": "Odsetek żądań HTTP zakończonych błędem w ostatnim interwale próbkowania.",
|
||||
"overviewExtraErrorShareDetail": "Błędy / żądania",
|
||||
"overviewExtraRejectedRate": "Odrzucone przez limity / s",
|
||||
"overviewExtraRejectedRateDescription": "Żądania i połączenia odrzucone przez limity współbieżności. Stałe wartości oznaczają, że warto podnieść max-connections lub max-inflight.",
|
||||
"overviewExtraRejectedDetail": "Łącznie żądania / połączenia",
|
||||
"overviewExtraCertExpiry": "Najbliższe wygaśnięcie certyfikatu",
|
||||
"overviewExtraCertExpiryDescription": "Dni do wygaśnięcia najwcześniej kończącego się certyfikatu; pasek jest pełny przy 90 dniach lub więcej.",
|
||||
"overviewExtraCertNone": "Brak zainstalowanych certyfikatów",
|
||||
"overviewExtraCertCount": "certyfikatów",
|
||||
"overviewExtraDaysUnit": "dni",
|
||||
"overviewWideProtocolRates": "Ruch HTTP/1.1 · HTTP/2 · HTTP/3",
|
||||
"overviewWideProtocolRatesDetail": "Tempo żądań według protokołu frontendu.",
|
||||
"overviewWideCpu": "CPU systemu i procesu",
|
||||
@@ -2867,35 +2821,11 @@
|
||||
"overviewTransformErrors": "Błędy",
|
||||
"overviewMeterNoLimit": "Brak skonfigurowanego limitu",
|
||||
"overviewCacheHitsMisses": "HIT {hits}, MISS {misses}",
|
||||
"overviewSamplesRemember": "Zapamiętaj wybór",
|
||||
"overviewSamplesSaved": "Ustawienie zapisane",
|
||||
"overviewSamplesTemporary": "Tylko do odświeżenia strony",
|
||||
"overviewSamplesStorageUnavailable": "Nie można zapisać w przeglądarce",
|
||||
"overviewSamplesTitle": "Próbki wykresów na stronie głównej",
|
||||
"appHealthTitle": "Status aplikacji",
|
||||
"appHealthSelfCheck": "SELF-CHECK",
|
||||
"appHealthChecking": "Sprawdzanie statusu aplikacji",
|
||||
"appHealthOk": "Wszystkie systemy działają",
|
||||
"appHealthWarning": "Wymaga uwagi",
|
||||
"appHealthError": "Wykryto problem",
|
||||
"appHealthSidebarChecking": "SPRAWDZANIE",
|
||||
"appHealthSidebarOk": "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",
|
||||
"webMetricStatusHelp": "Klasy odpowiedzi serwera WWW od ostatniego resetu.",
|
||||
"webMetricKind": "Static / PHP",
|
||||
@@ -3015,13 +2945,11 @@
|
||||
"certificateInspectKept": "Odczytano certyfikat; zachowano wpisane nazwy SNI.",
|
||||
"certificateInspectNoNames": "Certyfikat nie zawiera nazw DNS w SAN.",
|
||||
"certificateInspectFailed": "Nie można odczytać certyfikatu. Sprawdź plik i jego uprawnienia, a następnie ponów odczyt.",
|
||||
"pathPermissionsOptions": "Opcje i pomoc",
|
||||
"overviewLayoutNarrow": "Zmniejsz szerokość karty",
|
||||
"overviewLayoutWiden": "Zwiększ szerokość karty",
|
||||
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn",
|
||||
"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ć)"
|
||||
}
|
||||
}
|
||||
"pathPermissionsOptions": "Opcje i pomoc"
|
||||
},
|
||||
"overviewLayoutNarrow": "Zmniejsz szerokość karty",
|
||||
"overviewLayoutWiden": "Zwiększ szerokość karty",
|
||||
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn",
|
||||
"overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany.",
|
||||
"dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie."
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ from scripts.version import project_version
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
MANAGED = [
|
||||
'src', 'tests', 'scripts', 'tools', 'integrations', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'grafana', 'third_party',
|
||||
'src', 'tests', 'scripts', 'tools', 'integrations', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'grafana',
|
||||
'.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',
|
||||
'CHANGELOG.md', 'AGENTS.md', '.env.example', '.gitignore', '.dockerignore',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Named, administrator-scoped Overview presets. Keep this separate from the
|
||||
-- appearance preferences document and proxy configuration.
|
||||
-- existing 4096-byte appearance preferences document and proxy configuration.
|
||||
CREATE TABLE IF NOT EXISTS dashboard_presets (
|
||||
administrator_id INTEGER NOT NULL CHECK (administrator_id = 1),
|
||||
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64),
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 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;
|
||||
Regular → Executable
+1
-108
@@ -14,7 +14,6 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -26,7 +25,6 @@ import zlib
|
||||
VERSION = "5.33.1"
|
||||
DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip"
|
||||
PREFIX = f"swagger-ui-{VERSION}/"
|
||||
VENDORED = Path(__file__).resolve().parents[2] / "third_party" / f"swagger-ui-{VERSION}"
|
||||
MAX_ARCHIVE = 64 * 1024 * 1024
|
||||
ASSETS = {
|
||||
"swagger-ui-bundle.js": ("dist/swagger-ui-bundle.js", 16 * 1024 * 1024),
|
||||
@@ -84,29 +82,11 @@ class HttpsRedirects(HTTPRedirectHandler):
|
||||
|
||||
|
||||
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."""
|
||||
started = time.monotonic()
|
||||
opener = build_opener(HttpsRedirects())
|
||||
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build"})
|
||||
with opener.open(request, timeout=60) as response, destination.open("wb") as output:
|
||||
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
|
||||
length = response.headers.get("Content-Length")
|
||||
if length and int(length) > MAX_ARCHIVE:
|
||||
raise AssetError("Swagger archive exceeds the 64 MiB limit.")
|
||||
@@ -182,75 +162,6 @@ def atomic_write(path: Path, data: bytes) -> None:
|
||||
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,
|
||||
*, offline: bool = False) -> list[Path]:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -258,27 +169,10 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
||||
if isinstance(source, Path):
|
||||
archive = source
|
||||
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:
|
||||
archive = cache
|
||||
reused = None
|
||||
if cache.exists():
|
||||
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:
|
||||
if offline:
|
||||
raise AssetError("Swagger assets are not cached and CARGO_NET_OFFLINE is set. " + HELP)
|
||||
@@ -291,7 +185,6 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
||||
os.replace(temporary, cache) # Cache only an already-validated archive.
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
remember_archive(out_dir, cache)
|
||||
else:
|
||||
raise AssetError("Unrecognized Swagger source.")
|
||||
destination = out_dir / "swagger-ui"
|
||||
|
||||
Regular → Executable
+1
-27
@@ -28,7 +28,6 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
|
||||
@@ -42,12 +41,6 @@ GEOIP_SOURCES = {
|
||||
'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_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:
|
||||
@@ -100,7 +93,7 @@ def digest(path: Path) -> str:
|
||||
return result.hexdigest()
|
||||
|
||||
|
||||
def _fetch_https_once(url: str, max_bytes: int) -> bytes:
|
||||
def fetch_https(url: str, max_bytes: int) -> bytes:
|
||||
if urllib.parse.urlparse(url).scheme != 'https':
|
||||
raise ValueError('GeoIP downloads require HTTPS.')
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer'})
|
||||
@@ -121,25 +114,6 @@ def _fetch_https_once(url: str, max_bytes: int) -> bytes:
|
||||
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:
|
||||
"""Serialize installer/manual GeoIP refresh with the in-process worker."""
|
||||
path = data / '.geoip-update.lock'
|
||||
|
||||
Regular → Executable
+2
-10
@@ -365,12 +365,7 @@ def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: boo
|
||||
if refresh_geoip:
|
||||
# Shared lock with the runtime worker prevents concurrent upgrades.
|
||||
geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid)
|
||||
try:
|
||||
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, []
|
||||
geoip_stage, geoip_names = prepare_geoip_update(data, prefix)
|
||||
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
|
||||
stop_for_managed_update(data)
|
||||
try:
|
||||
@@ -439,10 +434,7 @@ 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)
|
||||
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:
|
||||
try:
|
||||
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)
|
||||
download_geoip(data, account.pw_uid, account.pw_gid)
|
||||
release, version = stage(binary, prefix, record['user'], sha)
|
||||
record.update(release=release, version=version, sha256=sha)
|
||||
switch(prefix, release)
|
||||
|
||||
@@ -132,7 +132,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
flat = module.parent.with_suffix(".rs")
|
||||
if flat.is_file():
|
||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/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"]:
|
||||
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"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}")
|
||||
for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"):
|
||||
|
||||
+23
-62
@@ -171,7 +171,6 @@ async fn metrics_summary(State(state): State<Arc<App>>) -> Response {
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct EventsQuery {
|
||||
interval: Option<u64>,
|
||||
logs: Option<bool>,
|
||||
}
|
||||
|
||||
async fn events(
|
||||
@@ -180,7 +179,6 @@ async fn events(
|
||||
Query(query): Query<EventsQuery>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let interval = query.interval.unwrap_or(3);
|
||||
let include_logs = query.logs.unwrap_or(true);
|
||||
if ![1, 2, 3, 5, 10].contains(&interval) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
@@ -200,8 +198,8 @@ async fn events(
|
||||
)
|
||||
})?;
|
||||
let stream = futures_util::stream::unfold(
|
||||
(state, secret, permit, false, interval, include_logs),
|
||||
|(state, secret, permit, delayed, interval, include_logs)| async move {
|
||||
(state, secret, permit, false, interval),
|
||||
|(state, secret, permit, delayed, interval)| async move {
|
||||
if delayed {
|
||||
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||
}
|
||||
@@ -220,16 +218,13 @@ async fn events(
|
||||
Ok(Some(_)) => {}
|
||||
_ => return None,
|
||||
}
|
||||
let payload = if include_logs {
|
||||
let payload =
|
||||
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);
|
||||
Some((
|
||||
Ok::<Event, Infallible>(event),
|
||||
(state, secret, permit, true, interval, include_logs),
|
||||
(state, secret, permit, true, interval),
|
||||
))
|
||||
},
|
||||
);
|
||||
@@ -242,22 +237,11 @@ async fn events(
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct TrafficQuery {
|
||||
vhost: Option<String>,
|
||||
view: Option<String>,
|
||||
}
|
||||
async fn traffic_status(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(query): Query<TrafficQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let live = match query.view.as_deref() {
|
||||
None => false,
|
||||
Some("live") => true,
|
||||
_ => {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid traffic view".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let snapshot = state.config.load_full();
|
||||
if query.vhost.as_ref().is_some_and(|id| {
|
||||
id != "*"
|
||||
@@ -274,8 +258,14 @@ async fn traffic_status(
|
||||
));
|
||||
}
|
||||
let scope = query.vhost.as_deref().unwrap_or("*");
|
||||
let mut payload = json!({
|
||||
"revision": snapshot.envelope.revision,
|
||||
let vhosts = snapshot
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"scope": scope,
|
||||
"since": "process_start",
|
||||
"settings": {
|
||||
@@ -283,19 +273,9 @@ async fn traffic_status(
|
||||
"capture_country": snapshot.envelope.config.logging.capture_country
|
||||
},
|
||||
"geoip": state.geoip.load().view(),
|
||||
"analytics": state.traffic.view(Some(scope))
|
||||
});
|
||||
if live {
|
||||
return Ok(Json(payload));
|
||||
}
|
||||
payload["vhosts"] = json!(snapshot
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
|
||||
.collect::<Vec<_>>());
|
||||
Ok(Json(payload))
|
||||
"analytics": state.traffic.view(Some(scope)),
|
||||
"vhosts": vhosts
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
@@ -304,22 +284,11 @@ struct CacheQuery {
|
||||
vhost: Option<String>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
view: Option<String>,
|
||||
}
|
||||
async fn cache_status(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(query): Query<CacheQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let live = match query.view.as_deref() {
|
||||
None => false,
|
||||
Some("live") => true,
|
||||
_ => {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid cache view".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let snapshot = state.config.load_full();
|
||||
if query
|
||||
.vhost
|
||||
@@ -331,24 +300,16 @@ async fn cache_status(
|
||||
"unknown vhost cache scope".into(),
|
||||
));
|
||||
}
|
||||
let scopes: Vec<_> = snapshot.envelope.config.vhosts.iter().map(|vhost| {
|
||||
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
|
||||
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
|
||||
}).collect();
|
||||
let dashboard = state.cache.dashboard(
|
||||
query.vhost.as_deref(),
|
||||
query.offset.unwrap_or(0).min(16384),
|
||||
query.limit.unwrap_or(50),
|
||||
);
|
||||
let mut payload = json!({
|
||||
"revision": snapshot.envelope.revision,
|
||||
"metrics": state.cache.view(),
|
||||
"dashboard": dashboard,
|
||||
"bans": state.cache.bans()
|
||||
});
|
||||
if live {
|
||||
return Ok(Json(payload));
|
||||
}
|
||||
payload["settings"] = json!(snapshot.envelope.config.cache);
|
||||
payload["vhosts"] = json!(snapshot.envelope.config.vhosts.iter().map(|vhost| {
|
||||
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
|
||||
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
|
||||
}).collect::<Vec<_>>());
|
||||
Ok(Json(payload))
|
||||
Ok(Json(
|
||||
json!({"settings":snapshot.envelope.config.cache, "metrics":state.cache.view(), "vhosts":scopes, "dashboard":dashboard, "bans":state.cache.bans()}),
|
||||
))
|
||||
}
|
||||
+41
-286
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.36.7",
|
||||
"version": "0.35.1",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
@@ -443,31 +443,10 @@
|
||||
"type": "object",
|
||||
"description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"provider",
|
||||
"method"
|
||||
],
|
||||
"required": ["provider", "method"],
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"basic",
|
||||
"tinyauth",
|
||||
"authelia",
|
||||
"authentik",
|
||||
"pocket_id",
|
||||
"custom"
|
||||
]
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"basic",
|
||||
"forward_auth",
|
||||
"oidc"
|
||||
],
|
||||
"description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."
|
||||
}
|
||||
"provider": {"type": "string", "enum": ["basic", "tinyauth", "authelia", "authentik", "pocket_id", "custom"]},
|
||||
"method": {"type": "string", "enum": ["basic", "forward_auth", "oidc"], "description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."}
|
||||
}
|
||||
},
|
||||
"OidcConfig": {
|
||||
@@ -475,76 +454,20 @@
|
||||
"description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"issuer_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"
|
||||
},
|
||||
"backend_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"
|
||||
},
|
||||
"redirect_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"client_secret": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"description": "Confidential OIDC client secret; stored in Vhost configuration"
|
||||
},
|
||||
"session_secret": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 4096,
|
||||
"description": "High-entropy secret for encrypted session and temporary state cookies"
|
||||
},
|
||||
"scopes": {
|
||||
"type": "string",
|
||||
"default": "openid profile email"
|
||||
},
|
||||
"allow_private": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"address_allowlist": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tls_ca_pem": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"connect_timeout_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 30,
|
||||
"default": 5
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 60,
|
||||
"default": 10
|
||||
},
|
||||
"session_ttl_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 60,
|
||||
"maximum": 86400,
|
||||
"default": 3600
|
||||
}
|
||||
"enabled": {"type": "boolean", "default": false},
|
||||
"issuer_url": {"type": "string", "format": "uri", "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"},
|
||||
"backend_url": {"type": "string", "format": "uri", "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"},
|
||||
"redirect_url": {"type": "string", "format": "uri", "description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"},
|
||||
"client_id": {"type": "string", "maxLength": 256},
|
||||
"client_secret": {"type": "string", "maxLength": 2048, "description": "Confidential OIDC client secret; stored in Vhost configuration"},
|
||||
"session_secret": {"type": "string", "minLength": 32, "maxLength": 4096, "description": "High-entropy secret for encrypted session and temporary state cookies"},
|
||||
"scopes": {"type": "string", "default": "openid profile email"},
|
||||
"allow_private": {"type": "boolean", "default": false},
|
||||
"address_allowlist": {"type": "array", "items": {"type": "string"}},
|
||||
"tls_ca_pem": {"type": "string", "nullable": true},
|
||||
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
|
||||
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
|
||||
"session_ttl_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "default": 3600}
|
||||
}
|
||||
},
|
||||
"ForwardAuthConfig": {
|
||||
@@ -552,70 +475,17 @@
|
||||
"description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"description": "Internal service/outpost origin or the provider's verifier endpoint"
|
||||
},
|
||||
"login_url": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"description": "Required public HTTPS portal URL for Authelia; ignored for authentik"
|
||||
},
|
||||
"allow_private": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"address_allowlist": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tls_ca_pem": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"connect_timeout_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 30,
|
||||
"default": 5
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 60,
|
||||
"default": 10
|
||||
},
|
||||
"request_headers": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"
|
||||
},
|
||||
"response_headers": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"
|
||||
},
|
||||
"redirect_forbidden": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Authelia only: accept trusted portal Location on explicit 403 if provided"
|
||||
}
|
||||
"enabled": {"type": "boolean", "default": false},
|
||||
"url": {"type": "string", "maxLength": 2048, "description": "Internal service/outpost origin or the provider's verifier endpoint"},
|
||||
"login_url": {"type": "string", "maxLength": 2048, "description": "Required public HTTPS portal URL for Authelia; ignored for authentik"},
|
||||
"allow_private": {"type": "boolean", "default": false},
|
||||
"address_allowlist": {"type": "array", "maxItems": 32, "items": {"type": "string"}},
|
||||
"tls_ca_pem": {"type": "string", "nullable": true},
|
||||
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
|
||||
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
|
||||
"request_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"},
|
||||
"response_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"},
|
||||
"redirect_forbidden": {"type": "boolean", "default": false, "description": "Authelia only: accept trusted portal Location on explicit 403 if provided"}
|
||||
}
|
||||
},
|
||||
"Vhost": {
|
||||
@@ -721,13 +591,8 @@
|
||||
"$ref": "#/components/schemas/ForwardAuthConfig",
|
||||
"description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia."
|
||||
},
|
||||
"oidc": {
|
||||
"$ref": "#/components/schemas/OidcConfig"
|
||||
},
|
||||
"authentication": {
|
||||
"$ref": "#/components/schemas/AuthenticationChoice",
|
||||
"description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."
|
||||
},
|
||||
"oidc": {"$ref": "#/components/schemas/OidcConfig"},
|
||||
"authentication": {"$ref": "#/components/schemas/AuthenticationChoice", "description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."},
|
||||
"cache": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1753,11 +1618,6 @@
|
||||
"Cache": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Configuration revision used for this snapshot."
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -3574,19 +3434,6 @@
|
||||
"nullable": true,
|
||||
"description": "Optional HTTP Host override. When null for an HTTP vhost probe, RustProxy uses the first exact vhost DNS hostname; if none exists it falls back to the backend host."
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"nullable": true,
|
||||
"description": "Optional port for active probes. When null or equal to the backend port, probes use the backend address; otherwise TCP and HTTP probes connect to the same host on this port with the same TLS settings and address restrictions."
|
||||
},
|
||||
"expect_body": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 256,
|
||||
"description": "Optional text that must appear in the first 64 KiB of an HTTP probe response body. A response with an accepted status but without this text counts as a failed probe (result http_body)."
|
||||
},
|
||||
"interval_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 250,
|
||||
@@ -3812,15 +3659,7 @@
|
||||
"neon",
|
||||
"monochrome",
|
||||
"warm",
|
||||
"cool",
|
||||
"sunset",
|
||||
"aurora",
|
||||
"ocean",
|
||||
"forest",
|
||||
"pastel",
|
||||
"terminal",
|
||||
"blueprint",
|
||||
"colorblind"
|
||||
"cool"
|
||||
],
|
||||
"default": "default",
|
||||
"description": "Account-scoped visual presentation preset applied consistently to administration charts."
|
||||
@@ -3830,16 +3669,6 @@
|
||||
"default": false,
|
||||
"description": "Account-scoped optional X/Y scales on sampled time-series charts. Shows the same Y range used to draw each SVG and X sample indices rather than inferred elapsed time; does not change adaptive scaling."
|
||||
},
|
||||
"interface_icons": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Account-scoped administration UI preference. When enabled, semantic inline SVG icons are added to navigation, actions, tabs and dialogs while preserving text labels and accessibility."
|
||||
},
|
||||
"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": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -3937,10 +3766,6 @@
|
||||
"extra_h3_share",
|
||||
"extra_cache_miss_rate",
|
||||
"extra_backend_active",
|
||||
"extra_backends_healthy",
|
||||
"extra_error_share",
|
||||
"extra_rejected_rate",
|
||||
"extra_cert_expiry",
|
||||
"wide_protocol_rates",
|
||||
"wide_cpu",
|
||||
"wide_memory",
|
||||
@@ -4022,10 +3847,6 @@
|
||||
"extra_h3_share",
|
||||
"extra_cache_miss_rate",
|
||||
"extra_backend_active",
|
||||
"extra_backends_healthy",
|
||||
"extra_error_share",
|
||||
"extra_rejected_rate",
|
||||
"extra_cert_expiry",
|
||||
"wide_protocol_rates",
|
||||
"wide_cpu",
|
||||
"wide_memory",
|
||||
@@ -4114,10 +3935,6 @@
|
||||
"extra_h3_share",
|
||||
"extra_cache_miss_rate",
|
||||
"extra_backend_active",
|
||||
"extra_backends_healthy",
|
||||
"extra_error_share",
|
||||
"extra_rejected_rate",
|
||||
"extra_cert_expiry",
|
||||
"wide_protocol_rates",
|
||||
"wide_cpu",
|
||||
"wide_memory",
|
||||
@@ -4187,10 +4004,6 @@
|
||||
"extra_h3_share",
|
||||
"extra_cache_miss_rate",
|
||||
"extra_backend_active",
|
||||
"extra_backends_healthy",
|
||||
"extra_error_share",
|
||||
"extra_rejected_rate",
|
||||
"extra_cert_expiry",
|
||||
"wide_protocol_rates",
|
||||
"wide_cpu",
|
||||
"wide_memory",
|
||||
@@ -4948,11 +4761,6 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Configuration revision used for this snapshot."
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/TransformSettings"
|
||||
},
|
||||
@@ -6357,11 +6165,6 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Configuration revision used for this snapshot."
|
||||
},
|
||||
"scope": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -10040,14 +9843,14 @@
|
||||
},
|
||||
"/api/cache": {
|
||||
"get": {
|
||||
"summary": "Cache metrics, domains and paginated objects; optional lightweight live view",
|
||||
"summary": "Cache metrics, domains and paginated objects",
|
||||
"tags": [
|
||||
"Cache"
|
||||
],
|
||||
"operationId": "get_api_cache",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, live metrics, dashboard objects/domains and BAN rules.",
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -10128,18 +9931,6 @@
|
||||
"minimum": 1,
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "view",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"live"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -10154,14 +9945,14 @@
|
||||
},
|
||||
"/api/transform": {
|
||||
"get": {
|
||||
"summary": "Transformation runtime metrics and per-vhost effectiveness; optional lightweight live view",
|
||||
"summary": "Transformation runtime metrics and per-vhost effectiveness",
|
||||
"tags": [
|
||||
"Transform"
|
||||
],
|
||||
"operationId": "get_api_transform",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, resources and live metrics.",
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -10211,20 +10002,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "view",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"live"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"AdminSession": []
|
||||
@@ -10567,7 +10344,7 @@
|
||||
},
|
||||
"/api/events": {
|
||||
"get": {
|
||||
"summary": "Live SSE status snapshots with optional logs",
|
||||
"summary": "Live SSE status and log snapshots",
|
||||
"tags": [
|
||||
"Monitoring"
|
||||
],
|
||||
@@ -10624,7 +10401,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "interval",
|
||||
@@ -10642,16 +10419,6 @@
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13088,7 +12855,7 @@
|
||||
},
|
||||
"/api/traffic": {
|
||||
"get": {
|
||||
"summary": "Bounded per-vhost traffic analytics; optional lightweight live view",
|
||||
"summary": "Bounded per-vhost traffic analytics",
|
||||
"tags": [
|
||||
"Traffic"
|
||||
],
|
||||
@@ -13103,23 +12870,11 @@
|
||||
"type": "string",
|
||||
"default": "*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "view",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Set to live for polling. Omits the static vhost list; refetch the full view when revision changes.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"live"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Full response by default. view=live omits the static vhost list while retaining revision, current settings, GeoIP state and analytics.",
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -14609,4 +14364,4 @@
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
+5
-34
@@ -1,34 +1,6 @@
|
||||
#[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(),
|
||||
));
|
||||
}
|
||||
};
|
||||
async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = state.config.load_full();
|
||||
let global = &snapshot.envelope.config.transform;
|
||||
let resources = crate::transform::resource_usage(global);
|
||||
let metrics = state.transform_metrics.view();
|
||||
if live {
|
||||
return Ok(Json(json!({
|
||||
"revision": snapshot.envelope.revision,
|
||||
"resources": resources,
|
||||
"metrics": metrics
|
||||
})));
|
||||
}
|
||||
let vhosts: Vec<_> = snapshot
|
||||
.envelope
|
||||
.config
|
||||
@@ -42,13 +14,12 @@ async fn transform_status(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({
|
||||
"revision": snapshot.envelope.revision,
|
||||
Json(json!({
|
||||
"settings": global,
|
||||
"resources": resources,
|
||||
"metrics": metrics,
|
||||
"resources": crate::transform::resource_usage(global),
|
||||
"metrics": state.transform_metrics.view(),
|
||||
"vhosts": vhosts
|
||||
})))
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
|
||||
@@ -118,10 +118,6 @@ struct AdminPreferences {
|
||||
chart_style: String,
|
||||
#[serde(default)]
|
||||
chart_axes: bool,
|
||||
#[serde(default)]
|
||||
interface_icons: bool,
|
||||
#[serde(default)]
|
||||
logo_animation: bool,
|
||||
#[serde(default = "default_admin_button_style")]
|
||||
button_style: String,
|
||||
#[serde(default = "default_admin_corner_style")]
|
||||
@@ -184,8 +180,6 @@ impl Default for AdminPreferences {
|
||||
time_format: default_admin_time_format(),
|
||||
chart_style: default_admin_chart_style(),
|
||||
chart_axes: false,
|
||||
interface_icons: false,
|
||||
logo_animation: false,
|
||||
button_style: default_admin_button_style(),
|
||||
corner_style: default_admin_corner_style(),
|
||||
table_density: default_admin_table_density(),
|
||||
@@ -303,14 +297,6 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
|
||||
| "monochrome"
|
||||
| "warm"
|
||||
| "cool"
|
||||
| "sunset"
|
||||
| "aurora"
|
||||
| "ocean"
|
||||
| "forest"
|
||||
| "pastel"
|
||||
| "terminal"
|
||||
| "blueprint"
|
||||
| "colorblind"
|
||||
) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
||||
@@ -41,34 +41,6 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
|
||||
assert!(hidden.iter().all(|id| order.contains(id)));
|
||||
assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok());
|
||||
assert!(!AdminPreferences::default().chart_axes);
|
||||
assert!(!AdminPreferences::default().interface_icons);
|
||||
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]
|
||||
@@ -306,22 +278,3 @@ fn dashboard_width_preferences_preserve_legacy_and_allow_custom_grids() {
|
||||
.contains_key("not_a_card"));
|
||||
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,8 +57,6 @@ fn admin_app_javascript() -> &'static str {
|
||||
");",
|
||||
include_str!("web/app/overview-samples.js"),
|
||||
include_str!("web/app/core.js"),
|
||||
include_str!("web/app/icons.js"),
|
||||
include_str!("web/app/logo-animation.js"),
|
||||
include_str!("web/app/path-permissions.js"),
|
||||
include_str!("web/app/session-resume.js"),
|
||||
include_str!("web/app/refresh.js"),
|
||||
|
||||
@@ -2146,8 +2146,6 @@ mod tests {
|
||||
assert_eq!(initial["time_format"], "24h");
|
||||
assert_eq!(initial["chart_style"], "default");
|
||||
assert_eq!(initial["chart_axes"], false);
|
||||
assert_eq!(initial["interface_icons"], false);
|
||||
assert_eq!(initial["logo_animation"], false);
|
||||
assert_eq!(initial["button_style"], "normal");
|
||||
assert_eq!(initial["corner_style"], "default");
|
||||
assert_eq!(
|
||||
@@ -2185,8 +2183,6 @@ mod tests {
|
||||
assert_eq!(legacy["time_format"], "24h");
|
||||
assert_eq!(legacy["chart_style"], "default");
|
||||
assert_eq!(legacy["chart_axes"], false);
|
||||
assert_eq!(legacy["interface_icons"], false);
|
||||
assert_eq!(legacy["logo_animation"], false);
|
||||
assert_eq!(legacy["button_style"], "normal");
|
||||
assert_eq!(legacy["corner_style"], "default");
|
||||
assert_eq!(
|
||||
@@ -2209,8 +2205,6 @@ mod tests {
|
||||
"time_format":"12h",
|
||||
"chart_style":"neon",
|
||||
"chart_axes":true,
|
||||
"interface_icons":true,
|
||||
"logo_animation":true,
|
||||
"button_style":"compact",
|
||||
"corner_style":"rounded",
|
||||
"table_density":"ultra",
|
||||
@@ -3521,7 +3515,6 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
||||
let bad_sse = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/events?interval=4")
|
||||
.header("cookie", &cookie)
|
||||
@@ -3531,104 +3524,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let bad_sse_logs = router
|
||||
.oneshot(
|
||||
request("/api/events?logs=maybe")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bad_sse_logs.status(), StatusCode::BAD_REQUEST);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ window.rustProxyBackendDashboard = (() => {
|
||||
for (const id of ids) {
|
||||
if (!visible(id)) continue;
|
||||
set(`backend-dashboard-${id.slice("backend_".length)}`, "-");
|
||||
set(`backend-dashboard-${id.slice("backend_".length)}-detail`, id === "backend_latency" ? `${t("backendWidgetProbeCount")} -` : t("backendWidgetUnavailable"));
|
||||
set(`backend-dashboard-${id.slice("backend_".length)}-detail`, t("backendWidgetUnavailable"));
|
||||
}
|
||||
for (const id of ["backend-dashboard-in-rate", "backend-dashboard-out-rate"]) set(id, "-");
|
||||
for (const id of ["backend-dashboard-active-line", "backend-dashboard-error-line", "backend-dashboard-latency-line", "backend-dashboard-in-line", "backend-dashboard-out-line", "backend-dashboard-request-line", "backend-dashboard-capacity-line", "backend-dashboard-5xx-line", "backend-dashboard-timeout-line", "backend-dashboard-network-line", "backend-dashboard-retry-line", "backend-dashboard-queue-line"])
|
||||
@@ -191,7 +191,7 @@ window.rustProxyBackendDashboard = (() => {
|
||||
}
|
||||
if (visible("backend_latency")) {
|
||||
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));
|
||||
}
|
||||
if (visible("backend_traffic")) {
|
||||
|
||||
@@ -115,7 +115,7 @@ window.rustProxyChartScale = (() => {
|
||||
{ top = 0.08, bottom = 0.92, clipped = false, seriesScales = null } = {}) {
|
||||
const node = typeof target === "string" ? document.getElementById(target) : target;
|
||||
const svg = node?.tagName?.toLowerCase() === "svg" ? node : node?.ownerSVGElement;
|
||||
if (!svg || svg.classList.contains("ui-icon")) return;
|
||||
if (!svg) return;
|
||||
svg.rustProxyAxisData = {
|
||||
peak: Number.isFinite(peakValue) && peakValue > 0 ? peakValue : 1,
|
||||
unit, count: Number.isInteger(count) && count > 0 ? count : 0,
|
||||
@@ -127,7 +127,7 @@ window.rustProxyChartScale = (() => {
|
||||
}
|
||||
function refreshAxes() {
|
||||
const show = document.documentElement.dataset.chartAxes === "true";
|
||||
for (const svg of document.querySelectorAll("svg:not(.ui-icon)")) {
|
||||
for (const svg of document.querySelectorAll("svg")) {
|
||||
if (show && svg.rustProxyAxisData) renderAxes(svg);
|
||||
else if (!show && svg.parentElement?.classList.contains("rp-chart-axis-host")) {
|
||||
const host = svg.parentElement;
|
||||
|
||||
+11
-206
@@ -44,7 +44,7 @@ async function loadLanguages() {
|
||||
$("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
||||
$("language").value = languagePreference === "auto" ? "auto" : language;
|
||||
}
|
||||
let latestStatus = null, certificateInventory = new Map(), appHealthUpdatedAt = null;
|
||||
let latestStatus = null, certificateInventory = new Map();
|
||||
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
||||
function translate() {
|
||||
clearTimeout(toastTimer); hideToast();
|
||||
@@ -59,7 +59,6 @@ function translate() {
|
||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
||||
syncSettingsDirtyIndicators();
|
||||
window.rustProxyIcons?.refresh?.();
|
||||
}
|
||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
function raiseToast() {
|
||||
@@ -166,7 +165,7 @@ function setPanel(name, navigate = true) {
|
||||
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
|
||||
if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); });
|
||||
}
|
||||
function bytes(value) { if (value < 1024) return `${Math.round(value)} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
|
||||
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
|
||||
function uptime(value) {
|
||||
let seconds = Math.max(0, Math.floor(Number(value) || 0));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
@@ -180,68 +179,6 @@ function uptime(value) {
|
||||
}
|
||||
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
|
||||
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
|
||||
function appHealthChecks(status) {
|
||||
if (!connected || !status) return [{ level: "error", label: t("appHealthApi"), detail: t("appHealthApiError") }];
|
||||
const checks = [{ level: "ok", label: t("appHealthApi"), detail: t("appHealthApiOk") }];
|
||||
if (status.listeners?.restart_required) {
|
||||
checks.push({ level: "warning", label: t("appHealthListeners"), detail: t("appHealthListenersRestart") });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthListeners"), detail: t("appHealthListenersOk") });
|
||||
}
|
||||
|
||||
const pools = Array.isArray(status.backend_pools) ? status.backend_pools : [];
|
||||
const members = pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member })));
|
||||
const down = members.filter(member => member.status === "down");
|
||||
const checking = members.filter(member => member.status === "checking");
|
||||
const unavailablePools = pools.filter(pool => pool.ready === false && (pool.members || []).some(member => ["up", "down", "checking"].includes(member.status))).map(pool => pool.id).filter(Boolean);
|
||||
const affected = [...down, ...checking].slice(0, 5).map(member => `${member.pool}/${member.id}`).join(", ");
|
||||
if (unavailablePools.length) {
|
||||
let detail = t("appHealthBackendsError").replace("{pools}", unavailablePools.slice(0, 5).join(", ") + (unavailablePools.length > 5 ? " …" : ""));
|
||||
if (affected) detail += ` ${t("appHealthBackendsAffected").replace("{members}", affected + (down.length + checking.length > 5 ? " …" : ""))}`;
|
||||
checks.push({ level: "error", label: t("appHealthBackends"), detail });
|
||||
} else if (down.length || checking.length) {
|
||||
checks.push({ level: "warning", label: t("appHealthBackends"), detail: t("appHealthBackendsWarning").replace("{down}", String(down.length)).replace("{checking}", String(checking.length)).replace("{members}", affected + (down.length + checking.length > 5 ? " …" : "")) });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthBackends"), detail: pools.length ? t("appHealthBackendsOk") : t("appHealthBackendsNone") });
|
||||
}
|
||||
|
||||
const sink = status.logging?.sinks || {}, errorFile = status.logging?.error_file || {};
|
||||
const loggingDropped = (Number(sink.queue_dropped) || 0) + (Number(errorFile.queue_dropped) || 0);
|
||||
const loggingErrors = (Number(sink.errors) || 0) + (Number(errorFile.errors) || 0);
|
||||
if (loggingDropped || loggingErrors) {
|
||||
checks.push({ level: "warning", label: t("appHealthLogging"), detail: t("appHealthLoggingWarning").replace("{dropped}", String(loggingDropped)).replace("{errors}", String(loggingErrors)) });
|
||||
} else {
|
||||
checks.push({ level: "ok", label: t("appHealthLogging"), detail: t("appHealthLoggingOk") });
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
function appHealthLevel(checks) {
|
||||
if (!checks.length) return "checking";
|
||||
if (checks.some(check => check.level === "error")) return "error";
|
||||
if (checks.some(check => check.level === "warning")) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
function setAppHealthClass(node, level) {
|
||||
if (!node) return;
|
||||
node.classList.remove("is-checking", "is-ok", "is-warning", "is-error");
|
||||
node.classList.add(`is-${level}`);
|
||||
}
|
||||
function renderAppHealth(status = latestStatus) {
|
||||
const checks = appHealthChecks(status), level = appHealthLevel(checks);
|
||||
const labelKeys = { checking: "appHealthSidebarChecking", ok: "appHealthSidebarOk", warning: "appHealthSidebarWarning", error: "appHealthSidebarError" };
|
||||
const summaryKeys = { checking: "appHealthChecking", ok: "appHealthOk", warning: "appHealthWarning", error: "appHealthError" };
|
||||
const button = $("app-health-open"), label = $("app-health-label"), summaryRow = $("app-health-summary-row"), summary = $("app-health-summary");
|
||||
setAppHealthClass(button, level); setAppHealthClass(summaryRow, level);
|
||||
if (label) { label.dataset.i18n = labelKeys[level]; label.textContent = t(labelKeys[level]); }
|
||||
if (summary) { summary.dataset.i18n = summaryKeys[level]; summary.textContent = t(summaryKeys[level]); }
|
||||
const updated = $("app-health-updated");
|
||||
if (updated) updated.textContent = appHealthUpdatedAt ? t("appHealthUpdated").replace("{time}", appHealthUpdatedAt.toLocaleTimeString(locale())) : t("appHealthNoStatus");
|
||||
const list = $("app-health-checks");
|
||||
if (list) list.replaceChildren(...checks.map(check => {
|
||||
const row = el("div", undefined, `app-health-check is-${check.level}`), dot = el("i"), copy = el("div");
|
||||
dot.setAttribute("aria-hidden", "true"); copy.append(el("strong", check.label), el("p", check.detail, "fine")); row.append(dot, copy); return row;
|
||||
}));
|
||||
}
|
||||
function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
|
||||
const label = input?.closest("label"); if (!label) return () => { };
|
||||
label.classList.add("vh-enable-control", "vh-wide");
|
||||
@@ -295,23 +232,7 @@ window.rustProxyOverviewSamples.onChange(() => {
|
||||
}
|
||||
redrawOverviewHistoryCharts();
|
||||
});
|
||||
// Safari has no CSS scroll anchoring: keep the element under the viewport centre in place
|
||||
// when a live refresh changes the height of content above it.
|
||||
const nativeScrollAnchoring = typeof CSS !== "undefined" && CSS.supports?.("overflow-anchor", "auto");
|
||||
function preserveScrollAnchor(render) {
|
||||
if (nativeScrollAnchoring || window.scrollY <= 0) return render();
|
||||
const probe = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||
const anchor = probe?.closest?.("article, tr, .mini-live-chart, .network-interface-row") || probe;
|
||||
const before = anchor?.isConnected ? anchor.getBoundingClientRect().top : null;
|
||||
const result = render();
|
||||
if (before !== null && anchor.isConnected) {
|
||||
const shift = anchor.getBoundingClientRect().top - before;
|
||||
if (Math.abs(shift) >= 1) window.scrollBy(0, shift);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function renderStatus(s, systemSample = false) { return preserveScrollAnchor(() => renderStatusNow(s, systemSample)); }
|
||||
function renderStatusNow(s, systemSample = false) {
|
||||
function renderStatus(s, systemSample = false) {
|
||||
const firstStatus = latestStatus === null;
|
||||
latestStatus = {
|
||||
...(latestStatus || {}), ...s,
|
||||
@@ -320,7 +241,7 @@ function renderStatusNow(s, systemSample = false) {
|
||||
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
|
||||
};
|
||||
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); $("app-health-slot").hidden = false; appHealthUpdatedAt = new Date(); renderAppHealth(s); const m = s.metrics;
|
||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
|
||||
$("metric-ws").textContent = m.websocket_active || 0;
|
||||
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
|
||||
$("metric-ws-h2").textContent = m.websocket_h2_active || 0;
|
||||
@@ -362,7 +283,7 @@ function renderStatusNow(s, systemSample = false) {
|
||||
function renderFooter(s) {
|
||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||
const footer = $("footer-status");
|
||||
footer.replaceChildren(document.createTextNode(apiLatency));
|
||||
footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`));
|
||||
const update = s.software_update;
|
||||
if (update?.update_available && update.latest_version) {
|
||||
footer.append(document.createTextNode(" / "));
|
||||
@@ -382,7 +303,7 @@ let lastLogs = null;
|
||||
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
|
||||
async function refresh(force = false) {
|
||||
if (!authenticated) return;
|
||||
if (force) return rustProxyRefresh.manual("status-full");
|
||||
if (force) return Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback")]);
|
||||
return rustProxyRefresh.manual("status-fallback");
|
||||
}
|
||||
function renderLiveStatus(value) { renderStatus(value); }
|
||||
@@ -396,16 +317,15 @@ function startLive() {
|
||||
if (liveEvents && liveInterval === seconds) return;
|
||||
stopLive();
|
||||
liveInterval = seconds;
|
||||
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}&logs=false`);
|
||||
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}`);
|
||||
liveEvents = stream;
|
||||
stream.addEventListener("snapshot", event => {
|
||||
if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (!payload.status) throw new Error("Invalid SSE payload");
|
||||
if (!payload.status || !payload.logs) throw new Error("Invalid SSE payload");
|
||||
liveConnected = true; liveFallback = false;
|
||||
renderLiveStatus(payload.status);
|
||||
if (payload.logs) renderLogs(payload.logs);
|
||||
renderLiveStatus(payload.status); renderLogs(payload.logs);
|
||||
window.rustProxyRefresh?.sync();
|
||||
} catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); }
|
||||
});
|
||||
@@ -485,12 +405,12 @@ function lock(notify = true) {
|
||||
window.rustProxyInlineLayout?.cancel?.();
|
||||
window.rustProxyRefresh?.stop();
|
||||
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; $("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("");
|
||||
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("");
|
||||
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
||||
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
||||
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
||||
document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); });
|
||||
appHealthUpdatedAt = null; renderAppHealth(null); renderSession(); syncAuthForm();
|
||||
renderSession(); syncAuthForm();
|
||||
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
||||
}
|
||||
|
||||
@@ -570,8 +490,6 @@ function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
||||
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
||||
$("app-health-open")?.addEventListener("click", () => { renderAppHealth(connected ? latestStatus : null); const dialog = $("app-health-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||
$("app-health-close")?.addEventListener("click", () => $("app-health-dialog").close());
|
||||
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||
$("about-close").addEventListener("click", () => $("about-dialog").close());
|
||||
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
|
||||
@@ -668,116 +586,3 @@ $("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
||||
$("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); } });
|
||||
$("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 hiddenCount = $("overview-inline-hidden-count");
|
||||
const modalButton = $("overview-layout-open");
|
||||
const compactButton = $("overview-inline-compact");
|
||||
let draft = null;
|
||||
let original = null;
|
||||
let dragging = null;
|
||||
let opening = false;
|
||||
let saving = false;
|
||||
|
||||
@@ -72,7 +72,10 @@ window.rustProxyInlineLayout = (() => {
|
||||
if (widen) widen.disabled = at === widths.length - 1;
|
||||
}
|
||||
}
|
||||
function clearDrag() { if (pointerDrag) settle(false); }
|
||||
function clearDrag() {
|
||||
dragging = null;
|
||||
for (const node of nodes()) node.classList.remove("is-overview-dragging", "is-overview-drop-before", "is-overview-drop-after");
|
||||
}
|
||||
function move(id, target, after = false) {
|
||||
if (!draft || id === target) return;
|
||||
if (!visibleIds().includes(id) || !visibleIds().includes(target)) return;
|
||||
@@ -115,7 +118,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
const title = label(id);
|
||||
const bar = el("div", undefined, "overview-inline-toolbar");
|
||||
const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { });
|
||||
grip.setAttribute("aria-describedby", "overview-inline-hint");
|
||||
grip.draggable = true;
|
||||
grip.dataset.overviewDragHandle = id;
|
||||
const sizeControls = el("span", undefined, "overview-inline-width");
|
||||
const widthValue = el("span", "", "overview-inline-width-value");
|
||||
@@ -139,7 +142,6 @@ window.rustProxyInlineLayout = (() => {
|
||||
actions.hidden = false;
|
||||
hint.hidden = false;
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint");
|
||||
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
|
||||
if (modalButton) modalButton.disabled = true;
|
||||
for (const node of nodes()) toolbar(node);
|
||||
@@ -193,185 +195,40 @@ window.rustProxyInlineLayout = (() => {
|
||||
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
|
||||
}
|
||||
function cancel() { if (draft && !saving) finish(false); }
|
||||
// Pointer-driven sorting with a live placeholder. The grid never back-fills
|
||||
// holes while a block is lifted, so the placeholder shows exactly where the
|
||||
// block lands; neighbours glide (FLIP) instead of jumping.
|
||||
const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90;
|
||||
const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
let pointerDrag = null;
|
||||
function flip(mutate) {
|
||||
const before = new Map(nodes().filter(node => !node.hidden && node !== pointerDrag?.node)
|
||||
.map(node => [node, node.getBoundingClientRect()]));
|
||||
mutate();
|
||||
if (reducedMotion()) return;
|
||||
for (const [node, rect] of before) {
|
||||
const after = node.getBoundingClientRect();
|
||||
const dx = rect.left - after.left, dy = rect.top - after.top;
|
||||
if (!dx && !dy) continue;
|
||||
node.animate?.([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
|
||||
{ duration: 160, easing: "cubic-bezier(.2, .7, .3, 1)" });
|
||||
}
|
||||
function dropTarget(event) {
|
||||
const widget = event.target?.closest?.("[data-dashboard-widget]");
|
||||
return widget && grid.contains(widget) && !widget.hidden && visibleIds().includes(widget.dataset.dashboardWidget) ? widget : null;
|
||||
}
|
||||
function 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 positionAfter(widget, event) {
|
||||
const rect = widget.getBoundingClientRect();
|
||||
return event.clientX >= rect.left + rect.width / 2;
|
||||
}
|
||||
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 => {
|
||||
grid?.addEventListener("dragstart", event => {
|
||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||
if (!draft || saving || pointerDrag || !handle || !grid.contains(handle) || event.button !== 0) return;
|
||||
const node = nodeFor(handle.dataset.overviewDragHandle);
|
||||
if (!node) return;
|
||||
event.preventDefault();
|
||||
pointerDrag = { node, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, active: false };
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointercancel", onPointerCancel);
|
||||
window.addEventListener("keydown", onDragKey, true);
|
||||
if (!draft || !handle || !grid.contains(handle)) { event.preventDefault(); return; }
|
||||
dragging = handle.dataset.overviewDragHandle;
|
||||
event.dataTransfer?.setData("text/plain", dragging);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
nodeFor(dragging)?.classList.add("is-overview-dragging");
|
||||
});
|
||||
grid?.addEventListener("keydown", event => {
|
||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||
if (!draft || !handle) return;
|
||||
const delta = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key];
|
||||
if (!delta) return;
|
||||
grid?.addEventListener("dragover", event => {
|
||||
if (!draft || !dragging) return;
|
||||
const target = dropTarget(event);
|
||||
if (!target || target.dataset.dashboardWidget === dragging) return;
|
||||
event.preventDefault();
|
||||
nudge(handle.dataset.overviewDragHandle, delta);
|
||||
nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus();
|
||||
for (const node of nodes()) node.classList.remove("is-overview-drop-before", "is-overview-drop-after");
|
||||
target.classList.add(positionAfter(target, event) ? "is-overview-drop-after" : "is-overview-drop-before");
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
});
|
||||
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);
|
||||
grid?.addEventListener("drop", event => {
|
||||
if (!draft || !dragging) return;
|
||||
event.preventDefault();
|
||||
const target = dropTarget(event), from = dragging;
|
||||
clearDrag();
|
||||
if (target && target.dataset.dashboardWidget !== from)
|
||||
move(from, target.dataset.dashboardWidget, positionAfter(target, event));
|
||||
});
|
||||
grid?.addEventListener("dragend", clearDrag);
|
||||
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
|
||||
saveButton?.addEventListener("click", () => { void save(); });
|
||||
cancelButton?.addEventListener("click", cancel);
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
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 };
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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 };
|
||||
@@ -8,12 +8,10 @@ window.rustProxyExtraOverview = (() => {
|
||||
"extra_response_latency", "extra_backend_unhealthy",
|
||||
"extra_total_rps", "extra_active_exchanges", "extra_h1_share", "extra_h3_share",
|
||||
"extra_cache_miss_rate", "extra_backend_active",
|
||||
"extra_backends_healthy", "extra_error_share", "extra_rejected_rate", "extra_cert_expiry",
|
||||
];
|
||||
const bounded = new Set([
|
||||
"extra_process_fds", "extra_cache_memory", "extra_cold_queue_memory", "extra_cold_storage",
|
||||
"extra_h1_share", "extra_h2_share", "extra_h3_share",
|
||||
"extra_backends_healthy", "extra_cert_expiry",
|
||||
]);
|
||||
const histories = new Map(), lastCounter = new Map(), lastValue = new Map();
|
||||
const lastUnits = new Map(), lastSegments = new Map();
|
||||
@@ -100,36 +98,11 @@ window.rustProxyExtraOverview = (() => {
|
||||
unit: "count", detail: ""
|
||||
};
|
||||
}
|
||||
if (id === "extra_backends_healthy") {
|
||||
const members = Array.isArray(status.backend_pools)
|
||||
? status.backend_pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member }))) : null;
|
||||
if (!members) return { value: null, unit: "count", detail: "" };
|
||||
const monitored = members.filter(member => member.health_check_enabled !== false && member.health_mode !== "off");
|
||||
const down = monitored.filter(member => member.status === "down" || member.healthy === false);
|
||||
const up = monitored.length - down.length;
|
||||
return {
|
||||
value: monitored.length ? `${fmt(up)} / ${fmt(monitored.length)}` : null, unit: "text",
|
||||
meterPercent: monitored.length ? up / monitored.length * 100 : null,
|
||||
detail: !monitored.length ? t("overviewExtraBackendsNone")
|
||||
: down.length ? `${t("overviewExtraBackendsDown")}: ${down.slice(0, 3).map(member => `${member.pool}/${member.id}`).join(", ")}${down.length > 3 ? " …" : ""}`
|
||||
: t("overviewExtraBackendsAllUp"),
|
||||
};
|
||||
}
|
||||
if (id === "extra_cert_expiry") {
|
||||
const inventory = typeof certificateInventory !== "undefined" && certificateInventory instanceof Map ? [...certificateInventory.values()] : [];
|
||||
const dated = inventory.filter(cert => Number.isFinite(Number(cert?.days_remaining)))
|
||||
.sort((a, b) => Number(a.days_remaining) - Number(b.days_remaining));
|
||||
if (!dated.length) return { value: null, unit: "days", detail: t("overviewExtraCertNone") };
|
||||
const nearest = dated[0], days = Number(nearest.days_remaining);
|
||||
const name = (Array.isArray(nearest.covered_hosts) && nearest.covered_hosts[0]) || (Array.isArray(nearest.hosts) && nearest.hosts[0]) || nearest.id;
|
||||
return { value: days, unit: "days", meterPercent: Math.max(0, Math.min(100, days / 90 * 100)), detail: `${name} · ${fmt(dated.length)} ${t("overviewExtraCertCount")}` };
|
||||
}
|
||||
if (id === "extra_cache_memory") return ramBudget(c.resident_bytes, c.hot_limit_bytes);
|
||||
if (id === "extra_cold_queue_memory") return ramBudget(c.cold_queue_bytes, c.cold_queue_limit_bytes);
|
||||
if (id === "extra_cold_storage") return ramBudget(c.cold_bytes, c.cold_limit_bytes);
|
||||
if (!["extra_tls_error_rate", "extra_request_error_rate", "extra_cache_hit_rate", "extra_h2_share",
|
||||
"extra_total_rps", "extra_h1_share", "extra_h3_share", "extra_cache_miss_rate",
|
||||
"extra_error_share", "extra_rejected_rate"].includes(id))
|
||||
"extra_total_rps", "extra_h1_share", "extra_h3_share", "extra_cache_miss_rate"].includes(id))
|
||||
return { value: null, unit: "count", detail: "" };
|
||||
const fields = ({
|
||||
extra_tls_error_rate: [m.tls_handshake_errors],
|
||||
@@ -140,10 +113,8 @@ window.rustProxyExtraOverview = (() => {
|
||||
extra_h3_share: [m.http3_requests, m.requests],
|
||||
extra_total_rps: [m.requests],
|
||||
extra_cache_miss_rate: [c.misses],
|
||||
extra_error_share: [m.errors, m.requests],
|
||||
extra_rejected_rate: [m.rejected_requests, m.rejected_connections],
|
||||
})[id].map(positive);
|
||||
const unit = ["extra_cache_hit_rate", "extra_h2_share", "extra_h1_share", "extra_h3_share", "extra_error_share"].includes(id)
|
||||
const unit = ["extra_cache_hit_rate", "extra_h2_share", "extra_h1_share", "extra_h3_share"].includes(id)
|
||||
? "percent" : "rate";
|
||||
if (fields.some(v => v === null)) {
|
||||
lastCounter.delete(id);
|
||||
@@ -153,12 +124,6 @@ window.rustProxyExtraOverview = (() => {
|
||||
const result = elapsedDelta(id, fields, now);
|
||||
if (!result) return { value: null, unit, detail: "", slices: null };
|
||||
const { deltas, seconds } = result;
|
||||
if (id === "extra_rejected_rate") return {
|
||||
value: (deltas[0] + deltas[1]) / seconds, unit, detail: `${t("overviewExtraRejectedDetail")}: ${fmt(fields[0])} / ${fmt(fields[1])}`
|
||||
};
|
||||
if (id === "extra_error_share") return {
|
||||
value: deltas[1] > 0 ? Math.min(100, deltas[0] / deltas[1] * 100) : 0, unit, detail: `${t("overviewExtraErrorShareDetail")}: ${fmt(deltas[0])} / ${fmt(deltas[1])}`
|
||||
};
|
||||
if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" };
|
||||
const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1];
|
||||
return {
|
||||
@@ -172,8 +137,6 @@ window.rustProxyExtraOverview = (() => {
|
||||
if (unit === "bytes") return fmtBytes(value);
|
||||
if (unit === "ms") return `${fmt(value, 1)} ms`;
|
||||
if (unit === "rate") return `${fmt(value, 1)}/s`;
|
||||
if (unit === "days") return `${fmt(value)} ${t("overviewExtraDaysUnit")}`;
|
||||
if (unit === "text") return String(value);
|
||||
return fmt(value);
|
||||
}
|
||||
function points(id, history, unit) {
|
||||
@@ -219,11 +182,7 @@ window.rustProxyExtraOverview = (() => {
|
||||
donut("extra-cache-ratio", slices?.[0], slices?.[1]);
|
||||
}
|
||||
const detail = $(`overview-extra-${id}-detail`);
|
||||
if (detail && id !== "extra_cache_hit_rate") {
|
||||
const detailText = metric.detail || "";
|
||||
detail.textContent = detailText;
|
||||
detail.hidden = !detailText;
|
||||
}
|
||||
if (detail && id !== "extra_cache_hit_rate") detail.textContent = metric.detail || t("overviewExtraRecentSamples");
|
||||
}
|
||||
}
|
||||
function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); lastSegments.clear(); window.rustProxyChartScale.reset("extra:"); }
|
||||
|
||||
@@ -2,40 +2,55 @@ window.rustProxyOverviewSamples = (() => {
|
||||
const allowed = new Set([60, 120, 300, 600]);
|
||||
const storageKey = "rustproxy.overview.liveChartSamples";
|
||||
const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60;
|
||||
let limit = allowed.has(factory) ? factory : 60;
|
||||
let saved = allowed.has(factory) ? factory : 60;
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(storageKey));
|
||||
if (allowed.has(stored)) limit = stored;
|
||||
} catch { /* Private browsing or storage policy: selection still works for this visit. */ }
|
||||
if (allowed.has(stored)) saved = stored;
|
||||
} catch { /* Private browsing or storage policy: temporary selection still works. */ }
|
||||
let limit = saved;
|
||||
const callbacks = new Set();
|
||||
const getLimit = () => limit;
|
||||
const trim = array => {
|
||||
if (Array.isArray(array) && array.length > limit) array.splice(0, array.length - limit);
|
||||
return array;
|
||||
};
|
||||
function sync() {
|
||||
function sync(messageKey = limit === saved ? "overviewSamplesSaved" : "overviewSamplesTemporary") {
|
||||
const select = document.getElementById("overview-live-samples");
|
||||
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) {
|
||||
const count = Number(value);
|
||||
if (!allowed.has(count)) return false;
|
||||
const changed = count !== limit;
|
||||
limit = count;
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(count));
|
||||
saved = count;
|
||||
} catch {
|
||||
if (typeof toast === "function") toast(t("overviewSamplesStorageUnavailable"), true);
|
||||
const changed = count !== limit;
|
||||
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);
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
function init() {
|
||||
const select = document.getElementById("overview-live-samples");
|
||||
select?.addEventListener("change", () => setLimit(select.value, true));
|
||||
const save = document.getElementById("overview-samples-save");
|
||||
select?.addEventListener("change", () => setLimit(select.value));
|
||||
save?.addEventListener("click", () => setLimit(select.value, true));
|
||||
sync();
|
||||
}
|
||||
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_TIME_FORMATS = new Set(["24h", "12h"]);
|
||||
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_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]);
|
||||
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
|
||||
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
|
||||
const ADMIN_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({
|
||||
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
|
||||
chart_axes: false, interface_icons: false, logo_animation: false,
|
||||
chart_axes: false,
|
||||
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null,
|
||||
});
|
||||
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool", sunset: "chartStyleSunset", aurora: "chartStyleAurora", ocean: "chartStyleOcean", forest: "chartStyleForest", pastel: "chartStylePastel", terminal: "chartStyleTerminal", blueprint: "chartStyleBlueprint", colorblind: "chartStyleColorblind" });
|
||||
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
|
||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
||||
|
||||
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
||||
@@ -123,8 +123,6 @@ function applyAdminChartAxes(value) {
|
||||
window.rustProxyChartScale?.refreshAxes?.();
|
||||
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) {
|
||||
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
|
||||
const select = $("ui-button-style"); if (select) select.value = style; return style;
|
||||
@@ -289,7 +287,7 @@ function applyAdminPreferences(value, cache = true) {
|
||||
...value,
|
||||
refresh: normalizeRefreshPreferences(value.refresh),
|
||||
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
|
||||
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), 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),
|
||||
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),
|
||||
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes,
|
||||
};
|
||||
adminPreferences = normalized;
|
||||
@@ -337,7 +335,7 @@ function updateAdminPreferences(patch) {
|
||||
const next = { ...current, ...patch };
|
||||
next.refresh = normalizeRefreshPreferences(next.refresh);
|
||||
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
|
||||
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.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.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.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes);
|
||||
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
@@ -345,7 +343,7 @@ function updateAdminPreferences(patch) {
|
||||
});
|
||||
adminPreferencesWrite = task.catch(() => undefined); return task;
|
||||
}
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); applyAdminInterfaceIcons(false); applyAdminLogoAnimation(false); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
|
||||
function renderRefreshPreferences(value = REFRESH_DEFAULTS) {
|
||||
const refresh = normalizeRefreshPreferences(value);
|
||||
const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled;
|
||||
@@ -421,22 +419,6 @@ $("ui-chart-axes")?.addEventListener("change", () => {
|
||||
applyAdminChartAxes(previous);
|
||||
});
|
||||
});
|
||||
$("ui-interface-icons")?.addEventListener("change", () => {
|
||||
const control = $("ui-interface-icons"), previous = adminPreferences?.interface_icons === true, next = control.checked;
|
||||
applyAdminInterfaceIcons(next); if (adminPreferences) adminPreferences.interface_icons = next;
|
||||
void saveAppearancePreference({ interface_icons: next }, control, () => {
|
||||
if (adminPreferences) adminPreferences.interface_icons = previous;
|
||||
applyAdminInterfaceIcons(previous);
|
||||
});
|
||||
});
|
||||
$("ui-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", () => {
|
||||
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); });
|
||||
@@ -468,7 +450,7 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
|
||||
}
|
||||
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
|
||||
const patches = {
|
||||
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", interface_icons: false, logo_animation: false, 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", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
|
||||
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null },
|
||||
};
|
||||
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
||||
|
||||
@@ -167,10 +167,10 @@ const rustProxyRefresh = (() => {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status?view=live", { signal });
|
||||
if (isCurrent()) renderLiveStatus(value);
|
||||
}, onError: error => { if (error?.status !== 401) { connected = false; $("connection-label").textContent = t("disconnected"); $("signal").classList.add("status-error"); $("app-health-slot").hidden = false; renderAppHealth(null); } }
|
||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }
|
||||
});
|
||||
register("logs-fallback", {
|
||||
interval: () => options.overview_seconds, active: () => !!window.rustProxyLogs?.active?.(), run: async ({ signal, isCurrent }) => {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/logs", { signal });
|
||||
if (isCurrent()) renderLogs(value);
|
||||
}
|
||||
@@ -182,7 +182,7 @@ const rustProxyRefresh = (() => {
|
||||
const value = await api("/api/system-info", { signal });
|
||||
if (!isCurrent()) return;
|
||||
latestStatus = { ...(latestStatus || {}), ...value };
|
||||
if (latestStatus.metrics) preserveScrollAnchor(() => renderDiagnostics(latestStatus, false, true));
|
||||
if (latestStatus.metrics) renderDiagnostics(latestStatus, false, true);
|
||||
window.rustProxySettings?.render();
|
||||
}
|
||||
});
|
||||
@@ -199,6 +199,6 @@ const rustProxyRefresh = (() => {
|
||||
window.rustProxyRefresh = rustProxyRefresh;
|
||||
|
||||
$("overview-refresh")?.addEventListener("click", () => {
|
||||
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("system-info")]);
|
||||
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback"), 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]) {
|
||||
input.maxLength = external ? 4096 : 96;
|
||||
if (external) input.removeAttribute("pattern");
|
||||
else input.pattern = "[A-Za-z0-9_.\\-]+";
|
||||
else input.pattern = "[A-Za-z0-9_.-]+";
|
||||
}
|
||||
cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem";
|
||||
key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key";
|
||||
@@ -282,15 +282,6 @@ function svgNode(tag, attrs = {}) {
|
||||
|
||||
function setChartText(node, 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.data !== text) node.firstChild.data = text;
|
||||
} else if (node.textContent !== text) {
|
||||
@@ -355,12 +346,9 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
|
||||
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-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55");
|
||||
// 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,
|
||||
window.rustProxyChartScale.axes(row.querySelector("svg"), peak, "bytes/s", history.rx.length,
|
||||
{ top: 5 / 60, bottom: 55 / 60 });
|
||||
chart.setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
|
||||
row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
|
||||
children.push(row);
|
||||
}
|
||||
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
|
||||
@@ -407,33 +395,15 @@ function cacheOutcomeMix(totals) {
|
||||
const counts = [hits, misses, bypasses, requests - cacheTotal];
|
||||
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) {
|
||||
const names = ["hit", "miss", "bypass", "other"];
|
||||
const unavailable = t(totalRequests === 0 ? "cacheMixWaiting" : "cacheMixInvalid");
|
||||
names.forEach((name, i) => {
|
||||
const pct = mix ? mix.percentages[i] : 0;
|
||||
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}`).style.width = `${pct}%`;
|
||||
$(`cache-mix-${name}-count`).textContent = mix ? fmt(mix.counts[i]) : "-";
|
||||
segment.title = detail; $(`cache-mix-${name}-key`).title = detail;
|
||||
$(`cache-mix-${name}-percent`).textContent = mix ? `${fmt(pct, 1)}%` : "-";
|
||||
});
|
||||
fitCacheMixLabels();
|
||||
$("cache-mix-headline").textContent = mix ? `${fmt(mix.percentages[0], 1)}% HIT` : "-";
|
||||
const caption = $("cache-mix-caption");
|
||||
const validTotal = Number.isSafeInteger(totalRequests) && totalRequests >= 0;
|
||||
@@ -454,7 +424,6 @@ function renderCacheMix(mix, totalRequests) {
|
||||
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
|
||||
const d = status.system || {}, live = status.live_system || d, c = status.cache || {}, m = status.metrics || {};
|
||||
$("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 cacheEvents = cacheHits !== null && cacheMisses !== null ? cacheHits + cacheMisses : 0;
|
||||
$("metric-cache-ratio").textContent = cacheEvents > 0 ? `${fmt(cacheHits / cacheEvents * 100, 1)}%` : "-";
|
||||
@@ -466,6 +435,8 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
||||
$("process-summary").hidden = d.process_threads == null;
|
||||
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)}%`);
|
||||
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));
|
||||
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
|
||||
const ramMeter = $("memory-meter"), ramFill = $("memory-meter-fill");
|
||||
@@ -486,9 +457,7 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
||||
setOptionalMetric("system-swap", systemSwap != null, systemSwap);
|
||||
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(" / ") : "");
|
||||
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);
|
||||
setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : 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-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(" "));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
let loading = false, offset = 0, generation = 0, staticData = null, staticRevision = null;
|
||||
let loading = false, offset = 0, generation = 0;
|
||||
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
|
||||
const liveChartSamples = { hits: [], misses: [], bypasses: [] };
|
||||
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
|
||||
@@ -67,22 +67,6 @@
|
||||
return groups.filter(row => cacheEnabledFor(data, row) === wanted);
|
||||
};
|
||||
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()) {
|
||||
liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null;
|
||||
for (const values of Object.values(liveChartSamples)) values.length = 0;
|
||||
@@ -178,13 +162,8 @@
|
||||
$("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)}`;
|
||||
$("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)]) tr.append(el("td", value));
|
||||
// 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 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 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;
|
||||
}));
|
||||
const domainEmpty = $("cache-domains-empty");
|
||||
domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains");
|
||||
@@ -193,17 +172,11 @@
|
||||
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));
|
||||
const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue);
|
||||
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);
|
||||
}
|
||||
if (row.transformed) target.append(el("span", t("transformBadge"), "cache-transform-badge"));
|
||||
tr.append(target);
|
||||
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
|
||||
tr.append(makeCopyCell(keyTags));
|
||||
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));
|
||||
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));
|
||||
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 || [];
|
||||
@@ -256,16 +229,7 @@
|
||||
const query = new URLSearchParams({ offset: String(offset), limit: "50" });
|
||||
if (scope() !== "*") query.set("vhost", scope());
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
const data = await api(`/api/cache?${query}`, { signal });
|
||||
if (isCurrent() && token === generation) render(data);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||
@@ -379,7 +343,7 @@
|
||||
$("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
||||
window.rustProxyCachePanel = {
|
||||
refresh, configure, reason, reset() {
|
||||
generation++; offset = 0; staticData = null; staticRevision = null; resetLiveChart(); closeInvalidation(); closeBans();
|
||||
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans();
|
||||
$("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren();
|
||||
$("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status");
|
||||
}
|
||||
|
||||
@@ -243,4 +243,5 @@ curl --fail --show-error --request POST \
|
||||
}
|
||||
};
|
||||
renderCertificateApi();
|
||||
loadCertificateApi();
|
||||
})();
|
||||
+14
-208
@@ -257,6 +257,20 @@ html[data-theme=cream] {
|
||||
--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 {
|
||||
--button-padding: 3px 7px;
|
||||
--button-font-size: 10px;
|
||||
@@ -319,62 +333,6 @@ html[data-button-style=compact] body {
|
||||
--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] {
|
||||
--chart-accent: var(--accent);
|
||||
--chart-secondary: var(--green);
|
||||
@@ -527,158 +485,6 @@ html[data-chart-style=warm] {
|
||||
--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] {
|
||||
--traffic-h1: var(--chart-accent);
|
||||
--traffic-h2: var(--chart-secondary);
|
||||
|
||||
@@ -87,53 +87,6 @@ button:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.ui-icon {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-inline-end: 6px;
|
||||
vertical-align: -2px;
|
||||
flex: 0 0 auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html[data-interface-icons="true"] svg.ui-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.nav>.ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
html[data-interface-icons="true"] .nav>svg.ui-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.backend-sort-direction>.ui-icon,
|
||||
.dashboard-widget-scroll-button>.ui-icon,
|
||||
.host-chip button>.ui-icon,
|
||||
.acl-picker-remove>.ui-icon,
|
||||
.draft-dock-remove>.ui-icon,
|
||||
.vhost-backend-popup-close>.ui-icon,
|
||||
.dashboard-widget-toggle>.ui-icon {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
button.is-saving>.ui-icon[data-ui-icon="loader"] {
|
||||
animation: ui-icon-spin .8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ui-icon-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
html[data-interface-icons="true"] .nav>span:first-of-type,
|
||||
[hidden],
|
||||
#file-log-view-counts[hidden],
|
||||
#file-log-view-entries[hidden],
|
||||
@@ -1015,8 +968,6 @@ dialog .vh-form,
|
||||
}
|
||||
|
||||
#vhost-list,
|
||||
#redirect-list,
|
||||
#notfound-list,
|
||||
#web-service-list,
|
||||
#stream-list,
|
||||
.vh-www-choices,
|
||||
@@ -1069,10 +1020,43 @@ dialog .vh-form,
|
||||
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 {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1120,7 +1104,7 @@ html[data-dashboard-density=compact] #panel-overview .dashboard-overview-grid>.c
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
|
||||
.network-interface-row svg:not(.ui-icon) {
|
||||
.network-interface-row svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
@@ -1220,29 +1204,6 @@ html[data-dashboard-density=compact] #panel-overview .dashboard-overview-grid>.c
|
||||
min-height: 24px;
|
||||
padding: 3px 7px;
|
||||
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,
|
||||
@@ -1448,16 +1409,9 @@ html[data-interface-icons="true"] .network-interface-hidden .quiet>svg.ui-icon {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.about-repository span:first-child {
|
||||
margin-right: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.about-repository span:last-child {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font: 10px var(--mono);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1562,6 +1516,14 @@ html[data-table-density=ultra] :is(table th, table td) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-permissions-commands {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.path-permissions-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.path-permissions-options>summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1583,7 +1545,6 @@ html[data-table-density=ultra] :is(table th, table td) {
|
||||
}
|
||||
|
||||
.path-permissions-commands {
|
||||
max-height: 180px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1593,13 +1554,4 @@ html[data-table-density=ultra] :is(table th, table td) {
|
||||
|
||||
.path-permissions-actions {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+37
-253
@@ -175,37 +175,6 @@ nav {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-health-button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-health-button i {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.app-health-button.is-checking,
|
||||
.app-health-summary.is-checking { color: var(--muted); }
|
||||
.app-health-button.is-ok,
|
||||
.app-health-summary.is-ok,
|
||||
.app-health-check.is-ok { color: var(--green); }
|
||||
.app-health-button.is-warning,
|
||||
.app-health-summary.is-warning,
|
||||
.app-health-check.is-warning { color: var(--notice-text); }
|
||||
.app-health-button.is-error,
|
||||
.app-health-summary.is-error,
|
||||
.app-health-check.is-error { color: var(--red); }
|
||||
|
||||
.app-health-button:hover { opacity: .82; }
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -247,71 +216,11 @@ nav {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.app-health-dialog {
|
||||
width: min(620px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.app-health-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.app-health-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-health-summary>i {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.app-health-summary strong {
|
||||
color: currentColor;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.app-health-summary .fine {
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.app-health-checks {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.app-health-check {
|
||||
display: grid;
|
||||
grid-template-columns: 9px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--neutral-soft);
|
||||
}
|
||||
|
||||
.app-health-check>i {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.app-health-check strong {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-health-check .fine {
|
||||
margin: 2px 0 0;
|
||||
color: var(--muted);
|
||||
.sidebar-bottom p {
|
||||
font-size: 11px;
|
||||
line-height: 1.9;
|
||||
color: var(--text-subtle);
|
||||
margin: 13px 0 26px;
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -604,6 +513,37 @@ main {
|
||||
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,
|
||||
.backend-column-picker>summary,
|
||||
.file-log-search-help>summary {
|
||||
@@ -838,160 +778,4 @@ main {
|
||||
to {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
-333
@@ -15,16 +15,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
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 {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
@@ -84,6 +74,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
stroke-dasharray: var(--chart-grid-dash, 2 5);
|
||||
}
|
||||
|
||||
/* Axis labels are HTML overlays, not stretched SVG text. */
|
||||
.rp-chart-axis-host {
|
||||
position: relative;
|
||||
display: flow-root;
|
||||
@@ -91,10 +82,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rp-chart-axis-host>svg {
|
||||
display: block;
|
||||
vertical-align: top;
|
||||
}
|
||||
.rp-chart-axis-host>svg { display: block; vertical-align: top; }
|
||||
|
||||
.rp-chart-axis-y {
|
||||
position: absolute;
|
||||
@@ -115,20 +103,9 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
background: color-mix(in srgb, var(--panel) 82%, transparent);
|
||||
}
|
||||
|
||||
.rp-chart-axis-top {
|
||||
top: var(--rp-axis-top, 8%);
|
||||
transform: translateY(-30%);
|
||||
}
|
||||
|
||||
.rp-chart-axis-middle {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.rp-chart-axis-bottom {
|
||||
top: var(--rp-axis-bottom, 92%);
|
||||
transform: translateY(-70%);
|
||||
}
|
||||
.rp-chart-axis-top { top: var(--rp-axis-top, 8%); transform: translateY(-30%); }
|
||||
.rp-chart-axis-middle { top: 50%; transform: translateY(-50%); }
|
||||
.rp-chart-axis-bottom { top: var(--rp-axis-bottom, 92%); transform: translateY(-70%); }
|
||||
|
||||
.rp-chart-axis-x {
|
||||
display: grid;
|
||||
@@ -144,29 +121,12 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rp-chart-axis-x .rp-chart-axis-start {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rp-chart-axis-x .rp-chart-axis-end {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rp-chart-axis-x .rp-chart-axis-start { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rp-chart-axis-x .rp-chart-axis-end { min-width: 0; text-align: right; }
|
||||
.rp-chart-axis-compact .rp-chart-axis-y,
|
||||
.rp-chart-axis-multiple .rp-chart-axis-y,
|
||||
.rp-chart-axis-series[hidden] {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.rp-chart-axis-compact .rp-chart-axis-x {
|
||||
font-size: 8px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rp-chart-axis-series[hidden] { display: none; visibility: hidden; }
|
||||
.rp-chart-axis-compact .rp-chart-axis-x { font-size: 8px; gap: 2px; }
|
||||
.rp-chart-axis-series {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -178,13 +138,8 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.rp-chart-axis-y .rp-chart-axis-middle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rp-chart-axis-x {
|
||||
font-size: 8px;
|
||||
}
|
||||
.rp-chart-axis-y .rp-chart-axis-middle { display: none; }
|
||||
.rp-chart-axis-x { font-size: 8px; }
|
||||
}
|
||||
|
||||
.chart-footer {
|
||||
@@ -280,7 +235,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mini-live-chart svg:not(.ui-icon) {
|
||||
.mini-live-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
@@ -324,19 +279,13 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
.pool-table td:nth-child(n
|
||||
|
||||
/**/
|
||||
+4) {
|
||||
.pool-table td:nth-child(n/**/+4) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir=rtl] table,
|
||||
.pool-table th:nth-child(n
|
||||
|
||||
/**/
|
||||
+4),
|
||||
.pool-table th:nth-child(n/**/+4),
|
||||
.logging-builder-action .small-label {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -361,11 +310,11 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.activity-card svg:not(.ui-icon) {
|
||||
.activity-card svg {
|
||||
height: 105px;
|
||||
}
|
||||
|
||||
.system-card svg:not(.ui-icon) {
|
||||
.system-card svg {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
@@ -383,6 +332,11 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
max-height: 205px;
|
||||
}
|
||||
|
||||
body[data-density=dense] .stat strong {
|
||||
font-size: 22px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.traffic-chart-grid,
|
||||
.logging-grid {
|
||||
display: grid;
|
||||
@@ -463,7 +417,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
padding: 7px 10px 0;
|
||||
}
|
||||
|
||||
.vhost-chart svg:not(.ui-icon) {
|
||||
.vhost-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
@@ -545,12 +499,37 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
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=ultra] #panel-overview .stat-cache-outcomes .cache-mix-header>span,
|
||||
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-caption {
|
||||
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 {
|
||||
display: grid;
|
||||
max-height: 260px;
|
||||
@@ -570,71 +549,11 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
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 {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.stat-chart svg:not(.ui-icon) {
|
||||
.stat-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
@@ -739,7 +658,7 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
padding: 14px 15px 12px;
|
||||
}
|
||||
|
||||
.function-live-chart svg:not(.ui-icon) {
|
||||
.function-live-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
@@ -821,7 +740,7 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.connection-card svg:not(.ui-icon) {
|
||||
.connection-card svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
@@ -1022,7 +941,7 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg:not(.ui-icon),
|
||||
:is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg,
|
||||
:is(.traffic-donut, .traffic-bar-chart span) {
|
||||
filter: var(--chart-svg-filter);
|
||||
}
|
||||
@@ -1054,10 +973,6 @@ html[data-dashboard-density=compact] #panel-overview .stat>span {
|
||||
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 {
|
||||
font-size: 22px;
|
||||
margin: 3px 0 2px;
|
||||
@@ -1082,10 +997,6 @@ html[data-dashboard-density=ultra] #panel-overview .stat>span {
|
||||
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 {
|
||||
font-size: 18px;
|
||||
margin: 2px 0 1px;
|
||||
@@ -1097,15 +1008,15 @@ html[data-dashboard-density=ultra] #panel-overview .stat small {
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
html[data-dashboard-density=ultra] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||
html[data-dashboard-density=ultra] #panel-overview .stat-chart svg {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
html[data-dashboard-density=normal] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||
html[data-dashboard-density=normal] #panel-overview .stat-chart svg {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
html[data-dashboard-density=compact] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||
html[data-dashboard-density=compact] #panel-overview .stat-chart svg {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@@ -1243,92 +1154,44 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
position: relative;
|
||||
outline: 1px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
padding-top: 40px !important;
|
||||
padding-top: 101px;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:hover,
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:focus-within {
|
||||
outline-style: solid;
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-dragging {
|
||||
opacity: .45;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-overview-sorting {
|
||||
grid-auto-flow: row !important;
|
||||
user-select: none;
|
||||
cursor: grabbing;
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-before {
|
||||
box-shadow: inset 4px 0 var(--accent);
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder {
|
||||
grid-column: auto/span 2;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: var(--rp-radius-surface, 9px);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] {
|
||||
grid-column: auto/span 3;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] {
|
||||
grid-column: auto/span 4;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] {
|
||||
grid-column: auto/span 6;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.is-overview-lifted,
|
||||
#panel-overview .dashboard-overview-grid>.is-overview-lifted * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid>.is-overview-lifted {
|
||||
box-shadow: 0 18px 40px rgb(0 0 0 / .28), 0 0 0 2px var(--accent);
|
||||
opacity: .94;
|
||||
transition: none;
|
||||
will-change: transform;
|
||||
overflow: hidden;
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-after {
|
||||
box-shadow: inset -4px 0 var(--accent);
|
||||
}
|
||||
|
||||
.overview-inline-toolbar {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
top: 3px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#panel-overview .overview-inline-toolbar button {
|
||||
min-width: 26px;
|
||||
min-height: 26px;
|
||||
padding: 2px 6px;
|
||||
min-width: 28px;
|
||||
min-height: 27px;
|
||||
padding: 2px 7px;
|
||||
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 {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#panel-overview .overview-inline-toolbar .overview-inline-grip:active {
|
||||
@@ -1707,7 +1570,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
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, .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, .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(.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;
|
||||
}
|
||||
@@ -1827,13 +1690,26 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
margin: 0 0 0 auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.page-intro .overview-samples-toolbar .function-live-chart-samples select {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.page-intro .overview-samples-toolbar button {
|
||||
width: auto;
|
||||
flex: none;
|
||||
min-height: 28px;
|
||||
padding: 5px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-intro .overview-samples-toolbar .fine {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#panel-overview .stat-donut .overview-donut-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1946,7 +1822,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] {
|
||||
grid-auto-flow: row;
|
||||
grid-auto-flow: row dense;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="2"] {
|
||||
@@ -2044,7 +1920,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
gap: 8px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--rp-radius-surface, 8px);
|
||||
border-radius: var(--rp-radius-surface, var(--radius, 8px));
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
@@ -2162,12 +2038,7 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#panel-overview .stat-cache-outcomes .cache-mix-caption {
|
||||
@@ -2183,8 +2054,8 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
#panel-overview .stat-cache-outcomes .cache-mix-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
margin: 3px 0 4px;
|
||||
height: 12px;
|
||||
margin: 7px 0 9px;
|
||||
border-radius: var(--rp-progress-radius);
|
||||
background: var(--rp-progress-track);
|
||||
overflow: hidden;
|
||||
@@ -2194,17 +2065,6 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
display: block;
|
||||
height: 100%;
|
||||
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 {
|
||||
@@ -2213,18 +2073,16 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
}
|
||||
|
||||
#panel-overview .cache-mix-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 7px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 5px 8px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#panel-overview .cache-mix-legend>span {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -2247,105 +2105,17 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
}
|
||||
|
||||
#panel-overview .cache-mix-legend b {
|
||||
margin-left: 1px;
|
||||
color: var(--text);
|
||||
font: 500 9px var(--mono);
|
||||
}
|
||||
|
||||
#panel-overview .system-card .system-uptime-meta,
|
||||
#panel-overview .connection-card .connection-backends-meta {
|
||||
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;
|
||||
}
|
||||
|
||||
.mini-live-chart-head> :last-child,
|
||||
.network-interface-row-head .network-interface-head-actions {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini-live-chart-head>span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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 {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-left: auto;
|
||||
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;
|
||||
font: 500 10px var(--mono);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#panel-overview .cache-mix-legend .cache-mix-percent {
|
||||
color: var(--muted);
|
||||
font: 400 10px var(--mono);
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
.traffic-chart-card,
|
||||
#panel-overview .backend-mode-bar span,
|
||||
#panel-overview .stats-grid>.stat,
|
||||
#panel-overview .dashboard-overview-grid>.activity-card svg:not(.ui-icon),
|
||||
#panel-overview .dashboard-overview-grid>.activity-card svg,
|
||||
#panel-overview .dashboard-overview-grid>[data-dashboard-widget],
|
||||
.dashboard-density-setting>.fine,
|
||||
.dashboard-density-setting>select,
|
||||
@@ -1140,7 +1140,7 @@ html[data-dashboard-density=ultra] #panel-overview .dashboard-overview-grid>.car
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 11px;
|
||||
background: transparent;
|
||||
background: var(--surface, transparent);
|
||||
}
|
||||
|
||||
.vh-backend-health-editor h3 {
|
||||
@@ -1483,22 +1483,18 @@ html[data-dashboard-density=ultra] #panel-overview .dashboard-overview-grid>.car
|
||||
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 {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--pill-line);
|
||||
border-radius: var(--rp-radius-control, 0);
|
||||
background: transparent;
|
||||
border-radius: var(--rp-radius-control, 4px);
|
||||
background: var(--panel2);
|
||||
color: var(--muted);
|
||||
min-height: 0;
|
||||
padding: 4px 8px;
|
||||
font: 9px/10px var(--mono);
|
||||
letter-spacing: 1px;
|
||||
padding: 3px 7px;
|
||||
font: 10px var(--mono);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1508,10 +1504,6 @@ html.app-loading,
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.vhost-policy-pills>.vhost-backend-protocol-badge {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.vhost-backend-protocol-badge[data-observed=true] {
|
||||
color: var(--green);
|
||||
border-color: var(--pill-live-line);
|
||||
@@ -1796,12 +1788,12 @@ html.app-loading,
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
gap: 5px;
|
||||
padding: 4px 5px 4px 8px;
|
||||
border-radius: var(--rp-radius-control, 0);
|
||||
gap: 7px;
|
||||
padding: 2px 4px 2px 9px;
|
||||
border-radius: var(--rp-radius-control, 15px);
|
||||
border: 1px solid var(--pill-live-line);
|
||||
background: var(--panel);
|
||||
font: 10px var(--mono);
|
||||
background: var(--nav-selected-bg);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.acl-picker-tag>span {
|
||||
@@ -1811,12 +1803,13 @@ html.app-loading,
|
||||
|
||||
.acl-picker-tag .acl-picker-remove {
|
||||
border: none;
|
||||
border-radius: var(--rp-radius-control, 0);
|
||||
border-radius: var(--rp-radius-control, 50%);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 0 3px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1947,93 +1940,4 @@ html.app-loading,
|
||||
.web-service-preset-toggles>.check {
|
||||
margin: 0 !important;
|
||||
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,15 +81,11 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.appearance-interface-icons-setting,
|
||||
.appearance-logo-animation-setting,
|
||||
.appearance-chart-axes-setting {
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appearance-interface-icons-setting>span,
|
||||
.appearance-logo-animation-setting>span,
|
||||
.appearance-chart-axes-setting>span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -130,6 +126,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
body[data-density=compact] .sidebar nav,
|
||||
.certificate-coverage {
|
||||
margin-top: 14px;
|
||||
}
|
||||
@@ -687,6 +684,40 @@
|
||||
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 {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -1172,7 +1203,6 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Global security policy forms */
|
||||
.global-policy-form .global-policy-section {
|
||||
display: grid;
|
||||
@@ -1182,301 +1212,18 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
padding-top: 17px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.global-policy-form .global-policy-section h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.global-policy-heading {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.global-policy-heading p {
|
||||
margin: 0;
|
||||
max-width: 85ch;
|
||||
}
|
||||
|
||||
.global-policy-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.global-rate-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.global-policy-fields label,
|
||||
.global-rate-fields label,
|
||||
.global-policy-section>label:not(.check) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.global-policy-section textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.global-policy-form .form-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cache-static-guidance {
|
||||
margin: 9px 0 0;
|
||||
max-width: 88ch;
|
||||
}
|
||||
|
||||
.cache-header-settings-heading {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 18px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.transform-operations-title {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 18px;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.transform-operations-grid {
|
||||
margin-top: 3px;
|
||||
row-gap: 12px;
|
||||
}
|
||||
|
||||
.global-policy-form .global-policy-section h3 { margin: 0; font-size: 14px; }
|
||||
.global-policy-heading { display: grid; gap: 5px; }
|
||||
.global-policy-heading p { margin: 0; max-width: 85ch; }
|
||||
.global-policy-fields { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr); gap: 14px; align-items: start; }
|
||||
.global-rate-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: end; }
|
||||
.global-policy-fields label, .global-rate-fields label, .global-policy-section > label:not(.check) { display: grid; gap: 6px; min-width: 0; }
|
||||
.global-policy-section textarea { width: 100%; box-sizing: border-box; }
|
||||
.global-policy-form .form-actions { margin-top: 20px; }
|
||||
.cache-static-guidance { margin: 9px 0 0; max-width: 88ch; }
|
||||
.cache-header-settings-heading { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 24px; margin-bottom: 13px; }
|
||||
.transform-operations-title { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 22px; margin-bottom: 14px; }
|
||||
.transform-operations-grid { margin-top: 3px; row-gap: 12px; }
|
||||
@media (max-width: 760px) {
|
||||
|
||||
.global-policy-fields,
|
||||
.global-rate-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
/* 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;
|
||||
.global-policy-fields, .global-rate-fields { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -430,12 +430,13 @@ html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
padding: 2px 6px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--pill-line);
|
||||
border-radius: var(--rp-radius-control, 0);
|
||||
border-radius: var(--rp-radius-control, 3px);
|
||||
color: var(--accent);
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .35px;
|
||||
}
|
||||
|
||||
.cache-toolbar-message {
|
||||
@@ -573,8 +574,8 @@ html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool
|
||||
button.config-history-day.has-revisions {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: var(--bg);
|
||||
border-color: var(--line-strong, var(--line));
|
||||
background: var(--surface, var(--bg));
|
||||
}
|
||||
|
||||
button.config-history-day.has-revisions:hover,
|
||||
@@ -833,6 +834,7 @@ html[data-table-density=ultra] .file-log-inline-detail {
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
body[data-density=compact] .mini-metrics div,
|
||||
.acl-policy-mode-card input,
|
||||
.docker-service-certificate-bar>.check>input,
|
||||
html[data-table-density=ultra] .file-log-field-grid {
|
||||
@@ -1004,7 +1006,7 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
||||
.file-log-advanced-parser {
|
||||
margin: 0 0 1rem;
|
||||
padding: .75rem 1rem;
|
||||
border-radius: var(--rp-radius-surface, 12px);
|
||||
border-radius: var(--rp-radius-surface, var(--radius, 12px));
|
||||
}
|
||||
|
||||
.file-log-aggregate-form {
|
||||
@@ -1072,133 +1074,3 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
||||
max-height: 68dvh;
|
||||
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; }
|
||||
|
||||
@@ -286,14 +286,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile-nav-open .sidebar-bottom {
|
||||
grid-column: 1/-1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 46px;
|
||||
}
|
||||
@@ -302,6 +294,18 @@
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -698,8 +702,8 @@
|
||||
}
|
||||
|
||||
.page-intro .overview-samples-toolbar {
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
margin-left: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,7 +889,8 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.logs-card>.card-heading {
|
||||
.logs-card>.card-heading,
|
||||
.about-repository {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1281,6 +1286,16 @@
|
||||
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 {
|
||||
width: min(330px, calc(100vw - 32px));
|
||||
min-width: 0;
|
||||
@@ -1442,10 +1457,6 @@
|
||||
}
|
||||
|
||||
@media (max-width:550px) {
|
||||
.stats-grid.cache-stats>.stat {
|
||||
flex-basis: 140px;
|
||||
}
|
||||
|
||||
.overview-layout-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -1463,19 +1474,7 @@
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] {
|
||||
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;
|
||||
padding-top: 135px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1624,10 +1623,6 @@
|
||||
padding-right: 11px;
|
||||
}
|
||||
|
||||
#panel-overview .stat-with-action>span:first-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
#panel-overview .dashboard-overview-grid.is-aligned .stat-with-action .stat-corner-action {
|
||||
position: static;
|
||||
display: block;
|
||||
@@ -1681,117 +1676,3 @@
|
||||
--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; }
|
||||
}
|
||||
@@ -78,11 +78,7 @@
|
||||
"www_failures",
|
||||
"www_traffic",
|
||||
"www_top_services",
|
||||
"www_status_trend",
|
||||
"extra_backends_healthy",
|
||||
"extra_error_share",
|
||||
"extra_rejected_rate",
|
||||
"extra_cert_expiry"
|
||||
"www_status_trend"
|
||||
],
|
||||
"dashboard_hidden_widgets": [
|
||||
"cache_outcomes",
|
||||
@@ -136,11 +132,7 @@
|
||||
"www_status_trend",
|
||||
"extra_cold_queue_memory",
|
||||
"wide_cache_activity",
|
||||
"backend_rotations",
|
||||
"extra_backends_healthy",
|
||||
"extra_error_share",
|
||||
"extra_rejected_rate",
|
||||
"extra_cert_expiry"
|
||||
"backend_rotations"
|
||||
],
|
||||
"dashboard_density": "compact",
|
||||
"overview_samples": 600
|
||||
|
||||
@@ -867,49 +867,5 @@
|
||||
"category": "web_server",
|
||||
"default_hidden": true,
|
||||
"display_type": "chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_backends_healthy",
|
||||
"label_key": "overviewExtraBackendsHealthy",
|
||||
"description_key": "overviewExtraBackendsHealthyDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_backends_healthy\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "backends",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_error_share",
|
||||
"label_key": "overviewExtraErrorShare",
|
||||
"description_key": "overviewExtraErrorShareDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_error_share\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_rejected_rate",
|
||||
"label_key": "overviewExtraRejectedRate",
|
||||
"description_key": "overviewExtraRejectedRateDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_rejected_rate\"]",
|
||||
"data_source": "status_metrics",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "performance",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
},
|
||||
{
|
||||
"id": "extra_cert_expiry",
|
||||
"label_key": "overviewExtraCertExpiry",
|
||||
"description_key": "overviewExtraCertExpiryDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"extra_cert_expiry\"]",
|
||||
"data_source": "system_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "overview",
|
||||
"default_hidden": true,
|
||||
"display_type": "value_chart"
|
||||
}
|
||||
]
|
||||
+8
-19
@@ -2,7 +2,7 @@
|
||||
(() => {
|
||||
const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"];
|
||||
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false };
|
||||
const collapsedMetrics = new Set(), metricHistory = new Map();
|
||||
const expandedMetrics = 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 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(); }
|
||||
@@ -156,7 +156,7 @@
|
||||
const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled")));
|
||||
const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim());
|
||||
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions);
|
||||
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_\\-]{1,64}"; if (agent) id.readOnly = true;
|
||||
const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_-]{1,64}"; if (agent) id.readOnly = true;
|
||||
const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true;
|
||||
const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876";
|
||||
const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password";
|
||||
@@ -221,7 +221,7 @@
|
||||
const section = el("details", undefined, "docker-service-editor docker-service-metrics");
|
||||
section.dataset.viewKey = viewKey(agent.id, serviceKey);
|
||||
section.dataset.agentId = agent.id; section.dataset.serviceKey = serviceKey;
|
||||
section.open = !collapsedMetrics.has(section.dataset.viewKey);
|
||||
section.open = expandedMetrics.has(section.dataset.viewKey);
|
||||
const summary = el("summary"); summary.append(el("span", t("dockerTrafficStatistics"))); section.append(summary);
|
||||
const grid = el("div", undefined, "vhost-live-grid"), fields = {};
|
||||
for (const key of ["status", "vhRequests", "vhActive", "vhErrors", "vhLatency", "vhTraffic"]) {
|
||||
@@ -237,8 +237,8 @@
|
||||
svg.append(line); chart.append(svg); section.append(chart);
|
||||
section.dockerMetricView = { fields, chartValue, line };
|
||||
section.addEventListener("toggle", () => {
|
||||
if (section.open) collapsedMetrics.delete(section.dataset.viewKey);
|
||||
else collapsedMetrics.add(section.dataset.viewKey);
|
||||
if (section.open) expandedMetrics.add(section.dataset.viewKey);
|
||||
else expandedMetrics.delete(section.dataset.viewKey);
|
||||
syncMetricsToggle();
|
||||
});
|
||||
return section;
|
||||
@@ -516,11 +516,6 @@
|
||||
}
|
||||
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); }
|
||||
syncMetricsToggle();
|
||||
}
|
||||
@@ -565,8 +560,8 @@
|
||||
const expand = !sections.every(section => section.open);
|
||||
for (const section of sections) {
|
||||
section.open = expand;
|
||||
if (expand) collapsedMetrics.delete(section.dataset.viewKey);
|
||||
else collapsedMetrics.add(section.dataset.viewKey);
|
||||
if (expand) expandedMetrics.add(section.dataset.viewKey);
|
||||
else expandedMetrics.delete(section.dataset.viewKey);
|
||||
}
|
||||
syncMetricsToggle();
|
||||
});
|
||||
@@ -575,12 +570,6 @@
|
||||
$("docker-search")?.addEventListener("input", 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-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-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"));
|
||||
@@ -602,5 +591,5 @@
|
||||
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
|
||||
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(); 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(); } };
|
||||
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(); } };
|
||||
})();
|
||||
@@ -3,7 +3,7 @@
|
||||
const form = $("notfound-form"), dialog = $("notfound-dialog"), list = $("notfound-list");
|
||||
if (!form || !dialog || !list) return;
|
||||
const sortColumns = ["id", "name", "hostname", "status", "mode", "listeners", "certificate"];
|
||||
let notFoundAutoCertificate = null, editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [], listPrefs = { sort: "id", descending: false };
|
||||
let 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 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(); }
|
||||
@@ -44,11 +44,9 @@
|
||||
editing = item.id || null; revision = envelope?.revision ?? null; dirty = false;
|
||||
$("notfound-editing").textContent = editing || t("notfoundNewLabel");
|
||||
$("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;
|
||||
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(); } });
|
||||
const hostHolder = $("notfound-hosts-editor"); hostHolder.replaceChildren(); readHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "notfoundHosts", helpKey: "vhHostsHelp", requiredKey: "notfoundHostsRequired", onChange: changed });
|
||||
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();
|
||||
}
|
||||
function suspendDraft() {
|
||||
@@ -81,15 +79,9 @@
|
||||
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 => {
|
||||
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); head.append(identity);
|
||||
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 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;
|
||||
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);
|
||||
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 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;
|
||||
}));
|
||||
if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine"));
|
||||
}
|
||||
|
||||
+92
-243
@@ -58,12 +58,9 @@
|
||||
data-i18n="transform">Transform</b></button>
|
||||
<button class="nav" data-panel="settings"><span>13</span><b data-i18n="settings">Settings</b></button>
|
||||
</nav>
|
||||
<div class="sidebar-bottom" id="app-health-slot" hidden>
|
||||
<button class="local-tag app-health-button is-checking" id="app-health-open" type="button"
|
||||
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 class="sidebar-bottom"><span class="small-label" data-i18n="runtime">RUNTIME</span>
|
||||
<p>Rust / Tokio / Hyper<br />Axum / Tower / SQLite</p>
|
||||
<div class="local-tag"><i></i><span data-i18n="controlPlane">CONTROL PLANE</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main>
|
||||
@@ -100,7 +97,7 @@
|
||||
</div>
|
||||
<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}" 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"
|
||||
id="password" maxlength="128" required="" type="password" />
|
||||
<div hidden="" id="confirm-field"><label data-i18n="confirmPassword" for="password-confirm">Confirm
|
||||
@@ -124,13 +121,17 @@
|
||||
aria-label="Overview chart samples" data-i18n-aria="overviewSamplesTitle">
|
||||
<label class="function-live-chart-samples" for="overview-live-samples"><span
|
||||
data-i18n="liveChartSamples">Samples</span>
|
||||
<select id="overview-live-samples">
|
||||
<select id="overview-live-samples" aria-describedby="overview-samples-state">
|
||||
<option value="60">60</option>
|
||||
<option value="120">120</option>
|
||||
<option value="300">300</option>
|
||||
<option value="600">600</option>
|
||||
</select>
|
||||
</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 class="page-intro-title-row">
|
||||
@@ -144,9 +145,6 @@
|
||||
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
|
||||
<button class="primary" id="overview-inline-save" type="button"
|
||||
data-i18n="overviewLayoutInlineSave">Save and lock</button>
|
||||
<button class="quiet" id="overview-inline-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"
|
||||
data-i18n="overviewLayoutInlineCancel">Cancel changes</button>
|
||||
</div>
|
||||
@@ -628,8 +626,7 @@
|
||||
data-i18n="dockerExpandAllMetrics" id="docker-metrics-toggle-all" type="button"
|
||||
aria-expanded="false" disabled>Expand all statistics</button><button class="quiet"
|
||||
data-i18n="dockerAgentsSettings" id="docker-settings-open" type="button">Docker agents
|
||||
settings</button><button class="quiet" data-i18n="dockerHideDisabled"
|
||||
id="docker-hide-disabled" type="button" disabled>Hide disabled</button><button class="quiet" data-i18n="dockerShowHidden"
|
||||
settings</button><button class="quiet" data-i18n="dockerShowHidden"
|
||||
id="docker-show-hidden" type="button">Show hidden</button></div>
|
||||
</article>
|
||||
<div class="docker-agent-observations" id="docker-discovery-list"></div>
|
||||
@@ -773,10 +770,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
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>
|
||||
<section class="acl-policy-builder" aria-labelledby="acl-policy-mode-label">
|
||||
<div class="acl-policy-step-head">
|
||||
@@ -985,7 +982,7 @@
|
||||
<div class="vh-grid">
|
||||
<label><span data-i18n="certificateId">Certificate ID</span><input
|
||||
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
|
||||
autocomplete="off" id="acme-email" maxlength="254" required=""
|
||||
type="email" /></label>
|
||||
@@ -1066,7 +1063,7 @@
|
||||
<h3 data-i18n="certImport"></h3>
|
||||
<p class="fine" data-i18n="certLibraryHint"></p>
|
||||
<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="" />
|
||||
<label data-i18n="certCheckDomains" for="cert-import-domains"></label><input
|
||||
id="cert-import-domains" placeholder="app.example.com" required="" />
|
||||
@@ -1084,7 +1081,7 @@
|
||||
<form class="edit-form" id="certificate-form">
|
||||
<h3 data-i18n="addCertificate"></h3>
|
||||
<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="" />
|
||||
<label data-i18n="certLegacyNames" for="certificate-hosts"></label><input
|
||||
id="certificate-hosts" placeholder="app.localhost" />
|
||||
@@ -1097,10 +1094,10 @@
|
||||
<p class="fine" id="certificate-file-source-help"
|
||||
data-i18n="certificateSourceManagedHelp"></p>
|
||||
<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="" />
|
||||
<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="" />
|
||||
<div hidden id="certificate-external-acl-example"></div>
|
||||
<div class="form-actions"><button type="button" class="quiet"
|
||||
@@ -1399,8 +1396,8 @@
|
||||
<article class="card">
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="cacheObjectList">Stored objects</h2>
|
||||
<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" aria-live="polite"></span><button
|
||||
<div class="entity-actions"><button class="quiet" data-i18n="cachePrevious" id="cache-prev"
|
||||
type="button">Previous</button><span class="fine" id="cache-page"></span><button
|
||||
class="quiet" data-i18n="cacheNext" id="cache-next" type="button">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1520,7 +1517,7 @@
|
||||
<h2 data-i18n="transformByType">Transformations by type</h2>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="cache-table transform-type-table">
|
||||
<table class="cache-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="transformType">Type</th>
|
||||
@@ -1547,7 +1544,7 @@
|
||||
<p class="fine" data-i18n="transformScopeHint">Counters are in-memory runtime diagnostics. A
|
||||
transformed cache HIT does not execute the transformer again.</p>
|
||||
<div class="table-scroll">
|
||||
<table class="cache-table transform-vhost-table">
|
||||
<table class="cache-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="vhVhost">Vhost</th>
|
||||
@@ -1794,17 +1791,6 @@
|
||||
</select><small class="fine" data-i18n="buttonStyleHelp">Normal uses the
|
||||
refreshed controls; Compact restores the smaller source-style buttons
|
||||
across all actions and dialog tabs.</small></label>
|
||||
<label 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
|
||||
id="ui-corner-style">
|
||||
<option data-i18n="cornerStyleDefault" value="default">Original
|
||||
@@ -1822,7 +1808,7 @@
|
||||
<section class="appearance-section">
|
||||
<div class="appearance-section-heading">
|
||||
<div><span class="small-label" data-i18n="appearanceChartsSection">CHARTS</span>
|
||||
<h3 data-i18n="appearanceChartsTitle">Charts</h3>
|
||||
<h3 data-i18n="chartStyle">Chart style</h3>
|
||||
</div><button class="quiet appearance-section-reset"
|
||||
data-appearance-reset="charts" data-i18n="resetSection" type="button">Reset
|
||||
section</button>
|
||||
@@ -1841,14 +1827,6 @@
|
||||
</option>
|
||||
<option data-i18n="chartStyleWarm" value="warm">Warm</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>
|
||||
<label class="check appearance-chart-axes-setting"><input id="ui-chart-axes"
|
||||
type="checkbox" /><span><b data-i18n="chartAxes">Show X/Y scales on
|
||||
@@ -1873,7 +1851,7 @@
|
||||
<div class="appearance-section-heading">
|
||||
<div><span class="small-label" data-i18n="appearanceDataSection">TABLES /
|
||||
LOGS</span>
|
||||
<h3 data-i18n="appearanceTablesTitle">Tables and logs</h3>
|
||||
<h3 data-i18n="tableDensity">Tables and logs density</h3>
|
||||
</div><button class="quiet appearance-section-reset"
|
||||
data-appearance-reset="tables" data-i18n="resetSection" type="button">Reset
|
||||
section</button>
|
||||
@@ -2123,184 +2101,79 @@
|
||||
<div data-policy-settings-page="block-allow">
|
||||
<form class="card edit-form global-policy-form" id="global-security-form">
|
||||
<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>
|
||||
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
|
||||
</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">
|
||||
<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">
|
||||
<label><span data-i18n="globalCountryMode">Country policy</span><select
|
||||
id="global-country-mode">
|
||||
<option value="off" data-i18n="vhOff">Off</option>
|
||||
<option value="block" data-i18n="vhCountryBlock">Block listed
|
||||
</option>
|
||||
<option value="allow" data-i18n="vhCountryAllow">Allow listed only
|
||||
</option>
|
||||
</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="globalCountryMode">Country policy</span><select id="global-country-mode">
|
||||
<option value="off" data-i18n="vhOff">Off</option>
|
||||
<option value="block" data-i18n="vhCountryBlock">Block listed</option>
|
||||
<option value="allow" data-i18n="vhCountryAllow">Allow listed only</option>
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
<label class="check"><input id="global-ip-enabled" type="checkbox" /><span
|
||||
data-i18n="globalIpEnabled">Enable global IP rules</span></label>
|
||||
<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>
|
||||
<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">
|
||||
<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="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>
|
||||
<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="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>
|
||||
</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">
|
||||
<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="globalMethodList">Methods (comma, space or newline
|
||||
separated)</span><textarea id="global-method-list" rows="2"
|
||||
maxlength="1024" spellcheck="false"
|
||||
placeholder="TRACE, TRACK"></textarea></label>
|
||||
<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="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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
<label class="check"><input id="global-rate-enabled" type="checkbox" /><span
|
||||
data-i18n="globalRateEnabled">Enable global request rate
|
||||
limit</span></label>
|
||||
<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>
|
||||
<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">
|
||||
<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="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>
|
||||
<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="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>
|
||||
</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>
|
||||
</div>
|
||||
<div data-policy-settings-page="security-headers" hidden="">
|
||||
<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>
|
||||
<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="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>
|
||||
<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">
|
||||
<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 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>
|
||||
<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 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>
|
||||
</section>
|
||||
<section class="global-policy-section">
|
||||
<h3 data-i18n="vhSecurityHeadersSection">Response security headers</h3>
|
||||
<label class="check"><input id="global-nosniff" type="checkbox" /><span
|
||||
data-i18n="vhNosniff">X-Content-Type-Options: nosniff</span></label>
|
||||
<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><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 class="global-policy-section"><h3 data-i18n="vhSecurityHeadersSection">Response security headers</h3>
|
||||
<label class="check"><input id="global-nosniff" type="checkbox"/><span data-i18n="vhNosniff">X-Content-Type-Options: nosniff</span></label>
|
||||
<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><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>
|
||||
<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>
|
||||
</div>
|
||||
<div data-policy-settings-page="headers" hidden="">
|
||||
@@ -2393,7 +2266,6 @@
|
||||
data-i18n="cacheTabDiagnostics" type="button">Static & diagnostics</button>
|
||||
</nav>
|
||||
<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
|
||||
data-i18n="enableCache">Enable shared cache</span></label>
|
||||
<p class="fine" data-i18n="cacheSafety">Only complete public-safe GET responses.
|
||||
@@ -2439,17 +2311,7 @@
|
||||
<input id="cache-varnish-grace" type="number" min="0" max="604800" step="1"
|
||||
required />
|
||||
</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">
|
||||
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
|
||||
<span class="pill" id="cache-memory-preset-selected">-</span>
|
||||
@@ -2503,7 +2365,14 @@
|
||||
<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
|
||||
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 data-cache-settings-page="resilience" hidden>
|
||||
<h3 class="subheading" data-i18n="cacheCollapseTitle">Request collapsing</h3>
|
||||
@@ -2631,11 +2500,9 @@
|
||||
<label data-i18n="cacheStaticExtensions" for="cache-static-extensions">Global static
|
||||
extensions</label><textarea id="cache-static-extensions" rows="5"
|
||||
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>
|
||||
<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"
|
||||
type="checkbox" /><span>X-Cache: HIT / MISS /
|
||||
BYPASS</span></label><label class="check"><input id="cache-hits-header"
|
||||
@@ -2741,8 +2608,7 @@
|
||||
concurrent transformations</label><input id="transform-max-inflight" min="1"
|
||||
max="1024" required="" type="number" /></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">
|
||||
<label class="check"><input id="transform-javascript" type="checkbox" /><span
|
||||
data-i18n="transformJavascript">JavaScript minification</span></label>
|
||||
@@ -3277,8 +3143,8 @@
|
||||
<p class="fine" data-i18n="tlsPqcHelp">TLS 1.3 only: prioritize hybrid
|
||||
X25519MLKEM768 with classic key exchanges as fallback. This is a key exchange
|
||||
group, not a cipher suite. Disabled by default.</p>
|
||||
<a href="/docs#doc=docs%2FTRANSPORT.md" target="_blank" rel="noopener noreferrer"
|
||||
data-i18n="tlsPqcDocs">Learn about PQC, compatibility
|
||||
<a href="/docs#doc=docs%2FTRANSPORT.md" target="_blank"
|
||||
rel="noopener noreferrer" data-i18n="tlsPqcDocs">Learn about PQC, compatibility
|
||||
and testing (documentation)</a>
|
||||
</div>
|
||||
<p class="fine" id="tls-policy-key-exchange"></p>
|
||||
@@ -3432,7 +3298,8 @@
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>__RUSTPROXY_VERSION__</b></span><span
|
||||
id="footer-status">API -</span></footer>
|
||||
id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -3800,10 +3667,10 @@
|
||||
service ID/name and PHP-FPM endpoint are not replaced.</p>
|
||||
</div>
|
||||
</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"
|
||||
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
|
||||
id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
|
||||
<div class="vh-wide" hidden id="web-service-permissions"></div>
|
||||
@@ -4290,10 +4157,10 @@ log_errors = On</code></pre>
|
||||
<fieldset class="vh-group editor-identity-section">
|
||||
<legend data-i18n="hostIdentitySection">Host and domains</legend>
|
||||
<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"
|
||||
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
|
||||
data-i18n="enabled">Enabled</span></label>
|
||||
<div class="vh-wide" id="redirect-hosts-editor"></div>
|
||||
@@ -4348,10 +4215,10 @@ log_errors = On</code></pre>
|
||||
<fieldset class="vh-group editor-identity-section">
|
||||
<legend data-i18n="hostIdentitySection">Host and domains</legend>
|
||||
<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"
|
||||
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
|
||||
data-i18n="enabled">Enabled</span></label>
|
||||
<div class="vh-wide" id="notfound-hosts-editor"></div>
|
||||
@@ -4423,23 +4290,6 @@ log_errors = On</code></pre>
|
||||
<p class="fine" data-i18n="streamRestartHelp">Changing a stream listener requires a RustProxy restart.</p>
|
||||
</form>
|
||||
</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">
|
||||
<div class="window-heading">
|
||||
<h2 data-i18n="aboutTitle" id="about-title">About RustProxy</h2>
|
||||
@@ -4485,16 +4335,15 @@ log_errors = On</code></pre>
|
||||
</ul>
|
||||
</section>
|
||||
<div class="about-links">
|
||||
<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>
|
||||
<a class="outline about-repository" data-ui-icon-name="none" href="https://git.linuxiarz.pl/gru/RustProxy"
|
||||
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
|
||||
target="_blank"><span data-i18n="aboutPackagesRepository">Website and packages</span><span>rustproxy.linuxiarz.pl</span></a>
|
||||
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy"
|
||||
rel="noopener noreferrer" target="_blank"><span data-i18n="aboutRepository">Source
|
||||
code</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
|
||||
<a class="outline about-repository" data-ui-icon-name="none" href="/docs#doc=docs%2FREADME.md" rel="noopener"
|
||||
<a class="outline about-repository" href="/docs#doc=docs%2FREADME.md" rel="noopener"
|
||||
target="_blank"><span data-i18n="aboutDocumentation">Documentation and API
|
||||
reference</span><span>/docs</span></a>
|
||||
<a class="outline about-repository" data-ui-icon-name="none" href="/docs#doc=LICENSE" rel="noopener" target="_blank"><span
|
||||
<a class="outline about-repository" href="/docs#doc=LICENSE" rel="noopener" target="_blank"><span
|
||||
data-i18n="aboutLicenseText">Full license text</span><span>LICENSE</span></a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
window.rustProxySettings?.selectHttpTab?.("http2");
|
||||
document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" });
|
||||
});
|
||||
h2Settings.classList.add("listener-h2-settings-link"); h2Settings.dataset.uiIconName = "settings";
|
||||
h2Settings.classList.add("listener-h2-settings-link");
|
||||
const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null);
|
||||
const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox");
|
||||
const h3Settings = button(protocolActions, "h3Configure", () => {
|
||||
@@ -141,7 +141,7 @@
|
||||
window.rustProxySettings?.selectHttpTab?.("http3");
|
||||
document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" });
|
||||
});
|
||||
h3Settings.classList.add("listener-h3-settings-link"); h3Settings.dataset.uiIconName = "settings";
|
||||
h3Settings.classList.add("listener-h3-settings-link");
|
||||
const sync = () => {
|
||||
h2.disabled = !tls.checked; h2c.disabled = tls.checked; h3.disabled = !tls.checked;
|
||||
h2Settings.hidden = !((tls.checked && h2.checked) || (!tls.checked && h2c.checked));
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
type="password" />
|
||||
</div>
|
||||
<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" />
|
||||
<label data-i18n="password" for="password">Password</label><input autocomplete="current-password"
|
||||
id="password" maxlength="128" required="" type="password" />
|
||||
|
||||
@@ -85,11 +85,6 @@
|
||||
];
|
||||
const defaultColumns = () => new Set(columns.map(column => column.id));
|
||||
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 mergeExpanded(data) {
|
||||
if (!expanded || paused || !data?.entries) return;
|
||||
@@ -194,7 +189,7 @@
|
||||
if (!preferencesLoaded) void loadPreferences();
|
||||
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(); }
|
||||
render(); syncLivePolling(true);
|
||||
render();
|
||||
}
|
||||
syncColumnPicker(); syncHeaders();
|
||||
$("logs-open").addEventListener("click", () => { $("logs-menu").open = false; open(); }); $("logs-close").addEventListener("click", () => dialog.close());
|
||||
@@ -205,14 +200,14 @@
|
||||
expanded = { ...snapshot, capacity: 4096, window: 4096 }; render();
|
||||
toast(t("logBufferExpanded").replace("{count}", String(snapshot.entries?.length || 0)));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { button.disabled = Boolean(expanded); syncLivePolling(); }
|
||||
finally { button.disabled = Boolean(expanded); }
|
||||
});
|
||||
$("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(); syncLivePolling(true); });
|
||||
dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); syncLivePolling(); });
|
||||
$("logs-pause").addEventListener("click", () => { paused = paused ? null : structuredClone(source()); 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(); });
|
||||
dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); });
|
||||
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); });
|
||||
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(); } };
|
||||
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(); } };
|
||||
})();
|
||||
|
||||
(() => {
|
||||
@@ -366,20 +361,15 @@
|
||||
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();
|
||||
}
|
||||
function attachAclProfileId() { window.rustProxyAutoId.attach($("acl-profile-id"), { elements: [$("acl-profile-name")], taken: () => (envelope?.config.acl_profiles || []).map(value => value.id) }); }
|
||||
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; attachAclProfileId(); $("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; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy();
|
||||
}
|
||||
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; attachAclProfileId();
|
||||
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id;
|
||||
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");
|
||||
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) {
|
||||
const mode = inferMode(profile), countries = profile.country_access?.countries || [], rulesList = profile.acl?.rules || [];
|
||||
if (mode === "allow_exceptions" || mode === "block_exceptions") {
|
||||
@@ -426,7 +416,7 @@
|
||||
attached.append(el("summary", `${t("aclProfileAttached")} (${users.length})`), el("p", users.join(", "), "entity-details"));
|
||||
box.append(attached);
|
||||
}
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("copyEntry"), () => copyProfile(profile)), action(t("remove"), async () => {
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => {
|
||||
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();
|
||||
})); box.append(actions); return box;
|
||||
|
||||
@@ -334,8 +334,7 @@
|
||||
<div>
|
||||
<h3 class="overview-chart-heading" data-i18n="systemLoad">System load</h3>
|
||||
<h2><span id="system-cpu">-</span> <small>CPU</small></h2>
|
||||
</div><span class="meta system-uptime-meta"><span data-i18n="systemUptime">System uptime</span>: <b
|
||||
id="system-uptime">-</b></span>
|
||||
</div><span class="meta" id="system-source">-</span>
|
||||
</div><svg preserveAspectRatio="none" aria-label="System CPU history" data-i18n-aria="systemChart" id="system-chart"
|
||||
role="img" viewbox="0 0 300 100">
|
||||
<path class="chart-grid" d="M0 25H300 M0 50H300 M0 75H300"></path>
|
||||
@@ -349,15 +348,14 @@
|
||||
<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>
|
||||
<div class="chart-footer"><span data-i18n="systemUptime">System uptime</span><span id="system-uptime">-</span></div>
|
||||
</article>
|
||||
<article class="card connection-card" data-dashboard-widget="connection_chart">
|
||||
<div class="card-heading">
|
||||
<div class="connection-heading-main">
|
||||
<div>
|
||||
<h3 class="overview-chart-heading" data-i18n="connectionActivity">Connection activity</h3>
|
||||
<h2><span class="connection-current-main"><span id="connection-current">0</span> <small
|
||||
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>
|
||||
<h2><span id="connection-current">0</span> <small data-i18n="connections">Open
|
||||
connections</small></h2>
|
||||
</div>
|
||||
</div>
|
||||
<svg preserveAspectRatio="none" aria-label="Connection activity history" data-i18n-aria="connectionActivity"
|
||||
@@ -370,4 +368,6 @@
|
||||
connections</span> <b id="connection-open-value">0</b></span><span
|
||||
class="connection-exchange-key"><i></i><span data-i18n="activeExchanges">Active
|
||||
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>
|
||||
@@ -34,42 +34,39 @@
|
||||
<article class="stat stat-cache-outcomes" data-dashboard-widget="cache_outcomes">
|
||||
<div class="cache-mix-header">
|
||||
<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>
|
||||
<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" aria-label="Cumulative HTTP cache outcomes since service start">
|
||||
<span class="cache-mix-hit" id="cache-mix-hit" style="width:0%"></span>
|
||||
<span class="cache-mix-miss" id="cache-mix-miss" style="width:0%"></span>
|
||||
<span class="cache-mix-bypass" id="cache-mix-bypass" style="width:0%"></span>
|
||||
<span class="cache-mix-other" id="cache-mix-other" style="width:0%"></span>
|
||||
</div>
|
||||
<small class="cache-mix-legend">
|
||||
<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" id="cache-mix-miss-key"><i></i>MISS <b id="cache-mix-miss-count">-</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" id="cache-mix-other-key"><i></i><span data-i18n="cacheMixOther">OTHER</span> <b id="cache-mix-other-count">-</b></span>
|
||||
<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-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-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-other-key"><i></i><span data-i18n="cacheMixOther">OTHER</span> <b><span id="cache-mix-other-count">-</span><span class="cache-mix-percent"
|
||||
id="cache-mix-other-percent">-</span></b></span>
|
||||
</small>
|
||||
</article>
|
||||
<article class="stat" data-dashboard-widget="memory"><span data-i18n="processMemory">Process
|
||||
memory</span><strong id="metric-rss">-</strong><small id="process-summary">-</small>
|
||||
</article>
|
||||
<article class="stat stat-with-action" data-dashboard-widget="websocket"><span data-i18n="wsActive">WebSocket
|
||||
tunnels</span><button class="pill stat-corner-action stat-corner-badge" data-i18n="tunnelsOpen"
|
||||
data-ui-icon-name="none" id="tunnels-open" type="button">Active tunnels</button><div class="stat-value-row"><strong
|
||||
id="metric-ws">0</strong><small class="stat-side-value" title="Established"><span
|
||||
data-i18n="wsEstablished">Established</span> <b id="metric-ws-total">0</b></small></div><small
|
||||
aria-label="Active WebSocket tunnels by frontend protocol"><span data-i18n="wsH1" title="H1 Upgrade">H1</span>
|
||||
<span id="metric-ws-h1">0</span> · <span data-i18n="wsH2Rfc8441" title="H2 RFC 8441">H2</span>
|
||||
<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>
|
||||
tunnels</span><button class="quiet stat-corner-action" data-i18n="tunnelsOpen" id="tunnels-open"
|
||||
type="button">Active tunnels</button><strong id="metric-ws">0</strong><small
|
||||
aria-label="Active WebSocket tunnels by frontend protocol"><span data-i18n="wsH1">H1
|
||||
Upgrade</span> <span id="metric-ws-h1">0</span> · <span data-i18n="wsH2Rfc8441">H2
|
||||
RFC 8441</span> <span id="metric-ws-h2">0</span> · <span data-i18n="wsH3Rfc9220">H3
|
||||
RFC 9220</span> <span id="metric-ws-h3">0</span><br><span data-i18n="wsEstablished">Established</span>:
|
||||
<span id="metric-ws-total">0</span></small></article>
|
||||
<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">H2
|
||||
requests</span><strong id="metric-h2">0</strong><small><span data-i18n="h2ActiveConnections">Active H2
|
||||
connections</span>: <span id="metric-h2-active">0</span></small></article>
|
||||
<article class="stat" data-dashboard-widget="http3"><span data-i18n="h3Requests">HTTP/3
|
||||
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>
|
||||
requests</span><strong id="metric-h3">0</strong></article>
|
||||
<article class="stat" data-dashboard-widget="tls"><span data-i18n="tlsFailures">TLS handshake
|
||||
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
|
||||
@@ -145,14 +142,13 @@
|
||||
</polyline>
|
||||
</svg><small id="backend-dashboard-errors-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart backend-latency-stat" data-dashboard-widget="backend_latency" hidden>
|
||||
<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>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_latency" hidden>
|
||||
<span data-i18n="backendWidgetLatency">Health probe RTT</span><strong id="backend-dashboard-latency">-</strong>
|
||||
<svg 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>
|
||||
<polyline class="diagnostic-line proto-h2-line" id="backend-dashboard-latency-line" points="0,55 300,55">
|
||||
</polyline>
|
||||
</svg>
|
||||
</svg><small id="backend-dashboard-latency-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="backend_traffic" hidden>
|
||||
<span data-i18n="backendWidgetTraffic">Backend traffic</span><strong id="backend-dashboard-traffic">-</strong>
|
||||
@@ -369,47 +365,13 @@
|
||||
</polyline>
|
||||
</svg><small id="overview-extra-extra_backend_unhealthy-detail">-</small>
|
||||
</article>
|
||||
<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>
|
||||
<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="-">
|
||||
<span class="overview-meter-fill" id="overview-extra-extra_backends_healthy-meter-fill"></span>
|
||||
</div><small id="overview-extra-extra_backends_healthy-detail">-</small>
|
||||
</article>
|
||||
<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>
|
||||
<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>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_error_share-line" points="0,55 300,55">
|
||||
</polyline>
|
||||
</svg><small id="overview-extra-extra_error_share-detail">-</small>
|
||||
</article>
|
||||
<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>
|
||||
<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>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_rejected_rate-line" points="0,55 300,55">
|
||||
</polyline>
|
||||
</svg><small id="overview-extra-extra_rejected_rate-detail">-</small>
|
||||
</article>
|
||||
<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>
|
||||
<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="-">
|
||||
<span class="overview-meter-fill" id="overview-extra-extra_cert_expiry-meter-fill"></span>
|
||||
</div><small id="overview-extra-extra_cert_expiry-detail">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_total_rps" hidden>
|
||||
<span data-i18n="overviewExtraTotalRps">HTTP requests / s</span><strong
|
||||
id="overview-extra-extra_total_rps">-</strong>
|
||||
<svg 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>
|
||||
<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" hidden></small>
|
||||
</svg><small id="overview-extra-extra_total_rps-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_active_exchanges" hidden>
|
||||
<span data-i18n="overviewExtraActiveExchanges">Active HTTP exchanges</span><strong
|
||||
@@ -418,21 +380,21 @@
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_active_exchanges-line" points="0,55 300,55">
|
||||
</polyline>
|
||||
</svg><small id="overview-extra-extra_active_exchanges-detail" hidden></small>
|
||||
</svg><small id="overview-extra-extra_active_exchanges-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<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>
|
||||
<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="-">
|
||||
<span class="overview-meter-fill" id="overview-extra-extra_h1_share-meter-fill"></span>
|
||||
</div><small id="overview-extra-extra_h1_share-detail" hidden></small>
|
||||
</div><small id="overview-extra-extra_h1_share-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<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>
|
||||
<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="-">
|
||||
<span class="overview-meter-fill" id="overview-extra-extra_h3_share-meter-fill"></span>
|
||||
</div><small id="overview-extra-extra_h3_share-detail" hidden></small>
|
||||
</div><small id="overview-extra-extra_h3_share-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_cache_miss_rate" hidden>
|
||||
<span data-i18n="overviewExtraCacheMissRate">Cache misses / s</span><strong
|
||||
@@ -441,7 +403,7 @@
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="overview-extra-extra_cache_miss_rate-line" points="0,55 300,55">
|
||||
</polyline>
|
||||
</svg><small id="overview-extra-extra_cache_miss_rate-detail" hidden></small>
|
||||
</svg><small id="overview-extra-extra_cache_miss_rate-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<article class="stat stat-chart" data-dashboard-widget="extra_backend_active" hidden>
|
||||
<span data-i18n="overviewExtraBackendActive">In-flight backend requests</span><strong
|
||||
@@ -449,7 +411,7 @@
|
||||
<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>
|
||||
<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" hidden></small>
|
||||
</svg><small id="overview-extra-extra_backend_active-detail" data-i18n="overviewExtraRecentSamples">-</small>
|
||||
</article>
|
||||
<!-- RP_METRICS_SPLIT -->
|
||||
<article class="stat stat-donut" data-dashboard-widget="www_status" hidden>
|
||||
@@ -525,4 +487,5 @@
|
||||
<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>
|
||||
</svg>
|
||||
<small data-i18n="webMetricPerSecond">Requests per second</small>
|
||||
</article>
|
||||
@@ -3,7 +3,7 @@
|
||||
const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules");
|
||||
if (!form || !dialog || !list || !rules) return;
|
||||
const sortColumns = ["id", "name", "hostname", "status", "target", "rules", "listeners", "certificate"];
|
||||
let redirectAutoCertificate = null, editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [], listPrefs = { sort: "id", descending: false };
|
||||
let 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 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(); }
|
||||
@@ -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 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 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 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;
|
||||
@@ -70,11 +70,9 @@
|
||||
$("redirect-editing").textContent = editing ? editing : t("redirectNewLabel");
|
||||
$("redirect-id").value = item.id || ""; $("redirect-id").disabled = Boolean(editing);
|
||||
$("redirect-name").value = item.name || "";
|
||||
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(); } });
|
||||
const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: changed });
|
||||
$("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);
|
||||
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);
|
||||
dialog.showModal();
|
||||
}
|
||||
@@ -120,18 +118,11 @@
|
||||
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 => {
|
||||
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); head.append(identity);
|
||||
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);
|
||||
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);
|
||||
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 targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))];
|
||||
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"));
|
||||
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;
|
||||
const targetSummary = targets.slice(0, 2).join(" · ") + (targets.length > 2 ? ` · +${targets.length - 2}` : "");
|
||||
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;
|
||||
}));
|
||||
if (!entries.length) list.append(el("p", t(query ? "redirectSearchEmpty" : "redirectEmpty"), "fine"));
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
window.rustProxyRefresh?.setSettingsTab(name);
|
||||
if (authenticated && name === "security") void window.rustProxyApiTokens?.refresh?.();
|
||||
if (authenticated && name === "prometheus") void window.rustProxyPrometheusToken?.refresh?.();
|
||||
if (authenticated && name === "certificate-api") void window.rustProxyCertificateApi?.load?.();
|
||||
if (name !== "prometheus") window.rustProxyPrometheusToken?.hideSecret?.();
|
||||
if (name !== "security") window.rustProxyApiTokens?.hideSecret?.();
|
||||
}
|
||||
@@ -252,8 +251,7 @@
|
||||
$("tls-policy-key-exchange").textContent = kxGroups.length ? `${t("tlsPqcKeyExchangeGroups")}: ${kxGroups.join(", ")}` : "";
|
||||
$("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : "";
|
||||
$("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : "";
|
||||
window.rustProxyCertificateApi?.render();
|
||||
if (authenticated && active === "certificate-api") void window.rustProxyCertificateApi?.load?.();
|
||||
window.rustProxyCertificateApi?.render(); window.rustProxyCertificateApi?.load();
|
||||
}
|
||||
$("geoip-format").addEventListener("change", syncGeoipFormat); $("geoip-enabled").addEventListener("change", syncGeoipFormat);
|
||||
bindSettingsForm("geoip-form", next => {
|
||||
|
||||
@@ -127,9 +127,8 @@
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
||||
$("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel");
|
||||
let g = group("streamEditor", "details");
|
||||
const streamName = 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) });
|
||||
field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
|
||||
field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
|
||||
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");
|
||||
window.rustProxyEnableControl(enabledControl);
|
||||
@@ -214,7 +213,7 @@
|
||||
if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill"));
|
||||
identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
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 () => {
|
||||
actions.append(button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), 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(); }
|
||||
}));
|
||||
head.append(actions); box.append(head);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
let loading = false, lastData = null, activeTab = "overview", staticData = null, staticRevision = null;
|
||||
let loading = false, lastData = null, activeTab = "overview";
|
||||
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
|
||||
const scope = () => $("traffic-scope")?.value || "*";
|
||||
const number = value => fmt(Number(value) || 0);
|
||||
@@ -116,17 +116,7 @@
|
||||
if (!authenticated || currentPanel !== "traffic" || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
const data = await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`, { signal });
|
||||
if (isCurrent()) render(data);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError" && isCurrent()) $("traffic-panel-state").textContent = error.message;
|
||||
@@ -135,7 +125,7 @@
|
||||
function refresh() { return rustProxyRefresh.manual("traffic"); }
|
||||
|
||||
function reset() {
|
||||
lastData = null; staticData = null; staticRevision = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
||||
lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
||||
document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren());
|
||||
for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) {
|
||||
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
let loading = false, generation = 0, lastData = null, staticData = null, staticRevision = null;
|
||||
let loading = false, generation = 0, lastData = null;
|
||||
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
|
||||
const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] };
|
||||
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
|
||||
@@ -103,7 +103,7 @@
|
||||
function renderReasons(counters) {
|
||||
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 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;
|
||||
const tag = el("span", `${key}: ${number(count)}`, "cache-reason"); tag.title = key; return tag;
|
||||
}));
|
||||
$("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");
|
||||
@@ -113,12 +113,16 @@
|
||||
const rows = (data?.metrics?.scopes || []).filter(row => wanted === "*" || row.vhost === wanted);
|
||||
$("transform-vhost-rows").replaceChildren(...rows.map(row => {
|
||||
const tr = el("tr");
|
||||
tr.append(el("td", row.vhost, "transform-vhost-name"));
|
||||
for (const value of [number(row.applied), number(row.cache_hits), number(row.skipped)]) tr.append(el("td", value));
|
||||
tr.append(el("td", number(row.errors), Number(row.errors) > 0 ? "transform-errors-nonzero" : "transform-zero"), el("td", fmtBytes(row.saved_bytes || 0)));
|
||||
const original = Number(row.original_bytes) || 0, share = original > 0 ? Math.max(0, Math.min(100, (Number(row.saved_bytes) || 0) / original * 100)) : 0;
|
||||
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);
|
||||
tr.append(reduction, el("td", averageMs(row)));
|
||||
for (const value of [
|
||||
row.vhost,
|
||||
number(row.applied),
|
||||
number(row.cache_hits),
|
||||
number(row.skipped),
|
||||
number(row.errors),
|
||||
fmtBytes(row.saved_bytes || 0),
|
||||
percent(row),
|
||||
averageMs(row)
|
||||
]) tr.append(el("td", value));
|
||||
return tr;
|
||||
}));
|
||||
$("transform-vhosts-empty").hidden = rows.length > 0;
|
||||
@@ -151,14 +155,7 @@
|
||||
loading = true;
|
||||
const epoch = authEpoch, token = generation;
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
const data = await api("/api/transform", { signal });
|
||||
if (isCurrent() && token === generation) render(data);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||
@@ -182,7 +179,7 @@
|
||||
window.rustProxyTransformPanel = {
|
||||
refresh,
|
||||
reset() {
|
||||
generation++; lastData = null; staticData = null; staticRevision = null; resetLiveChart();
|
||||
generation++; lastData = null; resetLiveChart();
|
||||
$("transform-type-rows")?.replaceChildren();
|
||||
$("transform-vhost-rows")?.replaceChildren();
|
||||
$("transform-reason-list")?.replaceChildren();
|
||||
|
||||
@@ -38,7 +38,6 @@ const num = (name, scope) => { const value = Number(str(name, scope)); if (!Numb
|
||||
const yes = (name, scope) => read(name, scope).checked;
|
||||
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
const nullable = (name, scope) => str(name, scope) || null;
|
||||
const optionalPort = (name, scope) => { const value = str(name, scope); return value ? Number(value) : null; };
|
||||
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
|
||||
const changed = () => { dirty = true; };
|
||||
const fillWebServices = (select, chosen = "") => {
|
||||
@@ -237,8 +236,6 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
|
||||
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
|
||||
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
|
||||
field(healthFields, "member-health-port", "vhHealthPort", health.port ?? "", "number", { min: 1, max: 65535, step: 1 }).placeholder = t("vhHealthPortPlaceholder");
|
||||
field(healthFields, "member-health-expect-body", "vhHealthExpectBody", health.expect_body ?? "", "text", { maxLength: 256 });
|
||||
field(healthFields, "member-health-interval", "vhInterval", health.interval_ms ?? 5000, "number", { min: 250, max: 300000, required: true });
|
||||
field(healthFields, "member-health-timeout", "vhTimeout", health.timeout_ms ?? 2000, "number", { min: 50, max: 60000, required: true });
|
||||
field(healthFields, "member-health-slow", "vhSlow", health.slow_response_ms ?? 1500, "number", { min: 0, max: 60000, required: true });
|
||||
|
||||
@@ -69,9 +69,8 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
let g = group("vhEditor"); const basicFields = subsection(g, "vhGeneralDetailsSection");
|
||||
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 = () => { }, 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 });
|
||||
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 = () => { };
|
||||
const enabledControl = field(basicFields, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
||||
const enabledLabel = enabledControl.closest("label");
|
||||
@@ -89,8 +88,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
const websocketLegacy = field(websocketFields, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(websocketFields, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => { refreshCanonical(hosts); vhostAutoId?.refresh(); vhostAutoCertificate?.refresh(hosts); });
|
||||
if (!editing) g.lastElementChild?.after(vhostIdInput.closest("label"));
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||
@@ -240,8 +238,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
|
||||
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
|
||||
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
|
||||
field(g, "health-port", "vhHealthPort", health.port ?? "", "number", { min: 1, max: 65535, step: 1 }).placeholder = t("vhHealthPortPlaceholder");
|
||||
field(g, "health-expect-body", "vhHealthExpectBody", health.expect_body || "", "text", { maxLength: 256 });
|
||||
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
|
||||
backendHealthEditorControl = backendHealthEditor(g, members);
|
||||
note(g, "backendHealthOverridesHelp");
|
||||
@@ -350,7 +346,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
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 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"); }));
|
||||
note(g, "certSelectHint");
|
||||
const mismatch = field(g, "allow-certificate-mismatch", "vhCertificateMismatchForce", value.allow_certificate_mismatch === true, "checkbox");
|
||||
@@ -565,7 +560,7 @@ function collect() {
|
||||
pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row),
|
||||
health_check_enabled: yes("member-health-enabled", row),
|
||||
health_check: yes("member-health-custom", row.memberHealthPanel) ? {
|
||||
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel), port: optionalPort("member-health-port", row.memberHealthPanel), expect_body: nullable("member-health-expect-body", row.memberHealthPanel),
|
||||
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel),
|
||||
interval_ms: num("member-health-interval", row.memberHealthPanel), timeout_ms: num("member-health-timeout", row.memberHealthPanel), slow_response_ms: num("member-health-slow", row.memberHealthPanel),
|
||||
fall: num("member-health-fall", row.memberHealthPanel), rise: num("member-health-rise", row.memberHealthPanel),
|
||||
expected_status_min: num("member-health-min", row.memberHealthPanel), expected_status_max: num("member-health-max", row.memberHealthPanel)
|
||||
@@ -635,7 +630,7 @@ function collect() {
|
||||
load_balancing: {
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") },
|
||||
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
|
||||
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), port: optionalPort("health-port"), expect_body: nullable("health-expect-body"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
|
||||
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
|
||||
passive_health: {
|
||||
enabled: yes("passive-enabled"),
|
||||
count_connect_errors: yes("passive-connect"),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
function inheritBackendHealthInputs(panel) {
|
||||
for (const [local, shared] of [
|
||||
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
|
||||
["member-health-host", "health-host"], ["member-health-port", "health-port"], ["member-health-expect-body", "health-expect-body"], ["member-health-interval", "interval"],
|
||||
["member-health-host", "health-host"], ["member-health-interval", "interval"],
|
||||
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
|
||||
["member-health-fall", "fall"], ["member-health-rise", "rise"],
|
||||
["member-health-min", "status-min"], ["member-health-max", "status-max"]
|
||||
|
||||
@@ -327,7 +327,7 @@ function render() {
|
||||
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"}`);
|
||||
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
|
||||
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 () => {
|
||||
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
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(); }
|
||||
@@ -590,15 +590,6 @@ form.addEventListener("submit", async event => {
|
||||
wwwRedirect: item?.www_redirect === true,
|
||||
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 (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;
|
||||
|
||||
@@ -330,7 +330,6 @@
|
||||
$("web-service-id").value = item.id || "";
|
||||
$("web-service-id").readOnly = !!editing;
|
||||
$("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 || "";
|
||||
populateSettings(item);
|
||||
$("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> {
|
||||
ensure!(
|
||||
(2..=crate::db::ADMIN_PREFERENCES_MAX_BYTES).contains(&document.len()),
|
||||
(2..=4096).contains(&document.len()),
|
||||
"administrator preferences document is too large"
|
||||
);
|
||||
let result = sqlx::query(
|
||||
|
||||
@@ -450,7 +450,6 @@ pub struct Member {
|
||||
pub backend: Arc<CompiledBackend>,
|
||||
pub definition: PoolMember,
|
||||
pub state: Arc<BackendState>,
|
||||
health_probe: Option<Arc<CompiledBackend>>,
|
||||
health_settings: Arc<HealthCheck>,
|
||||
passive_settings: Arc<PassiveHealth>,
|
||||
token: String,
|
||||
|
||||
@@ -291,36 +291,12 @@ impl Pool {
|
||||
text
|
||||
}
|
||||
}
|
||||
const PROBE_BODY_LIMIT: usize = 65_536;
|
||||
|
||||
async fn probe_body_contains(mut body: hyper::body::Incoming, expected: &[u8]) -> bool {
|
||||
use http_body_util::BodyExt;
|
||||
let mut seen = Vec::new();
|
||||
while seen.len() < PROBE_BODY_LIMIT {
|
||||
match body.frame().await {
|
||||
Some(Ok(frame)) => {
|
||||
if let Ok(data) = frame.into_data() {
|
||||
let room = PROBE_BODY_LIMIT - seen.len();
|
||||
seen.extend_from_slice(&data[..data.len().min(room)]);
|
||||
if seen.windows(expected.len()).any(|window| window == expected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => return false,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
seen.windows(expected.len()).any(|window| window == expected)
|
||||
}
|
||||
|
||||
async fn probe(member: Arc<Member>, settings: Arc<HealthCheck>) {
|
||||
let start = Instant::now();
|
||||
let target = member.health_probe.as_deref().unwrap_or(&member.backend);
|
||||
let result = tokio::time::timeout(Duration::from_millis(settings.timeout_ms), async {
|
||||
match settings.mode {
|
||||
HealthMode::Tcp => {
|
||||
crate::upstream::tcp_probe(&target.origin, &target.address_policy)
|
||||
crate::upstream::tcp_probe(&member.backend.origin, &member.backend.address_policy)
|
||||
.await
|
||||
.map(|_| "ok")
|
||||
.unwrap_or("connect")
|
||||
@@ -329,14 +305,14 @@ async fn probe(member: Arc<Member>, settings: Arc<HealthCheck>) {
|
||||
let request = Request::builder()
|
||||
.uri(format!(
|
||||
"{}{}",
|
||||
target.request_origin, settings.path
|
||||
member.backend.request_origin, settings.path
|
||||
))
|
||||
.header(
|
||||
"host",
|
||||
settings
|
||||
.host
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| target.host_header.to_str().unwrap_or("")),
|
||||
.unwrap_or_else(|| member.backend.host_header.to_str().unwrap_or("")),
|
||||
)
|
||||
.header("connection", "close")
|
||||
.header(
|
||||
@@ -345,20 +321,14 @@ async fn probe(member: Arc<Member>, settings: Arc<HealthCheck>) {
|
||||
)
|
||||
.body(Body::empty());
|
||||
match request {
|
||||
Ok(request) => match target.websocket_client.request(request).await {
|
||||
Ok(request) => match member.backend.websocket_client.request(request).await {
|
||||
Ok(response) => {
|
||||
if !(settings.expected_status_min..=settings.expected_status_max)
|
||||
if (settings.expected_status_min..=settings.expected_status_max)
|
||||
.contains(&response.status().as_u16())
|
||||
{
|
||||
"http_status"
|
||||
} else if let Some(expected) = settings.expect_body.as_deref() {
|
||||
if probe_body_contains(response.into_body(), expected.as_bytes()).await {
|
||||
"ok"
|
||||
} else {
|
||||
"http_body"
|
||||
}
|
||||
} else {
|
||||
"ok"
|
||||
} else {
|
||||
"http_status"
|
||||
}
|
||||
}
|
||||
Err(_) => "connect",
|
||||
|
||||
@@ -99,8 +99,6 @@ pub struct HealthCheck {
|
||||
pub mode: HealthMode,
|
||||
pub path: String,
|
||||
pub host: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub expect_body: Option<String>,
|
||||
pub interval_ms: u64,
|
||||
pub timeout_ms: u64,
|
||||
pub slow_response_ms: u64,
|
||||
@@ -115,8 +113,6 @@ impl Default for HealthCheck {
|
||||
mode: HealthMode::Tcp,
|
||||
path: "/".into(),
|
||||
host: None,
|
||||
port: None,
|
||||
expect_body: None,
|
||||
interval_ms: 5000,
|
||||
timeout_ms: 2000,
|
||||
slow_response_ms: 1500,
|
||||
@@ -265,13 +261,6 @@ impl Settings {
|
||||
&& !h.path.bytes().any(|b| b <= 32 || b == 127),
|
||||
"invalid health HTTP path"
|
||||
);
|
||||
ensure!(h.port != Some(0), "health check port must be 1..65535");
|
||||
if let Some(expected) = &h.expect_body {
|
||||
ensure!(
|
||||
!expected.is_empty() && expected.len() <= 256 && !expected.chars().any(char::is_control),
|
||||
"health check expected body text must be 1..256 printable characters"
|
||||
);
|
||||
}
|
||||
if let Some(host) = &h.host {
|
||||
crate::config::request_host(host)?;
|
||||
let _: HeaderValue = host.parse()?;
|
||||
|
||||
@@ -24,7 +24,6 @@ impl Pool {
|
||||
let passive_settings = Arc::new(settings.passive_for(&definition));
|
||||
Arc::new(Member {
|
||||
state: Arc::new(BackendState::new(health_settings.mode)),
|
||||
health_probe: backend.health_probe.clone(),
|
||||
definition,
|
||||
backend,
|
||||
health_settings,
|
||||
|
||||
@@ -832,133 +832,4 @@ mod tests {
|
||||
assert_eq!(state.last_status.load(Ordering::Relaxed), 503);
|
||||
assert_eq!(lock(&state.last_failure).detail, "status=503");
|
||||
}
|
||||
|
||||
fn health_port_config(backend_port: u16, health: serde_json::Value) -> crate::config::Config {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]},
|
||||
"vhosts":[{
|
||||
"id":"app","frontend":{"hosts":["app.localhost"],"listener_ids":["http"]},
|
||||
"backends":[{"id":"a","url":format!("http://127.0.0.1:{backend_port}"),"allow_private":true}],
|
||||
"load_balancing":{"health_check":health}
|
||||
}]})).unwrap()
|
||||
}
|
||||
fn health_port_policy() -> crate::config::ValidationPolicy {
|
||||
crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn health_check_port_compiles_separate_probe_target() {
|
||||
let policy = health_port_policy();
|
||||
let snapshot = crate::config::compile(
|
||||
health_port_config(9000, serde_json::json!({"mode":"tcp","port":8079})), 0, &policy).unwrap();
|
||||
let backend = &snapshot.pools[0].members[0].backend;
|
||||
assert_eq!(backend.origin, "http://127.0.0.1:9000");
|
||||
assert_eq!(backend.health_probe.as_ref().unwrap().origin, "http://127.0.0.1:8079");
|
||||
for health in [
|
||||
serde_json::json!({"mode":"tcp","port":9000}),
|
||||
serde_json::json!({"mode":"tcp"}),
|
||||
serde_json::json!({"mode":"off","port":8079}),
|
||||
] {
|
||||
let snapshot = crate::config::compile(health_port_config(9000, health), 0, &policy).unwrap();
|
||||
assert!(snapshot.pools[0].members[0].backend.health_probe.is_none());
|
||||
}
|
||||
assert!(crate::config::compile(
|
||||
health_port_config(9000, serde_json::json!({"mode":"tcp","port":0})), 0, &policy).is_err());
|
||||
assert!(crate::config::compile(
|
||||
health_port_config(9000, serde_json::json!({"mode":"http","port":9090})), 0, &policy).is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn health_check_probes_configured_port_instead_of_traffic_port() {
|
||||
let traffic = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let traffic_port = traffic.local_addr().unwrap().port();
|
||||
let closed_port = {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
};
|
||||
let policy = health_port_policy();
|
||||
let config = health_port_config(traffic_port, serde_json::json!({
|
||||
"mode":"tcp","port":closed_port,"fall":1,"rise":1,"interval_ms":250,"timeout_ms":200,"slow_response_ms":0}));
|
||||
let snapshot = crate::config::compile(config, 0, &policy).unwrap();
|
||||
let member = snapshot.pools[0].members[0].clone();
|
||||
let settings = member.health_settings.clone();
|
||||
probe(member.clone(), settings.clone()).await;
|
||||
assert!(!member.state.healthy(), "probe must use the closed health port");
|
||||
|
||||
let health = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let health_port = health.local_addr().unwrap().port();
|
||||
drop(traffic);
|
||||
let config = health_port_config(traffic_port, serde_json::json!({
|
||||
"mode":"tcp","port":health_port,"fall":1,"rise":1,"interval_ms":250,"timeout_ms":200,"slow_response_ms":0}));
|
||||
let snapshot = crate::config::compile(config, 0, &policy).unwrap();
|
||||
let member = snapshot.pools[0].members[0].clone();
|
||||
let settings = member.health_settings.clone();
|
||||
probe(member.clone(), settings).await;
|
||||
assert!(member.state.healthy(), "probe must succeed on the open health port while traffic port is closed");
|
||||
tokio::time::timeout(Duration::from_secs(1), health.accept())
|
||||
.await
|
||||
.expect("health port must receive the probe connection")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn one_shot_http(body: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok((mut socket, _)) = listener.accept().await {
|
||||
let mut buffer = [0u8; 2048];
|
||||
let _ = socket.read(&mut buffer).await;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
(port, task)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn http_health_check_requires_expected_body_text() {
|
||||
let policy = health_port_policy();
|
||||
for (body, healthy) in [("{\"status\":\"ok\"}", true), ("{\"status\":\"degraded\"}", false)] {
|
||||
let (port, server) = one_shot_http(body).await;
|
||||
let config = health_port_config(port, serde_json::json!({
|
||||
"mode":"http","path":"/health","expect_body":"\"status\":\"ok\"",
|
||||
"fall":1,"rise":1,"interval_ms":1000,"timeout_ms":800,"slow_response_ms":0}));
|
||||
let snapshot = crate::config::compile(config, 0, &policy).unwrap();
|
||||
let member = snapshot.pools[0].members[0].clone();
|
||||
probe(member.clone(), member.health_settings.clone()).await;
|
||||
assert_eq!(member.state.healthy(), healthy, "body {body}");
|
||||
if !healthy {
|
||||
assert_eq!(lock(&member.state.health).result, "http_body");
|
||||
}
|
||||
server.abort();
|
||||
}
|
||||
for invalid in ["", "line\nbreak"] {
|
||||
assert!(crate::config::compile(
|
||||
health_port_config(9000, serde_json::json!({"mode":"http","expect_body":invalid})), 0, &policy).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_port_survives_live_reload_with_reused_clients() {
|
||||
let policy = health_port_policy();
|
||||
let defaults = (policy.proxy_addr, policy.tls_addr);
|
||||
let first = crate::config::compile_reusing(
|
||||
health_port_config(9000, serde_json::json!({"mode":"tcp"})), 0, &policy, defaults, None).unwrap();
|
||||
assert!(first.pools[0].members[0].health_probe.is_none());
|
||||
let second = crate::config::compile_reusing(
|
||||
health_port_config(9000, serde_json::json!({"mode":"tcp","port":8079})), 1, &policy, defaults, Some(&first)).unwrap();
|
||||
let member = &second.pools[0].members[0];
|
||||
assert!(Arc::ptr_eq(&member.backend, &first.pools[0].members[0].backend), "traffic clients are reused");
|
||||
assert_eq!(member.health_probe.as_ref().unwrap().origin, "http://127.0.0.1:8079");
|
||||
let third = crate::config::compile_reusing(
|
||||
health_port_config(9000, serde_json::json!({"mode":"tcp"})), 2, &policy, defaults, Some(&second)).unwrap();
|
||||
assert!(third.pools[0].members[0].health_probe.is_none());
|
||||
}
|
||||
}
|
||||
Vendored
+2
-39
@@ -6,6 +6,7 @@ mod tests {
|
||||
h.insert("cache-control", value.parse().unwrap());
|
||||
h
|
||||
}
|
||||
// Legacy policy; varnish-compatible behavior is tested separately.
|
||||
fn settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
@@ -1558,10 +1559,7 @@ mod tests {
|
||||
let second = cache
|
||||
.ticket(1, "http", "example.test", "app", "/second", &headers)
|
||||
.unwrap();
|
||||
// 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;
|
||||
fill(&cache, first.clone(), "first", &s).await;
|
||||
assert!(cache.get(&first).is_some());
|
||||
let before = cache.view();
|
||||
assert!(before.hot_allocated_bytes <= s.max_bytes);
|
||||
@@ -1823,39 +1821,4 @@ mod tests {
|
||||
assert_eq!(cache.view().entries, 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
+2
-11
@@ -126,19 +126,10 @@ impl Cache {
|
||||
.uncacheable_until
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY && !memo.contains_key(&ticket.key) {
|
||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
|
||||
memo.retain(|_, until| *until > now);
|
||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
|
||||
// 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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
memo.insert(ticket.key, now + UNCACHEABLE_MEMO_TTL);
|
||||
|
||||
Vendored
+2
-40
@@ -1,16 +1,3 @@
|
||||
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> {
|
||||
counter: &'a AtomicU64,
|
||||
bytes: u64,
|
||||
@@ -115,9 +102,6 @@ impl Candidate {
|
||||
let (body, body_allocation) = if let Some(body) = self.shared.take() {
|
||||
(body, self.shared_allocated)
|
||||
} 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());
|
||||
(Bytes::from(std::mem::take(&mut self.data)), allocated)
|
||||
};
|
||||
@@ -127,12 +111,6 @@ impl Candidate {
|
||||
let domain = self.ticket.domain.clone();
|
||||
let path = self.ticket.path.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 cost = hot_entry_cost(
|
||||
body_allocation,
|
||||
@@ -248,21 +226,7 @@ impl HttpBody for FillBody {
|
||||
let needed = candidate.data.len().saturating_add(data.len());
|
||||
if needed <= candidate.settings.max_object_bytes {
|
||||
let before = candidate.data.capacity();
|
||||
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);
|
||||
let required_growth = needed.saturating_sub(before);
|
||||
if required_growth > 0
|
||||
&& !candidate
|
||||
.fill_gauge
|
||||
@@ -282,9 +246,7 @@ impl HttpBody for FillBody {
|
||||
} else if required_growth > 0
|
||||
&& candidate
|
||||
.data
|
||||
.try_reserve_exact(
|
||||
target_capacity.saturating_sub(candidate.data.len()),
|
||||
)
|
||||
.try_reserve_exact(needed.saturating_sub(candidate.data.len()))
|
||||
.is_err()
|
||||
{
|
||||
candidate.cache.record_reason(
|
||||
|
||||
Vendored
-1
@@ -106,7 +106,6 @@ impl Cache {
|
||||
cache.demote_idle();
|
||||
cache.sweep_cold().await;
|
||||
cache.gc_banned(256);
|
||||
flush_pending_allocator_release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
-21
@@ -43,18 +43,7 @@ impl Cache {
|
||||
ticket.freshness_headers(),
|
||||
);
|
||||
if state.servable() {
|
||||
// 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 sequence = store.next_sequence();
|
||||
let (
|
||||
old_sequence,
|
||||
headers,
|
||||
@@ -70,9 +59,7 @@ impl Cache {
|
||||
let old_sequence = entry.sequence;
|
||||
entry.hits = entry.hits.saturating_add(1);
|
||||
entry.last_access = Instant::now();
|
||||
if promote {
|
||||
entry.sequence = sequence;
|
||||
}
|
||||
entry.sequence = sequence;
|
||||
(
|
||||
old_sequence,
|
||||
Arc::clone(&entry.headers),
|
||||
@@ -85,10 +72,8 @@ impl Cache {
|
||||
entry.body.len(),
|
||||
)
|
||||
};
|
||||
if promote {
|
||||
store.lru.remove(&old_sequence);
|
||||
store.lru.insert(sequence, storage_key);
|
||||
}
|
||||
store.lru.remove(&old_sequence);
|
||||
store.lru.insert(sequence, storage_key);
|
||||
self.counters.hits.fetch_add(1, Ordering::Relaxed);
|
||||
drop(store);
|
||||
|
||||
@@ -103,7 +88,7 @@ impl Cache {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
}
|
||||
response.headers_mut().insert(::http::header::AGE,
|
||||
HeaderValue::from(age),
|
||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
||||
);
|
||||
self.domain_stats
|
||||
.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
|
||||
@@ -300,7 +285,7 @@ impl Cache {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
}
|
||||
response.headers_mut().insert(::http::header::AGE,
|
||||
HeaderValue::from(age),
|
||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
||||
);
|
||||
self.domain_stats
|
||||
.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);
|
||||
let mut response = cached.response(headers);
|
||||
response.headers_mut().insert(::http::header::AGE,
|
||||
HeaderValue::from(age),
|
||||
HeaderValue::from_str(&age.to_string()).expect("integer header"),
|
||||
);
|
||||
Some(Hit {
|
||||
response,
|
||||
|
||||
Vendored
+5
-5
@@ -277,8 +277,8 @@ struct ColdEntry {
|
||||
|
||||
#[derive(Default)]
|
||||
struct ColdStore {
|
||||
entries: KeyMap<ColdEntry>,
|
||||
variants: KeyMap<Vec<Key>>,
|
||||
entries: HashMap<Key, ColdEntry>,
|
||||
variants: HashMap<Key, Vec<Key>>,
|
||||
lru: BTreeMap<u64, Key>,
|
||||
bytes: u64,
|
||||
sequence: u64,
|
||||
@@ -304,8 +304,8 @@ enum ColdCommand {
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Store {
|
||||
entries: KeyMap<Entry>,
|
||||
variants: KeyMap<Vec<Key>>,
|
||||
entries: HashMap<Key, Entry>,
|
||||
variants: HashMap<Key, Vec<Key>>,
|
||||
lru: BTreeMap<u64, Key>,
|
||||
bytes: usize,
|
||||
logical_bytes: usize,
|
||||
@@ -314,7 +314,7 @@ struct Store {
|
||||
revision: u64,
|
||||
control_revision: u64,
|
||||
scope_generations: HashMap<String, u64>,
|
||||
key_generations: KeyMap<u64>,
|
||||
key_generations: HashMap<Key, u64>,
|
||||
}
|
||||
impl Store {
|
||||
fn index_variant(&mut self, base_key: Key, key: Key) {
|
||||
|
||||
Vendored
+25
-32
@@ -268,12 +268,13 @@ impl Settings {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if let Some(value) = self
|
||||
.via_header
|
||||
.as_deref()
|
||||
.and_then(crate::settings::cached_header_value)
|
||||
{
|
||||
headers.append(http::header::VIA, value);
|
||||
if let Some(value) = &self.via_header {
|
||||
headers.append(
|
||||
http::header::VIA,
|
||||
value
|
||||
.parse::<HeaderValue>()
|
||||
.expect("validated cache Via header"),
|
||||
);
|
||||
}
|
||||
if self.expose_only_hits && outcome != "HIT" {
|
||||
return;
|
||||
@@ -299,7 +300,7 @@ impl Settings {
|
||||
headers,
|
||||
"x-cache-hits",
|
||||
"x-upstream-cache-hits",
|
||||
HeaderValue::from(hits),
|
||||
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
|
||||
);
|
||||
}
|
||||
if self.expose_cache_status {
|
||||
@@ -310,34 +311,30 @@ impl Settings {
|
||||
)
|
||||
.then_some(("stale", Some(StatusCode::NOT_MODIFIED)))
|
||||
});
|
||||
use std::fmt::Write as _;
|
||||
let mut value = String::with_capacity(96);
|
||||
value.push_str("RustProxy; ");
|
||||
if let Some((why, status)) = forwarded {
|
||||
let _ = write!(value, "fwd={why}");
|
||||
let mut value = if let Some((why, status)) = forwarded {
|
||||
let mut value = format!("RustProxy; fwd={why}");
|
||||
if let Some(status) = status {
|
||||
let _ = write!(value, "; fwd-status={}", status.as_u16());
|
||||
value.push_str(&format!("; fwd-status={}", status.as_u16()));
|
||||
}
|
||||
value
|
||||
} else {
|
||||
match outcome {
|
||||
"HIT" => {
|
||||
let _ = write!(value, "hit; ttl={ttl}");
|
||||
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
|
||||
"MISS" => "RustProxy; fwd=miss".to_owned(),
|
||||
"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 {
|
||||
let _ = write!(value, "; detail=\"{reason}\"");
|
||||
value.push_str(&format!("; detail=\"{reason}\""));
|
||||
}
|
||||
if transformed {
|
||||
value.push_str("; transformed");
|
||||
}
|
||||
headers.append(
|
||||
::http::header::CACHE_STATUS,
|
||||
HeaderValue::from_maybe_shared(bytes::Bytes::from(value))
|
||||
.expect("generated Cache-Status member"),
|
||||
headers.append(::http::header::CACHE_STATUS,
|
||||
HeaderValue::from_str(&value).expect("generated Cache-Status member"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -348,7 +345,7 @@ impl Settings {
|
||||
headers,
|
||||
"x-cache-resident-age",
|
||||
"x-upstream-cache-resident-age",
|
||||
HeaderValue::from(resident_age),
|
||||
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -360,13 +357,9 @@ fn insert_cache_diagnostic(
|
||||
upstream_name: &'static str,
|
||||
value: HeaderValue,
|
||||
) {
|
||||
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 {
|
||||
headers.append(&upstream_name, upstream_value);
|
||||
}
|
||||
let upstream_values: Vec<HeaderValue> = headers.get_all(name).iter().cloned().collect();
|
||||
for upstream_value in upstream_values {
|
||||
headers.append(upstream_name, upstream_value);
|
||||
}
|
||||
headers.insert(name, value);
|
||||
}
|
||||
|
||||
Vendored
-69
@@ -28,36 +28,6 @@ const COLD_QUEUE: usize = 16;
|
||||
const COLD_FORMAT_VERSION: u8 = 3;
|
||||
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)]
|
||||
enum CleanupPriority {
|
||||
Automatic(u64, SystemTime, SystemTime),
|
||||
@@ -71,46 +41,7 @@ struct CleanupCandidate {
|
||||
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() {
|
||||
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"))]
|
||||
unsafe {
|
||||
libc::malloc_trim(0);
|
||||
|
||||
+10
-38
@@ -104,23 +104,8 @@ pub(crate) fn compile_backend_with_timeout(
|
||||
targets_admin_listener,
|
||||
client,
|
||||
websocket_client,
|
||||
health_probe: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Backend copy used only by active health checks that probe a different port.
|
||||
pub(crate) fn health_probe_backend(backend: &Backend, port: Option<u16>) -> Result<Option<Backend>> {
|
||||
let Some(port) = port else { return Ok(None) };
|
||||
let mut url = Url::parse(&backend.url).context("invalid backend URL")?;
|
||||
if url.port_or_known_default() == Some(port) {
|
||||
return Ok(None);
|
||||
}
|
||||
url.set_port(Some(port))
|
||||
.map_err(|_| anyhow::anyhow!("backend URL does not accept a health check port"))?;
|
||||
let mut probe = backend.clone();
|
||||
probe.url = url.as_str().trim_end_matches('/').to_owned() + "/";
|
||||
Ok(Some(probe))
|
||||
}
|
||||
pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Result<Snapshot> {
|
||||
compile_with_defaults(
|
||||
config,
|
||||
@@ -150,35 +135,28 @@ pub fn compile_reusing(
|
||||
type LocalHostBuild = (
|
||||
HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
HashMap<String, Vec<(String, crate::vhost::Listener)>>,
|
||||
HashMap<String, Vec<crate::vhost::Listener>>,
|
||||
);
|
||||
|
||||
fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
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>>> =
|
||||
HashMap::new();
|
||||
let mut claimed_hosts: HashMap<String, Vec<(String, crate::vhost::Listener)>> =
|
||||
HashMap::new();
|
||||
let mut claimed_hosts: HashMap<String, Vec<crate::vhost::Listener>> = HashMap::new();
|
||||
for redirect in &config.redirects {
|
||||
let bindings = redirect.frontend.resolved(&config.listeners)?;
|
||||
for name in &redirect.frontend.hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for (owner, a) in claimed.iter() {
|
||||
for a in claimed.iter() {
|
||||
for b in &bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||
name,
|
||||
owner,
|
||||
a.address
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(
|
||||
bindings
|
||||
.iter()
|
||||
.map(|binding| (format!("redirect {}", redirect.id), *binding)),
|
||||
);
|
||||
claimed.extend(bindings.iter().copied());
|
||||
}
|
||||
if redirect.enabled {
|
||||
let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?);
|
||||
@@ -194,22 +172,16 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
let bindings = host.frontend.resolved(&config.listeners)?;
|
||||
for name in &host.frontend.hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for (owner, a) in claimed.iter() {
|
||||
for a in claimed.iter() {
|
||||
for b in &bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||
name,
|
||||
owner,
|
||||
a.address
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(
|
||||
bindings
|
||||
.iter()
|
||||
.map(|binding| (format!("404 host {}", host.id), *binding)),
|
||||
);
|
||||
claimed.extend(bindings.iter().copied());
|
||||
}
|
||||
if host.enabled {
|
||||
let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?);
|
||||
|
||||
+8
-21
@@ -65,8 +65,7 @@ pub fn compile_with_active(
|
||||
php_profiles
|
||||
.insert(profile.id.clone(), profile.php.clone())
|
||||
.is_none(),
|
||||
"duplicate PHP-FPM profile id {:?}: every profile needs a unique ID",
|
||||
profile.id
|
||||
"duplicate PHP-FPM profile id"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
@@ -121,8 +120,7 @@ pub fn compile_with_active(
|
||||
);
|
||||
ensure!(
|
||||
web_services.insert(service.id.clone(), compiled).is_none(),
|
||||
"duplicate Web Service id {:?}: every Web Service needs a unique ID",
|
||||
service.id
|
||||
"duplicate Web Service id"
|
||||
);
|
||||
}
|
||||
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
||||
@@ -147,11 +145,7 @@ pub fn compile_with_active(
|
||||
validate_source(&route.source)?;
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
vhost_ids.insert(host.id.clone()),
|
||||
"duplicate vhost id {:?}: every vhost needs a unique ID",
|
||||
host.id
|
||||
);
|
||||
ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id");
|
||||
}
|
||||
ensure!(
|
||||
config.redirects.len() <= 64,
|
||||
@@ -164,8 +158,7 @@ pub fn compile_with_active(
|
||||
.with_context(|| format!("redirect {}", redirect.id))?;
|
||||
ensure!(
|
||||
redirect_ids.insert(redirect.id.clone()),
|
||||
"duplicate redirect id {:?}: every redirect needs a unique ID",
|
||||
redirect.id
|
||||
"duplicate redirect id"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
@@ -178,8 +171,7 @@ pub fn compile_with_active(
|
||||
.with_context(|| format!("404 host {}", host.id))?;
|
||||
ensure!(
|
||||
not_found_ids.insert(host.id.clone()),
|
||||
"duplicate 404 host id {:?}: every 404 host needs a unique ID",
|
||||
host.id
|
||||
"duplicate 404 host id"
|
||||
);
|
||||
}
|
||||
let effective_tls = crate::tls::for_all_frontends(
|
||||
@@ -287,11 +279,7 @@ pub fn compile_with_active(
|
||||
let mut acl_profiles: HashMap<String, (Option<CompiledAcl>, crate::rate_limit::CountryAccess)> =
|
||||
HashMap::new();
|
||||
for profile in &mut config.acl_profiles {
|
||||
ensure!(
|
||||
valid_id(&profile.id),
|
||||
"invalid ACL profile id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||
profile.id
|
||||
);
|
||||
ensure!(valid_id(&profile.id), "invalid ACL profile id");
|
||||
ensure!(
|
||||
!profile.name.trim().is_empty()
|
||||
&& profile.name.len() <= 96
|
||||
@@ -305,8 +293,7 @@ pub fn compile_with_active(
|
||||
acl_profiles
|
||||
.insert(profile.id.clone(), (acl, profile.country_access.clone()))
|
||||
.is_none(),
|
||||
"duplicate ACL profile id {:?}: every ACL profile needs a unique ID",
|
||||
profile.id
|
||||
"duplicate ACL profile id"
|
||||
);
|
||||
}
|
||||
crate::acl::dynamic::configured_hostnames(&config)?;
|
||||
@@ -393,7 +380,7 @@ fn normalize_prefix(prefix: &mut String) -> Result<()> {
|
||||
&& !prefix.contains('?')
|
||||
&& !prefix.contains('#')
|
||||
&& !prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||
"invalid path prefix: it must start with '/', be at most 512 bytes and contain no '?', '#', spaces or control characters"
|
||||
"invalid path prefix"
|
||||
);
|
||||
if prefix.len() > 1 {
|
||||
*prefix = prefix.trim_end_matches('/').to_owned();
|
||||
|
||||
@@ -224,7 +224,6 @@ pub struct CompiledBackend {
|
||||
pub tls_verify: bool,
|
||||
pub accept_local_listener_risk: bool,
|
||||
pub targets_admin_listener: bool,
|
||||
pub health_probe: Option<Arc<CompiledBackend>>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CompiledSource {
|
||||
|
||||
+13
-49
@@ -79,22 +79,16 @@ fn compile_routing(
|
||||
);
|
||||
for name in &routing_hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for (owner, a) in claimed.iter() {
|
||||
for a in claimed.iter() {
|
||||
for b in &host_bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||
name,
|
||||
owner,
|
||||
a.address
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(
|
||||
host_bindings
|
||||
.iter()
|
||||
.map(|binding| (format!("vhost {}", host.id), *binding)),
|
||||
);
|
||||
claimed.extend(host_bindings.iter().copied());
|
||||
}
|
||||
let needs_proxy_pool = host.source.is_reverse_proxy()
|
||||
|| host.routes.iter().any(|route| {
|
||||
@@ -105,38 +99,17 @@ fn compile_routing(
|
||||
});
|
||||
let mut compiled_members = Vec::new();
|
||||
for definition in host.backends.iter().filter(|_| needs_proxy_pool) {
|
||||
let connect_timeout = host
|
||||
.proxy_timeouts
|
||||
.connect_timeout()
|
||||
.unwrap_or(std::time::Duration::from_secs(10));
|
||||
let mut backend = compile_backend_with_timeout(
|
||||
&definition.backend(),
|
||||
desired_policy,
|
||||
guarded_bindings,
|
||||
&host.load_balancing.keepalive,
|
||||
connect_timeout,
|
||||
host.proxy_timeouts
|
||||
.connect_timeout()
|
||||
.unwrap_or(std::time::Duration::from_secs(10)),
|
||||
)
|
||||
.with_context(|| format!("vhost {} backend {}", host.id, definition.id))?;
|
||||
backend.id = format!("{}/{}", host.id, definition.id);
|
||||
let health = host.load_balancing.health_for(definition);
|
||||
if health.mode != crate::balance::HealthMode::Off {
|
||||
if let Some(probe) =
|
||||
health_probe_backend(&definition.backend(), health.port)?
|
||||
{
|
||||
let mut probe = compile_backend_with_timeout(
|
||||
&probe,
|
||||
desired_policy,
|
||||
guarded_bindings,
|
||||
&host.load_balancing.keepalive,
|
||||
connect_timeout,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("vhost {} backend {} health check port", host.id, definition.id)
|
||||
})?;
|
||||
probe.id = format!("{}/{}#health", host.id, definition.id);
|
||||
backend.health_probe = Some(Arc::new(probe));
|
||||
}
|
||||
}
|
||||
compiled_members.push(Arc::new(backend));
|
||||
}
|
||||
let pool = if compiled_members.is_empty() {
|
||||
@@ -329,8 +302,7 @@ fn compile_routing(
|
||||
for route in &mut host.routes {
|
||||
ensure!(
|
||||
valid_id(&route.id) && path_ids.insert(route.id.clone()),
|
||||
"location ID {:?} is invalid or used twice in this vhost (use 1-64 letters, digits, '-' or '_', unique per vhost)",
|
||||
route.id
|
||||
"invalid/duplicate vhost path id"
|
||||
);
|
||||
let path_regex = match route.match_type {
|
||||
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => {
|
||||
@@ -338,8 +310,7 @@ fn compile_routing(
|
||||
!route.path_prefix.is_empty()
|
||||
&& route.path_prefix.len() <= 1024
|
||||
&& !route.path_prefix.chars().any(char::is_control),
|
||||
"location {}: the regular expression must be 1-1024 characters without control characters",
|
||||
route.id
|
||||
"invalid vhost location regex"
|
||||
);
|
||||
let mut builder = regex::RegexBuilder::new(&route.path_prefix);
|
||||
builder
|
||||
@@ -348,23 +319,17 @@ fn compile_routing(
|
||||
)
|
||||
.size_limit(2 * 1024 * 1024)
|
||||
.dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(builder.build().with_context(|| {
|
||||
format!(
|
||||
"location {}: invalid or too complex regular expression",
|
||||
route.id
|
||||
)
|
||||
})?)
|
||||
Some(builder.build().context("invalid vhost location regex")?)
|
||||
}
|
||||
crate::vhost::PathMatch::Exact => {
|
||||
ensure!(
|
||||
!route.path_prefix.contains('?')
|
||||
&& !route.path_prefix.contains('#')
|
||||
&& !route.path_prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||
"location {}: an exact path must not contain '?', '#', spaces or control characters",
|
||||
route.id
|
||||
"invalid exact location path"
|
||||
);
|
||||
route.path_prefix = crate::acl::routing_path(&route.path_prefix)
|
||||
.with_context(|| format!("location {}: invalid exact path", route.id))?;
|
||||
.context("invalid exact location path")?;
|
||||
None
|
||||
}
|
||||
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => {
|
||||
@@ -375,8 +340,7 @@ fn compile_routing(
|
||||
if !route.internal {
|
||||
ensure!(
|
||||
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
||||
"location {}: another location already uses the same path and match type",
|
||||
route.id
|
||||
"duplicate vhost location matcher"
|
||||
);
|
||||
}
|
||||
validate_route_rewrite(route, path_regex.as_ref())?;
|
||||
|
||||
@@ -139,73 +139,6 @@ impl Snapshot {
|
||||
}
|
||||
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(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -271,13 +204,6 @@ impl Snapshot {
|
||||
})
|
||||
}
|
||||
}
|
||||
fn host_pattern_rank(pattern: &str, host: &str) -> usize {
|
||||
if pattern == host {
|
||||
usize::MAX
|
||||
} else {
|
||||
pattern.len()
|
||||
}
|
||||
}
|
||||
fn select_route<'a, F>(
|
||||
routes: &'a [Arc<CompiledRoute>],
|
||||
path: &str,
|
||||
|
||||
@@ -13,8 +13,7 @@ use sqlx::{
|
||||
};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
pub const SCHEMA_VERSION: i64 = 12;
|
||||
pub const ADMIN_PREFERENCES_MAX_BYTES: usize = 64 * 1024;
|
||||
pub const SCHEMA_VERSION: i64 = 11;
|
||||
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
|
||||
pub const AUDIT_CAPACITY: i64 = 10_000;
|
||||
|
||||
@@ -130,13 +129,6 @@ impl Store {
|
||||
sqlx::raw_sql(include_str!("../migrations/0011_tokens.sql"))
|
||||
.execute(&mut *tx)
|
||||
.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, ?)")
|
||||
.bind(serde_json::to_string(initial)?)
|
||||
.execute(&mut *tx)
|
||||
@@ -147,7 +139,7 @@ impl Store {
|
||||
.bind(crate::auth::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA user_version = 12")
|
||||
sqlx::query("PRAGMA user_version = 11")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user