fix: address review findings in the v2 stack fix PR

Board scaling: no sideways scrolling
- Revert the pannable narrow canvas. The canvas fits the viewport again and
  `.viewport` goes back to `overflow: clip`, so a board can never widen the
  document or scroll horizontally.
- Fix the unreadable-narrow-board regression at the real cause instead: cap the
  inverse UI compensation at 2x (50% canvas scale). Down to 50% text and
  controls keep their physical size; below it the board zooms out as a whole, so
  a 200x200 logical tile hosts at most 28px logical text rather than 96px and
  content stops overlapping its neighbours.

Touch editing
- Add `touch-action: none` to `.board-grid-entry`, the dnd-kit draggable and its
  own drag handle. The 200ms touch activation delay is useless if the browser
  has already handed the gesture to page panning and emitted `pointercancel`.
  Only edit mode renders this element.

Custom widgets
- Memoize the merged `data`/`status`/`options` passed to `CustomJsxRenderer`.
  They were rebuilt every render, so the compiled-template memo never hit and
  the new reference-identity boundary key changed on every render, resetting the
  error boundary and forcing an extra reconciliation of the widget subtree.
- Keep the cache key when the shared Redis cache is unavailable so the
  process-local cache still absorbs polling instead of sending every widget
  refresh upstream.
- Reclaim the shared cache's LRU/size/byte bookkeeping for TTL-expired entries
  and touch the LRU in the same script, so expired ghosts stop consuming the
  64 MiB budget and a read costs one round trip instead of two.

Forwarded IP
- Restore `ipAddressFromHeaders` to the full forwarding chain and add
  `trustedIpAddressFromHeaders` for the last hop. Repointing the shared helper
  had collapsed MCP failed-auth rate limiting into a single bucket for every
  client behind a reverse proxy and replaced the client IP with the proxy IP in
  API-key auth logs.

Runtime and restore
- Make the new /appdata ownership migration best-effort. Under `set -e` a mount
  that rejects chown (CIFS/SMB, fixed-uid NFS) turned into a container that
  refuses to start, and /appdata was never chowned before.
- Report malformed metadata.json as such instead of blaming the archive.
This commit is contained in:
ajnart committed 2026-08-21 01:40:46 +02:00
1 parent 496fa60b5f
commit 1b544f599c
10 files changed
+153 -69

No files matched your search

@@ -541,7 +541,14 @@ export async function POST(req: Request) {
const entries = await inspectArchiveAsync(zip);
await extractEntryToFileAsync(zip, entries.database, tempPath);
const metadataBuffer = await readBoundedEntryAsync(zip, entries.metadata, MAX_METADATA_BYTES);
const parsedMetadata: unknown = JSON.parse(metadataBuffer.toString());
let parsedMetadata: unknown;
try {
parsedMetadata = JSON.parse(metadataBuffer.toString());
} catch {
// Without this the archive itself gets blamed for a readable ZIP that
// simply carries malformed metadata.
throw new Error("Invalid backup: metadata.json is not valid JSON");
}
if (typeof parsedMetadata !== "object" || parsedMetadata === null || Array.isArray(parsedMetadata)) {
throw new Error("Invalid backup: metadata.json must contain an object");
}
@@ -2,24 +2,12 @@
width: 100%;
max-width: 100%;
min-width: 0;
overflow: visible;
}
.horizontalScroller {
width: 100%;
max-width: 100%;
min-width: 0;
/*
* Horizontal scrolling computes the other overflow axis to auto. Keep the
* board chrome inside that scrollport without changing the sizer's height.
* The canvas always scales to fit, so board content must never widen the
* document or scroll sideways. `clip` guarantees that without creating a
* scroll container.
*/
padding-block: var(--board-canvas-overflow-top) var(--board-canvas-overflow-bottom);
margin-block: calc(0px - var(--board-canvas-overflow-top)) calc(0px - var(--board-canvas-overflow-bottom));
overflow-x: auto;
}
.viewport[data-board-hydrated="false"] .horizontalScroller {
overflow: clip;
}
@@ -33,8 +21,10 @@
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
* zoom and enlarges everything together.
* is fitted below 100%, until the compensation is capped (see
* MAX_BOARD_UI_COMPENSATION) and the board zooms out as a whole instead.
* Above 100%, the canvas behaves like browser zoom and enlarges everything
* together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
@@ -8,9 +8,18 @@ import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
const MIN_READABLE_CANVAS_SCALE = 0.5;
const CANVAS_OVERFLOW_TOP = 48;
const CANVAS_OVERFLOW_BOTTOM = 8;
/**
* Below 100% the board keeps dashboard UI at its normal physical size by
* inverse-scaling it against the canvas. That compensation cannot continue
* forever: at 0.14 a `200 x 200` logical tile still has to host controls sized
* for a desktop, so labels, tables and buttons overlap their neighbours.
*
* Stop compensating at 50% and let the remaining reduction behave like zooming
* out a page. Tile content then keeps the proportions it was designed with
* instead of overflowing its tile, and the canvas can still always fit the
* viewport - the board never scrolls sideways.
*/
const MAX_BOARD_UI_COMPENSATION = 2;
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -18,13 +27,14 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return Math.max(MIN_READABLE_CANVAS_SCALE, availableWidth / logicalWidth);
return availableWidth / logicalWidth;
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
if (canvasScale >= 1) return 1;
return canvasScale < 1 ? 1 / canvasScale : 1;
return Math.min(1 / canvasScale, MAX_BOARD_UI_COMPENSATION);
};
interface ScaledBoardCanvasProps {
@@ -36,8 +46,8 @@ interface ScaledBoardCanvasProps {
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. Narrow viewports stop scaling at a readable threshold
* and scroll the remaining logical surface horizontally.
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
@@ -63,7 +73,6 @@ export const ScaledBoardCanvas = ({
const uiScale = calculateBoardUiScale(scale);
const inverseScale = scale > 0 ? 1 / scale : 1;
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
return (
<Box
@@ -73,31 +82,22 @@ export const ScaledBoardCanvas = ({
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scale={scale}
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
data-canvas-initial-height={initialLogicalHeight}
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
>
<Box
className={classes.horizontalScroller}
style={{
"--board-canvas-overflow-top": `${CANVAS_OVERFLOW_TOP * Math.max(1, scale)}px`,
"--board-canvas-overflow-bottom": `${CANVAS_OVERFLOW_BOTTOM * Math.max(1, scale)}px`,
}}
>
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
<Box
ref={canvasRef}
className={classes.canvas}
style={{
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
</Box>
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
<Box
ref={canvasRef}
className={classes.canvas}
style={{
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
@@ -8,6 +8,17 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
/*
* This element is the dnd-kit draggable and its own drag handle. The touch
* activation constraint only fires after a 200 ms hold, but the browser
* decides who owns a touch gesture when it begins: without this rule the
* board's horizontal scrollport (or the page) claims the pan, emits
* `pointercancel`, and cancels the grid transaction instead of moving the
* widget. Only edit mode renders this element, so ordinary view-mode
* scrolling over widgets is unaffected.
*/
touch-action: none;
}
.board-grid-entry:has([data-board-widget-header]):hover,