v0.36.10
This commit is contained in:
1 parent
2789991452
commit
e260612ef9
60 files changed
+63
-333
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# RustProxy source and documentation maintenance
|
||||
|
||||
Current source version: **0.36.9**. The Cargo package and binary are named `rust-proxy`.
|
||||
Current source version: **0.36.10**. 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.
|
||||
|
||||
|
||||
+9
-4
@@ -2,15 +2,20 @@
|
||||
|
||||
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.10 — Fit to screen, collapsible menu and smoother startup
|
||||
|
||||
- **Fit to screen (auto-arrange):** a **Fit to screen** switch in the unlocked layout toolbar (account preference `dashboard_auto_fit`, **on by default**) re-arranges a custom Overview for the current screen width, so the layout designed on a wide screen stays tidy on laptops and tablets. Each card keeps its saved width (but at least a readable minimum for its type), cards are placed in saved order into the first free spot from the top, stat tiles that start on one row get one height, a card whose side neighbour ends slightly lower is extended to that edge (only while the dashboard grows by at most two stat tiles), remaining holes are filled by widening a neighbouring card sideways or growing it into free space above or below. When a card is placed, a free strip beside it that is narrower than every card still to be placed is taken by that card at once, and at the end of the grid a chart or panel reaches the last row instead of leaving an empty area. The saved widths, heights and order are never changed; switch it off to see the layout exactly as saved. While a block is dragged the normal flow is shown so the drop placeholder stays accurate.
|
||||
- **No layout jump on reload:** the Overview grid stays invisible until the account's layout from the server has been applied and the first live data has been rendered, then appears already arranged; meanwhile the panel's startup screen (logo, spinner, "Loading RustProxy…") is shown in its place. Nothing about the layout is stored in the browser (an older cached layout key is removed).
|
||||
- **Collapsible left menu:** a **Collapse menu** button at the bottom of the left menu turns it into a narrow rail (logo, section numbers or icons with the section name as a tooltip, status dot). On screens wider than 1300 px the choice is stored in the account preference `sidebar_collapsed` (off by default); between 851 and 1300 px the menu is always a rail and the button opens the full menu temporarily as an overlay that closes after choosing a section, clicking outside or pressing Esc. The mobile menu (850 px and below) is unchanged. The startup screen now stays until the account preferences are applied, so the panel never appears first with default settings.
|
||||
- **Preferences API:** additive `dashboard_auto_fit` (boolean, default `true`) and `sidebar_collapsed` (boolean, default `false`) in `/api/preferences`; documents without them keep the defaults.
|
||||
- **Upgrade notes:** no configuration or database migration is required. Fit to screen is on for existing accounts and can be switched off in the unlocked layout toolbar; the left menu starts expanded on wide screens.
|
||||
|
||||
## 0.36.9 — memory breakdown card and row-based Overview layout
|
||||
|
||||
- **Memory breakdown (Overview card, hidden by default):** a new optional `memory_breakdown` dashboard card splits the process RSS into cache data (object bodies), cache bookkeeping (index, keys, headers), cache buffers (fills, COLD queue/IO), free memory held by the allocator (fragmentation, an upper bound), code and mapped files, and the rest of the process. Chips show RSS / cache ratio, cache overhead per object, HOT object count and heap fragmentation; an allocator section lists heap, in-use, free-held and large mmap blocks. An "Outside cache" chart tracks RSS minus accounted cache and, after 10 minutes of samples, labels the trend as stable, rising or falling (with the slope in MiB/h) to tell a leak apart from flat overhead. Enable it in the layout editor.
|
||||
- **Status API:** `GET /api/status` (full and `view=live`) adds an additive `memory_allocator` object with glibc `mallinfo2` statistics (`heap_bytes`, `in_use_bytes`, `free_bytes`, `releasable_bytes`, `mmap_bytes`, `mmap_blocks`), sampled at most every 2 seconds; it is `null` on musl, non-Linux and the optional mimalloc build.
|
||||
- **Fit to screen (auto-arrange):** a new **Fit to screen** switch in the unlocked layout toolbar (account preference `dashboard_auto_fit`, **on by default**) re-arranges a custom Overview for the current screen width, so the layout designed on a wide screen stays tidy on laptops and tablets. Each card keeps its saved width (but at least a readable minimum for its type), cards are placed in saved order into the first free spot from the top, stat tiles that start on one row get one height, a card whose side neighbour ends slightly lower is extended to that edge (only while the dashboard grows by at most two stat tiles), remaining holes are filled by widening a neighbouring card sideways or growing it into free space above or below. When a card is placed, a free strip beside it that is narrower than every card still to be placed is taken by that card at once, and at the end of the grid a chart or panel reaches the last row instead of leaving an empty area. The saved widths, heights and order are never changed; switch it off to see the layout exactly as saved. While a block is dragged the normal flow is shown so the drop placeholder stays accurate.
|
||||
- **No layout jump on reload:** the Overview grid stays invisible until the account's layout from the server has been applied and the first live data has been rendered, then appears already arranged; meanwhile the panel's startup screen (logo, spinner, "Loading RustProxy…") is shown in its place. Nothing about the layout is stored in the browser (an older cached layout key is removed).
|
||||
- **Overview layout with row heights:** in a custom (unlocked/saved) layout, card heights now snap to a shared row unit — half the height of the tallest compact stat card, so stat tiles always span two rows and line up — instead of every card in a row stretching to the tallest one. A tall panel (Process, RAM cache, Network…) can sit beside several rows of small cards or charts with aligned edges. Each card automatically gets the rows its content needs; in the layout editor, the new **↕ − / +** control next to the width makes a card taller (`A` marks automatic height, lowering it to the content height returns to automatic). Charts (also with chart scales enabled), chart stacks and tables grow into the extra height, and a card is stretched over the empty cells below it down to the next card or the end of the grid, so rows end on straight edges. **Fill gaps** packs blocks with their real heights. Small content changes (for example a growing table) cannot make the grid jump: a card grows at once but shrinks only after the smaller size is seen several times. Below a 700 px wide dashboard, cards stack by content height as before.
|
||||
- **Collapsible left menu:** a **Collapse menu** button at the bottom of the left menu turns it into a narrow rail (logo, section numbers or icons with the section name as a tooltip, status dot). On screens wider than 1300 px the choice is stored in the account preference `sidebar_collapsed` (off by default); between 851 and 1300 px the menu is always a rail and the button opens the full menu temporarily as an overlay that closes after choosing a section, clicking outside or pressing Esc. The mobile menu (850 px and below) is unchanged. The startup screen now stays until the account preferences are applied, so the panel never appears first with default settings.
|
||||
- **Preferences API:** additive optional `dashboard_widget_heights` (widget ID → 1–32 rows) in `/api/preferences` and dashboard presets, `dashboard_auto_fit` (boolean, default `true`) and `sidebar_collapsed` (boolean, default `false`) in `/api/preferences`. Older documents and presets without it keep automatic heights.
|
||||
- **Preferences API:** additive optional `dashboard_widget_heights` (widget ID → 1–32 rows) in `/api/preferences` and dashboard presets. Older documents and presets without it keep automatic heights.
|
||||
- **Upgrade notes:** no configuration or database migration is required; existing saved layouts receive the new card hidden at the end. Legacy (never customized) layouts keep their grouped rows until the layout is edited.
|
||||
|
||||
## 0.36.8 — editor forms, toasts and CSP fixes
|
||||
|
||||
Generated
+1
-1
@@ -1943,7 +1943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.36.9"
|
||||
version = "0.36.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.36.9"
|
||||
version = "0.36.10"
|
||||
edition = "2021"
|
||||
rust-version = "1.99.1"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
@@ -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.9` and the defaults this publishes:
|
||||
For example, with RustProxy version `0.36.10` and the defaults this publishes:
|
||||
|
||||
```text
|
||||
zot.example.net/network/rustproxy:0.36.9
|
||||
zot.example.net/network/rustproxy:0.36.10
|
||||
zot.example.net/network/rustproxy:latest
|
||||
zot.example.net/network/rustproxy:routeros
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.36.9",
|
||||
"version": "0.36.10",
|
||||
"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": [
|
||||
|
||||
@@ -144,13 +144,10 @@ struct AdminPreferences {
|
||||
dashboard_hidden_widgets: Vec<String>,
|
||||
#[serde(default)]
|
||||
dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>,
|
||||
/// Re-flow the custom Overview layout for the current screen width (on by default).
|
||||
#[serde(default = "default_admin_dashboard_auto_fit")]
|
||||
dashboard_auto_fit: bool,
|
||||
/// Left menu collapsed to a narrow rail on wide screens (off by default).
|
||||
#[serde(default)]
|
||||
sidebar_collapsed: bool,
|
||||
/// Optional per-card heights in Overview row units (1..=32, a compact stat card is 2); missing cards size to content.
|
||||
#[serde(default)]
|
||||
dashboard_widget_heights: Option<std::collections::BTreeMap<String, u8>>,
|
||||
#[serde(default = "default_file_log_columns")]
|
||||
|
||||
@@ -1020,7 +1020,6 @@ mod tests {
|
||||
);
|
||||
assert!(json["config"].get("user_agent_blocking").is_none());
|
||||
|
||||
// A partial section update must not silently reset the existing UA policy.
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"expected_revision":1,
|
||||
"value": {"method_policy":{"mode":"off","methods":[]}}
|
||||
@@ -1043,7 +1042,6 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// GET returns the same section shape that PUT accepts.
|
||||
let read = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
|
||||
@@ -21,7 +21,6 @@ function syncConfigHistoryRestoreButton() {
|
||||
button.hidden = configHistorySelectedRevision === null;
|
||||
button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision);
|
||||
}
|
||||
// Leaf-level diff keyed by object IDs, so reordering is not a change.
|
||||
function configDiff(before, after, path = "$", output = [], depth = 0) {
|
||||
if (output.length >= 200 || Object.is(before, after)) return output;
|
||||
if (depth >= 24) {
|
||||
@@ -58,7 +57,6 @@ function configDiff(before, after, path = "$", output = [], depth = 0) {
|
||||
}
|
||||
return output;
|
||||
}
|
||||
// New/deleted trees are traversed to show the affected fields only.
|
||||
const tree = oldObj ? before : newObj ? after : null;
|
||||
if (tree && !Array.isArray(tree)) {
|
||||
for (const key of Object.keys(tree).sort()) {
|
||||
|
||||
+22
-22
@@ -65,7 +65,7 @@ function translate() {
|
||||
}
|
||||
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
|
||||
if (!node) {
|
||||
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);
|
||||
}
|
||||
@@ -291,11 +291,15 @@ function redrawOverviewHistoryCharts() {
|
||||
}
|
||||
const httpValues = Object.values(protocolSamples), websocketValues = Object.values(websocketProtocolSamples);
|
||||
window.rustProxyChartScale.axes("http-protocol-chart", Math.max(1, ...httpValues.flat()), "rate",
|
||||
protocolSamples.h1.length, { seriesScales: Object.entries(protocolSamples).map(([name, values]) =>
|
||||
[name.toUpperCase(), Math.max(1, ...values)]) });
|
||||
protocolSamples.h1.length, {
|
||||
seriesScales: Object.entries(protocolSamples).map(([name, values]) =>
|
||||
[name.toUpperCase(), Math.max(1, ...values)])
|
||||
});
|
||||
window.rustProxyChartScale.axes("ws-protocol-chart", Math.max(1, ...websocketValues.flat()), "count",
|
||||
websocketProtocolSamples.h1.length, { seriesScales: Object.entries(websocketProtocolSamples).map(([name, values]) =>
|
||||
[name.toUpperCase(), Math.max(1, ...values)]) });
|
||||
websocketProtocolSamples.h1.length, {
|
||||
seriesScales: Object.entries(websocketProtocolSamples).map(([name, values]) =>
|
||||
[name.toUpperCase(), Math.max(1, ...values)])
|
||||
});
|
||||
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
|
||||
}
|
||||
window.rustProxyOverviewSamples.onChange(() => {
|
||||
@@ -305,8 +309,7 @@ window.rustProxyOverviewSamples.onChange(() => {
|
||||
}
|
||||
redrawOverviewHistoryCharts();
|
||||
});
|
||||
// Safari has no CSS scroll anchoring: keep the element under the viewport centre in place
|
||||
// when a live refresh changes the height of content above it.
|
||||
|
||||
const nativeScrollAnchoring = typeof CSS !== "undefined" && CSS.supports?.("overflow-anchor", "auto");
|
||||
function preserveScrollAnchor(render) {
|
||||
if (nativeScrollAnchoring || window.scrollY <= 0) return render();
|
||||
@@ -514,8 +517,6 @@ async function activate(info) {
|
||||
renderSession();
|
||||
document.documentElement.classList.remove("app-startup-failed");
|
||||
rustProxyRefresh.start();
|
||||
// Keep the startup screen until the account preferences (menu, layout, density)
|
||||
// are applied, so the panel never appears first with defaults and then changes.
|
||||
let preferences;
|
||||
try { preferences = await loadAdminPreferences(); }
|
||||
finally { document.documentElement.classList.remove("app-loading"); }
|
||||
@@ -606,8 +607,6 @@ document.addEventListener("click", event => { if (dashboardSidebar?.classList.co
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
|
||||
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); });
|
||||
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
|
||||
// Global HTTP controls are a single atomic API section. A header-only save preserves
|
||||
// the current block rules; a block-only save preserves the current security headers.
|
||||
const globalLines = id => [...new Set($(id).value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean))];
|
||||
const globalPatternLines = id => [...new Set($(id).value.split(/\r?\n/).map(v => v.trim()).filter(Boolean))];
|
||||
function globalSecurityPayload(security, ua) {
|
||||
@@ -635,10 +634,12 @@ $("global-security-form")?.addEventListener("submit", async event => {
|
||||
acl,
|
||||
method_policy: { mode: methodMode, methods },
|
||||
content_type_policy: { enabled: $("global-content-type-enabled").checked, blocked: contentTypes },
|
||||
rate_limit: { ...rate, enabled: $("global-rate-enabled").checked,
|
||||
rate_limit: {
|
||||
...rate, enabled: $("global-rate-enabled").checked,
|
||||
requests: Number($("global-rate-requests").value),
|
||||
window_seconds: Number($("global-rate-window").value),
|
||||
burst: Number($("global-rate-burst").value) },
|
||||
burst: Number($("global-rate-burst").value)
|
||||
},
|
||||
};
|
||||
if (await saveSection("global-security", globalSecurityPayload(security, ua))) {
|
||||
setSettingsDirty("global-security-form", false); renderSettings();
|
||||
@@ -652,16 +653,20 @@ $("global-security-headers-form")?.addEventListener("submit", async event => {
|
||||
await withFormSubmitState(form, event.submitter, async () => {
|
||||
const previous = envelope.config.global_security || {};
|
||||
const optional = id => $(id).value.trim() || null;
|
||||
const security = { ...previous, security_headers: {
|
||||
hsts: { enabled: $("global-hsts-enabled").checked,
|
||||
const security = {
|
||||
...previous, security_headers: {
|
||||
hsts: {
|
||||
enabled: $("global-hsts-enabled").checked,
|
||||
max_age_seconds: Number($("global-hsts-age").value),
|
||||
include_subdomains: $("global-hsts-enabled").checked && $("global-hsts-sub").checked,
|
||||
preload: $("global-hsts-enabled").checked && $("global-hsts-preload").checked },
|
||||
preload: $("global-hsts-enabled").checked && $("global-hsts-preload").checked
|
||||
},
|
||||
nosniff: $("global-nosniff").checked, frame_options: $("global-frame").value,
|
||||
referrer_policy: optional("global-referrer"),
|
||||
content_security_policy: optional("global-csp"),
|
||||
permissions_policy: optional("global-permissions")
|
||||
}};
|
||||
}
|
||||
};
|
||||
if (await saveSection("global-security", globalSecurityPayload(security, previous.user_agent_blocking || { enabled: false, patterns: [] }))) {
|
||||
setSettingsDirty("global-security-headers-form", false); renderSettings();
|
||||
}
|
||||
@@ -682,7 +687,6 @@ $("config-editor").addEventListener("input", () => setSettingsDirty("config-edit
|
||||
$("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();
|
||||
@@ -700,7 +704,6 @@ const rustProxyAutoId = (() => {
|
||||
}
|
||||
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);
|
||||
@@ -713,7 +716,6 @@ const rustProxyAutoId = (() => {
|
||||
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) {
|
||||
@@ -741,7 +743,6 @@ const rustProxyAutoId = (() => {
|
||||
})();
|
||||
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) => {
|
||||
@@ -769,7 +770,6 @@ const rustProxyAutoCertificate = (() => {
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -71,7 +71,6 @@ window.rustProxyInlineLayout = (() => {
|
||||
const value = node.querySelector(".overview-inline-width-value");
|
||||
if (value) {
|
||||
value.textContent = `${span}/12`;
|
||||
// Auto-fit may show the card wider on this screen than its saved width.
|
||||
const fitted = window.rustProxyOverviewRows?.fitFor?.(node.dataset.dashboardWidget);
|
||||
value.classList.toggle("is-fitted", !!fitted && draft.autoFit !== false);
|
||||
value.title = fitted && draft.autoFit !== false ? t("overviewLayoutAutoFitWidened").replace("{span}", String(fitted)) : t("overviewLayoutWidth");
|
||||
@@ -128,7 +127,6 @@ window.rustProxyInlineLayout = (() => {
|
||||
enterFreeLayout();
|
||||
draft.heights = { ...(draft.heights || {}) };
|
||||
const next = rows.span + delta;
|
||||
// At or below the content height the card returns to automatic sizing.
|
||||
if (next <= rows.min) delete draft.heights[id];
|
||||
else draft.heights[id] = Math.min(DASHBOARD_MAX_ROWS, next);
|
||||
paint();
|
||||
@@ -183,7 +181,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
unlock.setAttribute("aria-pressed", "true");
|
||||
unlock.dataset.i18n = "overviewLayoutLock";
|
||||
unlock.textContent = t("overviewLayoutLock");
|
||||
unlock.hidden = true; // Save and lock is the only save action while unlocked.
|
||||
unlock.hidden = true;
|
||||
actions.hidden = false;
|
||||
hint.hidden = false;
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
@@ -213,7 +211,6 @@ window.rustProxyInlineLayout = (() => {
|
||||
applyDashboardLayout(original.order, [...original.hidden], original.sizes, original.heights);
|
||||
}
|
||||
draft = original = null;
|
||||
// Recompute from scratch so the saved view is exactly what was arranged.
|
||||
window.rustProxyOverviewRows?.flush?.();
|
||||
}
|
||||
async function begin() {
|
||||
@@ -253,9 +250,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
|
||||
}
|
||||
function cancel() { if (draft && !saving) finish(false); }
|
||||
// Pointer-driven sorting with a live placeholder. The grid never back-fills
|
||||
// holes while a block is lifted, so the placeholder shows exactly where the
|
||||
// block lands; neighbours glide (FLIP) instead of jumping.
|
||||
|
||||
const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90;
|
||||
const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
let pointerDrag = null;
|
||||
@@ -279,7 +274,6 @@ window.rustProxyInlineLayout = (() => {
|
||||
if (widget && grid.contains(widget) && widget !== drag.node && !widget.hidden) return { node: widget };
|
||||
if (hit === drag.placeholder || drag.placeholder.contains(hit)) return null;
|
||||
if (hit !== grid) return null;
|
||||
// Empty grid area (gap or hole at a row end): attach to the nearest block in that row.
|
||||
const candidates = nodes().filter(node => node !== drag.node && !node.hidden);
|
||||
let best = null, bestDistance = Infinity;
|
||||
for (const node of candidates) {
|
||||
@@ -300,7 +294,6 @@ window.rustProxyInlineLayout = (() => {
|
||||
if (!target) return;
|
||||
const children = [...grid.children];
|
||||
const placeholderIndex = children.indexOf(drag.placeholder), targetIndex = children.indexOf(target.node);
|
||||
// Entering a block swaps the placeholder past it; this is stable and never oscillates.
|
||||
const after = target.after ?? placeholderIndex < targetIndex;
|
||||
const reference = after ? target.node.nextSibling : target.node;
|
||||
if (reference === drag.placeholder || (after ? target.node.nextSibling === drag.placeholder : target.node.previousSibling === drag.placeholder)) return;
|
||||
@@ -417,8 +410,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus();
|
||||
});
|
||||
function compact() {
|
||||
// Explicit, one-off gap filling: reads the dense packing the browser would
|
||||
// produce and adopts it as the new order, so nothing moves unexpectedly later.
|
||||
|
||||
if (!draft) return;
|
||||
enterFreeLayout();
|
||||
paint();
|
||||
|
||||
@@ -20,8 +20,10 @@ window.rustProxyDashboardLayout = (() => {
|
||||
function initialDraft() {
|
||||
const prefs = window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS;
|
||||
const order = normalizeDashboardWidgets(prefs.dashboard_widgets);
|
||||
return { order, hidden: new Set(normalizeHiddenDashboardWidgets(prefs.dashboard_hidden_widgets, prefs.dashboard_widgets)),
|
||||
sizes: normalizeDashboardWidgetSizes(prefs.dashboard_widget_sizes) };
|
||||
return {
|
||||
order, hidden: new Set(normalizeHiddenDashboardWidgets(prefs.dashboard_hidden_widgets, prefs.dashboard_widgets)),
|
||||
sizes: normalizeDashboardWidgetSizes(prefs.dashboard_widget_sizes)
|
||||
};
|
||||
}
|
||||
function defaultDraft() {
|
||||
return {
|
||||
@@ -144,7 +146,6 @@ window.rustProxyDashboardLayout = (() => {
|
||||
widen.addEventListener("click", () => resize(id, 1));
|
||||
const widthValue = el("span", `${width}/12`, "overview-layout-width-value");
|
||||
widthValue.title = t("overviewLayoutWidth");
|
||||
// Size and order controls are grouped separately so they wrap in narrow dialogs.
|
||||
const sizeActions = el("span", undefined, "overview-layout-size-actions");
|
||||
sizeActions.append(narrow, widthValue, widen);
|
||||
const orderActions = el("span", undefined, "overview-layout-order-actions");
|
||||
|
||||
@@ -131,16 +131,12 @@ function uiIconMatchesTranslation(target, keys) {
|
||||
const strings = uiIconStrings(target); if (!strings.length) return false;
|
||||
for (const key of keys) {
|
||||
const raw = String(t(key) || "").trim(), translated = raw.toLocaleLowerCase();
|
||||
// t() returns the key itself when a translation is missing; compare case-sensitively
|
||||
// so an English label such as "Edit" for the key "edit" still counts as translated.
|
||||
if (!translated || raw === key) continue;
|
||||
if (strings.some(value => value === translated || value.startsWith(`${translated} `) || value.startsWith(`${translated}:`))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Explicit choices for controls whose ids/i18n keys would otherwise collide with
|
||||
// broader keyword rules (for example "unlock" vs "lock", "presets" vs "reset",
|
||||
// "docker" vs "dock", or a domain noun such as "cache" before a verb such as "next").
|
||||
|
||||
const UI_ID_ICONS = Object.freeze({
|
||||
"about-open": "info", "login-submit": "login",
|
||||
"overview-layout-unlock": "unlock", "overview-inline-save": "lock", "overview-inline-compact": "grid",
|
||||
@@ -159,8 +155,7 @@ const UI_I18N_ICONS = Object.freeze({
|
||||
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;
|
||||
@@ -203,8 +198,7 @@ function uiIconForTarget(target) {
|
||||
if (/sort-direction|sort-button/.test(signature)) return /descending|desc/.test(aria) ? "sortDown" : "sortUp";
|
||||
if (/scroll-prev|move-earlier|layout-up|-prev\b/.test(signature)) return "chevronLeft";
|
||||
if (/scroll-next|move-later|layout-down|-next\b/.test(signature)) return "chevronRight";
|
||||
// Verbs win over domain nouns: a "Close" button inside the health or history
|
||||
// dialog is still a close action.
|
||||
|
||||
if (/close|cancel/.test(signature)) return "x";
|
||||
if (/sign-in|login/.test(signature)) return "login";
|
||||
if (/logout|sign-out/.test(signature)) return "logout";
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// 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;
|
||||
@@ -9,7 +6,6 @@ function logoAnimationMark() { return document.querySelector(".sidebar a.brand >
|
||||
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();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// Memory breakdown card (memory_breakdown): splits process RSS into cache data,
|
||||
// cache bookkeeping, cache buffers, allocator-held free memory, file-backed pages
|
||||
// and the rest of the process, and tracks the "outside cache" share over time so
|
||||
// a steady climb (a leak) is distinguishable from flat overhead.
|
||||
|
||||
window.rustProxyMemoryBreakdown = (() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const segments = ["cache-data", "cache-meta", "transient", "alloc-free", "files", "rest"];
|
||||
@@ -22,8 +19,7 @@ window.rustProxyMemoryBreakdown = (() => {
|
||||
let free = allocator ? (num(allocator.free_bytes) ?? 0) : 0;
|
||||
const values = { "cache-data": logical, "cache-meta": allocated - logical, transient, "alloc-free": free, files: fileBacked };
|
||||
let rest = rss - Object.values(values).reduce((sum, value) => sum + value, 0);
|
||||
// Allocator free memory may already be released to the kernel (malloc_trim),
|
||||
// so it is the first share to shrink when the parts exceed the measured RSS.
|
||||
|
||||
if (rest < 0) { const cut = Math.min(free, -rest); free -= cut; values["alloc-free"] = free; rest += cut; }
|
||||
values.rest = Math.max(0, rest);
|
||||
return {
|
||||
@@ -100,7 +96,6 @@ window.rustProxyMemoryBreakdown = (() => {
|
||||
const slope = span >= MIN_TREND_MS ? slopePerHour(samples) : null;
|
||||
let state = "collecting";
|
||||
if (slope != null) {
|
||||
// Rising faster than 2% of the current outside-cache size per hour (and at least 4 MiB/h).
|
||||
const threshold = Math.max(4 * 1048576, data.outside * 0.02);
|
||||
state = slope > threshold ? "rising" : slope < -threshold ? "falling" : "stable";
|
||||
}
|
||||
@@ -113,8 +108,10 @@ window.rustProxyMemoryBreakdown = (() => {
|
||||
}
|
||||
|
||||
function rowKey(key) {
|
||||
return { "cache-data": "memoryCacheData", "cache-meta": "memoryCacheMeta", transient: "memoryTransient",
|
||||
"alloc-free": "memoryAllocatorFree", files: "memoryFiles", rest: "memoryRest" }[key];
|
||||
return {
|
||||
"cache-data": "memoryCacheData", "cache-meta": "memoryCacheMeta", transient: "memoryTransient",
|
||||
"alloc-free": "memoryAllocatorFree", files: "memoryFiles", rest: "memoryRest"
|
||||
}[key];
|
||||
}
|
||||
|
||||
window.rustProxyOverviewSamples?.onChange?.(() => { samples = samples.slice(-limit()); });
|
||||
|
||||
@@ -9,8 +9,7 @@ window.rustProxyMasonry = (() => {
|
||||
|
||||
function refresh() {
|
||||
const previous = widgets();
|
||||
// Existing layouts (without a width map) retain their historical rows.
|
||||
// Once a user moves/resizes a block, DOM order is the actual order.
|
||||
|
||||
const legacy = grid.dataset.overviewLayoutMode !== "custom";
|
||||
const ordered = legacy ? previous.map((node, index) => ({ node, index }))
|
||||
.sort((a, b) => group(a.node) - group(b.node) || a.index - b.index)
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
// Row units for the custom Overview layout. Widths stay on the 12-column grid;
|
||||
// heights snap to a shared row unit (the tallest compact stat card), so a tall
|
||||
// panel can sit next to several rows of small cards and every edge lines up.
|
||||
// Each card gets at least the rows its content needs; an explicit height
|
||||
// (dashboard_widget_heights) can only make it taller, and charts stretch into
|
||||
// the extra space. Below the narrow breakpoint the grid falls back to content
|
||||
// height (CSS sets --overview-row-units: off).
|
||||
// With auto-fit on (the default) the layout is packed for the current screen:
|
||||
// every card keeps its saved width (but at least a readable minimum for its type),
|
||||
// cards are placed in saved order into the first free spot from the top, stat
|
||||
// tiles of one row get one height, remaining holes are filled by widening a
|
||||
// neighbouring card sideways, and small steps at the bottom are closed. The
|
||||
// saved widths, heights and order are never changed.
|
||||
window.rustProxyOverviewRows = (() => {
|
||||
const grid = document.getElementById("dashboard-overview-grid");
|
||||
// A compact stat card spans ROW_DIVISIONS rows; finer rows keep taller cards
|
||||
// close to their natural height instead of rounding up by a whole stat card.
|
||||
const MAX_ROWS = 32, ROW_DIVISIONS = 2, FALLBACK_STAT = 84, MIN_INTERVAL_MS = 1500, SHRINK_CONFIRMATIONS = 3;
|
||||
// Auto-fit (dashboard_auto_fit): the smallest readable width of a card by its
|
||||
// registry display type, in CSS pixels. Cards narrower than this on the current
|
||||
// screen get more columns; the saved widths are not changed.
|
||||
const MIN_CARD_WIDTH = { value: 150, value_chart: 210, chart: 300, panel: 340, table: 640 };
|
||||
const noop = { schedule() { }, flush() { }, setHeights() { }, setAutoFit() { }, reveal() { }, dataReady() { }, rowsFor() { return null; }, fitFor() { return null; } };
|
||||
if (!grid) return noop;
|
||||
@@ -57,7 +39,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
function widgetType(node) {
|
||||
return typeof dashboardWidgetType === "function" ? dashboardWidgetType(node.dataset.dashboardWidget) : "value";
|
||||
}
|
||||
// Widen cards that would be narrower than their readable minimum on this screen.
|
||||
function widenToMinimum(items, geometry) {
|
||||
const columns = geometry.starts.length;
|
||||
const column = geometry.starts.length > 1 ? geometry.starts[1] - geometry.starts[0] : 0;
|
||||
@@ -79,13 +60,9 @@ window.rustProxyOverviewRows = (() => {
|
||||
const items = cards().filter(node => !node.hidden && node.getClientRects().length);
|
||||
if (!items.length) return;
|
||||
clearFit();
|
||||
// Measure as the saved (locked) dashboard: editing styles move a few
|
||||
// controls inside cards, which must not change the layout being edited.
|
||||
const editing = grid.classList.contains("is-unlocked");
|
||||
if (editing) grid.classList.remove("is-unlocked");
|
||||
grid.classList.add("has-row-units", "is-measuring-rows");
|
||||
// Bounding boxes include CSS zoom (Settings → Interface scale); grid tracks,
|
||||
// gaps and the row unit are in CSS pixels, so divide measurements by it.
|
||||
const zoom = zoomOf();
|
||||
if (autoFit) {
|
||||
const preliminary = gridGeometry(1, zoom);
|
||||
@@ -96,8 +73,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
if (editing) grid.classList.add("is-unlocked");
|
||||
const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
|
||||
const isStat = node => node.classList.contains("stat");
|
||||
// The unit is half of the smallest stat card, so every card ends within a
|
||||
// few pixels of its natural height instead of being rounded up by a whole tile.
|
||||
const stats = natural.filter((_, index) => isStat(items[index]) && natural[index] > 0);
|
||||
const statHeight = stats.length ? Math.min(...stats) : FALLBACK_STAT;
|
||||
const unit = Math.max(16, (statHeight - gap * (ROW_DIVISIONS - 1)) / ROW_DIVISIONS);
|
||||
@@ -106,11 +81,8 @@ window.rustProxyOverviewRows = (() => {
|
||||
items.forEach((node, index) => {
|
||||
const id = node.dataset.dashboardWidget;
|
||||
seen.add(id);
|
||||
// Small tolerance so sub-pixel rounding never adds a whole row.
|
||||
let min = Math.max(1, Math.ceil((natural[index] + gap) / (unit + gap) - 0.04));
|
||||
const previous = state.get(id);
|
||||
// Grow at once; shrink only after the smaller size is seen repeatedly,
|
||||
// so live data (table rows, wrapping labels) cannot make the grid jump.
|
||||
if (previous && min < previous.min) {
|
||||
const votes = (shrinkVotes.get(id) || 0) + 1;
|
||||
if (votes < SHRINK_CONFIRMATIONS) { shrinkVotes.set(id, votes); min = previous.min; }
|
||||
@@ -128,8 +100,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
equalizeStatRows(items.filter(isStat), geometry);
|
||||
alignBottom(items, geometry);
|
||||
}
|
||||
// Auto-fit packing on a model of the grid (columns × rows), then explicit
|
||||
// placement through CSS variables. Pure arithmetic: no layout passes per card.
|
||||
function packCards(items, geometry, isStat) {
|
||||
const columns = geometry.starts.length;
|
||||
const cardsModel = items.map(node => {
|
||||
@@ -166,9 +136,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
if (free(r, c, card.w, card.h)) { card.r = r; card.c = c; mark(r, c, card.w, card.h); placed = true; break; }
|
||||
}
|
||||
}
|
||||
// A free strip beside the new card (over its whole height) that is
|
||||
// narrower than every card still to be placed could never be filled:
|
||||
// the card takes it now instead of leaving a hole.
|
||||
let narrowest = Infinity;
|
||||
for (let next = index + 1; next < cardsModel.length; next++) narrowest = Math.min(narrowest, cardsModel[next].base);
|
||||
let right = 0;
|
||||
@@ -179,8 +146,7 @@ window.rustProxyOverviewRows = (() => {
|
||||
if (left > 0 && left < narrowest) { card.c -= left; card.w += left; mark(card.r, card.c, left, card.h); }
|
||||
});
|
||||
};
|
||||
// Stat tiles starting on the same row share the tallest height; re-pack until
|
||||
// stable (raising a row can move later cards).
|
||||
|
||||
const settle = () => {
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
pack();
|
||||
@@ -196,10 +162,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
return Math.max(...cardsModel.map(card => card.r + card.h));
|
||||
};
|
||||
let total = settle();
|
||||
// A card whose side neighbour ends slightly lower (up to one stat tile) is
|
||||
// extended to that edge, so the cards below start on one line instead of a
|
||||
// staggered notch. Each extension is kept only while the whole dashboard
|
||||
// grows by at most two stat tiles in total, so it can never cascade.
|
||||
const snap = ROW_DIVISIONS, budget = total + ROW_DIVISIONS * 2;
|
||||
for (let attempt = 0; attempt < cardsModel.length; attempt++) {
|
||||
const candidates = [];
|
||||
@@ -222,8 +184,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
if (next > budget) { cardsModel.forEach((item, index) => { item.h = saved[index]; }); card.h = previous; total = settle(); }
|
||||
else total = next;
|
||||
}
|
||||
// Holes beside a card (over its whole height) are taken by widening it,
|
||||
// first to the right, then to the left.
|
||||
const order = [...cardsModel].sort((a, b) => a.r - b.r || a.c - b.c);
|
||||
const columnFree = (x, r, h) => {
|
||||
for (let y = r; y < r + h; y++) if (occupancy[y]?.[x]) return false;
|
||||
@@ -233,8 +193,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
while (card.c + card.w < columns && columnFree(card.c + card.w, card.r, card.h)) { mark(card.r, card.c + card.w, 1, card.h); card.w++; }
|
||||
while (card.c > 0 && columnFree(card.c - 1, card.r, card.h)) { card.c--; card.w++; mark(card.r, card.c, 1, card.h); }
|
||||
}
|
||||
// Free cells directly above a card (left by a taller neighbour that started
|
||||
// earlier) are taken by growing it upwards, within the same limit.
|
||||
const rowFree = (y, c, w) => {
|
||||
if (y < 0) return false;
|
||||
for (let x = c; x < c + w; x++) if (occupancy[y]?.[x]) return false;
|
||||
@@ -245,9 +203,7 @@ window.rustProxyOverviewRows = (() => {
|
||||
while (up < Math.max(ROW_DIVISIONS * 2, card.h) && rowFree(card.r - up - 1, card.c, card.w)) up++;
|
||||
if (up) { mark(card.r - up, card.c, card.w, up); card.r -= up; card.h += up; }
|
||||
}
|
||||
// Steps below a card (up to two stat tiles or its own height) are closed by
|
||||
// stretching it down to the next card or the end of the grid; charts and
|
||||
// stacks inside grow into the extra height.
|
||||
|
||||
const bottom = Math.max(...cardsModel.map(card => card.r + card.h));
|
||||
for (const card of order) {
|
||||
const end = card.r + card.h;
|
||||
@@ -256,8 +212,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
if (other !== card && other.c < card.c + card.w && other.c + other.w > card.c && other.r >= end) next = Math.min(next, other.r);
|
||||
}
|
||||
const extra = next - end;
|
||||
// At the end of the grid a chart or panel always reaches the last row (its
|
||||
// chart grows) instead of leaving an empty area; stat tiles stay limited.
|
||||
const limit = next === bottom && !card.stat ? Infinity : Math.max(ROW_DIVISIONS * 2, card.h);
|
||||
if (extra > 0 && extra <= limit) { mark(end, card.c, card.w, extra); card.h += extra; }
|
||||
}
|
||||
@@ -281,7 +235,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
const visual = grid.getBoundingClientRect().width;
|
||||
return css > 0 && visual > 0 ? visual / css : 1;
|
||||
}
|
||||
// Geometry in the grid's own CSS pixels (content box), independent of zoom.
|
||||
function gridGeometry(step, zoom) {
|
||||
const style = getComputedStyle(grid);
|
||||
const tracks = style.gridTemplateColumns.split(" ").map(parseFloat).filter(Number.isFinite);
|
||||
@@ -307,9 +260,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
const row = Math.max(0, Math.round(top / geometry.step));
|
||||
return { node, first, last, row, end: row + rowsOf(node) };
|
||||
}
|
||||
// Stat cards that start on the same grid row get the height of the tallest of
|
||||
// them, like a classic row of tiles. Raising a row can move later cards, so the
|
||||
// pass repeats until nothing changes. Large cards never stretch the tiles.
|
||||
function equalizeStatRows(stats, geometry) {
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
const groups = new Map();
|
||||
@@ -326,12 +276,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
if (!changed) return;
|
||||
}
|
||||
}
|
||||
// A card is stretched down over the empty cells beneath it, up to the next card
|
||||
// below (or the grid's last row), so rows end on straight edges instead of
|
||||
// jagged ones. Only empty cells are taken, which never changes where any other
|
||||
// card is placed. The stored / edited height (state.span) is unaffected.
|
||||
// Only small steps are closed (up to two stat tiles or half the card's own
|
||||
// height); a large hole is left visible rather than hidden by a stretched card.
|
||||
function alignBottom(items, geometry) {
|
||||
const cards = items.map(node => place(node, geometry));
|
||||
const bottom = Math.max(...cards.map(card => card.end));
|
||||
@@ -358,9 +302,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
heights = value && typeof value === "object" ? { ...value } : null;
|
||||
schedule(true);
|
||||
}
|
||||
// The grid is shown (see is-layout-pending) once the saved layout has been
|
||||
// applied (reveal) and the first live data has been rendered (dataReady), and
|
||||
// the layout is computed from scratch at that moment, so nothing moves after.
|
||||
let layoutReady = false, contentReady = false;
|
||||
function show() {
|
||||
if (!layoutReady || !contentReady || !grid.classList.contains("is-layout-pending")) return;
|
||||
@@ -369,7 +310,6 @@ window.rustProxyOverviewRows = (() => {
|
||||
}
|
||||
function reveal() { layoutReady = true; show(); }
|
||||
function dataReady() { contentReady = true; show(); }
|
||||
// Never leave the dashboard hidden if preferences cannot be loaded.
|
||||
setTimeout(() => grid.classList.contains("is-layout-pending") && grid.classList.remove("is-layout-pending"), 4000);
|
||||
function setAutoFit(value) {
|
||||
const next = value !== false;
|
||||
|
||||
@@ -6,7 +6,7 @@ window.rustProxyOverviewSamples = (() => {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(storageKey));
|
||||
if (allowed.has(stored)) limit = stored;
|
||||
} catch { /* Private browsing or storage policy: selection still works for this visit. */ }
|
||||
} catch { }
|
||||
const callbacks = new Set();
|
||||
const getLimit = () => limit;
|
||||
const trim = array => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
// These are reviewable shell instructions only. The panel never executes them.
|
||||
const views = new WeakMap();
|
||||
const quote = value => `'${String(value).replaceAll("'", "'\"'\"'")}'`;
|
||||
function absolutePath(value, directory) {
|
||||
|
||||
@@ -323,8 +323,6 @@ function applyAdminPreferences(value, cache = true) {
|
||||
try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); }
|
||||
try { applyAdminUiScale(localStorage.getItem("rp-ui-scale") || 100, false); } catch { applyAdminUiScale(100, false); }
|
||||
try { applyAdminCornerStyle(localStorage.getItem("rp-corner-style") || "default", false); } catch { applyAdminCornerStyle("default", false); }
|
||||
// No Overview layout is kept in the browser: the grid stays invisible until the
|
||||
// account's layout from the server is applied (rustProxyOverviewRows.reveal).
|
||||
try { localStorage.removeItem("rp-dashboard-layout"); } catch { }
|
||||
|
||||
async function loadAdminPreferences(force = false) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Shared rate sampling: rebase after background-tab pauses instead of plotting a false spike.
|
||||
const rustProxyRateSampling = (() => {
|
||||
let generation = 0;
|
||||
const pause = () => { generation += 1; };
|
||||
|
||||
@@ -344,8 +344,6 @@ function svgNode(tag, attrs = {}) {
|
||||
|
||||
function setChartText(node, value) {
|
||||
const text = String(value);
|
||||
// Controls decorated by the optional SVG icons hold [svg.ui-icon, text]; keep the icon
|
||||
// and update only the text node so periodic refreshes do not rebuild the button.
|
||||
const icon = node.querySelector(":scope > svg.ui-icon");
|
||||
if (icon) {
|
||||
const last = node.lastChild;
|
||||
@@ -417,8 +415,6 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
|
||||
setChartText(row.querySelector(".network-interface-hide"), t("hideMetric"));
|
||||
row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55");
|
||||
row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55");
|
||||
// The Hide button precedes the chart and may carry an injected svg.ui-icon:
|
||||
// address the chart explicitly so axes/labels never attach to the icon.
|
||||
const chart = row.querySelector("svg:not(.ui-icon)");
|
||||
window.rustProxyChartScale.axes(chart, peak, "bytes/s", history.rx.length,
|
||||
{ top: 5 / 60, bottom: 55 / 60 });
|
||||
@@ -469,8 +465,6 @@ 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;
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// Collapsible left menu. On wide screens (above 1300 px) the account preference
|
||||
// sidebar_collapsed shows the menu as a narrow rail (logo, section numbers or icons,
|
||||
// status dot). Between 851 and 1300 px the menu is always a rail and the toggle
|
||||
// opens it temporarily as an overlay (closed by choosing a section, clicking
|
||||
// outside or Esc). At 850 px and below the existing mobile menu is used.
|
||||
window.rustProxySidebar = (() => {
|
||||
const root = document.documentElement;
|
||||
const sidebar = document.querySelector(".sidebar");
|
||||
|
||||
@@ -68,8 +68,6 @@
|
||||
};
|
||||
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]);
|
||||
@@ -179,7 +177,6 @@
|
||||
$("cp-rejected").textContent = `${t("cacheRejected")}: ${number(m.rejected_fills)} · ${t("cacheMemoryPressureShort")}: ${number(m.memory_pressure_rejections)} · ${t("cacheNukeLimitShort")}: ${number(m.hot_eviction_limited)}`;
|
||||
$("cache-domain-rows").replaceChildren(...shownGroups.map(row => {
|
||||
const tr = el("tr"); for (const value of [row.vhost || t("cacheLegacy"), row.domain, number(row.hot_entries), number(row.cold_entries), fmtBytes(row.resident_bytes), fmtBytes(row.cold_bytes)]) tr.append(el("td", value));
|
||||
// HIT / MISS / BYPASS: coloured counts that match the dashboard cache bar, plus a hit-ratio meter.
|
||||
const outcomes = el("td", undefined, "cache-outcomes-cell");
|
||||
[[row.hits, "cache-outcome-hit"], [row.misses, "cache-outcome-miss"], [row.bypasses, "cache-outcome-bypass"]].forEach(([count, cls], index) => { if (index) outcomes.append(el("span", "/", "cache-outcome-sep")); outcomes.append(el("span", number(count), cls)); });
|
||||
const ratioPercent = Math.max(0, Math.min(100, (row.hit_ratio || 0) * 100)), ratio = el("td", undefined, "cache-ratio-cell"), meter = el("span", undefined, "cache-ratio-meter"), fill = el("i"); fill.style.width = `${ratioPercent}%`; meter.append(fill); ratio.append(el("span", `${fmt(ratioPercent, 1)}%`, "cache-ratio-value"), meter);
|
||||
@@ -194,7 +191,6 @@
|
||||
tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain));
|
||||
const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue);
|
||||
if (row.transformed) {
|
||||
// Keep the badge outside the ellipsised text so a long URL never clips it.
|
||||
const wrap = el("div", undefined, "cache-target-wrap");
|
||||
wrap.append(el("span", targetValue, "cache-target-text"), el("span", t("transformBadge"), "cache-transform-badge"));
|
||||
target.replaceChildren(wrap);
|
||||
|
||||
@@ -148,7 +148,6 @@ curl --fail --show-error --request POST \
|
||||
try {
|
||||
const data = await api(`${path}/regenerate`, { method: "POST" });
|
||||
setCertificateApiCredential(data.credential);
|
||||
// Never put the bearer token into the persistent credential list/state.
|
||||
showCertificateApiSecret(data.token || "");
|
||||
renderCertificateApi();
|
||||
toast(t("certificateApiRegenerated"));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Theme palettes and appearance tokens */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Shared reset, typography, controls, forms, tables, cards and overlay primitives */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -1226,8 +1225,6 @@ html[data-dashboard-density=compact] #panel-overview .dashboard-overview-grid>.c
|
||||
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;
|
||||
@@ -1595,8 +1592,6 @@ 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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Application startup, authentication, navigation, page chrome and draft dock */
|
||||
|
||||
html.app-loading .shell {
|
||||
visibility: hidden;
|
||||
@@ -839,7 +838,6 @@ main {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
/* Optional subtle logo animation (Settings → Appearance → Animate logo). */
|
||||
.brand-mark .brand-letter {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
@@ -996,9 +994,6 @@ a.brand>.brand-mark[data-logo-effect="wobble"]>span {
|
||||
}
|
||||
}
|
||||
|
||||
/* Collapsible left menu (sidebar.js). Above 850 px the menu can be a narrow rail:
|
||||
html.sidebar-collapsed (account preference on wide screens, always between 851
|
||||
and 1300 px) and html.sidebar-peek (rail temporarily opened as an overlay). */
|
||||
.sidebar-collapse {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Overview metrics, charts, dashboard density, inline layout controls and presets */
|
||||
|
||||
html[data-theme=ultra_dark] :is(.card, .stat, dialog) {
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, .18);
|
||||
@@ -326,7 +325,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
|
||||
.pool-table td:nth-child(n
|
||||
|
||||
/**/
|
||||
+4) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
@@ -335,7 +333,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
|
||||
[dir=rtl] table,
|
||||
.pool-table th:nth-child(n
|
||||
|
||||
/**/
|
||||
+4),
|
||||
.logging-builder-action .small-label {
|
||||
text-align: right;
|
||||
@@ -649,9 +646,6 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Sparkline tiles with a one-line detail (e.g. Backend retry attempts: recent rate,
|
||||
successful retries): the detail sits on the right opposite the value, like a series
|
||||
legend, and the chart spans the full width underneath. */
|
||||
#panel-overview .stats-grid>.stat.stat-chart:has(>small:not([hidden], .stat-chart-legend, .backend-health-origins)):not(:has(>.stat-chart-legend)) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -708,9 +702,6 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
/* Sparkline tiles with a series legend (HTTP / WebSocket protocol mix, backend
|
||||
traffic): the legend sits on the right, opposite the main value, one series per
|
||||
line, and the chart spans the full width below. */
|
||||
#panel-overview .stats-grid>.stat.stat-chart:has(>.stat-chart-legend) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -749,7 +740,6 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
/* Row-unit layout: the chart takes the remaining height of a taller tile. */
|
||||
#panel-overview #dashboard-overview-grid.has-row-units>.stat.stat-chart:has(>.stat-chart-legend)>:is(svg:not(.ui-icon), .rp-chart-axis-host) {
|
||||
align-self: stretch;
|
||||
}
|
||||
@@ -1366,15 +1356,10 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Legacy grouped layout: reserve room for the editing toolbar. With row units the
|
||||
toolbar overlays the card header instead, so the unlocked grid has exactly the
|
||||
geometry of the saved one (what you arrange is what you get). */
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked:not(.has-row-units)>[data-dashboard-widget] {
|
||||
padding-top: 40px !important;
|
||||
}
|
||||
|
||||
/* Row-unit editing toolbar: a slim opaque header (handle, card name, hide) that
|
||||
expands to a second line with width / height / move controls on hover or focus. */
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked.has-row-units .overview-inline-toolbar {
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
@@ -1442,7 +1427,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* "Fit to screen" switch in the unlocked layout toolbar. */
|
||||
.overview-inline-autofit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1461,15 +1445,12 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Width shown wider on this screen by Fit to screen. */
|
||||
#panel-overview .overview-inline-width-value.is-fitted {
|
||||
color: var(--accent);
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Height value: readable between its − / + buttons. Automatic height (fits the
|
||||
content) is slightly dimmed, a fixed height is shown in full colour. */
|
||||
#panel-overview .dashboard-overview-grid.is-unlocked.has-row-units .overview-inline-width-value.overview-inline-height-value {
|
||||
flex: 0 0 auto;
|
||||
min-width: 34px;
|
||||
@@ -1486,9 +1467,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
/* Stat tiles taller than their content (a taller neighbour in the same row): keep
|
||||
the label on top, centre the value in the free space, keep meters and legends at
|
||||
the bottom and let sparklines grow, instead of leaving an empty band below. */
|
||||
#panel-overview #dashboard-overview-grid.has-row-units>.stat:not([hidden], .stat-donut, .stat-chart) {
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
@@ -2256,9 +2234,6 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
grid-column-start: auto;
|
||||
}
|
||||
|
||||
/* Row units (custom layout): heights snap to the compact stat card height so tall
|
||||
panels sit beside several rows of small cards with aligned edges.
|
||||
overview-rows.js sets --overview-row-unit and each card's --overview-rows. */
|
||||
#panel-overview .dashboard-overview-grid.is-aligned.has-row-units {
|
||||
grid-auto-rows: var(--overview-row-unit, 84px);
|
||||
}
|
||||
@@ -2272,26 +2247,17 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
/* Auto-fit: overview-rows.js packs the cards and places them explicitly. While a
|
||||
block is being dragged the normal flow is used, so the drop placeholder works. */
|
||||
#panel-overview #dashboard-overview-grid.has-row-units.has-auto-placement:not(.is-overview-sorting)>[data-dashboard-widget]:not([hidden]) {
|
||||
grid-column: var(--overview-col, auto)/span var(--overview-cols, 1);
|
||||
grid-row: var(--overview-row, auto)/span var(--overview-rows, 1);
|
||||
}
|
||||
|
||||
/* Until the account's layout is applied the cards stay invisible, so the page
|
||||
never shows the factory arrangement first and then jumps (no browser storage).
|
||||
Meanwhile the panel's startup screen is shown in their place (see below). */
|
||||
#panel-overview .dashboard-overview-grid.is-layout-pending {
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* The panel's startup screen (logo, spinner, "Loading RustProxy…") is shown in
|
||||
place of the cards while the layout is pending: a zero-height block right
|
||||
before the grid whose content overlays the still invisible grid, so the cards'
|
||||
order and first-child styles are unaffected. */
|
||||
#panel-overview .overview-layout-loading {
|
||||
height: 0;
|
||||
margin-inline: auto;
|
||||
@@ -2311,8 +2277,6 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
align-self: start !important;
|
||||
}
|
||||
|
||||
/* A card taller than its content gives the extra height to its charts instead of
|
||||
leaving an empty band at the bottom. */
|
||||
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget]:not([hidden]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2333,7 +2297,6 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
/* With chart scales enabled the SVG sits in an axis host: grow the host and its SVG. */
|
||||
#panel-overview .dashboard-overview-grid.has-row-units>.card[data-dashboard-widget] :is(.rp-chart-axis-host, .mini-live-chart>.rp-chart-axis-host) {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
@@ -2672,7 +2635,6 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
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;
|
||||
@@ -2703,8 +2665,6 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
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, .overview-inline-toolbar))):has(>small)) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -2730,7 +2690,6 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
|
||||
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; }
|
||||
@@ -2811,7 +2770,6 @@ 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); }
|
||||
@@ -2842,7 +2800,6 @@ html[data-table-density=ultra] .pr-chip { padding: 1px 7px; }
|
||||
.pr-cols, .pr-charts { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Memory breakdown card (memory_breakdown) */
|
||||
.mb-card { display: flex; flex-direction: column; }
|
||||
.mb-card .mb-chips { margin-bottom: 12px; }
|
||||
.mb-bar { display: flex; height: 22px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); gap: 1px; }
|
||||
@@ -2874,10 +2831,8 @@ html[data-table-density=compact] .mb-item { padding-top: 5px; padding-bottom: 5p
|
||||
html[data-table-density=ultra] .mb-item { padding-top: 3px; padding-bottom: 3px; }
|
||||
html[data-table-density=ultra] .mb-bar { height: 16px; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Shared service editors: Vhosts, backends, Web Server, Streams, routing and ACL pickers */
|
||||
|
||||
.host-chip-editor.field-invalid,
|
||||
.listener-choices.field-invalid {
|
||||
@@ -1483,8 +1482,6 @@ 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;
|
||||
@@ -1948,8 +1945,6 @@ html.app-loading,
|
||||
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),
|
||||
@@ -2021,7 +2016,6 @@ h2+.fine {
|
||||
}
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Settings, certificates, integrations and Docker agent/service management */
|
||||
|
||||
.settings-tabs button.has-unsaved:after {
|
||||
content: "";
|
||||
@@ -119,8 +118,6 @@
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.renewal-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
@@ -1173,7 +1170,6 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Global security policy forms */
|
||||
.global-policy-form .global-policy-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1260,7 +1256,6 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
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;
|
||||
}
|
||||
@@ -1335,8 +1330,6 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
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;
|
||||
@@ -1398,9 +1391,7 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -1419,7 +1410,6 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
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;
|
||||
@@ -1435,7 +1425,6 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Traffic, cache, transformations, live/stored logs and configuration history */
|
||||
|
||||
.certificate-card .pill,
|
||||
.cache-miss-key,
|
||||
@@ -1073,7 +1072,6 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
||||
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;
|
||||
@@ -1148,7 +1146,6 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
||||
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); }
|
||||
@@ -1172,7 +1169,6 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
||||
.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; }
|
||||
@@ -1185,7 +1181,6 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
||||
|
||||
.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; }
|
||||
@@ -1197,26 +1192,21 @@ html[data-table-density=ultra] .file-log-inline-detail pre {
|
||||
.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; }
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Responsive rules */
|
||||
|
||||
@media (min-width:1201px) {
|
||||
#panel-overview .stats-grid {
|
||||
@@ -1597,8 +1596,6 @@
|
||||
--overview-widget-placement: auto/span 3;
|
||||
}
|
||||
|
||||
/* A third of the wide grid becomes half of the 6-column grid, so two panels or
|
||||
charts sit side by side instead of leaving a two-column hole. */
|
||||
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="4"] {
|
||||
--overview-widget-placement: auto/span 3;
|
||||
}
|
||||
@@ -1703,7 +1700,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -1739,7 +1735,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -1787,7 +1782,6 @@
|
||||
.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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Documentation Reader layout. */
|
||||
* {
|
||||
box-sizing: border-box
|
||||
}
|
||||
@@ -376,7 +375,6 @@ main {
|
||||
padding: 18px
|
||||
}
|
||||
}
|
||||
/* Corner style follows the panel preference. */
|
||||
html[data-corner-style=square] {
|
||||
--rp-docs-radius-surface: 0px;
|
||||
--rp-docs-radius-control: 0px;
|
||||
|
||||
@@ -13,13 +13,12 @@
|
||||
let visibleColumns = new Set(defaultColumnIds);
|
||||
let sortBy = "time", sortDescending = true, pendingRefresh = false;
|
||||
|
||||
|
||||
const CONSOLIDATION_STORAGE_KEY = "rp-file-logs-consolidate-vhosts";
|
||||
const consolidationToggle = $("file-log-consolidate-vhosts");
|
||||
try {
|
||||
const saved = localStorage.getItem(CONSOLIDATION_STORAGE_KEY);
|
||||
if (consolidationToggle && (saved === "1" || saved === "0")) consolidationToggle.checked = saved === "1";
|
||||
} catch { /* html default */ }
|
||||
} catch { }
|
||||
|
||||
const sourceSelect = () => $("file-log-source");
|
||||
function setView(view) {
|
||||
|
||||
@@ -215,7 +215,6 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
|
||||
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
|
||||
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
|
||||
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
|
||||
// Preserve Host also applies to HTTP/2 :authority.
|
||||
|
||||
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
|
||||
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
@@ -229,7 +228,6 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
|
||||
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
|
||||
note(extra, "vhBackendCapacityHelp");
|
||||
// One persistent form per backend keeps unsaved edits when switching.
|
||||
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
|
||||
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
|
||||
const health = value.health_check || {};
|
||||
@@ -305,7 +303,6 @@ function debugOriginIssues(members) {
|
||||
let parsed; try { parsed = new URL(member.url); } catch { return false; }
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
||||
if (parsed.protocol !== "https:" || !ipHost) return false;
|
||||
// Warn only when TLS verification of an IP host needs an explicit server name.
|
||||
return member.tls_verify === true && !String(member.tls_server_name || "").trim();
|
||||
});
|
||||
}
|
||||
@@ -496,11 +493,9 @@ function locationsEditor(parent, values) {
|
||||
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
|
||||
rows.append(row); syncUi();
|
||||
}
|
||||
// The main backend is an internal fallback, never an editable location.
|
||||
for (const value of values) add(value);
|
||||
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
|
||||
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
|
||||
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
|
||||
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
|
||||
sync(); return readRows;
|
||||
}
|
||||
@@ -275,7 +275,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
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");
|
||||
// Policies share their original form/data model; the boundaries are presentational only.
|
||||
const aclFields = subsection(g, "vhAclProfile");
|
||||
const selectedProfiles = new Set([value.acl_profile_id, ...(value.acl_profile_ids || [])].filter(Boolean));
|
||||
const profileBox = el("div", undefined, "vh-wide vh-acl-profile-picker");
|
||||
|
||||
@@ -92,7 +92,7 @@ function refreshVhostBackendPopup(status = latestStatus) {
|
||||
scheme = url.protocol.replace(":", "").toUpperCase();
|
||||
const port = url.port || (scheme === "HTTPS" ? "443" : scheme === "HTTP" ? "80" : "");
|
||||
address = `${url.hostname}${port ? `:${port}` : ""}`;
|
||||
} catch { /* Configuration may have changed while status was refreshing. */ }
|
||||
} catch { }
|
||||
const upstream = upstreamProtocolBadgeText(backend, member);
|
||||
const protocol = `${scheme} · ${upstream.mode}${upstream.observed ? ` → ${upstream.observed}` : ""}`;
|
||||
const statusCode = Number(member?.last_status);
|
||||
@@ -256,8 +256,6 @@ function initPoolTableTools() {
|
||||
}));
|
||||
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;
|
||||
@@ -282,7 +280,7 @@ 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
|
||||
boBaseline(svg, width, kind === "latency" ? 10 : 19);
|
||||
return svg;
|
||||
}
|
||||
function boBaseline(svg, width, y) {
|
||||
@@ -426,7 +424,6 @@ function applyBackendOverviewView() {
|
||||
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; }
|
||||
@@ -600,8 +597,6 @@ function render() {
|
||||
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
|
||||
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
|
||||
}
|
||||
// Keep the upstream protocol indicators with the other Vhost policy pills.
|
||||
// Retain their own badge class so observed H1/H2 keeps its green styling.
|
||||
if (hasProxySource) {
|
||||
for (const backend of v.backends || []) {
|
||||
const badge = el("button", undefined, "vhost-backend-protocol-badge");
|
||||
@@ -632,7 +627,6 @@ function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||
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;
|
||||
// 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 });
|
||||
|
||||
Vendored
-3
@@ -1558,8 +1558,6 @@ mod tests {
|
||||
let second = cache
|
||||
.ticket(1, "http", "example.test", "app", "/second", &headers)
|
||||
.unwrap();
|
||||
// Stored bodies are trimmed to their real size, so the first object must
|
||||
// itself be large for the next fill reservation to need its space.
|
||||
let large: &'static str = Box::leak("f".repeat(60 * 1024).into_boxed_str());
|
||||
fill(&cache, first.clone(), large, &s).await;
|
||||
assert!(cache.get(&first).is_some());
|
||||
@@ -1828,7 +1826,6 @@ mod tests {
|
||||
let cache = Cache::new(1);
|
||||
let s = settings();
|
||||
let t = ticket(&cache, &HeaderMap::new());
|
||||
// Header values parsed by hyper are slices of a larger read buffer.
|
||||
let read_buffer = Bytes::from(vec![b'x'; 64 * 1024]);
|
||||
let mut h = headers("max-age=60");
|
||||
h.insert(
|
||||
|
||||
Vendored
-4
@@ -119,7 +119,6 @@ impl Cache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a key as uncacheable so requests skip coalescing.
|
||||
pub fn remember_uncacheable(&self, ticket: &Ticket) {
|
||||
let now = std::time::Instant::now();
|
||||
let mut memo = self
|
||||
@@ -129,9 +128,6 @@ impl Cache {
|
||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY && !memo.contains_key(&ticket.key) {
|
||||
memo.retain(|_, until| *until > now);
|
||||
if memo.len() >= UNCACHEABLE_MEMO_CAPACITY {
|
||||
// Keep hit-for-miss working for new keys under sustained churn:
|
||||
// drop the marker closest to expiry instead of silently
|
||||
// re-enabling request collapsing for this (uncacheable) key.
|
||||
if let Some(oldest) = memo
|
||||
.iter()
|
||||
.min_by_key(|(_, until)| **until)
|
||||
|
||||
Vendored
-4
@@ -43,10 +43,6 @@ impl Cache {
|
||||
ticket.freshness_headers(),
|
||||
);
|
||||
if state.servable() {
|
||||
// Approximate LRU: an entry already among the most recently used
|
||||
// eighth of the store is not re-linked on every hit. This keeps the
|
||||
// global HOT lock short for popular objects without changing which
|
||||
// cold entries are evicted first.
|
||||
let promote = store
|
||||
.entries
|
||||
.get(&storage_key)
|
||||
|
||||
Vendored
-13
@@ -28,10 +28,6 @@ const COLD_QUEUE: usize = 16;
|
||||
const COLD_FORMAT_VERSION: u8 = 3;
|
||||
type Key = [u8; 32];
|
||||
|
||||
/// Cache keys are SHA-256 digests, so their bytes are already uniformly
|
||||
/// distributed and not attacker-steerable. Hashing them again with SipHash
|
||||
/// costs measurable time on every HOT lookup; this hasher folds the digest
|
||||
/// cheaply instead (any other input is still mixed, Fx-style).
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct DigestKeyHasher(u64);
|
||||
impl std::hash::Hasher for DigestKeyHasher {
|
||||
@@ -53,7 +49,6 @@ impl std::hash::Hasher for DigestKeyHasher {
|
||||
}
|
||||
}
|
||||
fn write_usize(&mut self, _length: usize) {
|
||||
// The only usize written for `[u8; 32]` is its constant length prefix.
|
||||
}
|
||||
}
|
||||
type KeyMap<V> = HashMap<Key, V, std::hash::BuildHasherDefault<DigestKeyHasher>>;
|
||||
@@ -71,11 +66,6 @@ struct CleanupCandidate {
|
||||
key: Key,
|
||||
}
|
||||
|
||||
/// Minimum spacing between two `malloc_trim` calls. Trimming walks every glibc
|
||||
/// arena under its lock and can take milliseconds with a large heap; doing it
|
||||
/// after each eviction made a full HOT cache with constant turnover several
|
||||
/// times slower. Skipped requests are remembered and flushed by the cache
|
||||
/// maintenance tick, so freed memory still returns to the OS within seconds.
|
||||
const ALLOCATOR_RELEASE_INTERVAL_MS: u64 = 250;
|
||||
static ALLOCATOR_RELEASE_LAST_MS: AtomicU64 = AtomicU64::new(0);
|
||||
static ALLOCATOR_RELEASE_PENDING: std::sync::atomic::AtomicBool =
|
||||
@@ -83,7 +73,6 @@ static ALLOCATOR_RELEASE_PENDING: std::sync::atomic::AtomicBool =
|
||||
|
||||
fn allocator_clock_ms() -> u64 {
|
||||
static EPOCH: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||
// +1 so the very first request is never mistaken for "just trimmed".
|
||||
(EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64)
|
||||
.saturating_add(ALLOCATOR_RELEASE_INTERVAL_MS + 1)
|
||||
}
|
||||
@@ -103,7 +92,6 @@ fn release_allocator_memory() {
|
||||
cache_trim_allocator();
|
||||
}
|
||||
|
||||
/// Called from the periodic cache maintenance task.
|
||||
fn flush_pending_allocator_release() {
|
||||
if ALLOCATOR_RELEASE_PENDING.load(Ordering::Relaxed) {
|
||||
release_allocator_memory();
|
||||
@@ -133,7 +121,6 @@ pub enum StaleErrorClass {
|
||||
Tls,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryPreset {
|
||||
|
||||
Vendored
-3
@@ -92,11 +92,9 @@ pub fn plan(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Surrogate-Control: no-store always vetoes shared caching.
|
||||
if settings.varnish_compatible && surrogate_no_store(headers) {
|
||||
return None;
|
||||
}
|
||||
// Fallback TTL overrides origin no-cache only for public responses.
|
||||
let override_public_no_cache = settings.varnish_compatible
|
||||
&& control.contains_key("no-cache")
|
||||
&& control.contains_key("public")
|
||||
@@ -158,7 +156,6 @@ pub fn plan(
|
||||
None if requires_revalidation => 0,
|
||||
None => return None,
|
||||
};
|
||||
// Expired entries are kept for revalidation only; implicit grace needs no explicit SWR/no-cache.
|
||||
if settings.varnish_compatible
|
||||
&& !control.contains_key("stale-while-revalidate")
|
||||
&& !control.contains_key("no-cache")
|
||||
|
||||
Vendored
-1
@@ -395,7 +395,6 @@ impl Cache {
|
||||
}
|
||||
|
||||
impl Ticket {
|
||||
/// Ignores forced-reload directives only; other client constraints apply.
|
||||
pub fn with_varnish_compatible(mut self, compatible: bool) -> Self {
|
||||
if let std::borrow::Cow::Owned(freshness) =
|
||||
compatible_freshness_headers(&self.request_headers, compatible)
|
||||
|
||||
@@ -108,7 +108,6 @@ pub(crate) fn compile_backend_with_timeout(
|
||||
})
|
||||
}
|
||||
|
||||
/// Backend copy used only by active health checks that probe a different port.
|
||||
pub(crate) fn health_probe_backend(backend: &Backend, port: Option<u16>) -> Result<Option<Backend>> {
|
||||
let Some(port) = port else { return Ok(None) };
|
||||
let mut url = Url::parse(&backend.url).context("invalid backend URL")?;
|
||||
|
||||
@@ -139,8 +139,6 @@ impl Snapshot {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Specificity of the vhost frontend matching `host` (an exact name beats any wildcard,
|
||||
/// a longer wildcard suffix beats a shorter one).
|
||||
pub fn frontend_rank(&self, host: &str, local: SocketAddr, tls: bool) -> Option<usize> {
|
||||
let mut rank = None;
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
@@ -158,7 +156,6 @@ impl Snapshot {
|
||||
});
|
||||
rank
|
||||
}
|
||||
/// Specificity of the redirect host matching `host` and `path` (see `frontend_rank`).
|
||||
pub fn redirect_rank(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -186,7 +183,6 @@ impl Snapshot {
|
||||
});
|
||||
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| {
|
||||
|
||||
@@ -148,11 +148,6 @@ impl Diagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Heap statistics reported by the glibc allocator (`mallinfo2`).
|
||||
///
|
||||
/// `free_bytes` is memory the allocator keeps for reuse (fragmentation and
|
||||
/// not-yet-returned pages). Part of it may already be released to the kernel
|
||||
/// by `malloc_trim`, so it is an upper bound of the resident share.
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||
pub struct AllocatorStats {
|
||||
pub allocator: &'static str,
|
||||
@@ -164,9 +159,6 @@ pub struct AllocatorStats {
|
||||
pub mmap_blocks: u64,
|
||||
}
|
||||
|
||||
/// Returns glibc heap statistics, sampled at most every two seconds because
|
||||
/// `mallinfo2` walks every arena under its lock. `None` on other allocators
|
||||
/// (musl, non-Linux or the optional mimalloc build).
|
||||
pub fn allocator_stats() -> Option<AllocatorStats> {
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu", not(feature = "mimalloc")))]
|
||||
{
|
||||
|
||||
@@ -252,7 +252,6 @@ pub(crate) fn auth_context(
|
||||
method: &Method,
|
||||
context: &forwarding::Context,
|
||||
) -> Result<()> {
|
||||
// Never trust inbound original metadata, including markers from another auth module.
|
||||
for name in headers
|
||||
.keys()
|
||||
.filter(|name| name.as_str().starts_with("x-original-"))
|
||||
|
||||
@@ -35,7 +35,6 @@ impl PeerFormatting {
|
||||
untrusted_headers: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
/// Text form of the peer address when it needed no canonicalization.
|
||||
pub(crate) fn raw_peer_text(&self) -> Option<&str> {
|
||||
(self.peer.ip() == self.peer_ip)
|
||||
.then(|| self.peer_header.to_str().ok())
|
||||
|
||||
@@ -97,7 +97,6 @@ mod tests {
|
||||
assert!(gateway_502.contains("RustProxy"));
|
||||
assert!(gateway_502.contains("#131615"));
|
||||
assert!(!gateway_502.contains("Tinyauth"));
|
||||
// Stock error pages must stay unchanged.
|
||||
assert_eq!(
|
||||
ERROR_CODES,
|
||||
[400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/// Certificate expiry read from the certificate files, refreshed at most every five minutes.
|
||||
fn certificate_expiry(
|
||||
app: &App,
|
||||
certificates: &[crate::tls::Certificate],
|
||||
|
||||
@@ -139,8 +139,6 @@ async fn forward_request(
|
||||
}
|
||||
let websocket_legacy_support =
|
||||
handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
|
||||
// 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)
|
||||
@@ -205,7 +203,6 @@ async fn forward_request(
|
||||
{
|
||||
return response;
|
||||
}
|
||||
// Redirects are evaluated after global and per-vhost country/IP/rate checks below.
|
||||
}
|
||||
let mut context = match snapshot.forwarding.context_with_peer(
|
||||
request.headers(),
|
||||
@@ -264,7 +261,6 @@ async fn forward_request(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Global rules are cumulative: an explicit IP allow cannot bypass a country deny/allowlist.
|
||||
if !snapshot
|
||||
.global_security
|
||||
.country_access
|
||||
@@ -369,7 +365,6 @@ async fn forward_request(
|
||||
"RustProxy: denied by access policy\n",
|
||||
);
|
||||
}
|
||||
// Canonical / HTTPS redirects must not bypass global or per-vhost access policy.
|
||||
if !cache_http_invalidation_method(request.method()) {
|
||||
if let Some(mut response) = frontend.and_then(|frontend| {
|
||||
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
|
||||
|
||||
@@ -17,7 +17,6 @@ mod http2_tests {
|
||||
request
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn rfc8441_extended_connect_is_recognized() {
|
||||
let request = extended_connect("websocket");
|
||||
|
||||
@@ -3,7 +3,6 @@ mod http3_tests {
|
||||
use http::{Method, Request, Version};
|
||||
use super::{h3_protocol, validate_h3};
|
||||
|
||||
|
||||
#[test]
|
||||
fn rfc9220_extended_connect_is_recognized() {
|
||||
let mut request = Request::builder()
|
||||
|
||||
@@ -104,7 +104,6 @@ fn read_pem_with_policy(
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
// O_NONBLOCK avoids hanging on a FIFO; special files are rejected below.
|
||||
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
|
||||
}
|
||||
let file = options
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ mod tests {
|
||||
assert!(pqc.validate().is_err());
|
||||
pqc.tls13 = true;
|
||||
pqc.cipher_suites = vec!["X25519MLKEM768".into()];
|
||||
assert!(pqc.validate().is_err()); // KEX name is not a cipher suite
|
||||
assert!(pqc.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -229,7 +229,6 @@ impl Analytics {
|
||||
let (tx, mut rx) = mpsc::channel::<Sample>(QUEUE_CAPACITY);
|
||||
let worker_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
// Drain on a short interval instead of waking once per request.
|
||||
let mut tick = tokio::time::interval(DRAIN_INTERVAL);
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut batch = Vec::with_capacity(256);
|
||||
|
||||
@@ -81,7 +81,6 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Reads a regular file in bounded chunks on the calling task.
|
||||
#[cfg(unix)]
|
||||
pub(super) struct InlineFileStream {
|
||||
file: std::fs::File,
|
||||
|
||||
@@ -47,7 +47,6 @@ 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");
|
||||
@@ -71,8 +70,6 @@ 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");
|
||||
@@ -85,13 +82,11 @@ for (const source of [css, overviewCss, editorsCss]) assert.doesNotMatch(source,
|
||||
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"))) {
|
||||
@@ -99,21 +94,18 @@ console.log("Panel SVG icon preference contract: OK");
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
@@ -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.9");
|
||||
assert.equal(openapi.info.version, "0.36.10");
|
||||
console.log("Panel refresh/API contract checks: OK");
|
||||
Reference in new issue
Block a user