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:
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,
|
||||
|
||||
Reference in new issue
Block a user