This commit is contained in:
Mateusz Gruszczyński committed 2026-11-09 23:02:55 +01:00
1 parent fe945f51a5
commit 88728bdd5a
13 files changed
+320 -17

No files matched your search

+3 -1
View File
@@ -6,8 +6,10 @@ Release history focused on **features, settings, default behavior, compatibility
- **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. 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. 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.
- **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.
- **Preferences API:** additive optional `dashboard_widget_heights` (widget ID → 1–32 rows) in `/api/preferences` and dashboard presets, and `dashboard_auto_fit` (boolean, default `true`) in `/api/preferences`. 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
+3
View File
@@ -3077,6 +3077,9 @@
"overviewLayoutHeightAuto": "Automatic height: fits the content (shown dimmed). Use + to make the card taller.",
"overviewLayoutHeightFixed": "Fixed height in rows. Lower it to the content height to return to automatic sizing.",
"overviewLayoutWidth": "Card width in 12-column grid",
"overviewLayoutAutoFit": "Fit to screen",
"overviewLayoutAutoFitHint": "Re-flow this layout for the current screen width: cards keep a readable minimum width, holes at the end of a row are shared by the cards of that row and small steps at the bottom are closed. Saved widths and order do not change.",
"overviewLayoutAutoFitWidened": "Shown {span} columns wide on this screen (Fit to screen); the saved width is unchanged.",
"dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again.",
"overviewLayoutInlineDragging": "Dragging: drop the block on the dashed frame. Esc restores its previous position.",
"overviewLayoutInlineCompact": "Fill gaps",
+3
View File
@@ -3077,6 +3077,9 @@
"overviewLayoutHeightAuto": "Wysokość automatyczna: dopasowana do treści (wartość przygaszona). Użyj +, aby powiększyć blok.",
"overviewLayoutHeightFixed": "Stała wysokość w wierszach. Zmniejsz do wysokości treści, aby wrócić do trybu automatycznego.",
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn",
"overviewLayoutAutoFit": "Dopasuj do ekranu",
"overviewLayoutAutoFitHint": "Dopasowuje układ do bieżącej szerokości ekranu: bloki zachowują czytelną minimalną szerokość, puste miejsce na końcu rzędu dzielą bloki z tego rzędu, a małe ząbki na dole są domykane. Zapisane szerokości i kolejność się nie zmieniają.",
"overviewLayoutAutoFitWidened": "Na tym ekranie wyświetlany na {span} kolumnach (Dopasuj do ekranu); zapisana szerokość się nie zmienia.",
"dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie.",
"overviewLayoutInlineDragging": "Przeciąganie: upuść blok w miejscu ramki. Esc przywraca poprzednią pozycję.",
"overviewLayoutInlineCompact": "Uzupełnij luki",
+5
View File
@@ -4110,6 +4110,11 @@
},
"description": "Optional per-card heights in Overview row units (1-32; a compact stat card spans 2 rows). Cards without an entry size to their content; a height smaller than the content needs is raised automatically. Keys must be registered Overview widget IDs."
},
"dashboard_auto_fit": {
"type": "boolean",
"default": true,
"description": "Re-flow the custom Overview layout for the current screen width: cards keep a readable minimum width, holes at the end of a row are filled by widening the neighbouring cards and small steps at the bottom are closed. The saved widths and order are not changed."
},
"dashboard_hidden_widgets": {
"type": "array",
"maxItems": 78,
+7
View File
@@ -58,6 +58,9 @@ fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) {
}
}
fn default_admin_dashboard_auto_fit() -> bool {
true
}
fn default_file_log_sort() -> String {
"time".to_owned()
}
@@ -141,6 +144,9 @@ 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,
/// 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>>,
@@ -202,6 +208,7 @@ impl Default for AdminPreferences {
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
dashboard_widget_sizes: None,
dashboard_widget_heights: None,
dashboard_auto_fit: true,
file_log_columns: default_file_log_columns(),
file_log_sort: default_file_log_sort(),
file_log_descending: default_file_log_descending(),
+10
View File
@@ -372,3 +372,13 @@ fn dashboard_height_preferences_are_optional_bounded_and_reconciled() {
assert_eq!(heights.get("requests"), Some(&2));
assert!(validate_admin_preferences(&prefs).is_ok());
}
#[test]
fn dashboard_auto_fit_defaults_on_for_new_and_legacy_documents() {
assert!(AdminPreferences::default().dashboard_auto_fit);
let legacy: AdminPreferences = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap();
assert!(legacy.dashboard_auto_fit);
let off: AdminPreferences = serde_json::from_str(r#"{"dashboard_auto_fit":false}"#).unwrap();
assert!(!off.dashboard_auto_fit);
assert!(validate_admin_preferences(&off).is_ok());
}
+1 -1
View File
@@ -64,6 +64,7 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/app/refresh.js"),
include_str!("web/app/rate-sampling.js"),
include_str!("web/app/chart-scales.js"),
include_str!("web/app/overview-rows.js"),
"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(",
include_str!("web/dashboard-widgets.json"),
");",
@@ -76,7 +77,6 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/app/overview-wide-metrics.js"),
include_str!("web/app/web-overview.js"),
include_str!("web/app/overview-masonry.js"),
include_str!("web/app/overview-rows.js"),
include_str!("web/app/config-history.js"),
include_str!("web/app/certificate-inspection.js"),
include_str!("web/app/memory-breakdown.js"),
+28 -5
View File
@@ -10,6 +10,7 @@ window.rustProxyInlineLayout = (() => {
const hiddenCount = $("overview-inline-hidden-count");
const modalButton = $("overview-layout-open");
const compactButton = $("overview-inline-compact");
const autoFitToggle = $("overview-inline-autofit");
let draft = null;
let original = null;
let opening = false;
@@ -29,7 +30,8 @@ window.rustProxyInlineLayout = (() => {
const dirty = () => draft && original && (draft.order.some((id, i) => id !== original.order[i])
|| [...draft.hidden].some(id => !original.hidden.has(id)) || [...original.hidden].some(id => !draft.hidden.has(id))
|| JSON.stringify(draft.sizes) !== JSON.stringify(original.sizes)
|| JSON.stringify(normalizeDashboardWidgetHeights(draft.heights)) !== JSON.stringify(normalizeDashboardWidgetHeights(original.heights)));
|| JSON.stringify(normalizeDashboardWidgetHeights(draft.heights)) !== JSON.stringify(normalizeDashboardWidgetHeights(original.heights))
|| draft.autoFit !== original.autoFit);
function renderHidden() {
if (!hiddenList || !draft) return;
@@ -67,7 +69,13 @@ window.rustProxyInlineLayout = (() => {
const span = dashboardWidgetWidth(node.dataset.dashboardWidget, draft.sizes);
const at = widths.indexOf(span);
const value = node.querySelector(".overview-inline-width-value");
if (value) value.textContent = `${span}/12`;
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");
}
const narrow = node.querySelector(".overview-inline-narrow");
const widen = node.querySelector(".overview-inline-widen");
if (narrow) narrow.disabled = at <= 0;
@@ -180,6 +188,8 @@ window.rustProxyInlineLayout = (() => {
hint.hidden = false;
hint.textContent = t("overviewLayoutInlineHint");
if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint");
const autoFitLabel = $("overview-inline-autofit-label");
if (autoFitLabel) autoFitLabel.title = t("overviewLayoutAutoFitHint");
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
if (modalButton) modalButton.disabled = true;
for (const node of nodes()) toolbar(node);
@@ -198,7 +208,10 @@ window.rustProxyInlineLayout = (() => {
if (hiddenShelf) { hiddenShelf.open = false; hiddenShelf.hidden = true; }
if (hiddenList) hiddenList.replaceChildren();
if (modalButton) modalButton.disabled = false;
if (!preserveSaved && original) applyDashboardLayout(original.order, [...original.hidden], original.sizes, original.heights);
if (!preserveSaved && original) {
window.rustProxyOverviewRows?.setAutoFit?.(original.autoFit);
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?.();
@@ -214,8 +227,10 @@ window.rustProxyInlineLayout = (() => {
const hidden = new Set(normalizeHiddenDashboardWidgets(saved.dashboard_hidden_widgets, saved.dashboard_widgets));
const sizes = normalizeDashboardWidgetSizes(saved.dashboard_widget_sizes);
const heights = normalizeDashboardWidgetHeights(saved.dashboard_widget_heights);
original = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes }, heights: heights ? { ...heights } : null };
draft = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes }, heights: heights ? { ...heights } : null };
const autoFit = saved.dashboard_auto_fit !== false;
original = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes }, heights: heights ? { ...heights } : null, autoFit };
draft = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes }, heights: heights ? { ...heights } : null, autoFit };
if (autoFitToggle) autoFitToggle.checked = autoFit;
showEditing();
} catch (error) { if (error?.status !== 401) toast(error.message, true); }
finally { opening = false; unlock.disabled = false; }
@@ -230,6 +245,7 @@ window.rustProxyInlineLayout = (() => {
dashboard_widgets: [...draft.order], dashboard_hidden_widgets: [...draft.hidden],
dashboard_widget_sizes: draft.sizes === null ? null : { ...draft.sizes },
dashboard_widget_heights: normalizeDashboardWidgetHeights(draft.heights),
dashboard_auto_fit: draft.autoFit !== false,
});
finish(true);
toast(t("overviewLayoutEditorSaved"));
@@ -417,6 +433,12 @@ window.rustProxyInlineLayout = (() => {
});
}
compactButton?.addEventListener("click", compact);
autoFitToggle?.addEventListener("change", () => {
if (!draft) return;
draft.autoFit = autoFitToggle.checked;
window.rustProxyOverviewRows?.setAutoFit?.(draft.autoFit);
paint();
});
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
saveButton?.addEventListener("click", () => { void save(); });
cancelButton?.addEventListener("click", cancel);
@@ -426,6 +448,7 @@ window.rustProxyInlineLayout = (() => {
order: [...draft.order], hidden: [...draft.hidden],
sizes: draft.sizes === null ? null : { ...draft.sizes },
heights: draft.heights ? { ...draft.heights } : null,
autoFit: draft.autoFit !== false,
} : null,
cancel,
};
+201 -3
View File
@@ -5,14 +5,24 @@
// (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;
const noop = { schedule() { }, flush() { }, setHeights() { }, rowsFor() { return null; } };
// 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;
let heights = null, frame = 0, lastRun = 0, lastWidth = 0;
let heights = null, frame = 0, lastRun = 0, lastWidth = 0, autoFit = true;
const state = new Map();
const shrinkVotes = new Map();
const cards = () => [...grid.children].filter(node => node.dataset.dashboardWidget);
@@ -25,8 +35,41 @@ window.rustProxyOverviewRows = (() => {
grid.classList.remove("has-row-units");
grid.style.removeProperty("--overview-row-unit");
for (const node of cards()) { node.style.removeProperty("--overview-rows"); delete node.dataset.dashboardRows; }
clearFit();
state.clear(); shrinkVotes.clear();
}
function clearFit() {
grid.classList.remove("has-auto-placement");
for (const node of cards()) {
for (const name of ["--overview-col", "--overview-cols", "--overview-row"]) node.style.removeProperty(name);
}
for (const node of cards()) {
if (node.dataset.dashboardFit === undefined) continue;
node.style.removeProperty("--overview-widget-placement");
delete node.dataset.dashboardFit;
}
}
function setSpan(node, span, columns) {
node.style.setProperty("--overview-widget-placement", span >= columns ? "1/-1" : `auto/span ${span}`);
node.dataset.dashboardFit = String(Math.min(span, columns));
}
const spanOf = card => card.last - card.first + 1;
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;
if (!column) return;
const columnGap = column - geometry.tracks[0];
for (const node of items) {
const card = place(node, geometry);
const minimum = MIN_CARD_WIDTH[widgetType(node)] ?? MIN_CARD_WIDTH.value;
const needed = Math.min(columns, Math.ceil((minimum + columnGap) / column - 0.02));
if (needed > spanOf(card)) setSpan(node, needed, columns);
}
}
function apply() {
frame = 0;
if (!grid.getClientRects().length || grid.classList.contains("is-overview-sorting")) return;
@@ -35,6 +78,7 @@ window.rustProxyOverviewRows = (() => {
lastWidth = grid.clientWidth;
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");
@@ -43,6 +87,10 @@ window.rustProxyOverviewRows = (() => {
// 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);
if (preliminary) widenToMinimum(items, preliminary);
}
const natural = items.map(node => node.getBoundingClientRect().height / zoom);
grid.classList.remove("is-measuring-rows");
if (editing) grid.classList.add("is-unlocked");
@@ -76,9 +124,132 @@ window.rustProxyOverviewRows = (() => {
grid.classList.add("has-row-units");
const geometry = gridGeometry(unit + gap, zoom);
if (!geometry) return;
if (autoFit) { packCards(items, geometry, isStat); return; }
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 => {
const flow = place(node, geometry);
return { node, stat: isStat(node), w: Math.min(columns, spanOf(flow)), design: spanOf(flow), h: rowsOf(node), r: 0, c: 0 };
});
let occupancy = [];
const free = (r, c, w, h) => {
for (let y = r; y < r + h; y++) {
const row = occupancy[y];
if (!row) continue;
for (let x = c; x < c + w; x++) if (row[x]) return false;
}
return true;
};
const mark = (r, c, w, h, value = 1) => {
for (let y = r; y < r + h; y++) {
const row = occupancy[y] || (occupancy[y] = new Uint8Array(columns));
for (let x = c; x < c + w; x++) row[x] = value;
}
};
const pack = () => {
occupancy = [];
for (const card of cardsModel) {
let placed = false;
for (let r = 0; !placed; r++) {
for (let c = 0; c + card.w <= columns; c++) {
if (free(r, c, card.w, card.h)) { card.r = r; card.c = c; mark(r, c, card.w, card.h); placed = true; break; }
}
}
}
};
// 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();
const tallest = new Map();
for (const card of cardsModel) if (card.stat) tallest.set(card.r, Math.max(tallest.get(card.r) || 0, card.h));
let changed = false;
for (const card of cardsModel) {
const target = card.stat ? tallest.get(card.r) : card.h;
if (target !== card.h) { card.h = target; changed = true; }
}
if (!changed) break;
}
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 = [];
for (const card of cardsModel) {
const end = card.r + card.h;
let best = end;
for (const other of cardsModel) {
const touching = other.c === card.c + card.w || other.c + other.w === card.c;
const otherEnd = other.r + other.h;
if (touching && other.r < end && otherEnd > end && otherEnd - end <= snap) best = Math.max(best, otherEnd);
}
if (best !== end && !card.snapTried) candidates.push([card, best - card.r]);
}
if (!candidates.length) break;
const [card, height] = candidates[0];
card.snapTried = true;
const saved = cardsModel.map(item => item.h), previous = card.h;
card.h = height;
const next = settle();
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;
return true;
};
for (const card of order) {
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;
return true;
};
for (const card of order) {
let up = 0;
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;
let next = bottom;
for (const other of cardsModel) {
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;
if (extra > 0 && extra <= Math.max(ROW_DIVISIONS * 2, card.h)) { mark(end, card.c, card.w, extra); card.h += extra; }
}
for (const card of cardsModel) {
card.node.style.setProperty("--overview-col", String(card.c + 1));
card.node.style.setProperty("--overview-cols", String(card.w));
card.node.style.setProperty("--overview-row", String(card.r + 1));
setRows(card.node, card.h);
if (card.w !== card.design) card.node.dataset.dashboardFit = String(card.w);
}
grid.classList.add("has-auto-placement");
}
function setRows(node, rows) {
if (node.dataset.dashboardRows === String(rows)) return;
node.style.setProperty("--overview-rows", String(rows));
@@ -99,7 +270,7 @@ window.rustProxyOverviewRows = (() => {
const starts = [];
tracks.reduce((x, width) => { starts.push(x); return x + width + columnGap; }, 0);
return {
starts, step, zoom,
starts, step, zoom, tracks,
left: (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0),
top: (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.paddingTop) || 0),
};
@@ -167,6 +338,26 @@ 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;
flush();
grid.classList.remove("is-layout-pending");
}
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;
if (next === autoFit) return;
autoFit = next;
grid.dataset.overviewAutoFit = String(autoFit);
schedule(true);
}
if (typeof ResizeObserver === "function") {
new ResizeObserver(() => {
if (grid.clientWidth && grid.clientWidth !== lastWidth) { shrinkVotes.clear(); schedule(true); }
@@ -177,6 +368,13 @@ window.rustProxyOverviewRows = (() => {
schedule,
flush,
setHeights,
setAutoFit,
reveal,
dataReady,
fitFor(id) {
const node = cards().find(item => item.dataset.dashboardWidget === id);
return node?.dataset.dashboardFit ? Number(node.dataset.dashboardFit) : null;
},
rowsFor(id) {
return grid.classList.contains("has-row-units") ? state.get(id) || null : null;
},
+13 -6
View File
@@ -14,7 +14,7 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
chart_axes: false, interface_icons: false, logo_animation: false,
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, dashboard_widget_heights: null,
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, dashboard_widget_heights: null, dashboard_auto_fit: true,
});
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool", sunset: "chartStyleSunset", aurora: "chartStyleAurora", ocean: "chartStyleOcean", forest: "chartStyleForest", pastel: "chartStylePastel", terminal: "chartStyleTerminal", blueprint: "chartStyleBlueprint", colorblind: "chartStyleColorblind" });
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
@@ -295,6 +295,8 @@ function applyAdminPreferences(value, cache = true) {
const savedHidden = normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets);
const savedSizes = normalizeDashboardWidgetSizes(value.dashboard_widget_sizes);
const savedHeights = normalizeDashboardWidgetHeights(value.dashboard_widget_heights);
const savedAutoFit = value.dashboard_auto_fit !== false;
window.rustProxyOverviewRows?.setAutoFit?.(editing ? editing.autoFit !== false : savedAutoFit);
if (editing) applyDashboardLayout(editing.order, editing.hidden, editing.sizes, editing.heights);
else applyDashboardLayout(savedOrder, savedHidden, savedSizes, savedHeights);
const normalized = {
@@ -302,7 +304,7 @@ function applyAdminPreferences(value, cache = true) {
refresh: normalizeRefreshPreferences(value.refresh),
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), interface_icons: applyAdminInterfaceIcons(value.interface_icons), logo_animation: applyAdminLogoAnimation(value.logo_animation), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes, dashboard_widget_heights: savedHeights,
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes, dashboard_widget_heights: savedHeights, dashboard_auto_fit: savedAutoFit,
};
adminPreferences = normalized;
window.rustProxyRefresh?.update(normalized.refresh);
@@ -313,11 +315,16 @@ function applyAdminPreferences(value, cache = true) {
window.rustProxyNotFound?.applyPreferences(normalized);
window.rustProxyStreams?.applyPreferences(normalized);
if (languagePacks.size) translate();
refreshAppearanceUi(); return normalized;
refreshAppearanceUi();
window.rustProxyOverviewRows?.reveal?.();
return normalized;
}
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) {
if (!authenticated) return adminPreferences;
@@ -350,7 +357,7 @@ function updateAdminPreferences(patch) {
next.refresh = normalizeRefreshPreferences(next.refresh);
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.interface_icons = next.interface_icons === true; next.logo_animation = next.logo_animation === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); next.dashboard_widget_heights = normalizeDashboardWidgetHeights(next.dashboard_widget_heights);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); next.dashboard_widget_heights = normalizeDashboardWidgetHeights(next.dashboard_widget_heights); next.dashboard_auto_fit = next.dashboard_auto_fit !== false;
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
if (!authenticated || epoch !== authEpoch) return adminPreferences;
return applyAdminPreferences(saved);
@@ -481,9 +488,9 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
const patches = {
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", interface_icons: false, logo_animation: false, button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null, dashboard_widget_heights: null },
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null, dashboard_widget_heights: null, dashboard_auto_fit: true },
};
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
}));
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null, dashboard_widget_heights: null }, $("appearance-reset"), "appearanceResetDone"));
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null, dashboard_widget_heights: null, dashboard_auto_fit: true }, $("appearance-reset"), "appearanceResetDone"));
refreshAppearanceUi();
+1
View File
@@ -667,6 +667,7 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
renderProcessOverview(d, status, live, systemSample);
window.rustProxyMemoryBreakdown?.render(status, appendSample);
window.rustProxyOverviewRows?.schedule();
window.rustProxyOverviewRows?.dataReady?.();
updateCacheMemorySizing();
const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null;
if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`);
+39
View File
@@ -1442,6 +1442,32 @@ 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;
gap: 6px;
min-height: 28px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: var(--rp-radius-control, 0);
color: var(--text);
font-size: 12px;
white-space: nowrap;
cursor: pointer;
}
.overview-inline-autofit input {
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 {
@@ -2246,6 +2272,19 @@ 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 grid stays invisible, so the page
never shows the factory arrangement first and then jumps (no browser storage). */
#panel-overview .dashboard-overview-grid.is-layout-pending {
visibility: hidden;
}
#panel-overview .dashboard-overview-grid.is-measuring-rows>[data-dashboard-widget] {
align-self: start !important;
}
+6 -1
View File
@@ -147,6 +147,11 @@
<button class="quiet" id="overview-inline-compact" type="button"
data-i18n="overviewLayoutInlineCompact"
title="Move blocks into empty grid space once">Fill gaps</button>
<label class="overview-inline-autofit" id="overview-inline-autofit-label"
title="Re-flow this layout for the current screen width">
<input id="overview-inline-autofit" type="checkbox" checked>
<span data-i18n="overviewLayoutAutoFit">Fit to screen</span>
</label>
<button class="quiet" id="overview-inline-cancel" type="button"
data-i18n="overviewLayoutInlineCancel">Cancel changes</button>
</div>
@@ -164,7 +169,7 @@
id="overview-inline-hidden-count"></span></summary>
<div class="overview-inline-hidden-list" id="overview-inline-hidden-list"></div>
</details>
<div class="stats-grid dashboard-overview-grid" id="dashboard-overview-grid">
<div class="stats-grid dashboard-overview-grid is-layout-pending" id="dashboard-overview-grid">
<!-- RP_METRICS_MAIN --><!-- RP_CHARTS_MAIN --><!-- RP_METRICS_WEB --><!-- RP_CHARTS_WEB -->
<article class="card" data-dashboard-widget="cache_diagnostics">
<div class="mini-heading">