This commit is contained in:
Mateusz Gruszczyński committed 2026-11-10 00:15:50 +01:00
1 parent 2789991452
commit e260612ef9
60 files changed
+69 -339

No files matched your search

-2
View File
@@ -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
View File
@@ -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) {
+3 -11
View File
@@ -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();
+4 -3
View File
@@ -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");
+3 -9
View File
@@ -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 -5
View File
@@ -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();
+6 -9
View File
@@ -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()); });
+1 -2
View File
@@ -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)
+2 -62
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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) {
-2
View File
@@ -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
View File
@@ -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; };
-6
View File
@@ -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;
-5
View File
@@ -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");