fix release v2 review issues

This commit is contained in:
ajnart committed 2026-08-14 02:18:46 +02:00
1 parent ef2ed6c602
commit ca856f9f54
29 files changed
+508 -131

No files matched your search

+1 -1
View File
@@ -160,7 +160,7 @@ Resize handles stop at the tile's minimum allowed size while the pointer is stil
Exit edit mode to save the resulting positions.
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
Spotlight. It can add a widget, app, integration, Container, board, import, Workshop item, or Custom Widget when the
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
destinations before the widget is created, so the common flow does not require a second move step.
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconDeviceFloppy,
IconLayoutBoard,
@@ -98,6 +99,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
@@ -20,6 +20,7 @@ import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconR
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
@@ -32,8 +33,18 @@ type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][num
export const DockerReconciliation = () => {
const t = useScopedI18n("docker.reconciliation");
const utils = clientApi.useUtils();
const reconciliation = clientApi.docker.reconcileServices.useQuery();
const health = clientApi.docker.getServiceHealth.useQuery();
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() {
await Promise.all([
utils.docker.getContainers.invalidate(),
utils.docker.reconcileServices.invalidate(),
utils.docker.getServiceHealth.invalidate(),
]);
},
});
const [filter, setFilter] = useState<DockerReconciliationInboxFilter>("attention");
const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage<string[]>({
key: "homarr-docker-reconciliation-dismissed",
@@ -50,7 +61,7 @@ export const DockerReconciliation = () => {
["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key),
).length
: 0;
const isRefreshing = reconciliation.isFetching || health.isFetching;
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
return (
<Card withBorder>
@@ -89,7 +100,7 @@ export const DockerReconciliation = () => {
size="xs"
loading={isRefreshing}
leftSection={<IconRefresh size={14} />}
onClick={() => void Promise.all([reconciliation.refetch(), health.refetch()])}
onClick={() => refreshInventory.mutate()}
>
{t("action.refresh")}
</Button>
@@ -244,6 +255,13 @@ const DockerReconciliationCandidate = ({
};
const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => {
if (candidate.representation.signals.ambiguous) {
if (candidate.nextAction === "reviewIntegration" && candidate.match) {
const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) });
return { kind: "reviewIntegration" as const, href: `/manage/integrations?${params.toString()}` };
}
return { kind: "viewRepresentation" as const, href: "/manage/apps" };
}
if (candidate.state === "newRecognized" && candidate.match) {
const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name });
if (url) params.set("url", url);
@@ -1,6 +1,5 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core";
import { ActionIcon, Alert, Avatar, Badge, Box, Button, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
@@ -190,26 +189,31 @@ interface DockerTableProps {
export function DockerTable({ initialData }: DockerTableProps) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
const utils = clientApi.useUtils();
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
refetchOnMount: false,
});
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() {
await Promise.all([
utils.docker.getContainers.invalidate(),
utils.docker.reconcileServices.invalidate(),
utils.docker.getServiceHealth.invalidate(),
]);
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const mutate = useCallback(() => {
void refetch().then((result) => {
if (result.isError) {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
} else {
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
}
});
}, [refetch, tDocker]);
const containers = data?.containers ?? [];
const unavailableEndpoints = data?.endpoints.filter(({ status }) => status === "unavailable") ?? [];
@@ -217,7 +221,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
const table = useTranslatedMantineReactTable({
data: containers,
getRowId: (row) => row.resourceId,
state: { isLoading: isFetching },
state: { isLoading: isFetching || refreshInventory.isPending },
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
@@ -252,8 +256,8 @@ export function DockerTable({ initialData }: DockerTableProps) {
<Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isFetching}
onClick={() => refreshInventory.mutate()}
loading={isFetching || refreshInventory.isPending}
>
{tDocker("action.refresh.label")}
</Button>
+1 -1
View File
@@ -246,7 +246,7 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`).
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with \`targets\` containing the endpointId and id returned for each container.
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
@@ -36,11 +36,16 @@ export const createItemCallback =
section.id === targetSectionId && (section.kind === "empty" || section.kind === "container"),
)
: undefined;
const targetSection = requestedSection ?? firstCanvasSection;
let targetSection = requestedSection ?? firstCanvasSection;
if (!targetSection) return previous;
const itemLayouts = createItemLayouts(previous, targetSection, kind);
let itemLayouts = createItemLayouts(previous, targetSection, kind);
if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) {
targetSection = firstCanvasSection;
itemLayouts = createItemLayouts(previous, targetSection, kind);
}
if (itemLayouts.length === 0) return previous;
const widget = {
id: id ?? createId(),
kind,
@@ -73,15 +78,16 @@ const createItemLayouts = (
const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind);
return layouts.map((layoutId) => {
return layouts.flatMap((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const containerLayout =
currentSection.kind === "container"
? currentSection.layouts.find((layout) => layout.layoutId === layoutId)
: undefined;
const columnCount =
containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width);
const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main";
const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0);
if (columnCount === 0) return [];
const size = {
width: Math.min(itemSize.width, columnCount),
height: itemSize.height,
@@ -95,12 +101,14 @@ const createItemLayouts = (
throw new Error("Your board is full");
}
return {
width: size.width,
height: size.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
};
return [
{
width: size.width,
height: size.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
},
];
});
};
@@ -169,6 +169,36 @@ describe("item actions create-item", () => {
]);
});
test("sizes a new item for its requested rail and omits layouts where that rail is hidden", () => {
const board = new BoardMockBuilder()
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
.addEmptySection({ id: "canvas" })
.addEmptySection({ id: "left-rail", xOffset: -1 })
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
if (!baseLayout) throw new Error("Expected a base layout");
baseLayout.leftGutterColumnCount = 2;
const result = createItemCallback({ id: "missing", kind: "mediaMissing", targetSectionId: "left-rail" })(board);
expect(result.items.find((item) => item.id === "missing")?.layouts).toEqual([
expect.objectContaining({ layoutId: baseLayout.id, sectionId: "left-rail", width: 2 }),
]);
});
test("falls back to the main canvas instead of creating a ghost item for a disabled rail", () => {
const board = new BoardMockBuilder()
.addEmptySection({ id: "canvas" })
.addEmptySection({ id: "left-rail", xOffset: -1 })
.build();
const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "left-rail" })(board);
expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([
expect.objectContaining({ sectionId: "canvas" }),
]);
});
test("falls back to the main canvas when a requested destination no longer exists", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
const layout = board.layouts.at(0);
@@ -1,14 +1,16 @@
import { useMemo, useRef, useState } from "react";
import { Avatar, Badge, Box, Button, Center, Divider, Group, Image, Select, Stack, Text, Tooltip } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconApi } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import {
getIconUrl,
getBoardLaneColumnCount,
getIntegrationName,
getRootSectionLane,
widgetIntegrationSupport,
@@ -61,6 +63,8 @@ const ItemSelectModalContent = ({
const [search, setSearch] = useState(initialSearch);
const t = useI18n();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const mainCanvasSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
@@ -73,7 +77,7 @@ const ItemSelectModalContent = ({
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
if (lane === "main") return [];
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
return [
{
value: section.id,
@@ -88,7 +92,7 @@ const ItemSelectModalContent = ({
label: section.options.title.trim() || t("section.container.untitled"),
})),
],
[board.sections, mainCanvasSection, t],
[board.sections, currentLayout, mainCanvasSection, t],
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(mainCanvasSection?.id ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
@@ -149,12 +153,21 @@ const ItemSelectModalContent = ({
};
const notifyCreated = (itemId: string, name: string) => {
const notificationId = `board-item-created:${itemId}`;
showSuccessNotification({
id: notificationId,
title: t("item.create.notification.success.title"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">{t("item.create.notification.success.message", { name })}</Text>
<Button variant="subtle" size="compact-xs" onClick={() => removeItem({ itemId })}>
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
removeItem({ itemId });
notifications.hide(notificationId);
}}
>
{t("common.action.undo")}
</Button>
</Group>
@@ -10,7 +10,7 @@ describe("getUniversalCreateActionDefinitions", () => {
permissions: ["board-create", "integration-create"],
});
expect(actions.map(({ key }) => key)).toEqual(["integration", "board", "importBoard"]);
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
});
test("places globally-created apps in the library group", () => {
@@ -37,7 +37,6 @@ describe("getUniversalCreateActionDefinitions", () => {
"integration",
"container",
"board",
"importBoard",
"workshop",
"customWidget",
]);
@@ -6,7 +6,6 @@ export const universalCreateActionKeys = [
"integration",
"container",
"board",
"importBoard",
"workshop",
"customWidget",
] as const;
@@ -60,12 +59,6 @@ const definitions: readonly UniversalCreateActionDefinition[] = [
priority: 60,
isVisible: (context) => hasPermission(context, "board-create"),
},
{
key: "importBoard",
group: "boards",
priority: 50,
isVisible: (context) => hasPermission(context, "board-create"),
},
{
key: "workshop",
group: "library",
@@ -7,7 +7,6 @@ import {
IconApps,
IconBox,
IconCode,
IconFileImport,
IconLayoutDashboard,
IconLayoutGridAdd,
IconPlugConnected,
@@ -17,7 +16,7 @@ import {
import { useSession } from "@homarr/auth/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { AddBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
@@ -44,7 +43,6 @@ const actionIcons = {
integration: IconPlugConnected,
container: IconLayoutGridAdd,
board: IconLayoutDashboard,
importBoard: IconFileImport,
workshop: IconSearch,
customWidget: IconCode,
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
@@ -54,7 +52,6 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
const router = useRouter();
const t = useScopedI18n("universalCreate");
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
const { openModal: openImportBoardModal } = useModalAction(ImportBoardModal);
const [query, setQuery] = useState("");
const trackedOpen = useRef(false);
const boardActions = innerProps.boardActions;
@@ -100,9 +97,6 @@ export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ ac
case "board":
openAddBoardModal(undefined);
break;
case "importBoard":
openImportBoardModal(undefined);
break;
case "workshop":
router.push("/manage/workshop");
break;