v0.36.10
This commit is contained in:
1 parent
2789991452
commit
e260612ef9
60 files changed
+69
-339
No files matched your search
@@ -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()) {
|
||||
|
||||
+28
-28
@@ -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,
|
||||
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 },
|
||||
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")
|
||||
}};
|
||||
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
|
||||
},
|
||||
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");
|
||||
|
||||
Reference in new issue
Block a user