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;
+1 -1
View File
@@ -107,7 +107,7 @@ Universal Create ranks permission-aware actions for the current context:
- Add Widget, App, or Container to the current Board.
- Connect a service.
- Create or import a Board.
- Create a Board.
- Open Workshop.
- Create a Custom Widget.
-1
View File
@@ -20,7 +20,6 @@
"./onboarding-claim": "./src/onboarding-claim.ts",
"./assistant": "./src/assistant.ts",
"./onboarding-step": "./src/onboarding-step.ts",
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
"./mcp": "./src/mcp.ts",
"./custom-widget-configuration": "./src/custom-widget-configuration.ts"
},
@@ -53,7 +53,10 @@ export const getDockerReconciliationAsync = async (db: Database) => {
? sameKindIntegrations.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
: [];
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
const nameIntegration = nameIntegrationMatches.length === 1 ? nameIntegrationMatches[0] : undefined;
const nameIntegration =
exactIntegrationMatches.length === 0 && nameIntegrationMatches.length === 1
? nameIntegrationMatches[0]
: undefined;
const integration = exactIntegration ?? nameIntegration;
const exactAppMatches = apps.filter(({ href }) => {
const normalizedUrl = normalizeDockerServiceUrl(href);
@@ -63,11 +66,16 @@ export const getDockerReconciliationAsync = async (db: Database) => {
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
: [];
const exactApp = exactAppMatches.length === 1 ? exactAppMatches[0] : undefined;
const nameApp = nameAppMatches.length === 1 ? nameAppMatches[0] : undefined;
const nameApp = exactAppMatches.length === 0 && nameAppMatches.length === 1 ? nameAppMatches[0] : undefined;
const linkedApp = integration?.appId ? apps.find(({ id }) => id === integration.appId) : undefined;
const app = linkedApp ?? exactApp ?? nameApp;
const exactUrl = Boolean(exactIntegration || exactApp);
const linked = Boolean(integration?.appId && linkedApp);
const integrationAmbiguous =
exactIntegrationMatches.length > 1 || (exactIntegrationMatches.length === 0 && nameIntegrationMatches.length > 1);
const appAmbiguous =
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
const ambiguous = integrationAmbiguous || appAmbiguous;
const state: DockerReconciliationState = linked
? "linked"
: integration && !exactUrl
@@ -87,7 +95,11 @@ export const getDockerReconciliationAsync = async (db: Database) => {
match,
urlCandidates,
state,
nextAction: nextActionForState(state),
nextAction: integrationAmbiguous
? ("reviewIntegration" as const)
: appAmbiguous
? ("viewRepresentation" as const)
: nextActionForState(state),
representation: {
integration: integration
? {
@@ -105,11 +117,7 @@ export const getDockerReconciliationAsync = async (db: Database) => {
exactUrl,
nameMatch: Boolean(nameIntegration || nameApp),
linked,
ambiguous:
exactIntegrationMatches.length > 1 ||
nameIntegrationMatches.length > 1 ||
exactAppMatches.length > 1 ||
nameAppMatches.length > 1,
ambiguous,
},
},
};
@@ -148,7 +156,7 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
Boolean(candidate.representation.integration),
),
nextAction:
candidate.state === "moved"
candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.state === "newRecognized"
? ("setupIntegration" as const)
@@ -159,9 +167,11 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("testConnection" as const)
: candidate.match
? ("setupIntegration" as const)
: ("none" as const),
: candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.match
? ("setupIntegration" as const)
: ("none" as const),
},
{
layer: "apiRequest" as const,
@@ -173,7 +183,11 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
{
layer: "appRepresentation" as const,
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
nextAction: candidate.representation.app ? ("none" as const) : ("createApp" as const),
nextAction: candidate.representation.app
? ("none" as const)
: candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation"
? ("viewRepresentation" as const)
: ("createApp" as const),
},
{
layer: "widgetConfiguration" as const,
@@ -29,6 +29,12 @@ export const dockerRouter = createTRPCRouter({
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
refreshInventory: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.mutation(() => {
dockerContainersRequestHandler.invalidateCache();
}),
getContainers: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -31,6 +31,10 @@ export class KubernetesClient {
return KubernetesClient.getRegistry().getClient(contextId);
}
public static getDefaultInstance(): KubernetesClient {
return KubernetesClient.getRegistry().getDefaultClient();
}
public static async getContextsAsync() {
return await KubernetesClient.getRegistry().getContextsAsync();
}
@@ -68,6 +72,10 @@ export class KubernetesClientRegistry {
return client;
}
public getDefaultClient() {
return this.getClient(this.defaultContextId);
}
public async getContextsAsync() {
const contexts = await Promise.all(
[...this.clients.entries()].map(async ([contextId, client]): Promise<KubernetesContextStatus> => {
@@ -41,7 +41,7 @@ export const probeRuntimeCapabilitiesAsync = async ({
workshopApiUrl,
fetchImpl = fetch,
getKubernetesVersionAsync = async (signal) =>
await KubernetesClient.getInstance().versionApi.getCode({
await KubernetesClient.getDefaultInstance().versionApi.getCode({
middleware: [
{
pre: async (request: RequestContext) => {
@@ -13,6 +13,7 @@ import { dockerRouter } from "../../docker/docker-router";
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
const invalidateDockerCacheMock = vi.hoisted(() => vi.fn());
const getDockerDataAsyncMock = vi.hoisted(() =>
vi.fn<
() => Promise<{
@@ -75,6 +76,7 @@ vi.mock("@homarr/request-handler/docker", () => ({
return () => undefined;
},
dockerContainersRequestHandler: {
invalidateCache: invalidateDockerCacheMock,
handler: () => ({
getDataAsync: getDockerDataAsyncMock,
}),
@@ -103,6 +105,7 @@ const validInputs: {
} = {
reconcileServices: undefined,
getServiceHealth: undefined,
refreshInventory: undefined,
getContainers: undefined,
startAll: { targets: [{ endpointId: "one", id: "1" }] },
stopAll: { targets: [{ endpointId: "one", id: "1" }] },
@@ -317,6 +320,136 @@ test("does not adopt an ambiguous existing integration", async () => {
expect(result.candidates[0]?.representation.integration).toBeNull();
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true);
expect(result.candidates[0]?.state).toBe("newRecognized");
expect(result.candidates[0]?.nextAction).toBe("reviewIntegration");
});
test("prefers a unique exact integration match over duplicate name matches", async () => {
getDockerDataAsyncMock.mockResolvedValueOnce({
data: {
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
endpoints: [{ id: "home", name: "Home", status: "available" }],
},
timestamp: new Date(),
});
findIntegrationsAsyncMock.mockResolvedValueOnce([
{
id: "exact",
name: "Primary",
kind: "sonarr",
url: "http://home.example:8989",
appId: null,
items: [],
secrets: [],
},
{
id: "name-one",
name: "sonarr",
kind: "sonarr",
url: "http://other.example:8989",
appId: null,
items: [],
secrets: [],
},
{
id: "name-two",
name: "sonarr",
kind: "sonarr",
url: "http://third.example:8989",
appId: null,
items: [],
secrets: [],
},
]);
const result = await createAdminCaller().reconcileServices();
expect(result.candidates[0]?.representation.integration?.id).toBe("exact");
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(false);
});
test("does not fall back to a unique name when exact integration matches are ambiguous", async () => {
getDockerDataAsyncMock.mockResolvedValueOnce({
data: {
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
endpoints: [{ id: "home", name: "Home", status: "available" }],
},
timestamp: new Date(),
});
findIntegrationsAsyncMock.mockResolvedValueOnce([
{ id: "named", name: "sonarr", kind: "sonarr", url: "http://home.example:8989", appId: null },
{ id: "other", name: "Other", kind: "sonarr", url: "http://home.example:8989", appId: null },
]);
const result = await createAdminCaller().reconcileServices();
expect(result.candidates[0]?.representation.integration).toBeNull();
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true);
expect(result.candidates[0]?.nextAction).toBe("reviewIntegration");
});
test("prefers an explicitly linked app over duplicate app matches", async () => {
getDockerDataAsyncMock.mockResolvedValueOnce({
data: {
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
endpoints: [{ id: "home", name: "Home", status: "available" }],
},
timestamp: new Date(),
});
findIntegrationsAsyncMock.mockResolvedValueOnce([
{
id: "integration",
name: "Sonarr",
kind: "sonarr",
url: "http://home.example:8989",
appId: "linked",
items: [],
secrets: [],
},
]);
findAppsAsyncMock.mockResolvedValueOnce([
{ id: "linked", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" },
{ id: "duplicate", name: "Sonarr duplicate", href: "http://home.example:8989", iconUrl: "" },
]);
const result = await createAdminCaller().reconcileServices();
expect(result.candidates[0]?.representation.app?.id).toBe("linked");
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(false);
expect(result.candidates[0]?.state).toBe("linked");
});
test("does not offer to create an app when existing app matches are ambiguous", async () => {
const dockerData = {
data: {
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
endpoints: [{ id: "home", name: "Home", status: "available" }],
},
timestamp: new Date(),
} satisfies Awaited<ReturnType<typeof getDockerDataAsyncMock>>;
getDockerDataAsyncMock.mockResolvedValueOnce(dockerData).mockResolvedValueOnce(dockerData);
const apps = [
{ id: "first", name: "Sonarr one", href: "http://home.example:8989", iconUrl: "" },
{ id: "second", name: "Sonarr two", href: "http://home.example:8989", iconUrl: "" },
] satisfies Awaited<ReturnType<typeof findAppsAsyncMock>>;
findAppsAsyncMock.mockResolvedValueOnce(apps).mockResolvedValueOnce(apps);
const reconciliation = await createAdminCaller().reconcileServices();
const health = await createAdminCaller().getServiceHealth();
expect(reconciliation.candidates[0]?.representation.app).toBeNull();
expect(reconciliation.candidates[0]?.representation.signals.ambiguous).toBe(true);
expect(reconciliation.candidates[0]?.nextAction).toBe("viewRepresentation");
expect(health.services[0]?.layers).toContainEqual({
layer: "appRepresentation",
status: "missing",
nextAction: "viewRepresentation",
});
});
test("invalidates the shared Docker inventory on explicit refresh", async () => {
await createAdminCaller().refreshInventory();
expect(invalidateDockerCacheMock).toHaveBeenCalledOnce();
});
test("projects persisted service layers without inventing runtime health", async () => {
@@ -53,7 +53,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
peaNut: "peanut",
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
archiveTeamWarrior: "archiveteam-warrior",
wud: "whats-up-docker",
mock: null,
};
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ConfirmModal } from "./confirm-modal";
const ConfirmModalComponent = ConfirmModal.component;
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
describe("ConfirmModal", () => {
test("clears loading and stays open when confirmation rejects", async () => {
const closeModal = vi.fn();
let rejectConfirmation!: (error: Error) => void;
const pendingConfirmation = new Promise<void>((_resolve, reject) => {
rejectConfirmation = reject;
});
const onConfirm = vi.fn(() => pendingConfirmation);
await act(async () =>
root.render(
<MantineProvider>
<ConfirmModalComponent actions={{ closeModal }} innerProps={{ children: "Remove containers?", onConfirm }} />
</MantineProvider>,
),
);
const confirmButton = host.querySelectorAll("button").item(1);
await act(async () => confirmButton.click());
expect(confirmButton.disabled).toBe(true);
await act(async () => {
rejectConfirmation(new Error("Removal failed"));
await Promise.resolve();
});
expect(confirmButton.disabled).toBe(false);
expect(closeModal).not.toHaveBeenCalled();
});
});
+16 -12
View File
@@ -36,8 +36,8 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
const closeOnConfirm = innerProps.closeOnConfirm ?? true;
const closeOnCancel = innerProps.closeOnCancel ?? true;
const cancelLabel = labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel"));
const confirmLabel = labels?.confirm ?? ((t: TranslationFunction) => t("common.action.confirm"));
const cancelLabel = labels?.cancel ?? ((translate: TranslationFunction) => translate("common.action.cancel"));
const confirmLabel = labels?.confirm ?? ((translate: TranslationFunction) => translate("common.action.confirm"));
const handleCancel = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -59,19 +59,23 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
const handleConfirm = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
setLoading(true);
try {
if (typeof confirmProps?.onClick === "function") {
confirmProps.onClick(event);
}
if (typeof confirmProps?.onClick === "function") {
confirmProps.onClick(event);
}
if (typeof onConfirm === "function") {
await onConfirm();
}
if (typeof onConfirm === "function") {
await onConfirm();
if (closeOnConfirm) {
actions.closeModal();
}
} catch {
// Keep the modal open. Callers own action-specific error feedback.
} finally {
setLoading(false);
}
if (closeOnConfirm) {
actions.closeModal();
}
setLoading(false);
},
[confirmProps, onConfirm, closeOnConfirm, actions],
);
+2 -7
View File
@@ -281,11 +281,6 @@
"description": "Start a new dashboard and add its first widget.",
"keywords": "dashboard page"
},
"importBoard": {
"label": "Import board",
"description": "Bring an existing Homarr board configuration into this instance.",
"keywords": "upload oldmarr migrate"
},
"workshop": {
"label": "Browse Workshop",
"description": "Discover community-built widgets and install them.",
@@ -2072,8 +2067,8 @@
"tryAgain": "Try again",
"loading": "Loading",
"show": "Show",
"hide": "Hide"
,"undo": "Undo"
"hide": "Hide",
"undo": "Undo"
},
"here": "here",
"iconPicker": {
@@ -11,4 +11,13 @@ describe("buildSortedIntegrations", () => {
expect(kinds).toHaveLength(integrationKinds.length - 1);
expect(kinds).not.toContain("mock");
});
it("limits the grid to the allowed integration kinds", () => {
const kinds = buildSortedIntegrations({ allowedKinds: ["jellyfin", "sonarr"] }).map(
(integration) => integration.kind,
);
expect(kinds).toEqual(expect.arrayContaining(["jellyfin", "sonarr"]));
expect(kinds).toHaveLength(2);
});
});
@@ -47,10 +47,15 @@ export interface IntegrationGridItem {
}
export const buildSortedIntegrations = (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
options: {
enableMockIntegration?: boolean;
onboarding?: boolean;
allowedKinds?: readonly IntegrationKind[];
} = {},
): IntegrationGridItem[] =>
integrationKinds
.filter((kind) => {
if (options.allowedKinds && !options.allowedKinds.includes(kind)) return false;
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (!options.enableMockIntegration && kind === "mock") return false;
return true;
@@ -15,12 +15,20 @@ import classes from "./integration-select-grid.module.css";
export interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
}
export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => {
export const IntegrationSelectGrid = ({
onSelect,
enableMockIntegration = false,
allowedKinds,
}: IntegrationSelectGridProps) => {
const [search, setSearch] = useState("");
const t = useI18n();
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
const integrations = useMemo(
() => buildSortedIntegrations({ enableMockIntegration, allowedKinds }),
[allowedKinds, enableMockIntegration],
);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
+13 -3
View File
@@ -247,9 +247,19 @@ export default function DockerWidget({
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const isAdvanced = displayMode === "advanced";
const utils = clientApi.useUtils();
const containersQuery = clientApi.docker.getContainers.useQuery();
const data = getUsableWidgetQueryData(containersQuery);
const { refetch, isFetching } = containersQuery;
const { isFetching } = containersQuery;
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() {
await Promise.all([
utils.docker.getContainers.invalidate(),
utils.docker.reconcileServices.invalidate(),
utils.docker.getServiceHealth.invalidate(),
]);
},
});
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -437,8 +447,8 @@ export default function DockerWidget({
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
onClick={() => void refetch()}
loading={isFetching || refreshInventory.isPending}
onClick={() => refreshInventory.mutate()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh size={16} />
@@ -3,7 +3,8 @@ import { test } from "node:test";
import { resolve } from "node:path";
import { checkFeatureContracts } from "./contracts.mts";
import { planFeatureGeneration } from "./generate-feature.mts";
import { formatFeatureChanges, planFeatureGeneration } from "./generate-feature.mts";
// oxlint-disable-next-line import/no-unassigned-import -- Registers shared contract tests in this suite.
import "./check-feature.contract.mts";
const root = resolve(import.meta.dirname, "../..");
@@ -12,26 +13,30 @@ test("the checked-in feature contracts are consistent", () => {
assert.deepEqual(checkFeatureContracts(root), []);
});
test("paired generation plans every contract without writing", () => {
const changes = planFeatureGeneration(root, {
integration: {
kind: "exampleService",
name: "Example Service",
slug: "example-service",
description: "Example native integration.",
category: "miscellaneous",
secretKinds: ["apiKey"],
iconUrl: "https://example.com/icon.svg",
},
widget: {
kind: "exampleStatus",
name: "Example Status",
slug: "example-status",
description: "Example native widget.",
icon: "IconBox",
supportedIntegrations: ["exampleService"],
},
});
test("paired generation plans every formatter-clean contract without writing", async () => {
const description =
"A deliberately long feature description that verifies generated source stays formatter-clean when contributor input exceeds the configured print width.";
const changes = await formatFeatureChanges(
planFeatureGeneration(root, {
integration: {
kind: "exampleService",
name: 'Example "Home" Service',
slug: "example-service",
description,
category: "miscellaneous",
secretKinds: ["apiKey"],
iconUrl: "https://example.com/icon.svg",
},
widget: {
kind: "exampleStatus",
name: 'Example "Home" Status',
slug: "example-status",
description,
icon: "IconBox",
supportedIntegrations: ["exampleService"],
},
}),
);
assert.equal(changes.length, 20);
assert.equal(new Set(changes.map((change) => change.path)).size, changes.length);
@@ -41,6 +46,10 @@ test("paired generation plans every contract without writing", () => {
assert.ok(changes.some((change) => change.path === "apps/docs/docs/widgets/example-status/index.mdx"));
const integrationTest = changes.find((change) => change.path.endsWith("example-service-integration.spec.ts"));
assert.match(integrationTest?.content ?? "", /simulateResponseContractAsync/);
assert.match(
integrationTest?.content ?? "",
/expect\(\n results\.every\(\(result\) => result\.passed\),\n results,\n \)\.toBe\(true\)/,
);
const widgetDefinition = changes.find((change) => change.path.endsWith("example-status/index.ts"));
assert.match(widgetDefinition?.content ?? "", /nativeFeatureCapabilities\.exampleStatus\.integrations/);
const capabilityDescriptor = changes.find(
@@ -52,6 +61,7 @@ test("paired generation plans every contract without writing", () => {
const parsed = JSON.parse(translations.content) as { integration: object; widget: object };
assert.ok("exampleService" in parsed.integration);
assert.ok("exampleStatus" in parsed.widget);
assert.deepEqual(await formatFeatureChanges(changes), changes);
});
test("generation stops before overwriting an existing feature", () => {
@@ -71,3 +81,22 @@ test("generation stops before overwriting an existing feature", () => {
/already exists/,
);
});
test("generation rejects slugs that cannot form TypeScript identifiers", () => {
for (const slug of ["2fa-status", "example-2fa"]) {
assert.throws(
() =>
planFeatureGeneration(root, {
widget: {
kind: "twoFactorStatus",
name: "2FA Status",
slug,
description: "Must not generate an invalid component name.",
icon: "IconBox",
supportedIntegrations: [],
},
}),
/must start with a lowercase letter and use kebab-case/,
);
}
});
+30 -9
View File
@@ -1,6 +1,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { mkdirSync } from "node:fs";
import { format } from "oxfmt";
export interface IntegrationFeature {
kind: string;
@@ -112,8 +113,8 @@ const validate = (request: FeatureRequest) => {
if (!feature) continue;
if (!/^[a-z][A-Za-z0-9]*$/.test(feature.kind))
throw new Error(`Generator stopped: ${feature.kind} must be lower camel case.`);
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(feature.slug))
throw new Error(`Generator stopped: ${feature.slug} must be kebab-case.`);
if (!/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(feature.slug))
throw new Error(`Generator stopped: ${feature.slug} must start with a lowercase letter and use kebab-case.`);
}
if (request.widget && !/^Icon[A-Z][A-Za-z0-9]+$/.test(request.widget.icon)) {
throw new Error(`Generator stopped: ${request.widget.icon} must be a Tabler icon export such as IconBox.`);
@@ -173,12 +174,12 @@ describe("${className}", () => {
test("exposes its public integration data", () => {
const integration = new ${className}({
id: "test",
name: "${feature.name}",
name: ${JSON.stringify(feature.name)},
url: "http://localhost",
externalUrl: null,
decryptedSecrets: [],
});
expect(integration.publicIntegration.name).toBe("${feature.name}");
expect(integration.publicIntegration.name).toBe(${JSON.stringify(feature.name)});
});
test("parses documented response fixtures", async () => {
@@ -187,7 +188,10 @@ describe("${className}", () => {
{ name: "invalid response", payload: null, rejects: true },
]);
expect(results.every((result) => result.passed), results).toBe(true);
expect(
results.every((result) => result.passed),
results,
).toBe(true);
});
});
`;
@@ -294,14 +298,14 @@ const planWidget = (root: string, feature: WidgetFeature, changes: PlannedChange
changes,
root,
`${widgetPath}/component.tsx`,
`"use client";\n\nimport { Center, Text } from "@mantine/core";\n\nimport type { WidgetComponentProps } from "../definition";\n\nexport default function ${pascalCase(feature.slug)}Widget(_props: WidgetComponentProps<"${feature.kind}">) {\n return (\n <Center h="100%">\n <Text>${feature.name}</Text>\n </Center>\n );\n}\n`,
`"use client";\n\nimport { Center, Text } from "@mantine/core";\n\nimport type { WidgetComponentProps } from "../definition";\n\nexport default function ${pascalCase(feature.slug)}Widget(_props: WidgetComponentProps<"${feature.kind}">) {\n return (\n <Center h="100%">\n <Text>{${JSON.stringify(feature.name)}}</Text>\n </Center>\n );\n}\n`,
"create",
);
addChange(
changes,
root,
`${widgetPath}/definition.spec.ts`,
`import { describe, expect, test } from "vitest";\n\nimport { componentLoader, definition } from ".";\n\ndescribe("${feature.name} widget definition", () => {\n test("registers its kind and loader", () => {\n expect(definition.kind).toBe("${feature.kind}");\n expect(componentLoader).toBeTypeOf("function");\n });\n});\n`,
`import { describe, expect, test } from "vitest";\n\nimport { componentLoader, definition } from ".";\n\ndescribe(${JSON.stringify(`${feature.name} widget definition`)}, () => {\n test("registers its kind and loader", () => {\n expect(definition.kind).toBe("${feature.kind}");\n expect(componentLoader).toBeTypeOf("function");\n });\n});\n`,
"create",
);
@@ -444,8 +448,25 @@ export const planFeatureGeneration = (root: string, request: FeatureRequest): Pl
return changes;
};
export const generateFeature = (root: string, request: FeatureRequest) => {
const changes = planFeatureGeneration(root, request);
const isFormatterManaged = (path: string) =>
/\.(?:json|mdx|ts|tsx)$/.test(path) && path !== "packages/translation/src/lang/en.json";
export const formatFeatureChanges = async (changes: PlannedChange[]) =>
await Promise.all(
changes.map(async (change) => {
if (!isFormatterManaged(change.path)) return change;
const result = await format(change.path, change.content, { endOfLine: "lf", printWidth: 120 });
if (result.errors.length > 0) {
throw new Error(
`Generator stopped: oxfmt could not format ${change.path}: ${result.errors.map(({ message }) => message).join("; ")}`,
);
}
return { ...change, content: result.code };
}),
);
export const generateFeature = async (root: string, request: FeatureRequest) => {
const changes = await formatFeatureChanges(planFeatureGeneration(root, request));
for (const change of changes) {
mkdirSync(dirname(join(root, change.path)), { recursive: true });
writeFileSync(join(root, change.path), change.content);
+5 -3
View File
@@ -1,6 +1,7 @@
import type { PlopTypes } from "@turbo/gen";
import { generateFeature, type FeatureRequest } from "../../scripts/feature-platform/generate-feature.mts";
import type { FeatureRequest } from "../../scripts/feature-platform/generate-feature.mts";
import { generateFeature } from "../../scripts/feature-platform/generate-feature.mts";
type Answers = Record<string, unknown> & { turbo: { paths: { root: string } } };
@@ -13,7 +14,8 @@ const list = (value: unknown) =>
const lowerCamel = (value: string) =>
/^[a-z][A-Za-z0-9]*$/.test(value) || "Use lower camel case (for example, mediaServer).";
const kebab = (value: string) =>
/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) || "Use kebab-case (for example, media-server).";
/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(value) ||
"Start with a lowercase letter and use kebab-case (for example, media-server).";
const required = (value: string) => value.trim().length > 0 || "This value is required.";
const integrationCategories = [
"dnsHole",
@@ -142,7 +144,7 @@ const action =
(request: (answers: Answers) => FeatureRequest): PlopTypes.CustomActionFunction =>
async (rawAnswers) => {
const answers = rawAnswers as Answers;
const changes = generateFeature(answers.turbo.paths.root, request(answers));
const changes = await generateFeature(answers.turbo.paths.root, request(answers));
return `Created or updated ${changes.length} feature files. Run pnpm check:feature-contracts next.`;
};