Compare commits
14
Commits
6d7f152a0b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbe0566f5 | ||
|
|
0119b11005 | ||
|
|
1ac94213e0 | ||
|
|
213332d0b5 | ||
|
|
e7143151bd | ||
|
|
993062520f | ||
|
|
3d3e0ea5b8 | ||
|
|
0cde2c8a18 | ||
|
|
d7fb869783 | ||
|
|
6ef01212ec | ||
|
|
34490fd5fc | ||
|
|
17e314e159 | ||
|
|
5cb63f542c | ||
|
|
064772765b |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# RustProxy source and documentation maintenance
|
||||
|
||||
Current source version: **0.36.1**. The Cargo package and binary are named `rust-proxy`.
|
||||
Current source version: **0.36.8**. The Cargo package and binary are named `rust-proxy`.
|
||||
|
||||
Before editing, read [README.md](README.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
|
||||
|
||||
|
||||
@@ -2,6 +2,77 @@
|
||||
|
||||
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in the latest release.
|
||||
|
||||
## 0.36.8 — editor forms, toasts and CSP fixes
|
||||
|
||||
- **Editor forms:** the `pattern` attribute of generated ID fields (vhost, location, member, listener, stream ID, error fallback target, affinity cookie name) escaped the hyphen incorrectly for browsers that compile patterns with the `v` flag, which logged "Pattern attribute value ... is not a valid regular expression". The hyphen is now escaped.
|
||||
- **Toasts:** the toast element could be removed together with a rebuilt dialog, after which every save/validation message threw `Cannot set properties of null (setting 'textContent')` and the form could not be saved. The toast is now recreated on demand.
|
||||
- **Cancel new entries:** vhost, redirect, 404 host, stream and web service editors have a **Cancel** button next to "Discard changes". It closes the editor and drops the draft (with a confirmation when something was typed) instead of only minimizing it to the draft dock.
|
||||
- **Backends overview:** more trend charts per row: a wider latency line, request bars (requests per refresh) next to the request count (a faint baseline is drawn for idle rows), and an IN / OUT traffic chart with the inbound and outbound rates on two lines, measured from the per-backend byte counters (the server-reported total still wins when present).
|
||||
- **Tiny backends / Process simple:** the old backend card was forced visible whenever a proxy source existed, ignoring the dashboard layout, so it could not be hidden. Layout visibility is now respected (the "no proxy source" state uses a class instead of the `hidden` attribute).
|
||||
- **Backends overview:** with a row expanded, the periodic refresh no longer scrolls the table back to the focused row (focus is restored without scrolling and the scroll position is kept).
|
||||
- **CSP:** the cache outcomes bar no longer uses inline `style="width:0%"` attributes (blocked by `style-src 'self'`); the initial width comes from the stylesheet.
|
||||
|
||||
## 0.36.7 — Docker vhosts and cache panel polish
|
||||
|
||||
- **Backends table redesigned:** a richer replacement for the 14-column table. It takes over the existing `backend_table` slot (saved layouts keep its position, size and visibility); the previous compact table remains available as the optional **Tiny backends** (`backend_tiny`, hidden by default). Status badges with a coloured row edge, vhost and backend in one cell, error count with a share bar, latency with a short trend line, traffic per second and a "last event" column; filter chips (All / Up / Down / Other) with counts, search, sorting, column selection and "Manage vhosts" work as before and share the same saved sort, direction and column preferences. Client generation, generation requests/age, rotations and the last failure/recovery open under a chevron and stay open across refreshes. It follows the table density (normal, compact, ultra) and corner-style settings. The original table is still available as **Tiny backends**; the new card is visible in the factory layout (Tiny backends is hidden there), while existing saved layouts keep their current cards and can enable the new one in the layout editor.
|
||||
- **Process card redesigned:** chips for platform, threads, start and uptime, Load rows with usage meters (CPU, RSS, file descriptors, cgroup), a Memory section with an anonymous/file-backed split bar, and a 2×2 grid of history charts (CPU, RSS, file descriptors, OS threads); cache memory is no longer shown there (see RAM cache). It takes over the existing `process_diagnostics` slot, so saved layouts keep its position, size and visibility. The previous card is still available as the optional **Process simple** (`process_simple`, hidden by default).
|
||||
- **Docker vhosts:** the collapsible sections (Configure vhost, Protocols, Access control, timeouts, health checks) now align arrow, title and hint to the left with a common title column. Traffic statistics are expanded by default for every enabled app (a section you collapse stays collapsed). A new **Hide disabled** button hides all unpublished vhosts from the view at once; **Show hidden** brings them back.
|
||||
- **404 hosts:** cards use the same host chips → response layout as redirects, with listeners and certificate on a quiet meta line; redirect and 404 cards are separated by the same gap as vhost cards.
|
||||
- **Cache panel:** the pager follows the corner-style setting (outer corners only) and stays on one row (the heading wraps instead) with the arrow after "Next"; the "transformed" badge stays visible next to long object URLs (the URL is shortened instead). Decision counters show a thin share bar under each counter (relative to the largest) and a compact "+N" toggle; the pager no longer shows doubled arrows; the folded Decision counters ("+N") keep their open/closed state across automatic refreshes.
|
||||
- **Dashboard cache outcomes:** the legend under the bar shows only the request counts; percentages are shown once, inside the bar and in the headline (tooltips give both).
|
||||
- **Build:** `SHA256SUMS` uses repository-relative paths again.
|
||||
|
||||
## 0.36.6 — redirect priority, copy and SNI documentation
|
||||
|
||||
- **Deploy script:** `update.sh` / `install.sh` retry the GeoIP download (transient 502/503/504, timeouts) up to four times and, if it still fails, warn and continue with the installed GeoIP data instead of aborting the update with `HTTP Error 504`.
|
||||
- **Redirect priority fix:** the most specific host name now wins between vhosts, redirects and 404 hosts. A wildcard redirect such as `*.gru.one.pl` (or `gru.one.pl`) no longer shadows an exact vhost like `autodiscover.gru.one.pl` on the same listener; the same applies to wildcard 404 hosts.
|
||||
- **Redirect cards:** redirects are shown as host chips → target with a quiet meta line (rules, listeners, certificate) instead of nested boxes, and stack cleanly on narrow screens.
|
||||
- **Certificate mismatch on save:** when the selected certificate does not cover the vhost names, saving now asks whether to force it (and enables "Force an incompatible certificate") instead of failing with a vague error; the server error names the certificate and explains the fix.
|
||||
- **Certificates by SNI explained:** the vhost certificate hint and `docs/CERTIFICATES.md` now describe that a vhost left on "Global SNI / default certificate" gets the certificate per requested name from the stored certificates' SNI names (filled from their SAN entries), so one vhost can serve domains with different certificates. The editor also tells you when no single certificate covers all names but each name is covered. The "Legacy SNI names" field is now called "SNI names".
|
||||
- **Copy:** vhosts, redirects, 404 hosts, streams and ACL profiles have a **Copy** button next to Edit. It opens a new form filled from the original with the ID and name cleared (the ID is generated from the new name or host again) and the values that must be unique cleared too: host names and certificate for vhosts, redirects and 404 hosts, the listen address for streams. Nothing is saved until you press Save.
|
||||
- **Build without GitHub:** the official Swagger UI 5.33.1 files (`swagger-ui-dist`, Apache-2.0) are vendored in `third_party/swagger-ui-5.33.1/` and verified against `SHA256SUMS` on every build, so `bash dev.sh release` needs no network for Swagger UI. Setting `SWAGGER_UI_DOWNLOAD_URL` or `SWAGGER_UI_ARCHIVE_SHA256` still selects an explicit archive.
|
||||
- **Build:** the pinned Swagger UI archive is now reused from a previous build directory (or `target/swagger-ui-cache`, or `SWAGGER_UI_CACHE_DIR`) instead of being downloaded again on every version bump, and a download is retried up to four times. A flaky GitHub connection no longer fails `bash dev.sh release` right after a version change.
|
||||
|
||||
## 0.36.5 — automatic certificate choice and clearer host conflicts
|
||||
|
||||
- **Certificate auto-selection:** when creating a vhost, redirect or 404 host, RustProxy picks the stored certificate that covers every host name you enter (an exact name beats a wildcard, the narrowest certificate wins). A hint names the chosen certificate and the selector still lets you pick another one or the global default. Existing entries are never changed.
|
||||
- **Clearer host conflicts:** the "overlapping frontend bindings for hostname …" error now names the entry that already uses the host (`vhost`, `redirect` or `404 host` plus its ID) and the conflicting listener address.
|
||||
- **Clearer validation errors:** duplicate or invalid IDs, repeated hostnames, missing hostnames or listeners, invalid location IDs, regular expressions, exact paths and duplicate location matchers now say which value or location is wrong and how to fix it.
|
||||
|
||||
## 0.36.4 — cache readability and editor UX polish
|
||||
|
||||
- **Cache → Domains and effectiveness:** decision counters are one compact line (`name count`, top three, the rest in a `+N` tooltip) instead of a wall of chips; the rest of the table keeps its 0.36.3 look.
|
||||
- **Cache → Static response transformation:** the "Transformations by type" and "Vhost effectiveness" tables use the same presentation as Domains and effectiveness (right-aligned tabular numbers, emphasised vhost name, non-zero errors in red, reduction meter) and the skip reasons are compact `name count` lines.
|
||||
- **Cache → Stored objects:** the tier is a badge in the same style as the other badges (follows the corner-radius setting), numeric columns are right-aligned, long targets and cache keys are truncated (full text on hover, click still copies), rows highlight on hover, and the Previous / range / Next pager is a single segmented control.
|
||||
- **Docker vhosts:** the per-service link is now **Open** (same wording as in Vhosts); the expandable per-application forms and their protocol, ACL, timeout and health sections have a chevron, keyboard focus outline and a framed panel when open.
|
||||
- **Automatic IDs:** when creating a vhost, web service, stream, redirect, 404 host or ACL profile, the ID is generated from the name (for vhosts, from the first host name) as a unique slug; the ID field comes after the name (after the hosts for vhosts) and is shown as a plain value with a **Change** link — the input only appears when you ask for it (or when validation needs it) — `example.com` becomes `example-com`, Polish and other accented letters are folded to ASCII, and `-2`, `-3`… is appended when the ID is taken. Typing in the ID field switches to a manual ID; a "Generate automatically" button (or clearing the field) restores it. Existing IDs are never changed.
|
||||
- **Vhost, Web service, Stream, Redirect and ACL forms:** section tabs wrap onto a second line instead of scrolling horizontally, and use a readable 12 px underlined tab style with a clear active state and hover; form action bars are aligned consistently.
|
||||
|
||||
## 0.36.3 — settings, cache and dashboard layout polish
|
||||
|
||||
- **More chart styles:** Settings → Appearance → Chart style now offers eight additional presets: **Sunset**, **Aurora**, **Ocean**, **Forest**, **Pastel**, **Terminal** (green phosphor, square lines), **Blueprint** (thin technical lines on a blue tint) and **Colorblind safe** (Okabe–Ito palette with dashed grid). The `chart_style` preference accepts the new values (`sunset`, `aurora`, `ocean`, `forest`, `pastel`, `terminal`, `blueprint`, `colorblind`); existing values and the default are unchanged.
|
||||
- **Cache → Domains and effectiveness:** numeric columns are right-aligned with tabular digits, HIT / MISS / BYPASS counts use the same colours as the Dashboard cache bar, the hit ratio has a small meter, domain names are emphasised and decision-counter chips are quieter.
|
||||
- **Docker vhosts:** collapsed service rows are compact on desktop (publish switch, name with image/ports, and health with actions in one row instead of a stacked column).
|
||||
- **Cache and Static response transformation pages:** the Cache → General tab is split into *Behavior* and *Memory and limits* groups (the fallback TTL and bypass options moved up next to the other behavior switches), switch descriptions sit directly under their checkbox, the memory/transform preset cards have a compact header, and numeric fields use a responsive grid (up to four per row) instead of two fixed columns.
|
||||
- **Settings layout:** Settings → Appearance now uses one-setting-per-row layout (label and help on the left, control on the right, checkboxes on the right edge) instead of an uneven two-column grid, section headings no longer duplicate their first setting (`Charts`, `Tables and logs`) and the small eyebrow labels were dropped. Sub-tabs (Rules & headers, Cache, HTTP connections, Logging, …) are a horizontal tab bar on desktop instead of a vertical stack of full-width buttons, and the main settings menu has thin separators between its groups.
|
||||
- **Compact stat cards:** Dashboard cards that consist of a title, a value and one secondary line (Requests, Active exchanges, Payload transferred, Errors, Process memory, HTTP/2 requests, backend and stream cards, …) now show that line to the right of the value, which makes them about 20 px lower. WebSocket tunnels shows a plain `H1 · H2 · H3` split (full names in the tooltip) HTTP/2 shows `H2 connections` and HTTP/3 now shows `H3 connections` (active HTTP/3 connections); the labels were shortened in `langs/en_US.json` and `langs/pl_pl.json`.
|
||||
- **WebSocket tunnels card:** the *Established* count moved from its own text line to the right of the main value (it wraps below only when the card is too narrow), so the card is one line shorter.
|
||||
- **Cache outcomes bar:** the HIT / MISS / BYPASS / OTHER segments of the Dashboard cache bar now show their percentage inside the bar (bar height 12 → 20 px with tighter margins, so the card keeps its height). A label that does not fit a narrow segment is hidden automatically and re-evaluated on resize; the legend below keeps the exact count and percentage.
|
||||
- **CSS cleanup:** removed orphaned panel styles: the legacy `body[data-density=…]` rules (46 selectors across 7 modules; density is controlled only by `html[data-table-density]` and `html[data-dashboard-density]`), the unused `.badge` branch of the corner-style selector list, references to undefined custom properties (`--radius`, `--surface`, `--line-strong`; their fallbacks are now inlined) and duplicated `.path-permissions-*` blocks. A static audit found no other unused classes, ids, keyframes or custom properties. Computed styles of all panels at 402 px and 1280 px (several corner/density/icon settings) are identical before and after; the bundle is about 3 KB smaller.
|
||||
- **Upgrade notes:** interface-only release; no configuration or database migration is required. New interface labels were added to `langs/en_US.json` and `langs/pl_pl.json` (`appearanceChartsTitle`, `appearanceTablesTitle`, `cacheSectionBehavior`, `cacheSectionMemory`, `h3ActiveConnections`, `chartStyleSunset` … `chartStyleColorblind`) and the WebSocket / HTTP/2 labels were shortened.
|
||||
|
||||
## 0.36.2 — icon semantics review and optional logo animation
|
||||
|
||||
- **SVG icon review:** every panel control was re-checked against its function. Fixes include **Unlock layout** (was the sign-out icon, now an open padlock), **Save and lock** (padlock), **Dashboard presets** (layers instead of edit), **Docker agents settings** (was a pin because "docker" matched "dock"), cache/transform memory presets (were the undo icon because "preset" matched "reset"), **Previous/Next** in Cache and Stored logs (chevrons instead of database/list), **Close** in the health and history dialogs (close instead of activity/history), revision history **Reload/Restore/Compare** (refresh/undo/compare), **Sign in** (sign-in instead of check), **Sign out all sessions** (sign-out instead of trash), **Run operation** (play), **Purge / Ban** and **Active BANs** (ban), **Clean cold cache** and **Clear** (eraser), **Download now** (download), token generation/rotation (key), **Repeated values / Count repeats** (hash), **Open Logs** (list), **Configure fields** (sliders), **About** (info), automatic renewal enable/disable (toggles) and Live logs **Pause/Resume** (pause/play). Expand/collapse toggles now follow their current state.
|
||||
- **Distinct section icons:** Streams (transfer arrows), Listeners (plug), GeoIP updates (map pin) and Administration (users) no longer share icons with Vhosts, HTTP connections and Settings. The settings cog, pin, undo and sign-in/sign-out glyphs were redrawn to be more recognizable.
|
||||
- **Icon fixes:** English labels whose translation equals the key (for example **Edit**) no longer fall back to the generic placeholder icon; buttons labelled with a single symbol (`×`, `−`, `↑`, `←` …) keep the native glyph instead of showing a duplicate SVG; protocol badges and history calendar days no longer receive icons.
|
||||
- **Mobile/Safari chart fixes:** with SVG icons enabled, the **Hide** / **Show** buttons of the Traffic-by-interface charts made the Overview jump and flicker on every refresh. Root causes: the chart lookup (`row.querySelector("svg")`) could return the button's icon instead of the chart (axis labels were then drawn inside the button); the periodic label update rebuilt the button and its icon each time; and descendant chart rules (`.network-interface-row svg`, `.stat-chart svg`, `.system-card svg` …) stretched icons to full chart size. Charts are now addressed with `svg:not(.ui-icon)`, the axis renderer ignores icons, refreshes update only the text node, chart rules exclude `.ui-icon`, and small buttons use an 11 px icon so their height never changes. A measured 28-refresh run kept the container height constant (previously a 13 px swing) with zero icon rebuilds.
|
||||
- **Badge consistency:** the upstream protocol badges in the Vhost policy row (for example `origin · H2 compatible → H2`) now use the same geometry as the other policy pills (9 px mono, 1 px letter-spacing, 4×8 padding, transparent background, square corners in the original corner style, 20 px height); only the colour differs (muted = not observed, green = observed). The **Transformed** marker in the Cache objects table now uses pill typography (1 px letter-spacing, square corners). ACL picker tags (selected countries/IPs) now use the same chip look as host chips (square corners, 10 px mono, same padding and remove button) instead of a rounded pill with a different font. A style comparison of every pill/badge/chip family and of controls sharing a row found no further deviations besides intentional ones (ACL rule action labels that mimic form fields, quick enable/disable toggles, the sort-direction arrow).
|
||||
- **Spacing review:** a rendered scan of every panel, settings page and dialog (402 px and 1280 px wide) found descriptive text and blocks that touched the next element. Added spacing for TLS policy (description → TLS 1.2/1.3 switches, PQC help → documentation link, which is now styled as a link), Rules & headers, HTTP connections and Logging sub-tabs, Cache and Transform live-activity cards, empty-state notes under tables, Logs footnote, Dashboard presets footer, GeoIP and cache-cleanup field groups, htaccess/PHP help dialogs and several settings help paragraphs. The rules add space only where the layout had none.
|
||||
- **Mobile layout:** the mobile **Menu** button no longer shows two hamburger icons, and **System uptime** wraps inside the System load card instead of overflowing its right edge.
|
||||
- **Logo animation:** new **Settings → Appearance → Interface → Animate logo** option (account-scoped `logo_animation`, **off by default**) plays short, subtle random effects on the sidebar logo; it pauses in hidden tabs and respects reduced-motion preferences.
|
||||
- **Upgrade notes:** no configuration or database migration is required; `logo_animation` is an additive preference field that defaults to `false` for existing accounts.
|
||||
|
||||
## 0.36.1 — interface icon polish and on-demand live logs
|
||||
|
||||
- **SVG icon polish:** HTTP/2 and HTTP/3 tabs now use the same protocol icon; listener HTTP/2/HTTP/3 limit links use the settings cog; **Apply preset** uses a preset/wand icon; the top **Logs** menu receives its missing icon. Compact Overview layout controls keep their native `+ / − / ← / → / ×` glyphs instead of receiving duplicate SVGs, and UI icon sizing is protected from chart SVG rules.
|
||||
|
||||
Generated
+1
-1
@@ -1943,7 +1943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.36.1"
|
||||
version = "0.36.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.36.1"
|
||||
version = "0.36.8"
|
||||
edition = "2021"
|
||||
rust-version = "1.99.1"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
+579
-571
File diff suppressed because it is too large.
Load diff
Regular → Executable
File mode changed.
+1
-1
@@ -30,7 +30,7 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot
|
||||
| `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. |
|
||||
| `POST /api/certificates/inspect` | Read DNS SAN coverage from a managed/external public PEM path for the editor; admin browser session only. |
|
||||
| `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. |
|
||||
| `GET/PUT /api/preferences` | Personal UI settings, including theme/layout and optional `interface_icons` (default `false`). |
|
||||
| `GET/PUT /api/preferences` | Personal UI settings, including theme/layout and optional `interface_icons` and `logo_animation` (both default `false`). |
|
||||
| `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. |
|
||||
| `GET /docs/openapi.json` | Protected machine-readable API specification. |
|
||||
|
||||
|
||||
+12
-1
@@ -61,6 +61,17 @@ When another service owns the certificate, add a manual certificate with the act
|
||||
/etc/letsencrypt/live/example.com/privkey.pem
|
||||
```
|
||||
|
||||
### 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.
|
||||
@@ -69,7 +80,7 @@ The paths must be absolute, without `.`/`..` components or control characters, a
|
||||
|
||||
The existing trusted-path rules still apply: real PEM files and ancestors must be owned by root or the RustProxy service user, ancestors must not be group/world-writable, and a private key must not be world-readable or group/world-writable. ACLs alone do not make an untrusted path acceptable. Container mounts and mandatory access-control rules need matching deployment settings; see [Installation](INSTALL.md#linux-debrpm-read-only-access-to-external-paths).
|
||||
|
||||
After a public certificate becomes readable, the editor automatically fills **Legacy SNI names** from its DNS Subject Alternative Name (SAN) entries. Wildcard SANs are retained; filenames and a certificate's Common Name are not used to invent DNS names. Inspection applies to managed certificate filenames and external paths, reads only the public PEM, and does not activate or save the certificate. The certificate/key pair is still validated when applied.
|
||||
After a public certificate becomes readable, the editor automatically fills **SNI names** from its DNS Subject Alternative Name (SAN) entries. Wildcard SANs are retained; filenames and a certificate's Common Name are not used to invent DNS names. Inspection applies to managed certificate filenames and external paths, reads only the public PEM, and does not activate or save the certificate. The certificate/key pair is still validated when applied.
|
||||
|
||||
Automatic reads preserve manually edited names, intentional clearing and existing saved mappings. Results from an earlier path or form are ignored. Use **Read names from certificate** to retry after fixing filesystem permissions or to deliberately replace the current field with the certificate's names. Prefer explicit certificate assignment in the intended Vhost; auto-filled legacy SNI names do not create Vhosts or replace their assignments.
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ Under **Settings → Appearance → Interface**, enable **Show SVG interface ico
|
||||
|
||||
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.
|
||||
|
||||
+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 and Swagger assets must be available to the build environment. An example systemd unit is in `deploy/`.
|
||||
`dev.sh release` builds the application; it is not equivalent to a successful production rollout. Cargo dependencies must be available to the build environment; Swagger UI 5.33.1 is vendored in `third_party/swagger-ui-5.33.1/` and needs no download (set `SWAGGER_UI_DOWNLOAD_URL` or `SWAGGER_UI_ARCHIVE_SHA256` to build from an explicit archive instead). An example systemd unit is in `deploy/`.
|
||||
|
||||
For a first Linux/systemd installation:
|
||||
|
||||
|
||||
@@ -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.1` and the defaults this publishes:
|
||||
For example, with RustProxy version `0.36.8` and the defaults this publishes:
|
||||
|
||||
```text
|
||||
zot.example.net/network/rustproxy:0.36.1
|
||||
zot.example.net/network/rustproxy:0.36.8
|
||||
zot.example.net/network/rustproxy:latest
|
||||
zot.example.net/network/rustproxy:routeros
|
||||
```
|
||||
|
||||
+55
-7
@@ -253,7 +253,8 @@
|
||||
"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": "Active H2 connections",
|
||||
"h2ActiveConnections": "H2 connections",
|
||||
"h3ActiveConnections": "H3 connections",
|
||||
"tlsFailures": "TLS handshake errors",
|
||||
"tlsListener": "TLS listener",
|
||||
"notEnabled": "Disabled",
|
||||
@@ -395,6 +396,7 @@
|
||||
"vhImport": "Load into editor",
|
||||
"vhMigrationHint": "Migration is saved atomically. Routes sharing one host and one backend are grouped; ambiguous host ownership is rejected.",
|
||||
"vhEditor": "Vhost configuration",
|
||||
"vhCancel": "Cancel",
|
||||
"vhReset": "Discard changes",
|
||||
"vhSafetyHint": "Private IP binding is not authentication. Keep firewall/ACL rules. HSTS preload is opt-in; affinity cookies are not login sessions.",
|
||||
"vhId": "Vhost ID",
|
||||
@@ -637,13 +639,13 @@
|
||||
"certCheckDomains": "Domains to verify (comma-separated)",
|
||||
"certAcmeLater": "Issued certificates are imported into the library and assigned per vhost.",
|
||||
"certAdvanced": "Existing files / advanced",
|
||||
"certLegacyNames": "Legacy SNI names (optional; prefer per-vhost assignment)",
|
||||
"certLegacyNames": "SNI names (optional; used when a vhost is set to Global SNI / default certificate)",
|
||||
"certAssigned": "Assigned vhosts",
|
||||
"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. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.",
|
||||
"certSelectHint": "Select an imported certificate to use it for every name of this vhost. Leave \"Global SNI / default certificate\" to let RustProxy pick the certificate per requested name (SNI) from the stored certificates whose SNI names or SAN entries include it — useful when one vhost serves domains with different certificates. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.",
|
||||
"vhTinyauth": "Authentication",
|
||||
"vhTabAuth": "Authentication",
|
||||
"taUrl": "Tinyauth URL / verification endpoint",
|
||||
@@ -1677,6 +1679,7 @@
|
||||
"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",
|
||||
@@ -1725,7 +1728,16 @@
|
||||
"dockerAliasesShort": "aliases",
|
||||
"dockerAgentDetails": "Agent details",
|
||||
"dockerServices": "Services",
|
||||
"dockerOpenApp": "Open app",
|
||||
"dockerOpenApp": "Open",
|
||||
"autoIdHint": "Generated automatically from the name. Edit it to set your own ID.",
|
||||
"autoIdReset": "Generate automatically",
|
||||
"autoIdChange": "Change",
|
||||
"autoIdPending": "Generated from the name",
|
||||
"autoIdBadge": "auto",
|
||||
"vhCertificateUncoveredPrompt": "Certificate \"{certificate}\" does not cover: {hosts}.\n\nSave anyway and force this certificate for these names? Browsers will show a name-mismatch warning. Choose Cancel to pick a matching certificate instead.",
|
||||
"copyEntry": "Copy",
|
||||
"autoCertSniHint": "No single certificate covers all names, but each name is covered by a stored certificate. Keep \"Global SNI / default certificate\" and RustProxy will pick the right certificate for each name (SNI).",
|
||||
"autoCertHint": "Certificate \"{id}\" was selected automatically because it covers all host names. You can choose another one.",
|
||||
"dockerLiveAccessLogsHelp": "Live HTTP access logs for this Docker vhost, not the container stdout/stderr.",
|
||||
"networkInterfacesHistory": "Traffic by interface",
|
||||
"networkInterfaceRate": "RX / TX",
|
||||
@@ -1877,9 +1889,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 Upgrade",
|
||||
"wsH2Rfc8441": "H2 RFC 8441",
|
||||
"wsH3Rfc9220": "H3 RFC 9220",
|
||||
"wsH1": "H1",
|
||||
"wsH2Rfc8441": "H2",
|
||||
"wsH3Rfc9220": "H3",
|
||||
"wsEstablished": "Established",
|
||||
"httpProtocolMix": "HTTP protocol mix",
|
||||
"wsProtocolMix": "WebSocket protocol mix",
|
||||
@@ -2280,6 +2292,28 @@
|
||||
"vhPoolRecycleSeconds": "Maximum upstream pool age (seconds, 0 = off)",
|
||||
"vhHttp1MaxBuffer": "HTTP/1 upstream max read buffer (bytes)",
|
||||
"vhPoolRecycleHelp": "Recycling replaces only the Hyper client generation for each backend; active exchanges continue normally. Defaults bound long-lived pool/TLS buffer retention without reloading the vhost or cache. Request recycling accepts 0 or 1000..10000000; age accepts 0 or 60..604800.",
|
||||
"backendTinyTitle": "Tiny backends",
|
||||
"backendTinyDescription": "Compact backend diagnostics table with search and sorting (the original 14-column view).",
|
||||
"backendOverviewTitle": "Backends",
|
||||
"backendOverviewDescription": "Backend health with status badges, error share, latency trend and expandable client-generation details; search, sorting, filters and column selection.",
|
||||
"backendFilterAll": "All",
|
||||
"backendFilterOther": "Other",
|
||||
"backendFilterLabel": "Filter backends by status",
|
||||
"backendSummary": "{up} of {total} up",
|
||||
"backendLastEvent": "Last event",
|
||||
"backendEventFailed": "Failed",
|
||||
"backendEventRecovered": "Recovered",
|
||||
"backendEventNone": "No failures",
|
||||
"backendAgo": "{value} ago",
|
||||
"backendDetailsToggle": "Show client generation details",
|
||||
"processOverviewTitle": "Process",
|
||||
"processOverviewDescription": "Process CPU, file descriptors, memory split and CPU/RSS history (cache memory is on the RAM cache card).",
|
||||
"processClassicTitle": "Process simple",
|
||||
"processLoadTitle": "Load",
|
||||
"processMemoryTitle": "Memory",
|
||||
"processFdHistory": "File descriptors history",
|
||||
"processThreadHistory": "OS threads history",
|
||||
"backendRequestsTrend": "Requests per refresh",
|
||||
"backendSearch": "Search backends",
|
||||
"backendSearchPlaceholder": "Search vhost, backend or state",
|
||||
"backendSortBy": "Sort by",
|
||||
@@ -2360,6 +2394,14 @@
|
||||
"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",
|
||||
@@ -2368,6 +2410,10 @@
|
||||
"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",
|
||||
@@ -2404,6 +2450,8 @@
|
||||
"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",
|
||||
|
||||
+57
-9
@@ -253,7 +253,8 @@
|
||||
"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": "Aktywne połączenia H2",
|
||||
"h2ActiveConnections": "Połączenia H2",
|
||||
"h3ActiveConnections": "Połączenia H3",
|
||||
"tlsFailures": "Błędy handshake TLS",
|
||||
"tlsListener": "Listener TLS",
|
||||
"notEnabled": "Wyłączony",
|
||||
@@ -395,6 +396,7 @@
|
||||
"vhImport": "Wczytaj do edytora",
|
||||
"vhMigrationHint": "Migracja jest atomowa. Trasy tych samych hostów i backendu są grupowane; niejednoznaczne przypisanie hostów jest odrzucane.",
|
||||
"vhEditor": "Konfiguracja vhosta",
|
||||
"vhCancel": "Anuluj",
|
||||
"vhReset": "Odrzuć zmiany",
|
||||
"vhSafetyHint": "Prywatny IP nie zastępuje uwierzytelniania. Zachowaj firewall i ACL. Preload HSTS jest opcjonalny; cookie affinity nie jest sesją logowania.",
|
||||
"vhId": "Identyfikator vhosta",
|
||||
@@ -637,13 +639,13 @@
|
||||
"certCheckDomains": "Domeny do sprawdzenia (po przecinku)",
|
||||
"certAcmeLater": "Wydane certyfikaty trafiaja do biblioteki i sa przypisywane do vhostow.",
|
||||
"certAdvanced": "Istniejące pliki / zaawansowane",
|
||||
"certLegacyNames": "Stare nazwy SNI (opcjonalne; zalecane przypisanie w vhoście)",
|
||||
"certLegacyNames": "Nazwy SNI (opcjonalne; używane, gdy vhost ma Globalne SNI / certyfikat domyślny)",
|
||||
"certAssigned": "Przypisane vhosty",
|
||||
"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. Import i rotacja są w zakładce Certyfikaty. Przypisania są sprawdzane względem nazw vhosta.",
|
||||
"certSelectHint": "Wybierz zaimportowany certyfikat, aby użyć go dla wszystkich nazw tego vhosta. Zostaw \"Globalne SNI / certyfikat domyślny\", a RustProxy sam dobierze certyfikat do żądanej nazwy (SNI) spośród zapisanych certyfikatów, których nazwy SNI lub wpisy SAN ją zawierają — przydatne, gdy jeden vhost obsługuje domeny z różnymi certyfikatami. Import i rotacja są w zakładce Certyfikaty. Przypisania są sprawdzane względem nazw vhosta.",
|
||||
"vhTinyauth": "Uwierzytelnianie",
|
||||
"vhTabAuth": "Uwierzytelnianie",
|
||||
"taUrl": "URL Tinyauth / punkt weryfikacji",
|
||||
@@ -1677,6 +1679,7 @@
|
||||
"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",
|
||||
@@ -1725,7 +1728,16 @@
|
||||
"dockerAliasesShort": "aliasów",
|
||||
"dockerAgentDetails": "Szczegóły agenta",
|
||||
"dockerServices": "Usługi",
|
||||
"dockerOpenApp": "Otwórz aplikację",
|
||||
"dockerOpenApp": "Otwórz",
|
||||
"autoIdHint": "Generowane automatycznie z nazwy. Zmień, aby ustawić własne ID.",
|
||||
"autoIdReset": "Generuj automatycznie",
|
||||
"autoIdChange": "Zmień",
|
||||
"autoIdPending": "Generowane z nazwy",
|
||||
"autoIdBadge": "auto",
|
||||
"vhCertificateUncoveredPrompt": "Certyfikat \"{certificate}\" nie obejmuje: {hosts}.\n\nZapisać mimo to i wymusić ten certyfikat dla tych nazw? Przeglądarki pokażą ostrzeżenie o niezgodności nazwy. Wybierz Anuluj, aby wskazać pasujący certyfikat.",
|
||||
"autoCertSniHint": "Żaden pojedynczy certyfikat nie obejmuje wszystkich nazw, ale każda nazwa jest objęta jakimś zapisanym certyfikatem. Zostaw \"Globalne SNI / certyfikat domyślny\", a RustProxy dobierze właściwy certyfikat dla każdej nazwy (SNI).",
|
||||
"copyEntry": "Kopiuj",
|
||||
"autoCertHint": "Certyfikat \"{id}\" został wybrany automatycznie, bo pokrywa wszystkie nazwy hostów. Możesz wybrać inny.",
|
||||
"dockerLiveAccessLogsHelp": "Logi żądań HTTP dla tego Docker vhosta, nie stdout/stderr kontenera.",
|
||||
"networkInterfacesHistory": "Ruch według interfejsów",
|
||||
"networkInterfaceRate": "RX / TX",
|
||||
@@ -1877,9 +1889,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 Upgrade",
|
||||
"wsH2Rfc8441": "H2 RFC 8441",
|
||||
"wsH3Rfc9220": "H3 RFC 9220",
|
||||
"wsH1": "H1",
|
||||
"wsH2Rfc8441": "H2",
|
||||
"wsH3Rfc9220": "H3",
|
||||
"wsEstablished": "Utworzone",
|
||||
"httpProtocolMix": "Miks protokołów HTTP",
|
||||
"wsProtocolMix": "Miks protokołów WebSocket",
|
||||
@@ -2280,6 +2292,28 @@
|
||||
"vhPoolRecycleSeconds": "Maks. wiek puli upstream (sekundy, 0 = wył.)",
|
||||
"vhHttp1MaxBuffer": "Maks. bufor odczytu HTTP/1 upstream (bajty)",
|
||||
"vhPoolRecycleHelp": "Odświeżanie podmienia tylko generację klienta Hyper danego backendu; aktywne wymiany kończą się normalnie. Domyślne limity ograniczają retencję pamięci puli/TLS bez przeładowania vhosta ani cache. Limit żądań: 0 lub 1000..10000000; limit czasu: 0 lub 60..604800.",
|
||||
"backendTinyTitle": "Backendy (kompaktowe)",
|
||||
"backendTinyDescription": "Kompaktowa tabela diagnostyki backendów z wyszukiwaniem i sortowaniem (dotychczasowy widok 14 kolumn).",
|
||||
"backendOverviewTitle": "Backendy",
|
||||
"backendOverviewDescription": "Stan backendów ze znacznikami statusu, udziałem błędów, trendem opóźnień i rozwijanymi szczegółami generacji klienta; wyszukiwanie, sortowanie, filtry i wybór kolumn.",
|
||||
"backendFilterAll": "Wszystkie",
|
||||
"backendFilterOther": "Inne",
|
||||
"backendFilterLabel": "Filtruj backendy według statusu",
|
||||
"backendSummary": "{up} z {total} działa",
|
||||
"backendLastEvent": "Ostatnie zdarzenie",
|
||||
"backendEventFailed": "Awaria",
|
||||
"backendEventRecovered": "Przywrócony",
|
||||
"backendEventNone": "Brak awarii",
|
||||
"backendAgo": "{value} temu",
|
||||
"backendDetailsToggle": "Pokaż szczegóły generacji klienta",
|
||||
"processOverviewTitle": "Proces",
|
||||
"processOverviewDescription": "Obciążenie procesu, deskryptory plików, podział pamięci oraz historia CPU i RSS (pamięć cache jest w karcie RAM cache).",
|
||||
"processClassicTitle": "Proces prosty",
|
||||
"processLoadTitle": "Obciążenie",
|
||||
"processMemoryTitle": "Pamięć",
|
||||
"processFdHistory": "Historia deskryptorów",
|
||||
"processThreadHistory": "Historia wątków systemowych",
|
||||
"backendRequestsTrend": "Żądania w kolejnych odświeżeniach",
|
||||
"backendSearch": "Szukaj backendów",
|
||||
"backendSearchPlaceholder": "Szukaj vhosta, backendu lub stanu",
|
||||
"backendSortBy": "Sortuj według",
|
||||
@@ -2360,6 +2394,14 @@
|
||||
"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",
|
||||
@@ -2368,6 +2410,10 @@
|
||||
"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ę",
|
||||
@@ -2404,6 +2450,8 @@
|
||||
"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",
|
||||
@@ -2643,7 +2691,7 @@
|
||||
"backendWidgetInFlight": "W toku",
|
||||
"backendWidgetErrorsRate": "Błędy/s",
|
||||
"backendWidgetErrorPercent": "Błędne żądania w ostatnim okresie",
|
||||
"backendWidgetProbeCount": "Zdrowe źródła",
|
||||
"backendWidgetProbeCount": "Zdrowe backendy",
|
||||
"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.",
|
||||
@@ -2999,4 +3047,4 @@
|
||||
"overviewLayoutInlineCompact": "Uzupełnij luki",
|
||||
"overviewLayoutInlineCompactHint": "Dosuń bloki w puste miejsca siatki (jednorazowo, przed zapisaniem możesz to anulować)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ from scripts.version import project_version
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
MANAGED = [
|
||||
'src', 'tests', 'scripts', 'tools', 'integrations', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'grafana',
|
||||
'src', 'tests', 'scripts', 'tools', 'integrations', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'grafana', 'third_party',
|
||||
'.github', '.cargo', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'rust-toolchain.toml', 'UPDATE_CHECK.toml',
|
||||
'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'README.md', 'LICENSE',
|
||||
'CHANGELOG.md', 'AGENTS.md', '.env.example', '.gitignore', '.dockerignore',
|
||||
|
||||
Executable → Regular
+108
-1
@@ -14,6 +14,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -25,6 +26,7 @@ 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),
|
||||
@@ -82,11 +84,29 @@ 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=30) as response, destination.open("wb") as output:
|
||||
with opener.open(request, timeout=60) as response, destination.open("wb") as output:
|
||||
length = response.headers.get("Content-Length")
|
||||
if length and int(length) > MAX_ARCHIVE:
|
||||
raise AssetError("Swagger archive exceeds the 64 MiB limit.")
|
||||
@@ -162,6 +182,75 @@ def atomic_write(path: Path, data: bytes) -> None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
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)
|
||||
@@ -169,10 +258,27 @@ 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)
|
||||
@@ -185,6 +291,7 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
||||
os.replace(temporary, cache) # Cache only an already-validated archive.
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
remember_archive(out_dir, cache)
|
||||
else:
|
||||
raise AssetError("Unrecognized Swagger source.")
|
||||
destination = out_dir / "swagger-ui"
|
||||
|
||||
Executable → Regular
+27
-1
@@ -28,6 +28,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
|
||||
@@ -41,6 +42,12 @@ GEOIP_SOURCES = {
|
||||
'user-country-ipv6.csv': 'https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv',
|
||||
}
|
||||
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:
|
||||
@@ -93,7 +100,7 @@ def digest(path: Path) -> str:
|
||||
return result.hexdigest()
|
||||
|
||||
|
||||
def fetch_https(url: str, max_bytes: int) -> bytes:
|
||||
def _fetch_https_once(url: str, max_bytes: int) -> bytes:
|
||||
if urllib.parse.urlparse(url).scheme != 'https':
|
||||
raise ValueError('GeoIP downloads require HTTPS.')
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer'})
|
||||
@@ -114,6 +121,25 @@ def fetch_https(url: str, max_bytes: int) -> bytes:
|
||||
return bytes(result)
|
||||
|
||||
|
||||
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'
|
||||
|
||||
Executable → Regular
+10
-2
@@ -365,7 +365,12 @@ 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)
|
||||
geoip_stage, geoip_names = prepare_geoip_update(data, prefix)
|
||||
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, []
|
||||
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
|
||||
stop_for_managed_update(data)
|
||||
try:
|
||||
@@ -434,7 +439,10 @@ def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bo
|
||||
prefix.mkdir(parents=True, mode=0o755, exist_ok=True); os.chmod(prefix, 0o755)
|
||||
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:
|
||||
download_geoip(data, account.pw_uid, account.pw_gid)
|
||||
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)
|
||||
release, version = stage(binary, prefix, record['user'], sha)
|
||||
record.update(release=release, version=version, sha256=sha)
|
||||
switch(prefix, release)
|
||||
|
||||
+22
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.36.1",
|
||||
"version": "0.36.8",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
@@ -3812,7 +3812,15 @@
|
||||
"neon",
|
||||
"monochrome",
|
||||
"warm",
|
||||
"cool"
|
||||
"cool",
|
||||
"sunset",
|
||||
"aurora",
|
||||
"ocean",
|
||||
"forest",
|
||||
"pastel",
|
||||
"terminal",
|
||||
"blueprint",
|
||||
"colorblind"
|
||||
],
|
||||
"default": "default",
|
||||
"description": "Account-scoped visual presentation preset applied consistently to administration charts."
|
||||
@@ -3827,6 +3835,11 @@
|
||||
"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": [
|
||||
@@ -3940,8 +3953,10 @@
|
||||
"connection_chart",
|
||||
"cache_diagnostics",
|
||||
"process_diagnostics",
|
||||
"process_simple",
|
||||
"network_diagnostics",
|
||||
"backend_table",
|
||||
"backend_tiny",
|
||||
"extra_cold_queue_memory",
|
||||
"extra_cold_storage",
|
||||
"wide_transform_activity",
|
||||
@@ -4025,8 +4040,10 @@
|
||||
"connection_chart",
|
||||
"cache_diagnostics",
|
||||
"process_diagnostics",
|
||||
"process_simple",
|
||||
"network_diagnostics",
|
||||
"backend_table"
|
||||
"backend_table",
|
||||
"backend_tiny"
|
||||
],
|
||||
"description": "Account-scoped order for configurable Overview telemetry cards."
|
||||
},
|
||||
@@ -4117,8 +4134,10 @@
|
||||
"connection_chart",
|
||||
"cache_diagnostics",
|
||||
"process_diagnostics",
|
||||
"process_simple",
|
||||
"network_diagnostics",
|
||||
"backend_table",
|
||||
"backend_tiny",
|
||||
"extra_cold_queue_memory",
|
||||
"extra_cold_storage",
|
||||
"wide_transform_activity",
|
||||
|
||||
@@ -120,6 +120,8 @@ struct AdminPreferences {
|
||||
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")]
|
||||
@@ -183,6 +185,7 @@ impl Default for AdminPreferences {
|
||||
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(),
|
||||
@@ -300,6 +303,14 @@ 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,
|
||||
|
||||
@@ -18,6 +18,7 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
|
||||
"wide_compression_activity",
|
||||
"cache",
|
||||
"backend_table",
|
||||
"process_diagnostics",
|
||||
] {
|
||||
assert!(order.contains(&id.to_owned()), "not registered: {id}");
|
||||
assert!(
|
||||
@@ -28,6 +29,8 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
|
||||
for id in [
|
||||
"bandwidth",
|
||||
"backend_health",
|
||||
"backend_tiny",
|
||||
"process_simple",
|
||||
"extra_process_cpu",
|
||||
"stream_traffic",
|
||||
"wide_cache_activity",
|
||||
@@ -42,6 +45,20 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
|
||||
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]
|
||||
|
||||
@@ -58,6 +58,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
include_str!("web/app/overview-samples.js"),
|
||||
include_str!("web/app/core.js"),
|
||||
include_str!("web/app/icons.js"),
|
||||
include_str!("web/app/logo-animation.js"),
|
||||
include_str!("web/app/path-permissions.js"),
|
||||
include_str!("web/app/session-resume.js"),
|
||||
include_str!("web/app/refresh.js"),
|
||||
|
||||
@@ -2147,6 +2147,7 @@ mod tests {
|
||||
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,6 +2186,7 @@ mod tests {
|
||||
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!(
|
||||
@@ -2208,6 +2210,7 @@ mod tests {
|
||||
"chart_style":"neon",
|
||||
"chart_axes":true,
|
||||
"interface_icons":true,
|
||||
"logo_animation":true,
|
||||
"button_style":"compact",
|
||||
"corner_style":"rounded",
|
||||
"table_density":"ultra",
|
||||
|
||||
@@ -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) return;
|
||||
if (!svg || svg.classList.contains("ui-icon")) 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")) {
|
||||
for (const svg of document.querySelectorAll("svg:not(.ui-icon)")) {
|
||||
if (show && svg.rustProxyAxisData) renderAxes(svg);
|
||||
else if (!show && svg.parentElement?.classList.contains("rp-chart-axis-host")) {
|
||||
const host = svg.parentElement;
|
||||
|
||||
+125
-4
@@ -61,15 +61,23 @@ function translate() {
|
||||
syncSettingsDirtyIndicators();
|
||||
window.rustProxyIcons?.refresh?.();
|
||||
}
|
||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
function toastNode() {
|
||||
let node = document.getElementById("toast");
|
||||
if (!node) { // the node lived inside a dialog that was rebuilt; recreate it instead of failing every later toast
|
||||
node = document.createElement("div"); node.id = "toast"; node.className = "toast"; node.hidden = true;
|
||||
node.setAttribute("role", "status"); node.setAttribute("aria-live", "polite"); node.setAttribute("popover", "manual"); document.body.append(node);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function hideToast() { const node = toastNode(); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
function raiseToast() {
|
||||
const node = $("toast"); if (node.hidden) return;
|
||||
const node = toastNode(); if (node.hidden) return;
|
||||
const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body;
|
||||
if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover();
|
||||
if (node.parentElement !== parent) parent.append(node);
|
||||
if (typeof node.showPopover === "function") node.showPopover();
|
||||
}
|
||||
function toast(message, error = false) { clearTimeout(toastTimer); const node = $("toast"); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); }
|
||||
function toast(message, error = false) { clearTimeout(toastTimer); const node = toastNode(); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); }
|
||||
function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); }
|
||||
function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; }
|
||||
function clearFieldError(target) {
|
||||
@@ -134,7 +142,7 @@ function apiErrorMessage(path, response, value) {
|
||||
const area = apiArea(path);
|
||||
return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail;
|
||||
}
|
||||
new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = $("toast"); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] });
|
||||
new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = toastNode(); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] });
|
||||
async function api(path, options = {}) {
|
||||
const started = performance.now(), trackLatency = path.startsWith("/api/");
|
||||
try {
|
||||
@@ -668,3 +676,116 @@ $("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;
|
||||
@@ -81,7 +81,7 @@ window.rustProxyDashboardLayout = (() => {
|
||||
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") {
|
||||
const card = el("article", undefined, "overview-layout-panel-preview");
|
||||
card.append(el("strong", label(id)));
|
||||
const chart = id === "backend_table" ? null : original.querySelector?.("svg");
|
||||
const chart = id === "backend_tiny" || id === "backend_table" ? null : original.querySelector?.("svg");
|
||||
if (chart) {
|
||||
const graphic = chart.cloneNode(true);
|
||||
for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id");
|
||||
|
||||
+84
-29
@@ -3,6 +3,7 @@ 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" }]],
|
||||
@@ -13,11 +14,15 @@ const UI_ICON_SHAPES = Object.freeze({
|
||||
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" }]],
|
||||
@@ -26,33 +31,40 @@ const UI_ICON_SHAPES = Object.freeze({
|
||||
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: "M10 5H5v14h5M14 8l4 4-4 4M8 12h10" }]],
|
||||
logout: [["path", { d: "M14 5h5v14h-5M10 8l-4 4 4 4M16 12H6" }]],
|
||||
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: "m9 3 6 6-2 2 4 4-2 2-4-4-2 2-6-6 2-2 4 4 2-2-4-4 2-2ZM5 19l4-4" }]],
|
||||
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: [["circle", { cx: "12", cy: "12", r: "3" }], ["path", { d: "M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4" }]],
|
||||
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 7H4v-5M4 7l4-4M4.5 8A8 8 0 1 1 5 17" }]],
|
||||
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" }]],
|
||||
@@ -61,13 +73,13 @@ const UI_ICON_SHAPES = Object.freeze({
|
||||
|
||||
const UI_PANEL_ICONS = Object.freeze({
|
||||
overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box",
|
||||
redirects: "route", notfound: "fileX", streams: "network", "acl-profiles": "shield",
|
||||
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: "network", connections: "globe", "docker-agents": "box",
|
||||
appearance: "palette", listeners: "plug", connections: "globe", "docker-agents": "box",
|
||||
policies: "shield", cache: "database", transform: "wand", forwarding: "route", pages: "file",
|
||||
logging: "list", geoip: "globe", prometheus: "activity", administration: "settings",
|
||||
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({
|
||||
@@ -75,17 +87,19 @@ const UI_VHOST_TAB_ICONS = Object.freeze({
|
||||
cache: "database", compression: "archive", transform: "wand", security: "shield", pages: "file", advanced: "code",
|
||||
});
|
||||
const UI_TRANSLATED_ICON_KEYS = Object.freeze([
|
||||
["trash", ["remove", "delete", "draftDiscard", "cacheRemoveBan", "certificateApiRevokeAll", "revokeAll"]],
|
||||
["trash", ["remove", "delete", "draftDiscard", "cacheRemoveBan", "cachePurgeUrl", "certificateApiRevoke", "certificateApiRevokeAll", "apiTokenRevoke", "revokeAll"]],
|
||||
["x", ["close", "cancelAction", "overviewLayoutInlineCancel"]],
|
||||
["save", ["saveCertificate", "savePassword", "dashboardPresetsSave", "overviewLayoutInlineSave"]],
|
||||
["check", ["apply", "applyFilters", "savePolicy", "continueAction", "webServicePresetApply", "dockerApplyService"]],
|
||||
["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"]],
|
||||
["edit", ["edit"]],
|
||||
["eyeOff", ["hideMetric", "overviewLayoutEditorHide"]],
|
||||
["edit", ["edit", "certificateApiEdit"]],
|
||||
["key", ["certificateApiRegenerate"]],
|
||||
["download", ["export"]],
|
||||
["upload", ["acmeImport", "aclProfileImportRules"]],
|
||||
["eye", ["certificateReadNames", "dockerShowHidden", "dashboardWidgetsShowAll"]],
|
||||
["eye", ["certificateReadNames", "dockerShowHidden", "overviewLayoutEditorShow"]],
|
||||
["settings", ["settings", "vhManage", "dockerAgentsSettings"]],
|
||||
["book", ["webServiceHtaccessDocs", "webServicePhpDocsButton"]],
|
||||
["pause", ["logPause"]],
|
||||
@@ -116,20 +130,52 @@ function uiIconMatchesTranslation(target, keys) {
|
||||
if (typeof t !== "function") return false;
|
||||
const strings = uiIconStrings(target); if (!strings.length) return false;
|
||||
for (const key of keys) {
|
||||
const translated = String(t(key) || "").trim().toLocaleLowerCase();
|
||||
if (!translated || translated === key.toLocaleLowerCase()) continue;
|
||||
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")) return null;
|
||||
if (target.matches("#web-service-preset-apply")) return "wand";
|
||||
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";
|
||||
@@ -145,6 +191,9 @@ function uiIconForTarget(target) {
|
||||
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();
|
||||
@@ -152,10 +201,17 @@ function uiIconForTarget(target) {
|
||||
if (/mobile-menu|menu-toggle/.test(signature)) return "menu";
|
||||
if (/is-saving|saving/.test(signature)) return "loader";
|
||||
if (/sort-direction|sort-button/.test(signature)) return /descending|desc/.test(aria) ? "sortDown" : "sortUp";
|
||||
if (/scroll-prev|move-earlier|layout-up/.test(signature)) return "chevronLeft";
|
||||
if (/scroll-next|move-later|layout-down/.test(signature)) return "chevronRight";
|
||||
if (/collapse|narrow/.test(signature)) return "chevronDown";
|
||||
if (/expand|widen/.test(signature)) return "chevronUp";
|
||||
if (/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";
|
||||
@@ -163,21 +219,20 @@ function uiIconForTarget(target) {
|
||||
if (/search|find/.test(signature)) return "search";
|
||||
if (/filter/.test(signature)) return "filter";
|
||||
if (/inspect|preview|read-names|readnames|show-hidden|visibility|view-toggle/.test(signature)) return "eye";
|
||||
if (/history|revision/.test(signature)) return "history";
|
||||
if (/restore|factory/.test(signature)) return "undo";
|
||||
if (/(^|[^p])reset/.test(signature)) return "undo";
|
||||
if (/refresh|reload|retry|renew|update-now|updatenow/.test(signature)) return "refresh";
|
||||
if (/reset/.test(signature)) return "undo";
|
||||
if (/history|revision/.test(signature)) return "history";
|
||||
if (/revoke|remove|delete|discard|purge|kill/.test(signature)) return "trash";
|
||||
if (/close|cancel/.test(signature)) return "x";
|
||||
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 (/pin|dock/.test(signature)) return "pin";
|
||||
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 (/logout|sign-out|lock/.test(signature)) return "logout";
|
||||
if (/login|sign-in/.test(signature)) return "login";
|
||||
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";
|
||||
@@ -192,8 +247,8 @@ function uiIconForTarget(target) {
|
||||
if (/log/.test(signature)) return "list";
|
||||
if (/docs|help|documentation/.test(signature)) return "book";
|
||||
if (/php|json|advanced|code/.test(signature)) return "code";
|
||||
if (/layout|preset|dashboard|overview/.test(signature)) return "grid";
|
||||
if (/about|info/.test(signature)) return "book";
|
||||
if (/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") {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Optional, account-scoped subtle animation of the sidebar r/p logo
|
||||
// (Settings → Appearance → Interface → Animate logo). Off by default.
|
||||
// Effects are short (≤1.2 s), run at random intervals, pause while the tab is
|
||||
// hidden and are skipped entirely when the system asks for reduced motion.
|
||||
const LOGO_ANIMATION_EFFECTS = Object.freeze(["spin-slash", "flip", "wave", "glow", "sheen", "glitch", "wobble"]);
|
||||
const LOGO_ANIMATION_DURATION_MS = 1400;
|
||||
let logoAnimationTimer = null, logoAnimationReset = null, logoAnimationLast = "", logoAnimationHoverBound = false;
|
||||
function logoAnimationMark() { return document.querySelector(".sidebar a.brand > .brand-mark") || document.querySelector("a.brand > .brand-mark"); }
|
||||
function logoAnimationReducedMotion() { return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; }
|
||||
function prepareLogoAnimationMark(mark) {
|
||||
if (mark.dataset.logoPrepared === "true") return;
|
||||
// Wrap the bare "r" and "p" text nodes so each letter can be transformed on its own.
|
||||
for (const node of [...mark.childNodes]) {
|
||||
if (node.nodeType !== Node.TEXT_NODE || !node.textContent.trim()) continue;
|
||||
const letter = document.createElement("b"); letter.className = "brand-letter"; letter.textContent = node.textContent.trim();
|
||||
node.replaceWith(letter);
|
||||
}
|
||||
mark.dataset.logoPrepared = "true";
|
||||
}
|
||||
function playLogoAnimation(effect) {
|
||||
const mark = logoAnimationMark();
|
||||
if (!mark || document.documentElement.dataset.logoAnimation !== "true" || document.hidden || logoAnimationReducedMotion() || mark.dataset.logoEffect) return;
|
||||
prepareLogoAnimationMark(mark);
|
||||
const choices = LOGO_ANIMATION_EFFECTS.filter(name => name !== logoAnimationLast);
|
||||
const next = LOGO_ANIMATION_EFFECTS.includes(effect) ? effect : choices[Math.floor(Math.random() * choices.length)];
|
||||
logoAnimationLast = next; mark.dataset.logoEffect = next;
|
||||
clearTimeout(logoAnimationReset);
|
||||
logoAnimationReset = setTimeout(() => { delete mark.dataset.logoEffect; }, LOGO_ANIMATION_DURATION_MS);
|
||||
}
|
||||
function scheduleLogoAnimation() {
|
||||
clearTimeout(logoAnimationTimer);
|
||||
if (document.documentElement.dataset.logoAnimation !== "true") return;
|
||||
const delay = 9000 + Math.random() * 14000;
|
||||
logoAnimationTimer = setTimeout(() => { playLogoAnimation(); scheduleLogoAnimation(); }, delay);
|
||||
}
|
||||
function setLogoAnimationEnabled(value) {
|
||||
const enabled = value === true;
|
||||
document.documentElement.dataset.logoAnimation = String(enabled);
|
||||
const input = $("ui-logo-animation"); if (input) input.checked = enabled;
|
||||
const mark = logoAnimationMark();
|
||||
if (!enabled) {
|
||||
clearTimeout(logoAnimationTimer); clearTimeout(logoAnimationReset); logoAnimationTimer = null;
|
||||
if (mark) delete mark.dataset.logoEffect;
|
||||
return false;
|
||||
}
|
||||
if (mark) prepareLogoAnimationMark(mark);
|
||||
if (!logoAnimationHoverBound) {
|
||||
mark?.closest("a.brand")?.addEventListener("mouseenter", () => playLogoAnimation());
|
||||
logoAnimationHoverBound = !!mark;
|
||||
}
|
||||
if (!logoAnimationTimer) { scheduleLogoAnimation(); setTimeout(() => playLogoAnimation(), 600); }
|
||||
return true;
|
||||
}
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && document.documentElement.dataset.logoAnimation === "true") scheduleLogoAnimation(); });
|
||||
window.rustProxyLogoAnimation = { setEnabled: setLogoAnimationEnabled, play: playLogoAnimation, effects: LOGO_ANIMATION_EFFECTS };
|
||||
@@ -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"]);
|
||||
const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool", "sunset", "aurora", "ocean", "forest", "pastel", "terminal", "blueprint", "colorblind"]);
|
||||
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
|
||||
const ADMIN_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,
|
||||
chart_axes: false, interface_icons: false, logo_animation: false,
|
||||
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null,
|
||||
});
|
||||
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
|
||||
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool", sunset: "chartStyleSunset", aurora: "chartStyleAurora", ocean: "chartStyleOcean", forest: "chartStyleForest", pastel: "chartStylePastel", terminal: "chartStyleTerminal", blueprint: "chartStyleBlueprint", colorblind: "chartStyleColorblind" });
|
||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
||||
|
||||
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
||||
@@ -124,6 +124,7 @@ function applyAdminChartAxes(value) {
|
||||
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;
|
||||
@@ -288,7 +289,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), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
|
||||
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), interface_icons: applyAdminInterfaceIcons(value.interface_icons), logo_animation: applyAdminLogoAnimation(value.logo_animation), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
|
||||
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes,
|
||||
};
|
||||
adminPreferences = normalized;
|
||||
@@ -336,7 +337,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.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
|
||||
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.interface_icons = next.interface_icons === true; next.logo_animation = next.logo_animation === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
|
||||
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes);
|
||||
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
@@ -344,7 +345,7 @@ function updateAdminPreferences(patch) {
|
||||
});
|
||||
adminPreferencesWrite = task.catch(() => undefined); return task;
|
||||
}
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); applyAdminInterfaceIcons(false); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); applyAdminInterfaceIcons(false); applyAdminLogoAnimation(false); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
|
||||
function renderRefreshPreferences(value = REFRESH_DEFAULTS) {
|
||||
const refresh = normalizeRefreshPreferences(value);
|
||||
const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled;
|
||||
@@ -428,6 +429,14 @@ $("ui-interface-icons")?.addEventListener("change", () => {
|
||||
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); });
|
||||
@@ -459,7 +468,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, button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
|
||||
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", interface_icons: false, logo_animation: false, button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
|
||||
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null },
|
||||
};
|
||||
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
||||
|
||||
@@ -226,7 +226,7 @@ $("reset-certificate").addEventListener("click", () => { $("certificate-form").r
|
||||
syncCertificateFileSource();
|
||||
|
||||
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, lastCacheOutcomeRates = null,
|
||||
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
|
||||
processCpuSamples = [], processRssSamples = [], processFdSamples = [], processThreadSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
|
||||
const hiddenNetworkInterfaces = (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]");
|
||||
@@ -257,6 +257,68 @@ function setOptionalMetric(id, supported, text) {
|
||||
const row = node.closest("div"); if (row) row.hidden = !supported;
|
||||
if (supported) node.textContent = text;
|
||||
}
|
||||
function setProcessMeter(id, used, limit) {
|
||||
const meter = $(id)?.closest("div")?.querySelector(".pr-meter"); if (!meter) return;
|
||||
const usedNumber = Number(used), limitNumber = Number(limit);
|
||||
const ok = used != null && limit != null && Number.isFinite(usedNumber) && Number.isFinite(limitNumber) && limitNumber > 0;
|
||||
meter.hidden = !ok; if (!ok) return;
|
||||
const percent = Math.max(0, Math.min(100, usedNumber / limitNumber * 100));
|
||||
const bar = meter.firstElementChild; bar.style.width = `${percent}%`; bar.classList.toggle("is-high", percent >= 85);
|
||||
}
|
||||
function renderProcessOverview(d, status, live, systemSample) {
|
||||
if (!$("po-cpu")) return;
|
||||
if (systemSample) {
|
||||
const memory = status.memory_accounting || {};
|
||||
const platform = [d.platform, d.environment].filter(Boolean).join(" / ");
|
||||
setOptionalMetric("po-platform", !!d.platform && d.platform !== "Unsupported", platform);
|
||||
setOptionalMetric("po-threads", d.process_threads != null, fmt(d.process_threads));
|
||||
setOptionalMetric("po-start", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : formatDateTime(Number(d.process_start_time_seconds) * 1000));
|
||||
setOptionalMetric("po-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
|
||||
setOptionalMetric("po-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`);
|
||||
setProcessMeter("po-cpu", d.process_cpu_percent, 100);
|
||||
setOptionalMetric("po-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
|
||||
setProcessMeter("po-rss-total", d.process_rss_bytes, d.cgroup_memory_max_bytes);
|
||||
const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited"));
|
||||
setOptionalMetric("po-fds", fds != null, fds);
|
||||
setProcessMeter("po-fds", d.open_fds, d.max_fds);
|
||||
const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
|
||||
setOptionalMetric("po-cgroup", cgroupMemory != null, cgroupMemory);
|
||||
setProcessMeter("po-cgroup", d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes);
|
||||
setOptionalMetric("po-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
|
||||
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
|
||||
setOptionalMetric("po-rss-file", rssFile != null, rssFile);
|
||||
setOptionalMetric("po-non-cache", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
|
||||
const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
|
||||
setOptionalMetric("po-virtual", virtualMemory != null, virtualMemory);
|
||||
const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value));
|
||||
setOptionalMetric("po-data-swap", dataSwap != null, dataSwap);
|
||||
const split = $("po-rss-split"), anon = Number(d.process_rss_anon_bytes), file = Number(d.process_rss_file_bytes);
|
||||
const known = d.process_rss_anon_bytes != null && d.process_rss_file_bytes != null && anon + file > 0;
|
||||
split.hidden = !known;
|
||||
if (known) { split.firstElementChild.style.width = `${anon / (anon + file) * 100}%`; split.lastElementChild.style.width = `${file / (anon + file) * 100}%`; }
|
||||
}
|
||||
$("po-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
|
||||
window.rustProxyChartScale.axes("po-cpu-sparkline", Math.max(1, ...processCpuSamples), "percent",
|
||||
processCpuSamples.length, { top: 5 / 60, bottom: 55 / 60 });
|
||||
setChartText($("po-cpu-chart-value"), live.process_cpu_percent == null ? "" : `${fmt(live.process_cpu_percent, 1)}%`);
|
||||
$("po-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
|
||||
window.rustProxyChartScale.axes("po-rss-sparkline", Math.max(1, ...processRssSamples), "bytes",
|
||||
processRssSamples.length, { top: 5 / 60, bottom: 55 / 60 });
|
||||
setChartText($("po-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
|
||||
$("po-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
|
||||
$("po-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
|
||||
const openFdsNow = live.open_fds ?? d.open_fds, threadsNow = live.process_threads ?? d.process_threads;
|
||||
$("po-fd-sparkline").setAttribute("points", sparklinePoints(processFdSamples));
|
||||
window.rustProxyChartScale.axes("po-fd-sparkline", Math.max(1, ...processFdSamples), "count",
|
||||
processFdSamples.length, { top: 5 / 60, bottom: 55 / 60 });
|
||||
setChartText($("po-fd-chart-value"), openFdsNow == null ? "" : fmt(openFdsNow));
|
||||
$("po-fd-sparkline").closest(".mini-live-chart").hidden = openFdsNow == null;
|
||||
$("po-thread-sparkline").setAttribute("points", sparklinePoints(processThreadSamples));
|
||||
window.rustProxyChartScale.axes("po-thread-sparkline", Math.max(1, ...processThreadSamples), "count",
|
||||
processThreadSamples.length, { top: 5 / 60, bottom: 55 / 60 });
|
||||
setChartText($("po-thread-chart-value"), threadsNow == null ? "" : fmt(threadsNow));
|
||||
$("po-thread-sparkline").closest(".mini-live-chart").hidden = threadsNow == null;
|
||||
}
|
||||
function knownPair(left, right, format, zeroRight = null) {
|
||||
if (left == null && right == null) return null;
|
||||
if (left == null) return format(right);
|
||||
@@ -282,6 +344,15 @@ 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) {
|
||||
@@ -346,9 +417,12 @@ 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");
|
||||
window.rustProxyChartScale.axes(row.querySelector("svg"), peak, "bytes/s", history.rx.length,
|
||||
// The Hide button precedes the chart and may carry an injected svg.ui-icon:
|
||||
// address the chart explicitly so axes/labels never attach to the icon.
|
||||
const chart = row.querySelector("svg:not(.ui-icon)");
|
||||
window.rustProxyChartScale.axes(chart, peak, "bytes/s", history.rx.length,
|
||||
{ top: 5 / 60, bottom: 55 / 60 });
|
||||
row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
|
||||
chart.setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
|
||||
children.push(row);
|
||||
}
|
||||
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
|
||||
@@ -395,15 +469,33 @@ function cacheOutcomeMix(totals) {
|
||||
const counts = [hits, misses, bypasses, requests - cacheTotal];
|
||||
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;
|
||||
$(`cache-mix-${name}`).style.width = `${pct}%`;
|
||||
const segment = $(`cache-mix-${name}`);
|
||||
segment.style.width = `${pct}%`;
|
||||
segment.textContent = mix && pct > 0 ? `${fmt(pct, pct < 10 ? 1 : 0)}%` : "";
|
||||
const detail = mix ? `${name.toUpperCase()}: ${fmt(mix.counts[i])} (${fmt(pct, 1)}%)` : "";
|
||||
$(`cache-mix-${name}-count`).textContent = mix ? fmt(mix.counts[i]) : "-";
|
||||
$(`cache-mix-${name}-percent`).textContent = mix ? `${fmt(pct, 1)}%` : "-";
|
||||
segment.title = detail; $(`cache-mix-${name}-key`).title = detail;
|
||||
});
|
||||
fitCacheMixLabels();
|
||||
$("cache-mix-headline").textContent = mix ? `${fmt(mix.percentages[0], 1)}% HIT` : "-";
|
||||
const caption = $("cache-mix-caption");
|
||||
const validTotal = Number.isSafeInteger(totalRequests) && totalRequests >= 0;
|
||||
@@ -424,6 +516,7 @@ 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)}%` : "-";
|
||||
@@ -497,6 +590,9 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
||||
if (appendSample) {
|
||||
if (live.process_cpu_percent != null) { processCpuSamples.push(Number(live.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||
if (live.process_rss_bytes != null) { processRssSamples.push(Number(live.process_rss_bytes)); processRssSamples = processRssSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||
const openFds = live.open_fds ?? d.open_fds, threadCount = live.process_threads ?? d.process_threads;
|
||||
if (openFds != null) { processFdSamples.push(Number(openFds)); processFdSamples = processFdSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||
if (threadCount != null) { processThreadSamples.push(Number(threadCount)); processThreadSamples = processThreadSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
|
||||
}
|
||||
const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples);
|
||||
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
|
||||
@@ -568,6 +664,7 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
||||
setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
|
||||
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
|
||||
$("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
|
||||
renderProcessOverview(d, status, live, systemSample);
|
||||
updateCacheMemorySizing();
|
||||
const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null;
|
||||
if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`);
|
||||
@@ -597,6 +694,7 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
|
||||
$("system-sample-age").hidden = age == null;
|
||||
if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`;
|
||||
$("system-sample-age").classList.toggle("status-error", age != null && age > 10);
|
||||
if ($("po-sample-age")) { $("po-sample-age").hidden = age == null; if (age != null) $("po-sample-age").textContent = `${fmt(age)} s`; $("po-sample-age").classList.toggle("status-error", age != null && age > 10); }
|
||||
const l = status.listeners || {};
|
||||
$("listener-pending").hidden = !l.restart_required;
|
||||
$("listener-pending").textContent = t("restartPending");
|
||||
|
||||
@@ -67,6 +67,22 @@
|
||||
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;
|
||||
@@ -162,8 +178,13 @@
|
||||
$("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), `${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 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 domainEmpty = $("cache-domains-empty");
|
||||
domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains");
|
||||
@@ -172,11 +193,17 @@
|
||||
const tr = el("tr"), resident = Number(row.resident_bytes) || 0, disk = Number(row.storage_bytes) || 0, body = Number(row.body_bytes) || 0, storage = resident || disk;
|
||||
tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain));
|
||||
const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue);
|
||||
if (row.transformed) target.append(el("span", t("transformBadge"), "cache-transform-badge"));
|
||||
if (row.transformed) {
|
||||
// Keep the badge outside the ellipsised text so a long URL never clips it.
|
||||
const wrap = el("div", undefined, "cache-target-wrap");
|
||||
wrap.append(el("span", targetValue, "cache-target-text"), el("span", t("transformBadge"), "cache-transform-badge"));
|
||||
target.replaceChildren(wrap);
|
||||
}
|
||||
tr.append(target);
|
||||
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
|
||||
tr.append(makeCopyCell(keyTags));
|
||||
for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
|
||||
tr.append(el("td", undefined)); tr.lastChild.append(el("span", row.tier === "cold" ? t("cacheCold") : t("cacheHot"), `cache-tier-pill ${row.tier === "cold" ? "is-cold" : "is-hot"}`));
|
||||
for (const value of [fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
|
||||
const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr;
|
||||
}));
|
||||
const bans = data.bans || [];
|
||||
|
||||
+208
-14
@@ -257,20 +257,6 @@ 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;
|
||||
@@ -333,6 +319,62 @@ 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);
|
||||
@@ -485,6 +527,158 @@ 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);
|
||||
|
||||
@@ -758,7 +758,8 @@ progress::-moz-progress-bar {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#pool-search {
|
||||
#pool-search,
|
||||
#bo-search {
|
||||
width: calc(min(250px, 30vw));
|
||||
min-width: 170px;
|
||||
}
|
||||
@@ -1015,6 +1016,8 @@ dialog .vh-form,
|
||||
}
|
||||
|
||||
#vhost-list,
|
||||
#redirect-list,
|
||||
#notfound-list,
|
||||
#web-service-list,
|
||||
#stream-list,
|
||||
.vh-www-choices,
|
||||
@@ -1067,43 +1070,10 @@ 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;
|
||||
@@ -1151,7 +1121,7 @@ body[data-density=compact] .page-footer {
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
|
||||
.network-interface-row svg {
|
||||
.network-interface-row svg:not(.ui-icon) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
@@ -1251,6 +1221,29 @@ body[data-density=compact] .page-footer {
|
||||
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,
|
||||
@@ -1570,14 +1563,6 @@ 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;
|
||||
}
|
||||
@@ -1599,6 +1584,7 @@ html[data-table-density=ultra] :is(table th, table td) {
|
||||
}
|
||||
|
||||
.path-permissions-commands {
|
||||
max-height: 180px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1608,4 +1594,13 @@ 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;
|
||||
}
|
||||
+157
-32
@@ -604,37 +604,6 @@ 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 {
|
||||
@@ -869,4 +838,160 @@ body[data-density=compact] .page-intro .lead {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mini-live-chart svg {
|
||||
.mini-live-chart svg:not(.ui-icon) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
@@ -361,11 +361,11 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.activity-card svg {
|
||||
.activity-card svg:not(.ui-icon) {
|
||||
height: 105px;
|
||||
}
|
||||
|
||||
.system-card svg {
|
||||
.system-card svg:not(.ui-icon) {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
@@ -383,11 +383,6 @@ 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;
|
||||
@@ -468,7 +463,7 @@ body[data-density=dense] .stat strong {
|
||||
padding: 7px 10px 0;
|
||||
}
|
||||
|
||||
.vhost-chart svg {
|
||||
.vhost-chart svg:not(.ui-icon) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
@@ -550,37 +545,12 @@ body[data-density=dense] .stat strong {
|
||||
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;
|
||||
@@ -664,7 +634,7 @@ body[data-density=compact] .pool-card .table-scroll {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.stat-chart svg {
|
||||
.stat-chart svg:not(.ui-icon) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
@@ -769,7 +739,7 @@ body[data-density=compact] .pool-card .table-scroll {
|
||||
padding: 14px 15px 12px;
|
||||
}
|
||||
|
||||
.function-live-chart svg {
|
||||
.function-live-chart svg:not(.ui-icon) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
@@ -851,7 +821,7 @@ body[data-density=compact] .pool-card .table-scroll {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.connection-card svg {
|
||||
.connection-card svg:not(.ui-icon) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
@@ -1052,7 +1022,7 @@ body[data-density=compact] .pool-card .table-scroll {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg,
|
||||
:is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg:not(.ui-icon),
|
||||
:is(.traffic-donut, .traffic-bar-chart span) {
|
||||
filter: var(--chart-svg-filter);
|
||||
}
|
||||
@@ -1127,15 +1097,15 @@ html[data-dashboard-density=ultra] #panel-overview .stat small {
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
html[data-dashboard-density=ultra] #panel-overview .stat-chart svg {
|
||||
html[data-dashboard-density=ultra] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
html[data-dashboard-density=normal] #panel-overview .stat-chart svg {
|
||||
html[data-dashboard-density=normal] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
html[data-dashboard-density=compact] #panel-overview .stat-chart svg {
|
||||
html[data-dashboard-density=compact] #panel-overview .stat-chart svg:not(.ui-icon) {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@@ -1737,7 +1707,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, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-restore),
|
||||
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(button:not(.nav):not(.file-log-tab):not(.vhost-backend-trigger), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-restore),
|
||||
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(.nav, .file-log-tab, .vhost-backend-trigger, .backend-column-options, .docker-service-quick-actions a, .docker-service-quick-actions button, .docker-view-toggle, .provider-picker, .renewal-meta-item) {
|
||||
border-radius: var(--rp-radius-control) !important;
|
||||
}
|
||||
@@ -2074,7 +2044,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, var(--radius, 8px));
|
||||
border-radius: var(--rp-radius-surface, 8px);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
@@ -2213,8 +2183,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: 12px;
|
||||
margin: 7px 0 9px;
|
||||
height: 20px;
|
||||
margin: 3px 0 4px;
|
||||
border-radius: var(--rp-progress-radius);
|
||||
background: var(--rp-progress-track);
|
||||
overflow: hidden;
|
||||
@@ -2224,6 +2194,17 @@ 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 {
|
||||
@@ -2266,23 +2247,11 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
}
|
||||
|
||||
#panel-overview .cache-mix-legend b {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
margin-left: 1px;
|
||||
color: var(--text);
|
||||
font: 500 9px var(--mono);
|
||||
}
|
||||
|
||||
#panel-overview .cache-mix-legend .cache-mix-percent {
|
||||
color: var(--muted);
|
||||
font: 400 8px var(--mono);
|
||||
}
|
||||
|
||||
#panel-overview .cache-mix-legend .cache-mix-percent::before {
|
||||
content: "· ";
|
||||
}
|
||||
|
||||
#panel-overview .system-card .system-uptime-meta,
|
||||
#panel-overview .connection-card .connection-backends-meta {
|
||||
white-space: nowrap;
|
||||
@@ -2322,4 +2291,193 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
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 {
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
/* Compact stat cards: a card made only of title, value and one secondary line shows that line
|
||||
beside the value (right-aligned) instead of on a line of its own. */
|
||||
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small)) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 10px;
|
||||
align-items: baseline;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>span:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>strong {
|
||||
grid-column: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>small {
|
||||
grid-column: 2;
|
||||
max-width: 14em;
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Backends overview card (0.36.7): follows table density (global table rules), corner style tokens and interface colours */
|
||||
#panel-overview .backend-overview-card.is-no-proxy,
|
||||
#panel-overview #backend-diagnostics-card.is-no-proxy { display: none !important; }
|
||||
.bo-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; min-width: 0; }
|
||||
.bo-summary { color: var(--muted); font: 11px var(--mono); }
|
||||
.bo-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; }
|
||||
.bo-chip { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); background: transparent; color: var(--muted); font: 500 12px/1 var(--sans); cursor: pointer; }
|
||||
.bo-chip:hover { color: var(--text); border-color: var(--border-hover); }
|
||||
.bo-chip[aria-pressed=true] { color: var(--text); background: var(--nav-active-bg); border-color: var(--pill-line); }
|
||||
.bo-chip b { color: var(--text-subtle); font: 600 11px var(--mono); }
|
||||
.bo-chip[aria-pressed=true] b { color: var(--accent); }
|
||||
.bo-chip.is-down b:not(.is-zero) { color: var(--red); }
|
||||
.backend-overview-card .table-scroll { max-height: 520px; overflow: auto; border-top: 1px solid var(--line); }
|
||||
.bo-table { width: 100%; min-width: 1300px; border-collapse: collapse; font-size: 12px; }
|
||||
.bo-table th { position: sticky; top: 0; z-index: 1; padding: 8px 10px; background: var(--panel); color: var(--muted); font: 600 10px/1.2 var(--sans); letter-spacing: .05em; text-align: left; text-transform: uppercase; white-space: nowrap; }
|
||||
.bo-table td { padding: 9px 10px; border-top: 1px solid var(--row-line); vertical-align: middle; white-space: nowrap; }
|
||||
.bo-table .is-num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.bo-table th.is-num { font-family: var(--sans); }
|
||||
.bo-table .is-zero { color: var(--text-subtle); }
|
||||
.bo-row:hover>td { background: var(--panel2); }
|
||||
.bo-row.is-disabled>td:not(:first-child) { opacity: .6; }
|
||||
.bo-row>td:first-child { box-shadow: inset 3px 0 0 var(--line); }
|
||||
.bo-row.is-up>td:first-child { box-shadow: inset 3px 0 0 var(--pill-live-line); }
|
||||
.bo-row.is-down>td:first-child { box-shadow: inset 3px 0 0 var(--red); }
|
||||
.bo-row.is-warn>td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.bo-expand { width: 24px; height: 24px; min-width: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--muted); cursor: pointer; line-height: 1; }
|
||||
.bo-expand::before { content: "›"; display: inline-block; font-size: 15px; transition: transform .15s ease; }
|
||||
.bo-expand[aria-expanded=true]::before { transform: rotate(90deg); }
|
||||
.bo-expand:hover { color: var(--text); border-color: var(--field-line); }
|
||||
.bo-identity { max-width: 360px; }
|
||||
.bo-name { overflow: hidden; color: var(--text); font-weight: 600; text-overflow: ellipsis; }
|
||||
.bo-sub { margin-top: 2px; overflow: hidden; color: var(--muted); font: .88em var(--mono); text-overflow: ellipsis; }
|
||||
.bo-badge { display: inline-flex; align-items: center; gap: 7px; padding: 2px 8px; border: 1px solid var(--field-line); border-radius: var(--rp-radius-control, 0); color: var(--muted); font: .82em var(--mono); letter-spacing: 1px; text-transform: uppercase; }
|
||||
.bo-badge>i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-subtle); }
|
||||
.bo-badge.is-up { border-color: var(--pill-live-line); color: var(--green); }
|
||||
.bo-badge.is-up>i { background: var(--green); }
|
||||
.bo-badge.is-down { border-color: var(--danger-line-strong); color: var(--red); }
|
||||
.bo-badge.is-down>i { background: var(--red); }
|
||||
.bo-badge.is-warn { border-color: var(--warn-line); color: var(--accent); }
|
||||
.bo-badge.is-warn>i { background: var(--accent); }
|
||||
.bo-errors { min-width: 150px; }
|
||||
.bo-meter-top { display: flex; justify-content: space-between; gap: 8px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.bo-meter-top small { color: var(--text-subtle); font-size: .9em; }
|
||||
.bo-meter { height: 2px; margin-top: 5px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
|
||||
.bo-meter>i { display: block; height: 100%; background: var(--accent); border-radius: var(--rp-progress-radius, 0); }
|
||||
.bo-meter>i.is-high { background: var(--red); }
|
||||
.bo-latency { min-width: 200px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.bo-latency>span { display: inline-block; min-width: 62px; }
|
||||
.bo-spark { height: 22px; margin-left: 6px; vertical-align: middle; flex: 0 0 auto; }
|
||||
.bo-spark-latency { width: 120px; }
|
||||
.bo-spark-traffic { width: 96px; margin-left: 0; }
|
||||
.bo-spark-bars { width: 84px; margin-left: 0; }
|
||||
.bo-spark polyline { fill: none; stroke: var(--green); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.bo-row.is-down .bo-spark polyline { stroke: var(--red); }
|
||||
.bo-row.is-warn .bo-spark polyline { stroke: var(--accent); }
|
||||
.bo-event small { display: block; margin-top: 1px; color: var(--text-subtle); font-size: .9em; }
|
||||
.bo-event .is-failed, .bo-detail-item .is-failed { color: var(--red); }
|
||||
.bo-event .is-recovered, .bo-detail-item .is-recovered { color: var(--green); }
|
||||
.bo-detail-row>td { padding: 0 10px 10px 40px; border-top: 0; white-space: normal; }
|
||||
.bo-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; padding: 10px 12px; border: 1px solid var(--row-line); border-radius: var(--rp-radius-surface, 0); background: var(--bg); }
|
||||
.bo-detail-item span { display: block; margin-bottom: 3px; color: var(--text-subtle); font-size: 11px; }
|
||||
.bo-detail-item b { font: 500 12px var(--mono); overflow-wrap: anywhere; }
|
||||
html[data-table-density=normal] .bo-table td { padding-top: 10px; padding-bottom: 10px; }
|
||||
html[data-table-density=ultra] .bo-table th { padding: 4px 8px; }
|
||||
html[data-table-density=ultra] .bo-table td { padding-left: 8px; padding-right: 8px; }
|
||||
html[data-table-density=ultra] .bo-sub { display: none; }
|
||||
html[data-table-density=ultra] .bo-spark { height: 14px; }
|
||||
html[data-table-density=ultra] .bo-spark-latency { width: 80px; }
|
||||
html[data-table-density=ultra] .bo-spark-traffic { width: 64px; }
|
||||
html[data-table-density=ultra] .bo-spark-bars { width: 56px; }
|
||||
html[data-table-density=ultra] .bo-meter { margin-top: 2px; }
|
||||
html[data-table-density=ultra] .bo-event small { display: inline; margin-left: 6px; }
|
||||
html[data-table-density=ultra] .bo-badge { padding: 0 6px; }
|
||||
html[data-table-density=ultra] .bo-chip { padding: 4px 8px; font-size: 11px; }
|
||||
html[data-table-density=compact] .bo-spark { height: 18px; }
|
||||
html[data-table-density=compact] .bo-spark-latency { width: 100px; }
|
||||
html[data-table-density=compact] .bo-spark-traffic { width: 80px; }
|
||||
html[data-table-density=compact] .bo-spark-bars { width: 70px; }
|
||||
html[data-table-density=compact] .bo-chip { padding: 5px 9px; }
|
||||
html[data-table-density=compact] .bo-meter { margin-top: 3px; }
|
||||
|
||||
/* Process overview card (process_diagnostics) */
|
||||
[data-dashboard-widget="process_diagnostics"] { display: flex; flex-direction: column; min-height: 560px; }
|
||||
.pr-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
|
||||
.pr-chip { display: inline-flex; align-items: baseline; gap: 7px; margin: 0; padding: 3px 9px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); font: 11px var(--mono); }
|
||||
.pr-chip dt { color: var(--text-subtle); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.pr-chip dd { margin: 0; font-weight: 500; }
|
||||
.pr-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 22px; margin-top: 6px; }
|
||||
.pr-title { margin: 10px 0 4px; color: var(--muted); font: 600 10px var(--sans); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.pr-list { margin: 0; }
|
||||
.pr-row { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 10px; padding: 7px 2px; border-top: 1px solid var(--row-line); }
|
||||
.pr-row dt { color: var(--muted); font-size: 11px; }
|
||||
.pr-row dd { margin: 0 0 0 auto; font: 12px var(--mono); font-variant-numeric: tabular-nums; text-align: end; overflow-wrap: anywhere; }
|
||||
.pr-meter { flex: 0 0 100%; height: 2px; margin-top: 3px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
|
||||
.pr-meter b { display: block; width: 0; height: 100%; background: var(--accent); border-radius: var(--rp-progress-radius, 0); }
|
||||
.pr-meter b.is-high { background: var(--red); }
|
||||
.pr-split { display: flex; height: 6px; margin: 2px 0 4px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
|
||||
.pr-split .a { display: block; background: var(--accent); }
|
||||
.pr-split .f { display: block; background: var(--blue); }
|
||||
.pr-charts { flex: 1 1 auto; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 10px; }
|
||||
.pr-charts .mini-live-chart { display: flex; flex-direction: column; margin-top: 0; }
|
||||
.pr-charts .mini-live-chart svg:not(.ui-icon) { flex: 1 1 0; height: 0; min-height: 56px; }
|
||||
.diagnostic-line.pr-line-blue { stroke: var(--blue); }
|
||||
.diagnostic-line.pr-line-muted { stroke: var(--muted); }
|
||||
html[data-table-density=compact] .pr-row { padding-top: 6px; padding-bottom: 6px; }
|
||||
html[data-table-density=ultra] .pr-row { padding-top: 3px; padding-bottom: 3px; }
|
||||
html[data-table-density=ultra] .pr-chip { padding: 1px 7px; }
|
||||
@media (max-width: 720px) {
|
||||
[data-dashboard-widget="process_diagnostics"] { min-height: 0; }
|
||||
.pr-cols, .pr-charts { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* initial width of the cache outcome segments (set from JS via CSSOM; inline style attributes are blocked by the CSP) */
|
||||
.cache-mix-hit, .cache-mix-miss, .cache-mix-bypass, .cache-mix-other { width: 0; }
|
||||
|
||||
/* Backends overview: trend charts in the requests, latency and traffic cells */
|
||||
.bo-chartcell { display: flex; align-items: center; justify-content: flex-end; gap: 10px; font: 12px var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.bo-requests .bo-chartcell>span { min-width: 44px; text-align: right; }
|
||||
.bo-spark-bars rect { fill: var(--accent); opacity: .75; }
|
||||
.bo-spark .bo-base { stroke: var(--line); stroke-width: 1.5; stroke-linecap: round; }
|
||||
.bo-spark-latency .bo-base { stroke-dasharray: 2 4; }
|
||||
.bo-row .bo-spark-traffic polyline.bo-line-in { stroke: var(--blue); }
|
||||
.bo-row .bo-spark-traffic polyline.bo-line-out { stroke: var(--green); }
|
||||
.bo-traffic { min-width: 230px; }
|
||||
.bo-traffic-text { min-width: 118px; font: 11px/1.55 var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.bo-traffic-text>div { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.bo-traffic-text span { color: var(--text-subtle); }
|
||||
.bo-traffic-text .bo-in b { color: var(--blue); font-weight: 500; }
|
||||
.bo-traffic-text .bo-out b { color: var(--green); font-weight: 500; }
|
||||
.bo-traffic-text b.is-zero { color: var(--text-subtle); }
|
||||
html[data-table-density=ultra] .bo-traffic-text { line-height: 1.25; }
|
||||
@@ -29,7 +29,7 @@
|
||||
.traffic-chart-card,
|
||||
#panel-overview .backend-mode-bar span,
|
||||
#panel-overview .stats-grid>.stat,
|
||||
#panel-overview .dashboard-overview-grid>.activity-card svg,
|
||||
#panel-overview .dashboard-overview-grid>.activity-card svg:not(.ui-icon),
|
||||
#panel-overview .dashboard-overview-grid>[data-dashboard-widget],
|
||||
.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: var(--surface, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vh-backend-health-editor h3 {
|
||||
@@ -1483,18 +1483,22 @@ 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: baseline;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--pill-line);
|
||||
border-radius: var(--rp-radius-control, 4px);
|
||||
background: var(--panel2);
|
||||
border-radius: var(--rp-radius-control, 0);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 3px 7px;
|
||||
font: 10px var(--mono);
|
||||
min-height: 0;
|
||||
padding: 4px 8px;
|
||||
font: 9px/10px var(--mono);
|
||||
letter-spacing: 1px;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1504,6 +1508,10 @@ 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);
|
||||
@@ -1788,12 +1796,12 @@ html.app-loading,
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
gap: 7px;
|
||||
padding: 2px 4px 2px 9px;
|
||||
border-radius: var(--rp-radius-control, 15px);
|
||||
gap: 5px;
|
||||
padding: 4px 5px 4px 8px;
|
||||
border-radius: var(--rp-radius-control, 0);
|
||||
border: 1px solid var(--pill-live-line);
|
||||
background: var(--nav-selected-bg);
|
||||
font-size: 11px;
|
||||
background: var(--panel);
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
|
||||
.acl-picker-tag>span {
|
||||
@@ -1803,13 +1811,12 @@ html.app-loading,
|
||||
|
||||
.acl-picker-tag .acl-picker-remove {
|
||||
border: none;
|
||||
border-radius: var(--rp-radius-control, 50%);
|
||||
border-radius: var(--rp-radius-control, 0);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 0 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1940,4 +1947,93 @@ 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; }
|
||||
}
|
||||
@@ -82,12 +82,14 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -128,7 +130,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
body[data-density=compact] .sidebar nav,
|
||||
.certificate-coverage {
|
||||
margin-top: 14px;
|
||||
}
|
||||
@@ -686,40 +687,6 @@ body[data-density=compact] .sidebar nav,
|
||||
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;
|
||||
@@ -1292,4 +1259,224 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
.global-rate-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Settings rows (0.36.3): label and help on the left, control on the right, one setting per row. */
|
||||
#settings-page-appearance .appearance-section-heading .small-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-section-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(170px, 260px);
|
||||
grid-template-areas: "name control" "help control";
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
column-gap: 24px;
|
||||
row-gap: 3px;
|
||||
margin: 0;
|
||||
padding: 11px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label>span:first-child {
|
||||
grid-area: name;
|
||||
align-self: end;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label>:is(select, input[type=range]) {
|
||||
grid-area: control;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label>small {
|
||||
grid-area: help;
|
||||
align-self: start;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label:not(:has(>small), .check) {
|
||||
grid-template-areas: "name control";
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label:not(:has(>small), .check)>span:first-child {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
#settings-page-appearance .refresh-settings-grid>label.check>span,
|
||||
#settings-page-appearance .dashboard-widget-settings-intro>span {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-scale-control>span:first-child {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Checkbox settings: text on the left, the checkbox on the right edge of the row
|
||||
(.check is display:flex !important, so the row is reversed instead of using grid areas). */
|
||||
#settings-page-appearance .appearance-settings-grid>label.check {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
align-self: auto;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label.check>input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label.check>span {
|
||||
grid-area: text;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label.check>span b {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label.check>span small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-chart-preview {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#settings-page-appearance .dashboard-appearance-grid>.dashboard-widget-settings {
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#settings-page-appearance .refresh-settings-grid>label.check {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
#settings-page-appearance .appearance-settings-grid>label {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: "name" "help" "control";
|
||||
row-gap: 5px;
|
||||
}
|
||||
|
||||
#settings-page-appearance .appearance-settings-grid>label>span:first-child {
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings navigation (0.36.3) */
|
||||
@media (min-width: 761px) {
|
||||
/* Sub-tabs are a horizontal tab bar; the vertical rail layout is for the main settings menu only. */
|
||||
#panel-settings .settings-tabs.sub-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
#panel-settings .settings-tabs.sub-tabs button {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 7px 14px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Visual groups in the main menu: appearance / traffic / rules / observability / system / security. */
|
||||
#panel-settings #settings-tabs button:is([data-settings-tab=listeners], [data-settings-tab=policies], [data-settings-tab=logging], [data-settings-tab=administration], [data-settings-tab=certificate-api]) {
|
||||
margin-top: 7px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#panel-settings #settings-tabs button:is([data-settings-tab=listeners], [data-settings-tab=policies], [data-settings-tab=logging], [data-settings-tab=administration], [data-settings-tab=certificate-api])::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
/* Cache and static transformation pages (0.36.3): grouped sections, help attached to its switch. */
|
||||
#cache-form .cache-section-title {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#cache-form [data-cache-settings-page=general]>.cache-section-title:first-child {
|
||||
margin-top: 2px;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
:is(#cache-form, #transform-form)>.check:first-of-type,
|
||||
#cache-form [data-cache-settings-page=general]>.check:first-of-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:is(#cache-form, #transform-form) .check+.fine {
|
||||
margin: 3px 0 12px 24px;
|
||||
}
|
||||
|
||||
:is(#cache-form, #transform-form) .cache-memory-sizing .card-heading {
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:is(#cache-form, #transform-form) .cache-memory-sizing .card-heading>h3 {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
:is(#cache-form, #transform-form) .field-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr));
|
||||
column-gap: 14px;
|
||||
}
|
||||
|
||||
#transform-form .switch-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr));
|
||||
}
|
||||
|
||||
#cache-form #cache-default-ttl {
|
||||
display: block;
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -430,13 +430,12 @@ html[data-dashboard-density] #panel-overview .dashboard-overview-grid>.card.pool
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--pill-line);
|
||||
border-radius: var(--rp-radius-control, 3px);
|
||||
border-radius: var(--rp-radius-control, 0);
|
||||
color: var(--accent);
|
||||
font: 9px var(--mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .35px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.cache-toolbar-message {
|
||||
@@ -574,8 +573,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-strong, var(--line));
|
||||
background: var(--surface, var(--bg));
|
||||
border-color: var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
button.config-history-day.has-revisions:hover,
|
||||
@@ -834,7 +833,6 @@ 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 {
|
||||
@@ -1006,7 +1004,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, var(--radius, 12px));
|
||||
border-radius: var(--rp-radius-surface, 12px);
|
||||
}
|
||||
|
||||
.file-log-aggregate-form {
|
||||
@@ -1074,3 +1072,151 @@ 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; }
|
||||
|
||||
|
||||
/* Cache pager (0.36.7): never wraps inside its frame; the heading wraps instead */
|
||||
#panel-cache .card-heading:has(.cache-pager) { flex-wrap: wrap; row-gap: 8px; }
|
||||
.cache-pager { flex-wrap: nowrap; flex: 0 0 auto; max-width: 100%; margin-left: auto; }
|
||||
.cache-pager button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; flex: 0 0 auto; }
|
||||
.cache-pager #cache-page { flex: 1 1 auto; white-space: nowrap; }
|
||||
|
||||
/* Cache pager follows the interface rounding setting: frame and the two outer buttons share the control radius */
|
||||
.cache-pager { overflow: visible; border-radius: var(--rp-radius-control, 0px); }
|
||||
.cache-pager button { border-radius: 0; }
|
||||
.cache-pager>button:first-child { border-radius: max(0px, calc(var(--rp-radius-control, 0px) - 1px)) 0 0 max(0px, calc(var(--rp-radius-control, 0px) - 1px)); }
|
||||
.cache-pager>button:last-child { border-radius: 0 max(0px, calc(var(--rp-radius-control, 0px) - 1px)) max(0px, calc(var(--rp-radius-control, 0px) - 1px)) 0; }
|
||||
.cache-pager button:focus-visible { outline-offset: -2px; }
|
||||
|
||||
/* The global corner-style rule forces the full control radius on every button with !important; the pager is a segmented control, so only its outer corners are rounded */
|
||||
#panel-cache .cache-pager>button { border-radius: 0 !important; }
|
||||
#panel-cache .cache-pager>button:first-child { border-radius: max(0px, calc(var(--rp-radius-control, 0px) - 1px)) 0 0 max(0px, calc(var(--rp-radius-control, 0px) - 1px)) !important; }
|
||||
#panel-cache .cache-pager>button:last-child { border-radius: 0 max(0px, calc(var(--rp-radius-control, 0px) - 1px)) max(0px, calc(var(--rp-radius-control, 0px) - 1px)) 0 !important; }
|
||||
@@ -302,18 +302,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -831,7 +819,8 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
#pool-search {
|
||||
#pool-search,
|
||||
#bo-search {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -1293,16 +1282,6 @@
|
||||
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;
|
||||
@@ -1703,3 +1682,117 @@
|
||||
--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; }
|
||||
}
|
||||
@@ -65,8 +65,10 @@
|
||||
"cache_diagnostics",
|
||||
"extra_cold_queue_memory",
|
||||
"process_diagnostics",
|
||||
"process_simple",
|
||||
"network_diagnostics",
|
||||
"backend_table",
|
||||
"backend_tiny",
|
||||
"www_status",
|
||||
"www_kind",
|
||||
"www_capacity",
|
||||
@@ -140,7 +142,9 @@
|
||||
"extra_backends_healthy",
|
||||
"extra_error_share",
|
||||
"extra_rejected_rate",
|
||||
"extra_cert_expiry"
|
||||
"extra_cert_expiry",
|
||||
"backend_tiny",
|
||||
"process_simple"
|
||||
],
|
||||
"dashboard_density": "compact",
|
||||
"overview_samples": 600
|
||||
|
||||
@@ -705,8 +705,8 @@
|
||||
},
|
||||
{
|
||||
"id": "process_diagnostics",
|
||||
"label_key": "processDiagnostics",
|
||||
"description_key": "overviewPanelProcessDescription",
|
||||
"label_key": "processOverviewTitle",
|
||||
"description_key": "processOverviewDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"process_diagnostics\"]",
|
||||
"data_source": "system_status",
|
||||
"no_data": "neutral_dash",
|
||||
@@ -714,6 +714,17 @@
|
||||
"default_hidden": false,
|
||||
"display_type": "panel"
|
||||
},
|
||||
{
|
||||
"id": "process_simple",
|
||||
"label_key": "processClassicTitle",
|
||||
"description_key": "overviewPanelProcessDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"process_simple\"]",
|
||||
"data_source": "system_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "panel"
|
||||
},
|
||||
{
|
||||
"id": "network_diagnostics",
|
||||
"label_key": "networkDiagnostics",
|
||||
@@ -727,8 +738,8 @@
|
||||
},
|
||||
{
|
||||
"id": "backend_table",
|
||||
"label_key": "vhHealthTitle",
|
||||
"description_key": "overviewPanelBackendsDescription",
|
||||
"label_key": "backendOverviewTitle",
|
||||
"description_key": "backendOverviewDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_table\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
@@ -736,6 +747,17 @@
|
||||
"default_hidden": false,
|
||||
"display_type": "table"
|
||||
},
|
||||
{
|
||||
"id": "backend_tiny",
|
||||
"label_key": "backendTinyTitle",
|
||||
"description_key": "backendTinyDescription",
|
||||
"element_selector": "[data-dashboard-widget=\"backend_tiny\"]",
|
||||
"data_source": "backend_status",
|
||||
"no_data": "neutral_dash",
|
||||
"category": "panels",
|
||||
"default_hidden": true,
|
||||
"display_type": "table"
|
||||
},
|
||||
{
|
||||
"id": "www_status",
|
||||
"label_key": "webMetricStatus",
|
||||
|
||||
+18
-7
@@ -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 expandedMetrics = new Set(), metricHistory = new Map();
|
||||
const collapsedMetrics = new Set(), metricHistory = new Map();
|
||||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.docker_vhost_sort) ? preferences.docker_vhost_sort : "service", descending: preferences?.docker_vhost_descending === true }; }
|
||||
function 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(); }
|
||||
@@ -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 = expandedMetrics.has(section.dataset.viewKey);
|
||||
section.open = !collapsedMetrics.has(section.dataset.viewKey);
|
||||
const summary = el("summary"); summary.append(el("span", t("dockerTrafficStatistics"))); section.append(summary);
|
||||
const 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) expandedMetrics.add(section.dataset.viewKey);
|
||||
else expandedMetrics.delete(section.dataset.viewKey);
|
||||
if (section.open) collapsedMetrics.delete(section.dataset.viewKey);
|
||||
else collapsedMetrics.add(section.dataset.viewKey);
|
||||
syncMetricsToggle();
|
||||
});
|
||||
return section;
|
||||
@@ -516,6 +516,11 @@
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -560,8 +565,8 @@
|
||||
const expand = !sections.every(section => section.open);
|
||||
for (const section of sections) {
|
||||
section.open = expand;
|
||||
if (expand) expandedMetrics.add(section.dataset.viewKey);
|
||||
else expandedMetrics.delete(section.dataset.viewKey);
|
||||
if (expand) collapsedMetrics.delete(section.dataset.viewKey);
|
||||
else collapsedMetrics.add(section.dataset.viewKey);
|
||||
}
|
||||
syncMetricsToggle();
|
||||
});
|
||||
@@ -570,6 +575,12 @@
|
||||
$("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"));
|
||||
@@ -591,5 +602,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(); expandedMetrics.clear(); metricHistory.clear(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, renderStatus: renderDockerMetrics, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); collapsedMetrics.clear(); metricHistory.clear(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
})();
|
||||
@@ -98,5 +98,11 @@
|
||||
run: async () => render()
|
||||
});
|
||||
|
||||
document.addEventListener("click", async event => {
|
||||
const button = event.target.closest?.("[data-editor-cancel]"); if (!button) return;
|
||||
const api = editors.get(button.dataset.editorCancel); if (!api?.isOpen?.()) return;
|
||||
if (api.isDirty?.() && !(await appConfirm(t("draftDiscardConfirm"), { danger: true, confirmLabel: t("discardAction") }))) return;
|
||||
api.close?.();
|
||||
});
|
||||
window.rustProxyDrafts = { register, dock: dockEditor, prepare, reset, refresh: render, captureControls, restoreControls, takeChildren, restoreChildren };
|
||||
})();
|
||||
@@ -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 editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [], listPrefs = { sort: "id", descending: false };
|
||||
let notFoundAutoCertificate = null, editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [], listPrefs = { sort: "id", descending: false };
|
||||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.notfound_list_sort) ? preferences.notfound_list_sort : "id", descending: preferences?.notfound_list_descending === true }; }
|
||||
function 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,9 +44,11 @@
|
||||
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 });
|
||||
const hostHolder = $("notfound-hosts-editor"); hostHolder.replaceChildren(); readHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "notfoundHosts", helpKey: "vhHostsHelp", requiredKey: "notfoundHostsRequired", onChange: () => { changed(); notFoundAutoCertificate?.refresh(); } });
|
||||
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
|
||||
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() {
|
||||
@@ -79,9 +81,15 @@
|
||||
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
|
||||
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, 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;
|
||||
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;
|
||||
}));
|
||||
if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine"));
|
||||
}
|
||||
|
||||
+236
-105
@@ -261,9 +261,87 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card" data-dashboard-widget="process_diagnostics">
|
||||
<article class="card pr-card" data-dashboard-widget="process_diagnostics">
|
||||
<div class="mini-heading">
|
||||
<h3 data-i18n="processDiagnostics">Process</h3>
|
||||
<h3 data-i18n="processOverviewTitle">Process</h3>
|
||||
<div class="mini-heading-actions">
|
||||
<details class="metric-info">
|
||||
<summary data-i18n-aria="memoryAccountingInfo"
|
||||
aria-label="Memory accounting information">i</summary>
|
||||
<div class="metric-info-popover">
|
||||
<p class="fine" data-i18n="memoryAccountingHint">RSS is the real process
|
||||
footprint. HOT/transient cache values are RustProxy-accounted
|
||||
allocations; transform memory is a conservative active reservation.
|
||||
Non-cache RSS is RSS minus accounted cache and includes transforms,
|
||||
connections, TLS, logging, Tokio/runtime state, allocator overhead and
|
||||
other buffers, so these rows are not all additive.</p>
|
||||
</div>
|
||||
</details>
|
||||
<span class="meta" id="po-sample-age">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="pr-chips">
|
||||
<div class="pr-chip"><dt data-i18n="platform">Platform</dt><dd id="po-platform">-</dd></div>
|
||||
<div class="pr-chip"><dt data-i18n="processThreads">OS threads</dt><dd id="po-threads">-</dd></div>
|
||||
<div class="pr-chip"><dt data-i18n="processStartTime">Process start</dt><dd id="po-start">-</dd></div>
|
||||
<div class="pr-chip"><dt data-i18n="uptime">Uptime</dt><dd id="po-uptime">-</dd></div>
|
||||
</dl>
|
||||
<div class="pr-cols">
|
||||
<section>
|
||||
<h4 class="pr-title" data-i18n="processLoadTitle">Load</h4>
|
||||
<dl class="pr-list">
|
||||
<div class="pr-row"><dt data-i18n="processCpu">CPU (100% = 1 core)</dt><dd id="po-cpu">-</dd><i class="pr-meter"><b></b></i></div>
|
||||
<div class="pr-row"><dt data-i18n="processRssTotal">RSS total</dt><dd id="po-rss-total">-</dd><i class="pr-meter"><b></b></i></div>
|
||||
<div class="pr-row"><dt data-i18n="fileDescriptors">File descriptors / limit</dt><dd id="po-fds">-</dd><i class="pr-meter"><b></b></i></div>
|
||||
<div class="pr-row"><dt data-i18n="containerMemory">cgroup RAM / limit</dt><dd id="po-cgroup">-</dd><i class="pr-meter"><b></b></i></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h4 class="pr-title" data-i18n="processMemoryTitle">Memory</h4>
|
||||
<div class="pr-split" id="po-rss-split" aria-hidden="true"><i class="a"></i><i class="f"></i></div>
|
||||
<dl class="pr-list pr-plain">
|
||||
<div class="pr-row"><dt data-i18n="processRssAnon">Anonymous RSS</dt><dd id="po-rss-anon">-</dd></div>
|
||||
<div class="pr-row"><dt data-i18n="processRssFile">File-backed / shared RSS</dt><dd id="po-rss-file">-</dd></div>
|
||||
<div class="pr-row"><dt data-i18n="memoryNonCacheRss">Non-cache process RSS</dt><dd id="po-non-cache">-</dd></div>
|
||||
<div class="pr-row"><dt data-i18n="processVirtualMemory">Virtual memory / limit</dt><dd id="po-virtual">-</dd></div>
|
||||
<div class="pr-row"><dt data-i18n="processDataSwap">Data address space / swap</dt><dd id="po-data-swap">-</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<div class="mini-live-chart-stack pr-charts">
|
||||
<div class="mini-live-chart">
|
||||
<div class="mini-live-chart-head"><span data-i18n="processCpuHistory">Process CPU history</span><b id="po-cpu-chart-value">-</b></div>
|
||||
<svg preserveAspectRatio="none" aria-label="Process CPU history" data-i18n-aria="processCpuHistory" role="img" viewbox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line" id="po-cpu-sparkline" points="0,55 300,55"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mini-live-chart">
|
||||
<div class="mini-live-chart-head"><span data-i18n="processRssHistory">Process RSS history</span><b id="po-rss-chart-value">-</b></div>
|
||||
<svg preserveAspectRatio="none" aria-label="Process RSS history" data-i18n-aria="processRssHistory" role="img" viewbox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line process-rss-line" id="po-rss-sparkline" points="0,55 300,55"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mini-live-chart">
|
||||
<div class="mini-live-chart-head"><span data-i18n="processFdHistory">File descriptors history</span><b id="po-fd-chart-value">-</b></div>
|
||||
<svg preserveAspectRatio="none" aria-label="File descriptors history" data-i18n-aria="processFdHistory" role="img" viewbox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line pr-line-blue" id="po-fd-sparkline" points="0,55 300,55"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mini-live-chart">
|
||||
<div class="mini-live-chart-head"><span data-i18n="processThreadHistory">OS threads history</span><b id="po-thread-chart-value">-</b></div>
|
||||
<svg preserveAspectRatio="none" aria-label="OS threads history" data-i18n-aria="processThreadHistory" role="img" viewbox="0 0 300 60">
|
||||
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
|
||||
<polyline class="diagnostic-line pr-line-muted" id="po-thread-sparkline" points="0,55 300,55"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card" data-dashboard-widget="process_simple">
|
||||
<div class="mini-heading">
|
||||
<h3 data-i18n="processClassicTitle">Process simple</h3>
|
||||
<div class="mini-heading-actions">
|
||||
<details class="metric-info">
|
||||
<summary data-i18n-aria="memoryAccountingInfo"
|
||||
@@ -279,78 +357,77 @@
|
||||
</details>
|
||||
<span class="meta" id="system-sample-age">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="mini-metrics">
|
||||
<div>
|
||||
<dt data-i18n="platform">Platform</dt>
|
||||
<dd id="process-platform">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processCpu">CPU (100% = 1 core)</dt>
|
||||
<dd id="process-cpu">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="fileDescriptors">File descriptors / limit</dt>
|
||||
<dd id="process-fds">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processThreads">OS threads</dt>
|
||||
<dd id="process-threads">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processVirtualMemory">Virtual memory / limit</dt>
|
||||
<dd id="process-virtual-memory">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processStartTime">Process start</dt>
|
||||
<dd id="process-start-time">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="uptime">Uptime</dt>
|
||||
<dd id="process-uptime">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processRssTotal">RSS total</dt>
|
||||
<dd id="process-rss-total">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryCacheHot">HOT cache RAM / limit</dt>
|
||||
<dd id="memory-cache-hot">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryCacheTransient">Cache transient RAM / limit</dt>
|
||||
<dd id="memory-cache-transient">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryTransform">Transform reserved RAM / budget</dt>
|
||||
<dd id="memory-transform">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryTransformInflight">Active transforms / limit</dt>
|
||||
<dd id="memory-transform-inflight">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryNonCacheRss">Non-cache process RSS</dt>
|
||||
<dd id="memory-non-cache-rss">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processRssAnon">Anonymous RSS</dt>
|
||||
<dd id="process-rss-anon">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processRssFile">File-backed / shared RSS</dt>
|
||||
<dd id="process-rss-file">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processDataSwap">Data address space / swap</dt>
|
||||
<dd id="process-data-swap">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="containerMemory">cgroup RAM / limit</dt>
|
||||
<dd id="cgroup-memory">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mini-live-chart-stack">
|
||||
</d <dl class="mini-metrics">
|
||||
<div>
|
||||
<dt data-i18n="platform">Platform</dt>
|
||||
<dd id="process-platform">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processCpu">CPU (100% = 1 core)</dt>
|
||||
<dd id="process-cpu">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="fileDescriptors">File descriptors / limit</dt>
|
||||
<dd id="process-fds">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processThreads">OS threads</dt>
|
||||
<dd id="process-threads">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processVirtualMemory">Virtual memory / limit</dt>
|
||||
<dd id="process-virtual-memory">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processStartTime">Process start</dt>
|
||||
<dd id="process-start-time">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="uptime">Uptime</dt>
|
||||
<dd id="process-uptime">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processRssTotal">RSS total</dt>
|
||||
<dd id="process-rss-total">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryCacheHot">HOT cache RAM / limit</dt>
|
||||
<dd id="memory-cache-hot">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryCacheTransient">Cache transient RAM / limit</dt>
|
||||
<dd id="memory-cache-transient">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryTransform">Transform reserved RAM / budget</dt>
|
||||
<dd id="memory-transform">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryTransformInflight">Active transforms / limit</dt>
|
||||
<dd id="memory-transform-inflight">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryNonCacheRss">Non-cache process RSS</dt>
|
||||
<dd id="memory-non-cache-rss">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processRssAnon">Anonymous RSS</dt>
|
||||
<dd id="process-rss-anon">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processRssFile">File-backed / shared RSS</dt>
|
||||
<dd id="process-rss-file">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processDataSwap">Data address space / swap</dt>
|
||||
<dd id="process-data-swap">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="containerMemory">cgroup RAM / limit</dt>
|
||||
<dd id="cgroup-memory">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mini-live-chart-stack">
|
||||
<div class="mini-live-chart">
|
||||
<div class="mini-live-chart-head"><span data-i18n="processCpuHistory">Process CPU
|
||||
history</span><b id="process-cpu-chart-value">-</b></div>
|
||||
@@ -433,10 +510,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card pool-card" id="backend-diagnostics-card"
|
||||
<article class="card pool-card backend-overview-card" id="backend-overview-card"
|
||||
data-dashboard-widget="backend_table">
|
||||
<div class="card-heading backend-card-heading">
|
||||
<h2 data-i18n="vhHealthTitle">Backends</h2>
|
||||
<div class="bo-heading">
|
||||
<h2 data-i18n="backendOverviewTitle">Backends</h2>
|
||||
<span class="bo-summary" id="bo-summary" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="backend-table-tools">
|
||||
<label class="sr-only" data-i18n="backendSearch" for="bo-search">Search
|
||||
backends</label>
|
||||
<input autocomplete="off" data-i18n-aria="backendSearch"
|
||||
data-i18n-placeholder="backendSearchPlaceholder" id="bo-search"
|
||||
type="search" />
|
||||
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
|
||||
by</span><select data-i18n-aria="backendSortBy" id="bo-sort"></select></label>
|
||||
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
|
||||
id="bo-sort-direction" type="button">↑</button>
|
||||
<details class="backend-column-picker">
|
||||
<summary class="quiet" data-i18n="backendColumns">Columns</summary>
|
||||
<div class="backend-column-options" id="bo-column-options"></div>
|
||||
</details>
|
||||
<button class="quiet" data-i18n="vhManage" id="bo-open" type="button">Manage
|
||||
vhosts</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bo-filters" id="bo-filters" role="group"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="bo-table">
|
||||
<thead>
|
||||
<tr id="bo-head"></tr>
|
||||
</thead>
|
||||
<tbody id="bo-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="fine" data-i18n="vhHealthEmpty" id="bo-empty">No backend diagnostics yet.</p>
|
||||
</article>
|
||||
<article class="card pool-card" id="backend-diagnostics-card"
|
||||
data-dashboard-widget="backend_tiny">
|
||||
<div class="card-heading backend-card-heading">
|
||||
<h2 data-i18n="backendTinyTitle">Tiny backends</h2>
|
||||
<div class="backend-table-tools">
|
||||
<label class="sr-only" data-i18n="backendSearch" for="pool-search">Search
|
||||
backends</label>
|
||||
@@ -628,7 +741,8 @@
|
||||
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="dockerShowHidden"
|
||||
settings</button><button class="quiet" data-i18n="dockerHideDisabled"
|
||||
id="docker-hide-disabled" type="button" disabled>Hide disabled</button><button class="quiet" data-i18n="dockerShowHidden"
|
||||
id="docker-show-hidden" type="button">Show hidden</button></div>
|
||||
</article>
|
||||
<div class="docker-agent-observations" id="docker-discovery-list"></div>
|
||||
@@ -772,10 +886,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">
|
||||
@@ -1398,8 +1512,8 @@
|
||||
<article class="card">
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="cacheObjectList">Stored objects</h2>
|
||||
<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
|
||||
<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
|
||||
class="quiet" data-i18n="cacheNext" id="cache-next" type="button">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1519,7 +1633,7 @@
|
||||
<h2 data-i18n="transformByType">Transformations by type</h2>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="cache-table">
|
||||
<table class="cache-table transform-type-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="transformType">Type</th>
|
||||
@@ -1546,7 +1660,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">
|
||||
<table class="cache-table transform-vhost-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="vhVhost">Vhost</th>
|
||||
@@ -1799,6 +1913,11 @@
|
||||
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
|
||||
@@ -1816,7 +1935,7 @@
|
||||
<section class="appearance-section">
|
||||
<div class="appearance-section-heading">
|
||||
<div><span class="small-label" data-i18n="appearanceChartsSection">CHARTS</span>
|
||||
<h3 data-i18n="chartStyle">Chart style</h3>
|
||||
<h3 data-i18n="appearanceChartsTitle">Charts</h3>
|
||||
</div><button class="quiet appearance-section-reset"
|
||||
data-appearance-reset="charts" data-i18n="resetSection" type="button">Reset
|
||||
section</button>
|
||||
@@ -1835,6 +1954,14 @@
|
||||
</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
|
||||
@@ -1859,7 +1986,7 @@
|
||||
<div class="appearance-section-heading">
|
||||
<div><span class="small-label" data-i18n="appearanceDataSection">TABLES /
|
||||
LOGS</span>
|
||||
<h3 data-i18n="tableDensity">Tables and logs density</h3>
|
||||
<h3 data-i18n="appearanceTablesTitle">Tables and logs</h3>
|
||||
</div><button class="quiet appearance-section-reset"
|
||||
data-appearance-reset="tables" data-i18n="resetSection" type="button">Reset
|
||||
section</button>
|
||||
@@ -2379,6 +2506,7 @@
|
||||
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.
|
||||
@@ -2424,7 +2552,17 @@
|
||||
<input id="cache-varnish-grace" type="number" min="0" max="604800" step="1"
|
||||
required />
|
||||
</div>
|
||||
<div class="card soft-card cache-memory-sizing">
|
||||
<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-heading">
|
||||
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
|
||||
<span class="pill" id="cache-memory-preset-selected">-</span>
|
||||
@@ -2478,14 +2616,7 @@
|
||||
<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>
|
||||
@@ -3782,10 +3913,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>
|
||||
@@ -4102,7 +4233,7 @@
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button>
|
||||
<button class="quiet" data-i18n="vhReset" id="web-service-reset" type="button">Discard changes</button>
|
||||
<button class="quiet" data-i18n="vhReset" id="web-service-reset" type="button">Discard changes</button><button class="quiet" data-editor-cancel="web-server" data-i18n="vhCancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -4249,7 +4380,7 @@ log_errors = On</code></pre>
|
||||
<div id="vhost-fields"></div>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply" id="vhost-save" type="submit">Validate,
|
||||
save and apply</button><button class="quiet" data-i18n="vhReset" id="vhost-reset"
|
||||
type="button">Discard changes</button></div>
|
||||
type="button">Discard changes</button><button class="quiet" data-editor-cancel="vhost" data-i18n="vhCancel" type="button">Cancel</button></div>
|
||||
<p class="fine" data-i18n="vhSafetyHint">Private IP binding is not authentication. Keep firewall/ACL rules.
|
||||
HSTS preload is opt-in; affinity cookies are not login sessions.</p>
|
||||
</form>
|
||||
@@ -4272,10 +4403,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>
|
||||
@@ -4314,7 +4445,7 @@ log_errors = On</code></pre>
|
||||
</section>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and
|
||||
apply</button><button class="quiet" data-i18n="vhReset" id="redirect-reset" type="button">Discard
|
||||
changes</button></div>
|
||||
changes</button><button class="quiet" data-editor-cancel="redirect" data-i18n="vhCancel" type="button">Cancel</button></div>
|
||||
<p class="fine" data-i18n="redirectSafetyHint">Redirect hosts do not contact a backend. Global host ACLs
|
||||
still apply. Avoid self-referential rules that can create browser redirect loops.</p>
|
||||
</form>
|
||||
@@ -4330,10 +4461,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>
|
||||
@@ -4385,7 +4516,7 @@ log_errors = On</code></pre>
|
||||
</section>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and
|
||||
apply</button><button class="quiet" data-i18n="vhReset" id="notfound-reset" type="button">Discard
|
||||
changes</button></div>
|
||||
changes</button><button class="quiet" data-editor-cancel="notfound" data-i18n="vhCancel" type="button">Cancel</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -4401,7 +4532,7 @@ log_errors = On</code></pre>
|
||||
<div id="stream-fields"></div>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply" id="stream-save" type="submit">Validate,
|
||||
save and apply</button><button class="quiet" data-i18n="vhReset" id="stream-reset"
|
||||
type="button">Discard changes</button></div>
|
||||
type="button">Discard changes</button><button class="quiet" data-editor-cancel="stream" data-i18n="vhCancel" type="button">Cancel</button></div>
|
||||
<p class="fine" data-i18n="streamRestartHelp">Changing a stream listener requires a RustProxy restart.</p>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
function addRow(item = { id: `listener-${$("named-listener-rows").children.length + 1}`, name: "", scope: "internal", enabled: true, binding: { address: "127.0.0.1:8181", tls: false, http2: false, http2_settings: null, h2c: false, http3: null } }) {
|
||||
if ($("named-listener-rows").children.length >= 64) { toast(t("listenerLimit"), true); return; }
|
||||
const row = el("fieldset", undefined, "named-listener-row"); row.append(label("legend", "listenerEndpoint")); const grid = el("div", undefined, "vh-grid"); row.append(grid); const b = item.binding || {};
|
||||
const id = field(grid, "listener-id", "listenerId", item.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
|
||||
const id = field(grid, "listener-id", "listenerId", item.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_\\-]{1,64}" });
|
||||
const name = field(grid, "listener-name", "listenerName", item.name, "text", { maxLength: 96 });
|
||||
const scope = field(grid, "listener-scope", "listenerScope", item.scope || "internal", "select", { values: [["internal", "listenerInternal"], ["public", "listenerPublic"]] });
|
||||
const currentAddresses = [b.address, ...(item.additional_addresses || [])].filter(Boolean);
|
||||
|
||||
@@ -366,15 +366,20 @@
|
||||
rules.placeholder = custom ? "allow 10.0.0.0/8\ndeny 10.21.0.0/16" : "10.0.0.0/8\n203.0.113.5";
|
||||
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; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy();
|
||||
editing = null; dirty = false; form.reset(); setMode("allow_exceptions"); aclDefault.value = "deny"; countryMode.value = "off"; countryEnabled.checked = false; countriesInput.value = ""; $("acl-profile-id").readOnly = false; attachAclProfileId(); $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncPolicy();
|
||||
}
|
||||
function edit(profile) {
|
||||
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id;
|
||||
editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id; attachAclProfileId();
|
||||
const cMode = profile.country_access?.mode || "off"; aclDefault.value = profile.acl?.default || (cMode === "allow" ? "deny" : "allow"); countryMode.value = cMode;
|
||||
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") {
|
||||
@@ -421,7 +426,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("remove"), async () => {
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("copyEntry"), () => copyProfile(profile)), action(t("remove"), async () => {
|
||||
if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; }
|
||||
if (!(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;
|
||||
|
||||
@@ -40,21 +40,16 @@
|
||||
<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">
|
||||
<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>
|
||||
<span class="cache-mix-hit" id="cache-mix-hit"></span>
|
||||
<span class="cache-mix-miss" id="cache-mix-miss"></span>
|
||||
<span class="cache-mix-bypass" id="cache-mix-bypass"></span>
|
||||
<span class="cache-mix-other" id="cache-mix-other"></span>
|
||||
</div>
|
||||
<small class="cache-mix-legend">
|
||||
<span class="cache-mix-hit-key"><i></i>HIT <b><span id="cache-mix-hit-count">-</span><span
|
||||
class="cache-mix-percent" id="cache-mix-hit-percent">-</span></b></span>
|
||||
<span class="cache-mix-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>
|
||||
<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>
|
||||
</small>
|
||||
</article>
|
||||
<article class="stat" data-dashboard-widget="memory"><span data-i18n="processMemory">Process
|
||||
@@ -62,18 +57,19 @@
|
||||
</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><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>
|
||||
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>
|
||||
<article class="stat" data-dashboard-widget="http2"><span data-i18n="h2Requests">HTTP/2
|
||||
requests</span><strong id="metric-h2">0</strong><small><span data-i18n="h2ActiveConnections">Active H2
|
||||
requests</span><strong id="metric-h2">0</strong><small><span data-i18n="h2ActiveConnections">H2
|
||||
connections</span>: <span id="metric-h2-active">0</span></small></article>
|
||||
<article class="stat" data-dashboard-widget="http3"><span data-i18n="h3Requests">HTTP/3
|
||||
requests</span><strong id="metric-h3">0</strong></article>
|
||||
requests</span><strong id="metric-h3">0</strong><small><span data-i18n="h3ActiveConnections">H3
|
||||
connections</span>: <span id="metric-h3-active">0</span></small></article>
|
||||
<article class="stat" data-dashboard-widget="tls"><span data-i18n="tlsFailures">TLS handshake
|
||||
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
|
||||
|
||||
@@ -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 editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [], listPrefs = { sort: "id", descending: false };
|
||||
let redirectAutoCertificate = null, editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [], listPrefs = { sort: "id", descending: false };
|
||||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.redirect_list_sort) ? preferences.redirect_list_sort : "id", descending: preferences?.redirect_list_descending === true }; }
|
||||
function 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(); }
|
||||
@@ -70,9 +70,11 @@
|
||||
$("redirect-editing").textContent = editing ? editing : t("redirectNewLabel");
|
||||
$("redirect-id").value = item.id || ""; $("redirect-id").disabled = Boolean(editing);
|
||||
$("redirect-name").value = item.name || "";
|
||||
const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: changed });
|
||||
window.rustProxyAutoId.attach($("redirect-id"), { elements: [$("redirect-name")], taken: () => (envelope?.config.redirects || []).map(value => value.id) });
|
||||
const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: () => { changed(); redirectAutoCertificate?.refresh(); } });
|
||||
$("redirect-enabled").checked = item.enabled !== false; syncEnabledControl(); $("redirect-http2").checked = item.http2_enabled !== false; $("redirect-http3").checked = item.http3_enabled !== false;
|
||||
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();
|
||||
}
|
||||
@@ -118,11 +120,18 @@
|
||||
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
|
||||
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, 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);
|
||||
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);
|
||||
const targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))];
|
||||
const targetSummary = targets.slice(0, 2).join(" · ") + (targets.length > 2 ? ` · +${targets.length - 2}` : "");
|
||||
const 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;
|
||||
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;
|
||||
}));
|
||||
if (!entries.length) list.append(el("p", t(query ? "redirectSearchEmpty" : "redirectEmpty"), "fine"));
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
return grid;
|
||||
};
|
||||
const connectionFields = backendSection("streamBackendConnectionSection");
|
||||
field(connectionFields, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
|
||||
field(connectionFields, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_\\-]{1,64}" });
|
||||
field(connectionFields, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
|
||||
field(connectionFields, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
|
||||
field(connectionFields, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
|
||||
@@ -127,8 +127,9 @@
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
||||
$("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel");
|
||||
let g = group("streamEditor", "details");
|
||||
field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
|
||||
field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
|
||||
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) });
|
||||
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);
|
||||
@@ -213,7 +214,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("remove", async () => {
|
||||
actions.append(button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), button("copyEntry", () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(stream); copy.id = ""; copy.name = ""; copy.listen = ""; load(copy); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("streamDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.streams = (next.streams || []).filter(item => item.id !== stream.id); if (await saveSection("streams", next.streams || [])) { if (editing === stream.id) load(); render(); }
|
||||
}));
|
||||
head.append(actions); box.append(head);
|
||||
|
||||
@@ -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", `${key}: ${number(count)}`, "cache-reason"); tag.title = key; return tag;
|
||||
const tag = el("span", undefined, "cache-reason-line"); tag.append(el("span", key, "cache-reason-name"), el("b", number(count), "cache-reason-count")); tag.title = key; return tag;
|
||||
}));
|
||||
$("transform-reasons-empty").hidden = entries.length > 0;
|
||||
$("tp-skip-top").textContent = entries.length ? entries.slice(0, 2).map(([key, count]) => `${key}: ${number(count)}`).join(" · ") : t("transformNoSkipsShort");
|
||||
@@ -113,16 +113,12 @@
|
||||
const rows = (data?.metrics?.scopes || []).filter(row => wanted === "*" || row.vhost === wanted);
|
||||
$("transform-vhost-rows").replaceChildren(...rows.map(row => {
|
||||
const tr = el("tr");
|
||||
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));
|
||||
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)));
|
||||
return tr;
|
||||
}));
|
||||
$("transform-vhosts-empty").hidden = rows.length > 0;
|
||||
|
||||
@@ -203,7 +203,7 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
|
||||
|
||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_\\-]{1,64}", maxLength: 64 });
|
||||
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
|
||||
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"], ["h2", "vhProtocolH2Strict"]] });
|
||||
@@ -379,7 +379,7 @@ function locationsEditor(parent, values) {
|
||||
const row = el("details", undefined, "vh-location"); row.open = true;
|
||||
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
||||
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_\\-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
||||
@@ -391,7 +391,7 @@ function locationsEditor(parent, values) {
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_\\-]{0,64}" });
|
||||
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(handlingBody, "locationErrorFallbackHelp");
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
|
||||
@@ -69,8 +69,9 @@ 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");
|
||||
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 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 });
|
||||
const enabledControl = field(basicFields, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
||||
const enabledLabel = enabledControl.closest("label");
|
||||
@@ -88,7 +89,8 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
const websocketLegacy = field(websocketFields, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(websocketFields, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => { refreshCanonical(hosts); vhostAutoId?.refresh(); vhostAutoCertificate?.refresh(hosts); });
|
||||
if (!editing) g.lastElementChild?.after(vhostIdInput.closest("label"));
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||
@@ -269,7 +271,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
passiveSlow.addEventListener("change", syncPassive);
|
||||
syncPassive();
|
||||
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
|
||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_\\-]*" });
|
||||
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
||||
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
|
||||
g = group("vhTrafficPolicy");
|
||||
@@ -340,7 +342,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
g = group("vhLocations");
|
||||
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_\\-]{0,64}" });
|
||||
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhErrorFallbackHelp");
|
||||
locationsReader = locationsEditor(g, value.routes || []);
|
||||
@@ -348,6 +350,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
const certificateRow = el("div", undefined, "vh-wide vh-certificate-select-row"); g.append(certificateRow);
|
||||
const 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");
|
||||
|
||||
+231
-16
@@ -202,7 +202,7 @@ async function savePoolTablePrefs() {
|
||||
if (!authenticated) return;
|
||||
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
|
||||
}
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
syncPoolColumns(); syncPoolSortDirection(); applyBackendOverviewView();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
finally { poolPreferenceSaveRunning = false; }
|
||||
}
|
||||
@@ -212,11 +212,15 @@ function syncPoolColumns() {
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
|
||||
}
|
||||
function syncPoolSortDirection() {
|
||||
const control = $("pool-sort-direction"); if (!control) return;
|
||||
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
for (const id of ["pool-sort-direction", "bo-sort-direction"]) {
|
||||
const control = $(id); if (!control) continue;
|
||||
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
}
|
||||
function applyPoolTableView() {
|
||||
for (const id of ["pool-sort", "bo-sort"]) { const control = $(id); if (control && control.value !== poolTablePrefs.sort) control.value = poolTablePrefs.sort; }
|
||||
applyBackendOverviewView();
|
||||
const body = $("pool-rows"); if (!body) return;
|
||||
const query = ($("pool-search")?.value || "").trim().toLowerCase();
|
||||
const key = $("pool-sort")?.value || poolTablePrefs.sort;
|
||||
@@ -234,18 +238,200 @@ function applyPoolTableView() {
|
||||
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
|
||||
}
|
||||
function initPoolTableTools() {
|
||||
const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search");
|
||||
if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); }
|
||||
direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||
search?.addEventListener("input", applyPoolTableView);
|
||||
buildBackendOverviewTools();
|
||||
for (const id of ["pool-sort", "bo-sort"]) {
|
||||
const sort = $(id); if (!sort) continue;
|
||||
sort.value = poolTablePrefs.sort;
|
||||
sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||
}
|
||||
for (const id of ["pool-sort-direction", "bo-sort-direction"]) $(id)?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||
$("pool-search")?.addEventListener("input", applyPoolTableView);
|
||||
$("bo-search")?.addEventListener("input", applyBackendOverviewView);
|
||||
$("bo-open")?.addEventListener("click", () => setPanel("vhosts"));
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
|
||||
const visible = new Set(poolTablePrefs.columns);
|
||||
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
|
||||
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
|
||||
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns();
|
||||
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns(); applyBackendOverviewView();
|
||||
}));
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
}
|
||||
// Backends overview (dashboard card "backend_table"): a richer view of the same backend data. It shares the sort,
|
||||
// direction and column preferences with the compact "Tiny backends" table and follows table density and corner style.
|
||||
const BO_SORT_LABELS = { vhost: "vhVhost", backend: "backend", status: "status", active: "vhActive", requests: "vhRequests", errors: "vhErrors", latency: "vhLatency", traffic: "vhTraffic", generation: "vhClientGeneration", generationRequests: "vhGenerationRequests", generationAge: "vhGenerationAge", rotations: "vhClientRotations", lastFailure: "vhLastFailure", lastRecovery: "vhLastRecovery" };
|
||||
const BO_DETAIL_COLUMNS = ["generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||
const BO_SAMPLES = 30;
|
||||
const boOpen = new Set(), boSeries = new Map();
|
||||
let boSnapshot = null;
|
||||
let boItems = [], boFilter = "all", boHasProxy = true;
|
||||
function buildBackendOverviewTools() {
|
||||
const sort = $("bo-sort"), options = $("bo-column-options");
|
||||
if (sort && !sort.options.length) for (const key of poolColumns) { const option = el("option", t(BO_SORT_LABELS[key])); option.value = key; option.dataset.i18n = BO_SORT_LABELS[key]; sort.append(option); }
|
||||
if (options && !options.children.length) for (const key of poolColumns) {
|
||||
const label = el("label", undefined, "check"), input = el("input"), text = el("span", t(BO_SORT_LABELS[key]));
|
||||
input.type = "checkbox"; input.dataset.poolColumnToggle = key; text.dataset.i18n = BO_SORT_LABELS[key]; label.append(input, text); options.append(label);
|
||||
}
|
||||
}
|
||||
function boSeriesOf(item) { return item.series || { latency: [], inRate: [], outRate: [], requests: [] }; }
|
||||
function boAgo(ms) {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - Number(ms)) / 1000));
|
||||
const value = seconds < 60 ? `${seconds} s` : seconds < 3600 ? `${Math.floor(seconds / 60)} min` : seconds < 86400 ? `${Math.floor(seconds / 3600)} h` : `${Math.floor(seconds / 86400)} d`;
|
||||
return t("backendAgo").replace("{value}", value);
|
||||
}
|
||||
const BO_SVG = "http://www.w3.org/2000/svg";
|
||||
function boSvg(width, kind) {
|
||||
const svg = document.createElementNS(BO_SVG, "svg");
|
||||
svg.setAttribute("viewBox", `0 0 ${width} 20`); svg.setAttribute("class", `bo-spark bo-spark-${kind}`); svg.setAttribute("aria-hidden", "true");
|
||||
boBaseline(svg, width, kind === "latency" ? 10 : 19); // idle or not-yet-sampled rows still show where the chart is
|
||||
return svg;
|
||||
}
|
||||
function boBaseline(svg, width, y) {
|
||||
const line = document.createElementNS(BO_SVG, "line");
|
||||
line.setAttribute("class", "bo-base"); line.setAttribute("x1", "0"); line.setAttribute("x2", String(width)); line.setAttribute("y1", String(y)); line.setAttribute("y2", String(y));
|
||||
svg.append(line);
|
||||
}
|
||||
function boPoints(values, width, min, max) {
|
||||
const flat = max - min < max * 0.05, step = width / Math.max(1, values.length - 1);
|
||||
return values.map((value, index) => `${(index * step).toFixed(1)},${(flat ? 10 : 17 - ((value - min) / (max - min)) * 14).toFixed(1)}`).join(" ");
|
||||
}
|
||||
function boSparkline(history, current) {
|
||||
const svg = boSvg(120, "latency"), samples = history.filter(value => value > 0);
|
||||
if (!(current > 0) || samples.length < 3) return svg;
|
||||
const line = document.createElementNS(BO_SVG, "polyline");
|
||||
line.setAttribute("points", boPoints(samples, 120, Math.min(...samples), Math.max(...samples)));
|
||||
svg.append(line); return svg;
|
||||
}
|
||||
function boBars(values) {
|
||||
const svg = boSvg(84, "bars"), max = Math.max(0, ...values);
|
||||
if (values.length < 3 || max <= 0) return svg;
|
||||
const width = 84 / values.length;
|
||||
values.forEach((value, index) => {
|
||||
const bar = document.createElementNS(BO_SVG, "rect"), height = value > 0 ? Math.max(1.5, value / max * 18) : 0;
|
||||
bar.setAttribute("x", (index * width + width * 0.15).toFixed(1)); bar.setAttribute("width", (width * 0.7).toFixed(1));
|
||||
bar.setAttribute("y", (20 - height).toFixed(1)); bar.setAttribute("height", height.toFixed(1)); svg.append(bar);
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
function boTrafficChart(inSamples, outSamples) {
|
||||
const svg = boSvg(96, "traffic"), max = Math.max(0, ...inSamples, ...outSamples);
|
||||
if (inSamples.length < 3 || max <= 0) return svg;
|
||||
for (const [values, kind] of [[inSamples, "in"], [outSamples, "out"]]) {
|
||||
const line = document.createElementNS(BO_SVG, "polyline"), step = 96 / (values.length - 1);
|
||||
line.setAttribute("class", `bo-line-${kind}`);
|
||||
line.setAttribute("points", values.map((value, index) => `${(index * step).toFixed(1)},${(18 - (value / max) * 16).toFixed(1)}`).join(" "));
|
||||
svg.append(line);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
function renderBackendOverview(items, hasProxy, snapshot = null) {
|
||||
boItems = items; boHasProxy = hasProxy !== false;
|
||||
const seen = new Set(), sample = snapshot === null || snapshot !== boSnapshot;
|
||||
boSnapshot = snapshot;
|
||||
const push = (list, value) => { list.push(value); if (list.length > BO_SAMPLES) list.shift(); };
|
||||
for (const item of items) {
|
||||
seen.add(item.key);
|
||||
const series = boSeries.get(item.key) || { latency: [], inRate: [], outRate: [], requests: [], lastRequests: null };
|
||||
if (sample) {
|
||||
push(series.latency, item.latency); push(series.inRate, item.trafficIn || 0); push(series.outRate, item.trafficOut || 0);
|
||||
push(series.requests, series.lastRequests === null ? 0 : Math.max(0, item.requests - series.lastRequests)); series.lastRequests = item.requests;
|
||||
}
|
||||
boSeries.set(item.key, series);
|
||||
item.series = series;
|
||||
}
|
||||
for (const key of boSeries.keys()) if (!seen.has(key)) boSeries.delete(key);
|
||||
for (const key of boOpen) if (!seen.has(key)) boOpen.delete(key);
|
||||
$("backend-overview-card")?.classList.toggle("is-no-proxy", !boHasProxy);
|
||||
applyBackendOverviewView();
|
||||
}
|
||||
function applyBackendOverviewView() {
|
||||
const body = $("bo-rows"); if (!body) return;
|
||||
const visibleColumns = new Set(poolTablePrefs.columns);
|
||||
const query = ($("bo-search")?.value || "").trim().toLowerCase();
|
||||
const focusKey = document.activeElement?.dataset?.boKey, hadFocus = Boolean(focusKey) && body.contains(document.activeElement);
|
||||
const counts = { all: boItems.length, up: 0, down: 0, other: 0 };
|
||||
for (const item of boItems) counts[item.group]++;
|
||||
const summary = $("bo-summary"); if (summary) summary.textContent = boItems.length ? t("backendSummary").replace("{up}", counts.up).replace("{total}", boItems.length) : "";
|
||||
const filters = $("bo-filters");
|
||||
if (filters) {
|
||||
filters.setAttribute("aria-label", t("backendFilterLabel"));
|
||||
filters.replaceChildren(...[["all", "backendFilterAll"], ["up", "vhUp"], ["down", "vhDown"], ["other", "backendFilterOther"]].map(([key, label]) => {
|
||||
const chip = el("button", undefined, `bo-chip is-${key}`), count = el("b", String(counts[key]), counts[key] ? "" : "is-zero");
|
||||
chip.type = "button"; chip.dataset.uiIconName = "none"; chip.setAttribute("aria-pressed", String(boFilter === key));
|
||||
chip.append(el("span", t(label)), count); chip.addEventListener("click", () => { boFilter = key; applyBackendOverviewView(); }); return chip;
|
||||
}));
|
||||
}
|
||||
const key = poolTablePrefs.sort;
|
||||
const list = boItems.filter(item => (boFilter === "all" || item.group === boFilter) && (!query || item.search.includes(query)));
|
||||
list.sort((left, right) => {
|
||||
const a = left.sort[key] ?? "", b = right.sort[key] ?? "";
|
||||
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return poolTablePrefs.descending ? -result : result;
|
||||
});
|
||||
const showIdentity = visibleColumns.has("vhost") || visibleColumns.has("backend"), showEvent = visibleColumns.has("lastFailure") || visibleColumns.has("lastRecovery");
|
||||
const detailColumns = BO_DETAIL_COLUMNS.filter(column => visibleColumns.has(column)), expandable = detailColumns.length > 0;
|
||||
const columns = [
|
||||
expandable && ["toggle", ""], showIdentity && ["identity", `${visibleColumns.has("vhost") ? t("vhVhost") : ""}${visibleColumns.has("vhost") && visibleColumns.has("backend") ? " / " : ""}${visibleColumns.has("backend") ? t("backend") : ""}`],
|
||||
visibleColumns.has("status") && ["status", t("status")], visibleColumns.has("active") && ["active", t("vhActive"), true], visibleColumns.has("requests") && ["requests", t("vhRequests"), true],
|
||||
visibleColumns.has("errors") && ["errors", t("vhErrors")], visibleColumns.has("latency") && ["latency", t("vhLatency")], visibleColumns.has("traffic") && ["traffic", t("vhTraffic"), true], showEvent && ["event", t("backendLastEvent")],
|
||||
].filter(Boolean);
|
||||
const head = $("bo-head"); if (head) head.replaceChildren(...columns.map(([id, label, numeric]) => { const th = el("th", label, numeric ? "is-num" : ""); th.dataset.boColumn = id; if (id === "toggle") th.setAttribute("aria-label", t("backendDetailsToggle")); return th; }));
|
||||
const rows = [];
|
||||
for (const item of list) {
|
||||
const row = el("tr", undefined, `bo-row is-${item.tone}${item.disabled ? " is-disabled" : ""}`), open = expandable && boOpen.has(item.key);
|
||||
for (const [id] of columns) {
|
||||
const cell = el("td"); cell.dataset.boColumn = id;
|
||||
if (id === "toggle") {
|
||||
const button = el("button", undefined, "bo-expand"); button.type = "button"; button.dataset.uiIconName = "none"; button.dataset.boKey = item.key;
|
||||
button.setAttribute("aria-expanded", String(open)); button.setAttribute("aria-label", t("backendDetailsToggle")); button.title = t("backendDetailsToggle");
|
||||
button.addEventListener("click", () => { if (boOpen.has(item.key)) boOpen.delete(item.key); else boOpen.add(item.key); applyBackendOverviewView(); });
|
||||
cell.append(button);
|
||||
} else if (id === "identity") {
|
||||
cell.className = "bo-identity"; const name = el("div", visibleColumns.has("vhost") ? item.vhost : item.backend, "bo-name"); name.title = item.vhost; cell.append(name);
|
||||
if (visibleColumns.has("vhost") && visibleColumns.has("backend")) { const sub = el("div", item.backend, "bo-sub"); sub.title = item.backend; cell.append(sub); }
|
||||
} else if (id === "status") {
|
||||
const badge = el("span", item.label, `bo-badge is-${item.tone}`); badge.title = item.title; badge.prepend(el("i")); cell.append(badge);
|
||||
} else if (id === "active") { cell.className = "is-num"; cell.textContent = item.activeText; }
|
||||
else if (id === "requests") {
|
||||
cell.className = "bo-requests"; const wrap = el("div", undefined, "bo-chartcell");
|
||||
wrap.append(boBars(boSeriesOf(item).requests), el("span", String(item.requests))); wrap.title = t("backendRequestsTrend"); cell.append(wrap);
|
||||
}
|
||||
else if (id === "errors") {
|
||||
const pct = item.requests ? item.errors / item.requests * 100 : 0, top = el("div", undefined, "bo-meter-top"), track = el("div", undefined, "bo-meter"), fill = el("i");
|
||||
top.append(el("span", String(item.errors), item.errors ? "" : "is-zero"), el("small", item.errors ? (pct < 0.1 ? "<0.1%" : `${pct.toFixed(pct < 10 ? 1 : 0)}%`) : "0%"));
|
||||
fill.style.width = `${item.errors ? Math.min(100, Math.max(4, pct)) : 0}%`; if (pct >= 5) fill.className = "is-high"; track.append(fill); cell.className = "bo-errors"; cell.append(top, track);
|
||||
} else if (id === "latency") {
|
||||
cell.className = "bo-latency"; cell.append(el("span", item.latency > 0 ? `${item.latency.toFixed(1)} ms` : "-", item.latency > 0 ? "" : "is-zero"), boSparkline(boSeriesOf(item).latency, item.latency));
|
||||
} else if (id === "traffic") {
|
||||
cell.className = "bo-traffic"; const wrap = el("div", undefined, "bo-chartcell"), text = el("div", undefined, "bo-traffic-text"); wrap.title = `${rateBytes(item.traffic)}/s`;
|
||||
const line = (kind, label, value) => { const row = el("div", undefined, `bo-${kind}`); row.append(el("span", label), el("b", `${rateBytes(value)}/s`, value > 0 ? "" : "is-zero")); return row; };
|
||||
text.append(line("in", "IN", item.trafficIn || 0), line("out", "OUT", item.trafficOut || 0));
|
||||
wrap.append(boTrafficChart(boSeriesOf(item).inRate, boSeriesOf(item).outRate), text); cell.append(wrap);
|
||||
}
|
||||
else if (id === "event") {
|
||||
cell.className = "bo-event";
|
||||
if (item.lastFailureMs && item.lastFailureMs >= item.lastRecoveryMs) { const line = el("span", item.lastFailureClass || t("backendEventFailed"), "is-failed"); line.title = item.lastFailureDetail || ""; cell.append(line, el("small", boAgo(item.lastFailureMs))); }
|
||||
else if (item.lastRecoveryMs) cell.append(el("span", t("backendEventRecovered"), "is-recovered"), el("small", boAgo(item.lastRecoveryMs)));
|
||||
else cell.append(el("span", t("backendEventNone"), "is-zero"));
|
||||
}
|
||||
row.append(cell);
|
||||
}
|
||||
rows.push(row);
|
||||
if (open) {
|
||||
const detail = el("tr", undefined, "bo-detail-row"), cell = el("td"), grid = el("div", undefined, "bo-detail-grid"); cell.colSpan = Math.max(1, columns.length);
|
||||
const values = { generation: item.generation, generationRequests: item.generationRequests, generationAge: item.generationAgeText, rotations: item.rotations, lastFailure: item.lastFailureText, lastRecovery: item.lastRecoveryText };
|
||||
for (const column of detailColumns) { const entry = el("div", undefined, "bo-detail-item"), value = el("b", String(values[column] ?? "-")); if (column === "lastFailure" && item.lastFailureMs) { value.className = "is-failed"; value.title = item.lastFailureDetail || ""; } else if (column === "lastRecovery" && item.lastRecoveryMs) value.className = "is-recovered"; entry.append(el("span", t(BO_SORT_LABELS[column])), value); grid.append(entry); }
|
||||
cell.append(grid); detail.append(cell); rows.push(detail);
|
||||
}
|
||||
}
|
||||
const scroller = body.closest(".table-scroll"), scrollTop = scroller?.scrollTop ?? 0, scrollLeft = scroller?.scrollLeft ?? 0;
|
||||
body.replaceChildren(...rows);
|
||||
if (scroller) { scroller.scrollTop = scrollTop; scroller.scrollLeft = scrollLeft; }
|
||||
// restore keyboard focus without scrolling: the table is rebuilt on every refresh and a plain focus() jumped back to the focused row
|
||||
if (hadFocus) [...body.querySelectorAll(".bo-expand")].find(button => button.dataset.boKey === focusKey)?.focus({ preventScroll: true });
|
||||
const empty = $("bo-empty");
|
||||
if (empty) { empty.textContent = boItems.length ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = list.length > 0 || !boHasProxy; }
|
||||
const toggleAll = document.querySelectorAll("#bo-column-options [data-pool-column-toggle]"); toggleAll.forEach(input => { input.checked = visibleColumns.has(input.dataset.poolColumnToggle); });
|
||||
}
|
||||
async function toggleVhostEnabled(vhost) {
|
||||
if (!envelope || busy) return;
|
||||
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
|
||||
@@ -286,7 +472,8 @@ function render() {
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
const diagnosticsCard = $("backend-diagnostics-card");
|
||||
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
|
||||
diagnosticsCard?.classList.toggle("is-no-proxy", !configuredHasProxySource);
|
||||
$("backend-overview-card")?.classList.toggle("is-no-proxy", !configuredHasProxySource);
|
||||
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
||||
const visibleVhosts = (config.vhosts || []).filter(v => {
|
||||
if (!query) return true;
|
||||
@@ -327,7 +514,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("remove", async () => {
|
||||
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("copyEntry", () => { window.rustProxyDrafts.prepare(); const copy = structuredClone(v); copy.id = ""; copy.frontend = { ...(copy.frontend || {}), hosts: [] }; copy.certificate_id = null; copy.allow_certificate_mismatch = false; copy.www_redirect_target = null; load(copy); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
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(); }
|
||||
@@ -435,16 +622,26 @@ function render() {
|
||||
}
|
||||
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
||||
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
|
||||
const bytesIn = Math.max(0, Number(member.bytes_in) || 0), bytesOut = Math.max(0, Number(member.bytes_out) || 0), total = bytesIn + bytesOut;
|
||||
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
||||
const previous = backendTrafficHistory.get(key);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds"));
|
||||
const observed = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0;
|
||||
const sampled = continuous && total >= previous.total, seconds = sampled ? (now - previous.at) / 1000 : 0;
|
||||
const observed = sampled ? (total - previous.total) / seconds : 0;
|
||||
let inRate = sampled && bytesIn >= previous.bytesIn ? (bytesIn - previous.bytesIn) / seconds : 0;
|
||||
let outRate = sampled && bytesOut >= previous.bytesOut ? (bytesOut - previous.bytesOut) / seconds : 0;
|
||||
const rate = reported > 0 ? reported : observed;
|
||||
backendTrafficHistory.set(key, { ...rustProxyRateSampling.stamp(now), total, snapshot, rate });
|
||||
// keep the in/out split consistent with the total shown elsewhere (the server-reported rate wins when present)
|
||||
if (rate > 0 && inRate + outRate === 0) { inRate = rate * (total > 0 ? bytesIn / total : 0.5); outRate = rate - inRate; }
|
||||
else if (reported > 0 && inRate + outRate > 0) { const scale = reported / (inRate + outRate); inRate *= scale; outRate *= scale; }
|
||||
backendTrafficHistory.set(key, { ...rustProxyRateSampling.stamp(now), total, bytesIn, bytesOut, snapshot, rate, inRate, outRate });
|
||||
return rate;
|
||||
}
|
||||
function backendTrafficSplit(poolId, member) {
|
||||
const history = backendTrafficHistory.get(`${poolId}\u0000${member.id}`);
|
||||
return { inRate: history?.inRate || 0, outRate: history?.outRate || 0 };
|
||||
}
|
||||
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||
seen.add(id);
|
||||
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
||||
@@ -459,7 +656,7 @@ function renderStatus(status) {
|
||||
renderUpstreamProtocolBadges(status);
|
||||
refreshVhostBackendPopup(status);
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
||||
const rows = [], summaries = new Map(), aggregate = new Map(), overviewItems = [];
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
||||
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
||||
@@ -481,6 +678,14 @@ function renderStatus(status) {
|
||||
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
|
||||
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
const tone = vhostDisabled ? "muted" : member.health_mode === "off" && member.status === "up" ? "muted" : member.status === "up" ? "up" : member.status === "down" ? "down" : member.status === "checking" || member.status === "draining" ? "warn" : "muted";
|
||||
overviewItems.push({
|
||||
key: `${pool.id}\u0000${member.id}`, vhost: poolLabel, backend: `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, disabled: vhostDisabled, tone, group: tone === "up" ? "up" : tone === "down" ? "down" : "other",
|
||||
label, title: vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`, activeText: activeLimit, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, ...(({ inRate, outRate }) => ({ trafficIn: inRate, trafficOut: outRate }))(backendTrafficSplit(pool.id, member)),
|
||||
generation: member.upstream_client_generation ?? "-", generationRequests: member.upstream_client_generation_requests ?? "-", generationAgeText, rotations: member.upstream_client_rotations_total ?? "-",
|
||||
lastFailureMs: Number(member.last_failure_at_ms) || 0, lastFailureClass: member.last_failure_class || "", lastFailureDetail: member.last_failure_detail || "", lastFailureText: lastFailure, lastRecoveryMs: Number(member.last_recovered_ms) || 0, lastRecoveryText: lastRecovery,
|
||||
sort: sortValues, search: row.poolSearchValue,
|
||||
});
|
||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||
}
|
||||
@@ -524,8 +729,9 @@ function renderStatus(status) {
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
$("pool-rows").replaceChildren(...rows);
|
||||
renderBackendOverview(overviewItems, hasConfiguredProxySource, status);
|
||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
||||
backendDiagnosticsCard?.classList.toggle("is-no-proxy", !hasConfiguredProxySource);
|
||||
applyPoolTableView();
|
||||
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
||||
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
||||
@@ -590,6 +796,15 @@ 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,6 +330,7 @@
|
||||
$("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";
|
||||
|
||||
+23
-10
@@ -150,28 +150,35 @@ pub fn compile_reusing(
|
||||
type LocalHostBuild = (
|
||||
HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
HashMap<String, Vec<crate::vhost::Listener>>,
|
||||
HashMap<String, Vec<(String, crate::vhost::Listener)>>,
|
||||
);
|
||||
|
||||
fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
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<crate::vhost::Listener>> = HashMap::new();
|
||||
let mut claimed_hosts: HashMap<String, Vec<(String, 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 a in claimed.iter() {
|
||||
for (owner, a) in claimed.iter() {
|
||||
for b in &bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||
name,
|
||||
owner,
|
||||
a.address
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(bindings.iter().copied());
|
||||
claimed.extend(
|
||||
bindings
|
||||
.iter()
|
||||
.map(|binding| (format!("redirect {}", redirect.id), *binding)),
|
||||
);
|
||||
}
|
||||
if redirect.enabled {
|
||||
let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?);
|
||||
@@ -187,16 +194,22 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
let bindings = host.frontend.resolved(&config.listeners)?;
|
||||
for name in &host.frontend.hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
for (owner, a) in claimed.iter() {
|
||||
for b in &bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||
name,
|
||||
owner,
|
||||
a.address
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(bindings.iter().copied());
|
||||
claimed.extend(
|
||||
bindings
|
||||
.iter()
|
||||
.map(|binding| (format!("404 host {}", host.id), *binding)),
|
||||
);
|
||||
}
|
||||
if host.enabled {
|
||||
let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?);
|
||||
|
||||
+21
-8
@@ -65,7 +65,8 @@ pub fn compile_with_active(
|
||||
php_profiles
|
||||
.insert(profile.id.clone(), profile.php.clone())
|
||||
.is_none(),
|
||||
"duplicate PHP-FPM profile id"
|
||||
"duplicate PHP-FPM profile id {:?}: every profile needs a unique ID",
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
@@ -120,7 +121,8 @@ pub fn compile_with_active(
|
||||
);
|
||||
ensure!(
|
||||
web_services.insert(service.id.clone(), compiled).is_none(),
|
||||
"duplicate Web Service id"
|
||||
"duplicate Web Service id {:?}: every Web Service needs a unique ID",
|
||||
service.id
|
||||
);
|
||||
}
|
||||
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
||||
@@ -145,7 +147,11 @@ pub fn compile_with_active(
|
||||
validate_source(&route.source)?;
|
||||
}
|
||||
}
|
||||
ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id");
|
||||
ensure!(
|
||||
vhost_ids.insert(host.id.clone()),
|
||||
"duplicate vhost id {:?}: every vhost needs a unique ID",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
config.redirects.len() <= 64,
|
||||
@@ -158,7 +164,8 @@ pub fn compile_with_active(
|
||||
.with_context(|| format!("redirect {}", redirect.id))?;
|
||||
ensure!(
|
||||
redirect_ids.insert(redirect.id.clone()),
|
||||
"duplicate redirect id"
|
||||
"duplicate redirect id {:?}: every redirect needs a unique ID",
|
||||
redirect.id
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
@@ -171,7 +178,8 @@ pub fn compile_with_active(
|
||||
.with_context(|| format!("404 host {}", host.id))?;
|
||||
ensure!(
|
||||
not_found_ids.insert(host.id.clone()),
|
||||
"duplicate 404 host id"
|
||||
"duplicate 404 host id {:?}: every 404 host needs a unique ID",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
let effective_tls = crate::tls::for_all_frontends(
|
||||
@@ -279,7 +287,11 @@ 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");
|
||||
ensure!(
|
||||
valid_id(&profile.id),
|
||||
"invalid ACL profile id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||
profile.id
|
||||
);
|
||||
ensure!(
|
||||
!profile.name.trim().is_empty()
|
||||
&& profile.name.len() <= 96
|
||||
@@ -293,7 +305,8 @@ pub fn compile_with_active(
|
||||
acl_profiles
|
||||
.insert(profile.id.clone(), (acl, profile.country_access.clone()))
|
||||
.is_none(),
|
||||
"duplicate ACL profile id"
|
||||
"duplicate ACL profile id {:?}: every ACL profile needs a unique ID",
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
crate::acl::dynamic::configured_hostnames(&config)?;
|
||||
@@ -380,7 +393,7 @@ fn normalize_prefix(prefix: &mut String) -> Result<()> {
|
||||
&& !prefix.contains('?')
|
||||
&& !prefix.contains('#')
|
||||
&& !prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||
"invalid path prefix"
|
||||
"invalid path prefix: it must start with '/', be at most 512 bytes and contain no '?', '#', spaces or control characters"
|
||||
);
|
||||
if prefix.len() > 1 {
|
||||
*prefix = prefix.trim_end_matches('/').to_owned();
|
||||
|
||||
+25
-10
@@ -79,16 +79,22 @@ fn compile_routing(
|
||||
);
|
||||
for name in &routing_hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
for (owner, a) in claimed.iter() {
|
||||
for b in &host_bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
"overlapping frontend bindings for hostname {} (already used by {} on {})",
|
||||
name,
|
||||
owner,
|
||||
a.address
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(host_bindings.iter().copied());
|
||||
claimed.extend(
|
||||
host_bindings
|
||||
.iter()
|
||||
.map(|binding| (format!("vhost {}", host.id), *binding)),
|
||||
);
|
||||
}
|
||||
let needs_proxy_pool = host.source.is_reverse_proxy()
|
||||
|| host.routes.iter().any(|route| {
|
||||
@@ -323,7 +329,8 @@ fn compile_routing(
|
||||
for route in &mut host.routes {
|
||||
ensure!(
|
||||
valid_id(&route.id) && path_ids.insert(route.id.clone()),
|
||||
"invalid/duplicate vhost path id"
|
||||
"location ID {:?} is invalid or used twice in this vhost (use 1-64 letters, digits, '-' or '_', unique per vhost)",
|
||||
route.id
|
||||
);
|
||||
let path_regex = match route.match_type {
|
||||
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => {
|
||||
@@ -331,7 +338,8 @@ fn compile_routing(
|
||||
!route.path_prefix.is_empty()
|
||||
&& route.path_prefix.len() <= 1024
|
||||
&& !route.path_prefix.chars().any(char::is_control),
|
||||
"invalid vhost location regex"
|
||||
"location {}: the regular expression must be 1-1024 characters without control characters",
|
||||
route.id
|
||||
);
|
||||
let mut builder = regex::RegexBuilder::new(&route.path_prefix);
|
||||
builder
|
||||
@@ -340,17 +348,23 @@ fn compile_routing(
|
||||
)
|
||||
.size_limit(2 * 1024 * 1024)
|
||||
.dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(builder.build().context("invalid vhost location regex")?)
|
||||
Some(builder.build().with_context(|| {
|
||||
format!(
|
||||
"location {}: invalid or too complex regular expression",
|
||||
route.id
|
||||
)
|
||||
})?)
|
||||
}
|
||||
crate::vhost::PathMatch::Exact => {
|
||||
ensure!(
|
||||
!route.path_prefix.contains('?')
|
||||
&& !route.path_prefix.contains('#')
|
||||
&& !route.path_prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||
"invalid exact location path"
|
||||
"location {}: an exact path must not contain '?', '#', spaces or control characters",
|
||||
route.id
|
||||
);
|
||||
route.path_prefix = crate::acl::routing_path(&route.path_prefix)
|
||||
.context("invalid exact location path")?;
|
||||
.with_context(|| format!("location {}: invalid exact path", route.id))?;
|
||||
None
|
||||
}
|
||||
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => {
|
||||
@@ -361,7 +375,8 @@ fn compile_routing(
|
||||
if !route.internal {
|
||||
ensure!(
|
||||
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
||||
"duplicate vhost location matcher"
|
||||
"location {}: another location already uses the same path and match type",
|
||||
route.id
|
||||
);
|
||||
}
|
||||
validate_route_rewrite(route, path_regex.as_ref())?;
|
||||
|
||||
@@ -139,6 +139,73 @@ 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,
|
||||
@@ -204,6 +271,13 @@ impl Snapshot {
|
||||
})
|
||||
}
|
||||
}
|
||||
fn host_pattern_rank(pattern: &str, host: &str) -> usize {
|
||||
if pattern == host {
|
||||
usize::MAX
|
||||
} else {
|
||||
pattern.len()
|
||||
}
|
||||
}
|
||||
fn select_route<'a, F>(
|
||||
routes: &'a [Arc<CompiledRoute>],
|
||||
path: &str,
|
||||
|
||||
+10
-2
@@ -37,7 +37,11 @@ fn default_page() -> crate::pages::Page {
|
||||
|
||||
impl Host {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(crate::config::valid_id(&self.id), "invalid 404 host id");
|
||||
ensure!(
|
||||
crate::config::valid_id(&self.id),
|
||||
"invalid 404 host id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||
self.id
|
||||
);
|
||||
if self.name.trim().is_empty() {
|
||||
self.name = self.id.clone();
|
||||
}
|
||||
@@ -55,7 +59,11 @@ impl Host {
|
||||
let mut hosts = std::collections::HashSet::new();
|
||||
for host in &mut self.frontend.hosts {
|
||||
*host = crate::config::normalize_host_pattern(host)?;
|
||||
ensure!(hosts.insert(host.clone()), "duplicate 404 hostname");
|
||||
ensure!(
|
||||
hosts.insert(host.clone()),
|
||||
"hostname {} is listed twice in this 404 host",
|
||||
host
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
!self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16,
|
||||
|
||||
@@ -139,8 +139,21 @@ async fn forward_request(
|
||||
}
|
||||
let websocket_legacy_support =
|
||||
handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
|
||||
let redirect = snapshot.redirect_on(&host, &path, local.0, tls);
|
||||
let local_host = snapshot.not_found_host_on(&host, local.0, tls);
|
||||
// The most specific host name wins: an exact vhost such as autodiscover.example.com must not be
|
||||
// shadowed by a wildcard redirect or 404 host (*.example.com) that happens to be evaluated first.
|
||||
let frontend_rank = frontend.and_then(|_| snapshot.frontend_rank(&host, local.0, tls));
|
||||
let redirect = snapshot
|
||||
.redirect_on(&host, &path, local.0, tls)
|
||||
.filter(|_| match (frontend_rank, snapshot.redirect_rank(&host, &path, local.0, tls)) {
|
||||
(Some(vhost), Some(redirect)) => redirect >= vhost,
|
||||
_ => true,
|
||||
});
|
||||
let local_host = snapshot
|
||||
.not_found_host_on(&host, local.0, tls)
|
||||
.filter(|_| match (frontend_rank, snapshot.not_found_rank(&host, local.0, tls)) {
|
||||
(Some(vhost), Some(local_host)) => local_host >= vhost,
|
||||
_ => true,
|
||||
});
|
||||
if request.version() == Version::HTTP_2
|
||||
&& (frontend.is_some_and(|frontend| !frontend.http2_enabled)
|
||||
|| redirect.is_some_and(|(redirect, _)| !redirect.http2_enabled)
|
||||
|
||||
+12
-4
@@ -127,7 +127,11 @@ pub struct Host {
|
||||
}
|
||||
impl Host {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(crate::config::valid_id(&self.id), "invalid redirect id");
|
||||
ensure!(
|
||||
crate::config::valid_id(&self.id),
|
||||
"invalid redirect id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||
self.id
|
||||
);
|
||||
if self.name.is_empty() {
|
||||
self.name = self.id.clone();
|
||||
}
|
||||
@@ -140,16 +144,20 @@ impl Host {
|
||||
);
|
||||
ensure!(
|
||||
!self.frontend.hosts.is_empty(),
|
||||
"redirect requires at least one hostname"
|
||||
"redirect requires at least one hostname (for example old.example.com)"
|
||||
);
|
||||
let mut hosts = std::collections::HashSet::new();
|
||||
for host in &mut self.frontend.hosts {
|
||||
*host = crate::config::normalize_host_pattern(host)?;
|
||||
ensure!(hosts.insert(host.clone()), "duplicate redirect hostname");
|
||||
ensure!(
|
||||
hosts.insert(host.clone()),
|
||||
"hostname {} is listed twice in this redirect",
|
||||
host
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
!self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16,
|
||||
"redirect requires 1..16 named listeners"
|
||||
"select between 1 and 16 listeners for this redirect"
|
||||
);
|
||||
if self.rules.is_empty() {
|
||||
self.rules.push(Rule::default());
|
||||
|
||||
@@ -367,7 +367,7 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<A
|
||||
let mut checker = ResolvesServerCertUsingSni::new();
|
||||
checker
|
||||
.add(&validation_name, key.clone())
|
||||
.with_context(|| format!("certificate does not cover {host}; explicit Vhost consent is required to override"))?;
|
||||
.with_context(|| format!("certificate {} does not cover {host}. Select a certificate that covers it, or enable \"Force an incompatible certificate\" in the vhost SSL tab (not available for redirects and 404 hosts)", cert.id))?;
|
||||
} else {
|
||||
tracing::warn!(certificate = %cert.id, hostname = %host,
|
||||
"Explicitly serving a TLS certificate without hostname verification; clients may reject it");
|
||||
|
||||
+14
-5
@@ -46,7 +46,11 @@ impl PathRoute {
|
||||
}
|
||||
impl Vhost {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(crate::config::valid_id(&self.id), "invalid vhost id");
|
||||
ensure!(
|
||||
crate::config::valid_id(&self.id),
|
||||
"invalid vhost id {:?}: use 1-64 letters, digits, '-' or '_'",
|
||||
self.id
|
||||
);
|
||||
ensure!(
|
||||
!self.websocket_legacy_support || self.websockets,
|
||||
"legacy WebSocket compatibility requires WebSocket support to be enabled"
|
||||
@@ -69,12 +73,16 @@ impl Vhost {
|
||||
);
|
||||
ensure!(
|
||||
!self.frontend.hosts.is_empty(),
|
||||
"vhost requires at least one hostname"
|
||||
"vhost requires at least one hostname (for example app.example.com) in the Frontend section"
|
||||
);
|
||||
let mut hosts = std::collections::HashSet::new();
|
||||
for host in &mut self.frontend.hosts {
|
||||
*host = crate::config::normalize_host_pattern(host)?;
|
||||
ensure!(hosts.insert(host.clone()), "duplicate vhost hostname");
|
||||
ensure!(
|
||||
hosts.insert(host.clone()),
|
||||
"hostname {} is listed twice in this vhost",
|
||||
host
|
||||
);
|
||||
}
|
||||
if let Some(target) = &mut self.www_redirect_target {
|
||||
*target = crate::config::normalize_host_pattern(target)?;
|
||||
@@ -101,7 +109,7 @@ impl Vhost {
|
||||
self.content_type_policy.validate()?;
|
||||
ensure!(
|
||||
!self.frontend.listener_ids.is_empty(),
|
||||
"vhost requires at least one named listener"
|
||||
"select at least one listener for this vhost in the Frontend section"
|
||||
);
|
||||
ensure!(
|
||||
self.frontend.listener_ids.len() <= 16,
|
||||
@@ -150,7 +158,8 @@ impl Vhost {
|
||||
for route in &mut self.routes {
|
||||
ensure!(
|
||||
crate::config::valid_id(&route.id) && route_ids.insert(route.id.clone()),
|
||||
"invalid/duplicate vhost path id"
|
||||
"location ID {:?} is invalid or used twice in this vhost (use 1-64 letters, digits, '-' or '_', unique per vhost)",
|
||||
route.id
|
||||
);
|
||||
route.source.validate()?;
|
||||
ensure!(
|
||||
|
||||
@@ -32,13 +32,13 @@ function t(key) { return translations[key] || key; }
|
||||
(() => {
|
||||
const fail = message => { document.getElementById("browser-icon-test").textContent = `FAIL: ${message}`; throw new Error(message); };
|
||||
const result = document.getElementById("browser-icon-test");
|
||||
const eligible = [...document.querySelectorAll("button, a.primary, a.outline, a.quiet")].filter(node => !node.matches(".chart-style-choice"));
|
||||
const eligible = [...document.querySelectorAll("button, a.primary, a.outline, a.quiet")].filter(node => window.rustProxyIcons.iconFor(node) !== null);
|
||||
if (document.querySelectorAll("svg.ui-icon").length !== 0) fail("icons must be absent before enabling the preference");
|
||||
window.rustProxyIcons.setEnabled(true);
|
||||
const decorated = eligible.filter(node => node.querySelector(":scope > svg.ui-icon"));
|
||||
if (decorated.length !== eligible.length) fail(`expected ${eligible.length} decorated controls, got ${decorated.length}`);
|
||||
if ([...document.querySelectorAll("svg.ui-icon")].some(icon => icon.getAttribute("aria-hidden") !== "true")) fail("SVG icons must be aria-hidden");
|
||||
const expectedPanels = { overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box", redirects: "route", notfound: "fileX", streams: "network", "acl-profiles": "shield", tls: "certificate", traffic: "activity", cache: "database", transform: "wand", settings: "settings" };
|
||||
const expectedPanels = { overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box", redirects: "route", notfound: "fileX", streams: "transfer", "acl-profiles": "shield", tls: "certificate", traffic: "activity", cache: "database", transform: "wand", settings: "settings" };
|
||||
for (const [panel, icon] of Object.entries(expectedPanels)) {
|
||||
const button = document.querySelector(`[data-panel="${panel}"]`);
|
||||
if (button?.querySelector(":scope > svg.ui-icon")?.dataset.uiIcon !== icon) fail(`panel ${panel} must use ${icon}`);
|
||||
|
||||
@@ -47,4 +47,89 @@ for (const icon of ["plus", "save", "check", "trash", "edit", "refresh", "settin
|
||||
assert.ok(new RegExp(`\\b${icon}:`).test(icons), `missing semantic SVG icon definition: ${icon}`);
|
||||
}
|
||||
|
||||
// 0.36.2 semantic review: verbs/explicit ids must win over colliding keywords.
|
||||
const explicitIcons = { "overview-layout-unlock": "unlock", "overview-inline-save": "lock", "overview-layout-open": "layers", "docker-settings-open": "settings", "login-submit": "login", "revoke-sessions": "logout", "about-open": "info" };
|
||||
for (const [id, icon] of Object.entries(explicitIcons)) assert.match(icons, new RegExp(`"${id}": "${icon}"`), `${id} must use ${icon}`);
|
||||
assert.match(icons, /\(\^\|\[\^p\]\)reset/, "preset buttons must not match the reset rule");
|
||||
assert.match(icons, /UI_GLYPH_LABEL/);
|
||||
assert.match(icons, /raw === key/, "English labels equal to their key must still count as translated");
|
||||
for (const icon of ["unlock", "layers", "expand", "collapse", "ban", "eraser", "hash", "info", "plug", "transfer", "mapPin", "compare"]) {
|
||||
assert.ok(new RegExp(`\\b${icon}:`).test(icons), `missing semantic SVG icon definition: ${icon}`);
|
||||
}
|
||||
|
||||
const logo = read("src/admin/web/app/logo-animation.js");
|
||||
const router = read("src/admin/router.rs");
|
||||
const shellCss = read("src/admin/web/css/20-shell.css");
|
||||
assert.match(html, /id="ui-logo-animation"[^>]*type="checkbox"/);
|
||||
assert.match(router, /web\/app\/logo-animation\.js/);
|
||||
assert.match(preferences, /logo_animation:\s*false/);
|
||||
assert.match(preferences, /applyAdminLogoAnimation\(value\.logo_animation\)/);
|
||||
assert.match(preferences, /saveAppearancePreference\(\{ logo_animation: next \}/);
|
||||
assert.match(logo, /prefers-reduced-motion: reduce/);
|
||||
assert.match(logo, /document\.hidden/);
|
||||
assert.match(shellCss, /prefers-reduced-motion: reduce[\s\S]*data-logo-effect/);
|
||||
assert.equal(openapi.components.schemas.AdminPreferences.properties.logo_animation.default, false);
|
||||
assert.ok(pl.translations.logoAnimation && pl.translations.logoAnimationHelp);
|
||||
|
||||
// Mobile/Safari regression: injected icons must never be mistaken for chart SVGs or
|
||||
// stretched by descendant chart rules, and refreshes must not rebuild decorated buttons.
|
||||
const runtimeJs = read("src/admin/web/app/runtime.js");
|
||||
const scalesJs = read("src/admin/web/app/chart-scales.js");
|
||||
const editorsCss = read("src/admin/web/css/40-editors.css");
|
||||
assert.match(runtimeJs, /row\.querySelector\("svg:not\(\.ui-icon\)"\)/);
|
||||
assert.doesNotMatch(runtimeJs, /row\.querySelector\("svg"\)/);
|
||||
assert.match(runtimeJs, /querySelector\(":scope > svg\.ui-icon"\)/);
|
||||
assert.match(scalesJs, /svg\.classList\.contains\("ui-icon"\)/);
|
||||
assert.match(scalesJs, /querySelectorAll\("svg:not\(\.ui-icon\)"\)/);
|
||||
for (const source of [css, overviewCss, editorsCss]) assert.doesNotMatch(source, /(^|\n)[^\n{}]*(\.network-interface-row|\.stat-chart|\.activity-card|\.system-card|\.function-live-chart|\.mini-live-chart|\.vhost-chart|\.connection-card) svg[ ,{]/, "descendant chart svg rules must exclude .ui-icon");
|
||||
assert.match(css, /html\[data-interface-icons="true"\] :is\(button, a, summary\)>svg\.ui-icon/);
|
||||
assert.match(icons, /\.mobile-menu-toggle"\) return null|draft-dock-toggle, \.mobile-menu-toggle/);
|
||||
|
||||
assert.match(editorsCss, /Spacing review \(0\.36\.2\)/);
|
||||
assert.match(editorsCss, /\.vhost-backend-protocol-badge \{[^}]*letter-spacing: 1px;[^}]*\}/);
|
||||
assert.match(editorsCss, /\.tls-pqc-control>a/);
|
||||
|
||||
console.log("Panel SVG icon preference contract: OK");
|
||||
|
||||
// CSS cleanup (0.36.3): the legacy body[data-density] mechanism must not come back.
|
||||
{
|
||||
const cssDir = path.join(__dirname, "..", "src", "admin", "web", "css");
|
||||
for (const name of fs.readdirSync(cssDir).filter((file) => file.endsWith(".css"))) {
|
||||
assert.doesNotMatch(fs.readFileSync(path.join(cssDir, name), "utf8"), /body\[data-density/, `${name} still has legacy body[data-density] rules`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache outcomes bar shows percentages inside its segments and hides labels that do not fit.
|
||||
{
|
||||
const rt = fs.readFileSync(path.join(__dirname, "..", "src", "admin", "web", "app", "runtime.js"), "utf8");
|
||||
assert.match(rt, /function fitCacheMixLabels/);
|
||||
assert.match(rt, /segment\.textContent = mix && pct > 0/);
|
||||
}
|
||||
|
||||
// Settings layout (0.36.3): rows pattern for Appearance and horizontal sub-tabs.
|
||||
{
|
||||
const css = fs.readFileSync(path.join(__dirname, "..", "src", "admin", "web", "css", "50-settings.css"), "utf8");
|
||||
assert.match(css, /#settings-page-appearance \.appearance-settings-grid>label \{/);
|
||||
assert.match(css, /#panel-settings \.settings-tabs\.sub-tabs \{\s*display: flex;\s*flex-direction: row;/);
|
||||
}
|
||||
|
||||
// Chart styles (0.36.3): every style id must be wired through Rust validation, OpenAPI, JS, CSS tokens, the selector and the language pack.
|
||||
{
|
||||
const root = path.join(__dirname, "..");
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const styles = ["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool", "sunset", "aurora", "ocean", "forest", "pastel", "terminal", "blueprint", "colorblind"];
|
||||
const rust = read("src/admin/preferences.rs"), js = read("src/admin/web/app/preferences.js"), tokens = read("src/admin/web/css/00-tokens.css"), html = read("src/admin/web/index.html");
|
||||
const openapi = JSON.parse(read("src/admin/openapi.json")).components.schemas;
|
||||
const enumValues = JSON.stringify(openapi).match(/"chart_style":\{"type":"string","enum":(\[[^\]]*\])/);
|
||||
assert.ok(enumValues, "chart_style enum must be present in OpenAPI");
|
||||
const documented = JSON.parse(enumValues[1]);
|
||||
for (const style of styles) {
|
||||
assert.ok(documented.includes(style), `OpenAPI chart_style missing ${style}`);
|
||||
assert.ok(rust.includes(`"${style}"`), `Rust validation missing ${style}`);
|
||||
assert.ok(js.includes(`"${style}"`), `preferences.js missing ${style}`);
|
||||
assert.ok(tokens.includes(`html[data-chart-style=${style}]`), `tokens missing ${style}`);
|
||||
assert.ok(tokens.includes(`.chart-style-choice[data-chart-preset=${style}]`), `swatches missing ${style}`);
|
||||
assert.ok(html.includes(`value="${style}"`), `selector missing ${style}`);
|
||||
}
|
||||
assert.equal(documented.length, styles.length, "OpenAPI chart_style enum must list exactly the supported styles");
|
||||
}
|
||||
@@ -51,5 +51,5 @@ const events = openapi.paths["/api/events"].get;
|
||||
const logsFlag = (events.parameters || []).find(parameter => parameter.name === "logs");
|
||||
assert.equal(logsFlag?.schema?.type, "boolean");
|
||||
assert.equal(logsFlag?.schema?.default, true);
|
||||
assert.equal(openapi.info.version, "0.36.1");
|
||||
assert.equal(openapi.info.version, "0.36.8");
|
||||
console.log("Panel refresh/API contract checks: OK");
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
swagger-ui
|
||||
Copyright 2020-2021 SmartBear Software Inc.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
# Vendored Swagger UI 5.33.1
|
||||
|
||||
Unmodified files from the official `swagger-ui-dist@5.33.1` npm package (Apache-2.0, see `LICENSE` and `NOTICE`).
|
||||
`scripts/build/swagger_assets.py` uses them when no `SWAGGER_UI_DOWNLOAD_URL` / `SWAGGER_UI_ARCHIVE_SHA256` is set,
|
||||
so a release build needs no access to github.com. `SHA256SUMS` is verified on every build; a mismatch aborts the build.
|
||||
|
||||
To update: replace these files from the new `swagger-ui-dist` tarball, regenerate `SHA256SUMS` and change `VERSION`
|
||||
in `scripts/build/swagger_assets.py` together with the directory name.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 swagger-ui-bundle.js
|
||||
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 swagger-ui.css
|
||||
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 LICENSE
|
||||
0d20d1adef18aee3f40dd258172155521ce702ac445cb5f7b7d60ed32dad2fb2 NOTICE
|
||||
63818894e4b04cd0e3180d9cb20761e227a939121e7484f8e1d528227c756f89 swagger-ui-bundle.js.LICENSE.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
|
||||
!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var s={67526(s,o){"use strict";o.byteLength=function byteLength(s){var o=getLens(s),i=o[0],a=o[1];return 3*(i+a)/4-a},o.toByteArray=function toByteArray(s){var o,i,_=getLens(s),w=_[0],x=_[1],C=new u(function _byteLength(s,o,i){return 3*(o+i)/4-i}(0,w,x)),j=0,L=x>0?w-4:w;for(i=0;i<L;i+=4)o=a[s.charCodeAt(i)]<<18|a[s.charCodeAt(i+1)]<<12|a[s.charCodeAt(i+2)]<<6|a[s.charCodeAt(i+3)],C[j++]=o>>16&255,C[j++]=o>>8&255,C[j++]=255&o;2===x&&(o=a[s.charCodeAt(i)]<<2|a[s.charCodeAt(i+1)]>>4,C[j++]=255&o);1===x&&(o=a[s.charCodeAt(i)]<<10|a[s.charCodeAt(i+1)]<<4|a[s.charCodeAt(i+2)]>>2,C[j++]=o>>8&255,C[j++]=255&o);return C},o.fromByteArray=function fromByteArray(s){for(var o,a=s.length,u=a%3,_=[],w=16383,x=0,C=a-u;x<C;x+=w)_.push(encodeChunk(s,x,x+w>C?C:x+w));1===u?(o=s[a-1],_.push(i[o>>2]+i[o<<4&63]+"==")):2===u&&(o=(s[a-2]<<8)+s[a-1],_.push(i[o>>10]+i[o>>4&63]+i[o<<2&63]+"="));return _.join("")};for(var i=[],a=[],u="undefined"!=typeof Uint8Array?Uint8Array:Array,_="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",w=0;w<64;++w)i[w]=_[w],a[_.charCodeAt(w)]=w;function getLens(s){var o=s.length;if(o%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var i=s.indexOf("=");return-1===i&&(i=o),[i,i===o?0:4-i%4]}function encodeChunk(s,o,a){for(var u,_,w=[],x=o;x<a;x+=3)u=(s[x]<<16&16711680)+(s[x+1]<<8&65280)+(255&s[x+2]),w.push(i[(_=u)>>18&63]+i[_>>12&63]+i[_>>6&63]+i[63&_]);return w.join("")}a["-".charCodeAt(0)]=62,a["_".charCodeAt(0)]=63},48287(s,o,i){"use strict";const a=i(67526),u=i(251),_="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;o.Buffer=Buffer,o.SlowBuffer=function SlowBuffer(s){+s!=s&&(s=0);return Buffer.alloc(+s)},o.INSPECT_MAX_BYTES=50;const w=2147483647;function createBuffer(s){if(s>w)throw new RangeError('The value "'+s+'" is invalid for option "size"');const o=new Uint8Array(s);return Object.setPrototypeOf(o,Buffer.prototype),o}function Buffer(s,o,i){if("number"==typeof s){if("string"==typeof o)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(s)}return from(s,o,i)}function from(s,o,i){if("string"==typeof s)return function fromString(s,o){"string"==typeof o&&""!==o||(o="utf8");if(!Buffer.isEncoding(o))throw new TypeError("Unknown encoding: "+o);const i=0|byteLength(s,o);let a=createBuffer(i);const u=a.write(s,o);u!==i&&(a=a.slice(0,u));return a}(s,o);if(ArrayBuffer.isView(s))return function fromArrayView(s){if(isInstance(s,Uint8Array)){const o=new Uint8Array(s);return fromArrayBuffer(o.buffer,o.byteOffset,o.byteLength)}return fromArrayLike(s)}(s);if(null==s)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s);if(isInstance(s,ArrayBuffer)||s&&isInstance(s.buffer,ArrayBuffer))return fromArrayBuffer(s,o,i);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(s,SharedArrayBuffer)||s&&isInstance(s.buffer,SharedArrayBuffer)))return fromArrayBuffer(s,o,i);if("number"==typeof s)throw new TypeError('The "value" argument must not be of type number. Received type number');const a=s.valueOf&&s.valueOf();if(null!=a&&a!==s)return Buffer.from(a,o,i);const u=function fromObject(s){if(Buffer.isBuffer(s)){const o=0|checked(s.length),i=createBuffer(o);return 0===i.length||s.copy(i,0,0,o),i}if(void 0!==s.length)return"number"!=typeof s.length||numberIsNaN(s.length)?createBuffer(0):fromArrayLike(s);if("Buffer"===s.type&&Array.isArray(s.data))return fromArrayLike(s.data)}(s);if(u)return u;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof s[Symbol.toPrimitive])return Buffer.from(s[Symbol.toPrimitive]("string"),o,i);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s)}function assertSize(s){if("number"!=typeof s)throw new TypeError('"size" argument must be of type number');if(s<0)throw new RangeError('The value "'+s+'" is invalid for option "size"')}function allocUnsafe(s){return assertSize(s),createBuffer(s<0?0:0|checked(s))}function fromArrayLike(s){const o=s.length<0?0:0|checked(s.length),i=createBuffer(o);for(let a=0;a<o;a+=1)i[a]=255&s[a];return i}function fromArrayBuffer(s,o,i){if(o<0||s.byteLength<o)throw new RangeError('"offset" is outside of buffer bounds');if(s.byteLength<o+(i||0))throw new RangeError('"length" is outside of buffer bounds');let a;return a=void 0===o&&void 0===i?new Uint8Array(s):void 0===i?new Uint8Array(s,o):new Uint8Array(s,o,i),Object.setPrototypeOf(a,Buffer.prototype),a}function checked(s){if(s>=w)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+w.toString(16)+"
Line truncated
|
||||
@@ -0,0 +1,129 @@
|
||||
/*!
|
||||
Copyright (c) 2018 Jed Watson.
|
||||
Licensed under the MIT License (MIT), see
|
||||
http://jedwatson.github.io/classnames
|
||||
*/
|
||||
|
||||
/*!
|
||||
* @description Recursive object extending
|
||||
* @author Viacheslav Lotsmanov <lotsmanov89@gmail.com>
|
||||
* @license MIT
|
||||
*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2013-2018 Viacheslav Lotsmanov
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* The buffer module from node.js, for the browser.
|
||||
*
|
||||
* @author Feross Aboukhadijeh <https://feross.org>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*!
|
||||
* https://github.com/Starcounter-Jack/JSON-Patch
|
||||
* (c) 2017-2021 Joachim Wester
|
||||
* MIT license
|
||||
*/
|
||||
|
||||
/*!
|
||||
* https://github.com/Starcounter-Jack/JSON-Patch
|
||||
* (c) 2017-2022 Joachim Wester
|
||||
* MIT licensed
|
||||
*/
|
||||
|
||||
/*!
|
||||
* repeat-string <https://github.com/jonschlinkert/repeat-string>
|
||||
*
|
||||
* Copyright (c) 2014-2015, Jon Schlinkert.
|
||||
* Licensed under the MIT License.
|
||||
*/
|
||||
|
||||
/*! @license DOMPurify 3.4.13 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.13/LICENSE */
|
||||
|
||||
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
|
||||
/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
|
||||
/**
|
||||
* @license
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2014-present, Lee Byron and other contributors.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* use-sync-external-store-with-selector.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
@@ -0,0 +1,3 @@
|
||||
.swagger-ui{color:#3b4151;container-name:swagger-ui;container-type:inline-size;/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */font-family:sans-serif}.swagger-ui html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.swagger-ui body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui footer,.swagger-ui header,.swagger-ui nav,.swagger-ui section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;overflow:visible}.swagger-ui pre{font-family:monospace,monospace;font-size:1em}.swagger-ui a{background-color:transparent;-webkit-text-decoration-skip:objects}.swagger-ui abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.swagger-ui b,.swagger-ui strong{font-weight:inherit;font-weight:bolder}.swagger-ui code,.swagger-ui kbd,.swagger-ui samp{font-family:monospace,monospace;font-size:1em}.swagger-ui dfn{font-style:italic}.swagger-ui mark{background-color:#ff0;color:#000}.swagger-ui small{font-size:80%}.swagger-ui sub,.swagger-ui sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}.swagger-ui sub{bottom:-.25em}.swagger-ui sup{top:-.5em}.swagger-ui audio,.swagger-ui video{display:inline-block}.swagger-ui audio:not([controls]){display:none;height:0}.swagger-ui img{border-style:none}.swagger-ui svg:not(:root){overflow:hidden}.swagger-ui button,.swagger-ui input,.swagger-ui optgroup,.swagger-ui select,.swagger-ui textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}.swagger-ui button,.swagger-ui input{overflow:visible}.swagger-ui button,.swagger-ui select{text-transform:none}.swagger-ui [type=reset],.swagger-ui [type=submit],.swagger-ui button,.swagger-ui html [type=button]{-webkit-appearance:button}.swagger-ui [type=button]::-moz-focus-inner,.swagger-ui [type=reset]::-moz-focus-inner,.swagger-ui [type=submit]::-moz-focus-inner,.swagger-ui button::-moz-focus-inner{border-style:none;padding:0}.swagger-ui [type=button]:-moz-focusring,.swagger-ui [type=reset]:-moz-focusring,.swagger-ui [type=submit]:-moz-focusring,.swagger-ui button:-moz-focusring{outline:1px dotted ButtonText}.swagger-ui fieldset{padding:.35em .75em .625em}.swagger-ui legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}.swagger-ui progress{display:inline-block;vertical-align:baseline}.swagger-ui textarea{overflow:auto}.swagger-ui [type=checkbox],.swagger-ui [type=radio]{box-sizing:border-box;padding:0}.swagger-ui [type=number]::-webkit-inner-spin-button,.swagger-ui [type=number]::-webkit-outer-spin-button{height:auto}.swagger-ui [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.swagger-ui [type=search]::-webkit-search-cancel-button,.swagger-ui [type=search]::-webkit-search-decoration{-webkit-appearance:none}.swagger-ui ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.swagger-ui details,.swagger-ui menu{display:block}.swagger-ui summary{display:list-item}.swagger-ui canvas{display:inline-block}.swagger-ui [hidden],.swagger-ui template{display:none}.swagger-ui .debug *{outline:1px solid gold}.swagger-ui .debug-white *{outline:1px solid #fff}.swagger-ui .debug-black *{outline:1px solid #000}.swagger-ui .debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) repeat 0 0}.swagger-ui .debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpLine truncated
|
||||
|
||||
/*# sourceMappingURL=swagger-ui.css.map*/
|
||||
Reference in new issue
Block a user