Compare commits

..
Author SHA1 Message Date
ajnart bfb834e087 revert: drop Umami concurrency change 2026-08-22 09:06:11 +02:00
ajnart 7a56a7d74f refactor: minimize review fixes 2026-08-22 09:04:17 +02:00
ajnart 396b78aa3e revert: keep only minimal review fixes 2026-08-22 09:00:57 +02:00
ajnart caf1f6621a Merge remote-tracking branch 'origin/release/v2' into feat/fix-v2-stack-review-findings 2026-08-22 08:57:25 +02:00
ajnart dfa32c6add revert: trim optional review fixes 2026-08-22 08:37:13 +02:00
ajnart 5522378ef2 fix: restore fast gate after release sync 2026-08-21 19:14:12 +02:00
ajnart e1775e524b Merge origin/release/v2 into feat/fix-v2-stack-review-findings 2026-08-21 19:08:02 +02:00
ajnart 1b544f599c 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.
2026-08-21 01:40:46 +02:00
ajnart 496fa60b5f Merge remote-tracking branch 'origin/feat/fix-v2-stack-review-findings' into feat/fix-v2-stack-review-findings
# Conflicts:
#	apps/docs/docs/management/assistant.mdx
2026-08-20 16:35:25 +02:00
ajnart 5fb44a49e3 Merge remote-tracking branch 'origin/release/v2' into feat/fix-v2-stack-review-findings
# Conflicts:
#	apps/docs/docs/management/assistant.mdx
2026-08-20 16:34:48 +02:00
Thomas 4faf81844a Merge branch 'release/v2' into feat/fix-v2-stack-review-findings 2026-08-20 16:26:51 +02:00
ajnart 19274cba54 fix: preserve restore and setup errors 2026-08-20 15:41:48 +02:00
ajnart cf624e2f23 fix: address coderabbit review feedback 2026-08-20 15:20:21 +02:00
ajnart 17aef0588f fix: keep custom widget limits client safe 2026-08-20 14:59:18 +02:00
ajnart ca20431d84 fix: address final review findings 2026-08-20 14:48:02 +02:00
ajnart c732c5045d docs: align v2 fix behavior 2026-08-20 13:08:54 +02:00
ajnart 263702210a fix: address v2 stack review findings 2026-08-20 13:08:54 +02:00
11 changed files with 33 additions and 122 deletions

No files matched your search

@@ -46,11 +46,6 @@ export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps
description={tSection("option.showOpenAll.description")}
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
/>
<Switch
label={tSection("option.scrollable.label")}
description={tSection("option.scrollable.description")}
{...form.getInputProps("scrollable", { type: "checkbox" })}
/>
<TextMultiSelect
label={tSection("container.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
@@ -296,21 +296,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
top: -0.625rem;
left: -0.625rem;
cursor: nwse-resize;
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
so it doesn't sit on top of the container menu button anchored in this same corner. */
clip-path: polygon(
evenodd,
0 0,
100% 0,
100% 100%,
0 100%,
0 0,
4px 4px,
4px 40px,
40px 40px,
40px 4px,
4px 4px
);
}
@media (prefers-reduced-motion: reduce) {
@@ -91,17 +91,7 @@ export const GridPreviewLayer = ({
const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) {
// This grows the viewport to match the inner grid while a drag needs extra room to show a
// drop target (maxRowCount === null - a "main"/rail section without a fixed cap). A capped
// section (e.g. a scrollable container, maxRowCount !== null) must never have this set: it
// isn't just a temporary default here, this effect reruns on every board mutation - so a
// stray value would silently blow the cap open for the rest of the session, well after any
// drag ended, since nothing else would ever clear it back out.
if (maxRowCount === null) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
} else {
viewport.style.removeProperty("--board-grid-drag-height");
}
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
}
}, [
entryElements,
@@ -3,28 +3,6 @@
overflow: visible;
}
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
that flips which of these two rules the cascade picks, silently un-capping the container even
though its height (set inline, see section-grid.tsx) stays correct throughout. */
.viewport.scrollableViewport {
overflow-x: hidden;
overflow-y: auto;
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
while growing during a drag happening anywhere else on the board - a nested scrollable
container sits inside that section's DOM tree, so the stray value cascades straight
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in
section-grid.tsx, which only kicks in when the property is entirely unset, not merely
inherited. Reset it here so this element can never inherit a stray value from anywhere
above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
--board-grid-drag-height: initial;
}
.staticGrid,
.editorGrid {
position: relative;
@@ -130,13 +130,8 @@ export const SectionGrid = ({
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const viewportHeight = getLogicalTrackSize(viewportRowCount);
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -195,18 +190,6 @@ export const SectionGrid = ({
section,
]);
const viewportRef = useRef<HTMLDivElement>(null);
const previousItemIdsRef = useRef<Set<string> | null>(null);
useEffect(() => {
const currentIds = new Set([...items, ...innerSections].map((item) => item.id));
const previousIds = previousItemIdsRef.current;
const hasNewItem = previousIds !== null && [...currentIds].some((id) => !previousIds.has(id));
previousItemIdsRef.current = currentIds;
if (hasNewItem && isScrollableContainer) {
viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
}
}, [innerSections, isScrollableContainer, items]);
return (
<SectionProvider
value={{
@@ -223,20 +206,18 @@ export const SectionGrid = ({
}}
>
<Box
ref={viewportRef}
{...canvasAttributes}
className={combineClasses(classes.viewport, isScrollableContainer && classes.scrollableViewport, className)}
className={combineClasses(classes.viewport, className)}
style={
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-section-id={section.id}
data-section-kind={section.kind}
data-rail-placement={railPlacement}
data-scrollable={isScrollableContainer ? "true" : undefined}
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
@@ -315,11 +296,6 @@ const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, la
if (visiting.has(sectionId)) return { width: 1, height: 1 };
visiting.add(sectionId);
const section = board.sections.find((candidate) => candidate.id === sectionId);
// A scrollable container isn't forced to grow with its content - it scrolls internally instead,
// so it shouldn't have a content-derived height floor imposed by its parent's grid.
const isScrollable = section?.kind === "container" && section.options.scrollable;
const itemBounds = directItemsBySectionId.get(sectionId) ?? [];
const sectionBounds = (directSectionsBySectionId.get(sectionId) ?? []).map(({ id, placement }) => {
const minimum = resolve(id);
@@ -332,7 +308,7 @@ const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, la
const children = [...itemBounds, ...sectionBounds];
const minimum = {
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
height: isScrollable ? 1 : Math.max(1, ...children.map((child) => child.yOffset + child.height)),
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
};
visiting.delete(sectionId);
minimumBySectionId.set(sectionId, minimum);
@@ -1396,7 +1396,6 @@ describe("saveBoard should save full board", () => {
showLabel: false,
collapsible: false,
showOpenAll: false,
scrollable: false,
});
expect(
await db.query.sectionLayouts.findFirst({
-4
View File
@@ -1943,10 +1943,6 @@
"showOpenAll": {
"label": "Show open-all action",
"description": "Show an action that opens every app in this container in new tabs."
},
"scrollable": {
"label": "Scrollable",
"description": "Keep this container at its set size and scroll its contents instead of growing to fit them."
}
},
"action": {
-2
View File
@@ -75,7 +75,6 @@ describe("section behavior validation", () => {
showLabel: false,
collapsible: true,
showOpenAll: true,
scrollable: true,
},
layouts: [],
});
@@ -89,7 +88,6 @@ describe("section behavior validation", () => {
showLabel: false,
collapsible: true,
showOpenAll: true,
scrollable: true,
},
});
});
-2
View File
@@ -61,7 +61,6 @@ export const containerSectionOptionsDefaults = {
showLabel: true,
collapsible: false,
showOpenAll: false,
scrollable: false,
} as const;
export const containerSectionOptionsSchema = z
@@ -72,7 +71,6 @@ export const containerSectionOptionsSchema = z
showLabel: z.boolean().default(containerSectionOptionsDefaults.showLabel),
collapsible: z.boolean().default(containerSectionOptionsDefaults.collapsible),
showOpenAll: z.boolean().default(containerSectionOptionsDefaults.showOpenAll),
scrollable: z.boolean().default(containerSectionOptionsDefaults.scrollable),
})
.default(containerSectionOptionsDefaults);
@@ -23,15 +23,3 @@
.bookmarkButton:focus-visible .bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
.compactScrollViewport > div {
display: flex !important;
width: 100%;
min-height: 100%;
}
.compactScrollContent {
flex: 1;
min-width: 100%;
min-height: 100%;
}
+29 -21
View File
@@ -2,7 +2,19 @@
import type { CSSProperties } from "react";
import { useMemo } from "react";
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import {
Anchor,
Box,
Card,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { IconLink } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -30,7 +42,6 @@ export interface CompactBookmarkLayout {
columns: number;
hideHostname: boolean;
hideTitle: boolean;
minimumItemWidth: number;
minimumItemSize: number;
}
@@ -53,7 +64,6 @@ export function getCompactBookmarkLayout(
columns,
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
hideTitle: cellWidth < 64 || cellHeight < 40,
minimumItemWidth: targetWidth,
minimumItemSize: usesHorizontalItems ? 48 : 96,
};
}
@@ -129,17 +139,12 @@ export default function BookmarksWidget({
/>
)}
{displayMode !== "advanced" && (
<ScrollArea
classNames={{ viewport: classes.compactScrollViewport }}
type="auto"
scrollbarSize={6}
style={{ flex: 1, minHeight: 0 }}
>
<div className={classes.compactScrollContent}>
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
<Box mih="100%">
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
<GridLayout
data={data}
minimumItemWidth={compactLayout.minimumItemWidth}
columns={compactLayout.columns}
minimumItemHeight={compactLayout.minimumItemSize}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={compactHideTitle}
@@ -162,7 +167,7 @@ export default function BookmarksWidget({
hasIconColor={board.iconColor !== null}
/>
)}
</div>
</Box>
</ScrollArea>
)}
</Stack>
@@ -335,7 +340,7 @@ interface GridLayoutProps {
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
minimumItemWidth: number;
columns: number;
minimumItemHeight: number;
}
@@ -348,13 +353,20 @@ const GridLayout = ({
withBorder,
itemDirection,
hasIconColor,
minimumItemWidth,
columns,
minimumItemHeight,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex gap={4} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}>
<SimpleGrid
cols={columns}
spacing={4}
verticalSpacing={4}
miw="100%"
mih="100%"
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
>
{data.map((app) => {
const href = getSafeAppHref(app.href);
return (
@@ -365,11 +377,7 @@ const GridLayout = ({
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
style={{
flex: `1 0 calc(${minimumItemWidth}px * var(--mantine-scale, 1))`,
minWidth: 0,
minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`,
}}
h="100%"
>
<Card
h="100%"
@@ -399,7 +407,7 @@ const GridLayout = ({
</UnstyledButton>
);
})}
</Flex>
</SimpleGrid>
);
};